Skip to content

Commit 2d795b3

Browse files
committed
vnc: Better error messages
When a second remote viewer starts, our in-page viewer will be cleanly disconnected. But when we fail to establish a connection because of protocol incompatabilities or authentication failures, we should make that clear in the UI by showing the error and saying "Retry" instead of "Connect". Unfortunately, there is not enough information coming from NoVNC that would allow us to detect that the connection failed because the VNC server wants encryption. But we can guess by looking into qemu.conf.
1 parent 53a19be commit 2d795b3

File tree

3 files changed

+173
-6
lines changed

3 files changed

+173
-6
lines changed

src/components/vm/consoles/vnc.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,31 @@ const _ = cockpit.gettext;
5656
export type VncSizeMode = "none" | "local" | "remote";
5757

5858
export class VncState extends ConsoleState {
59-
sizeMode: VncSizeMode = "none";
59+
sizeMode: VncSizeMode;
60+
failure_reason: string | null;
61+
62+
constructor() {
63+
super();
64+
this.sizeMode = "none";
65+
this.failure_reason = null;
66+
}
6067

6168
setSizeMode(val: VncSizeMode) {
6269
this.sizeMode = val;
6370
this.update();
6471
}
72+
73+
setConnected(val: boolean) {
74+
this.connected = val;
75+
this.failure_reason = null;
76+
this.update();
77+
}
78+
79+
setDisconnected(reason: string | null) {
80+
this.connected = false;
81+
this.failure_reason = reason;
82+
this.update();
83+
}
6584
}
6685

6786
const VncEditModal = ({
@@ -470,9 +489,25 @@ export class VncActive extends React.Component<VncActiveProps, VncActiveState> {
470489
this.observer.disconnect();
471490
}
472491

473-
onDisconnected(clean: boolean) { // server disconnected
492+
async onDisconnected(clean: boolean) { // server disconnected
474493
console.info('Connection lost: ', clean ? "clean" : "unclean");
475-
this.props.state.setConnected(false);
494+
let reason;
495+
if (clean)
496+
reason = null;
497+
else if (this.props.state.failure_reason) {
498+
// The reason might have been set by onSecurityFailure
499+
// already, so we keep it.
500+
reason = this.props.state.failure_reason;
501+
} else {
502+
// This might be TLS.
503+
const qemu_conf = await readQemuConf();
504+
if (qemu_conf.vnc_tls) {
505+
reason = _("VNC with TLS is not supported by the in-page viewer");
506+
} else {
507+
reason = _("Failed to connect");
508+
}
509+
}
510+
this.props.state.setDisconnected(reason);
476511
}
477512

478513
onInitFailed(detail: unknown) {
@@ -481,6 +516,7 @@ export class VncActive extends React.Component<VncActiveProps, VncActiveState> {
481516

482517
onSecurityFailure(reason: string | undefined) {
483518
console.info('Security failure:', reason || "unknown reason");
519+
this.props.state.setDisconnected(reason || _("Security failure"));
484520
}
485521

486522
render() {
@@ -548,10 +584,14 @@ export class VncActive extends React.Component<VncActiveProps, VncActiveState> {
548584
/>
549585
: <div className="vm-console-vnc">
550586
<EmptyState>
551-
<EmptyStateBody>{_("Disconnected")}</EmptyStateBody>
587+
<EmptyStateBody>
588+
{ state.failure_reason || _("Disconnected") }
589+
</EmptyStateBody>
552590
<EmptyStateFooter>
553-
<Button variant="primary" onClick={() => state.setConnected(true)}>
554-
{_("Connect")}
591+
<Button
592+
variant="primary"
593+
onClick={() => state.setConnected(true)}>
594+
{ state.failure_reason ? _("Retry") : _("Connect") }
555595
</Button>
556596
</EmptyStateFooter>
557597
</EmptyState>

src/helpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,20 +997,27 @@ export function vmHasVFIOHostDevs(vm: VM): boolean {
997997
}
998998

999999
export interface QemuConf {
1000+
vnc_tls: boolean;
10001001
vnc_password: string | null;
10011002
}
10021003

1004+
const VNC_TLS_RX = /^[ \t]*vnc_tls[ \t]*=[ \t]*([0-9]*)/m;
10031005
const VNC_PASSWORD_RX = /^[ \t]*vnc_password[ \t]*=[ \t]*"([^"\\]*(?:\\.[^"\\]*)*)"/m;
10041006

10051007
export async function readQemuConf(): Promise<QemuConf> {
10061008
const conf: QemuConf = {
1009+
vnc_tls: false,
10071010
vnc_password: null,
10081011
};
10091012

10101013
const text = await cockpit.file("/etc/libvirt/qemu.conf", { superuser: "try" }).read();
10111014
if (!text)
10121015
return conf;
10131016

1017+
const tls_match = text.match(VNC_TLS_RX);
1018+
if (tls_match)
1019+
conf.vnc_tls = Number(tls_match[1]) != 0;
1020+
10141021
const password_match = text.match(VNC_PASSWORD_RX);
10151022
if (password_match)
10161023
conf.vnc_password = password_match[1].replaceAll('\\"', '"');

test/check-machines-consoles

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,126 @@ fullscreen=0
744744
b.wait_visible(".vm-console-vnc canvas")
745745
self.assertIn("password=on", m.execute(f"grep ^-vnc /var/log/libvirt/qemu/{name}.log"))
746746

747+
if not m.ws_container:
748+
# We consistently get a 1006 error in the "ws-container"
749+
# scenario instead of the expected "Authentication
750+
# failed", for unknown reasons.
751+
#
752+
# TODO: Debug this properly.
753+
754+
# Change global password, but without restarting the VM. This
755+
# means that Cockpit will use the new password when
756+
# connecting, but the VM still expects the old.
757+
m.execute("sed -i -e 's/vnc_password = \"barfoo\"/vnc_password = \"foobar\"/' /etc/libvirt/qemu.conf")
758+
759+
# We are still connected, but reconnecting will fail.
760+
761+
b.click(".consoles-card button:contains(Disconnect)")
762+
b.wait_in_text(".consoles-card", "Disconnected")
763+
764+
# Quite often the reconnection here fails with code 1006
765+
# (Abnormal Closure), but retrying will give us the expected
766+
# error message, usually on the second try.
767+
768+
b.click(".consoles-card button:contains(Connect)")
769+
for _ in range(5):
770+
try:
771+
b.wait_in_text(".consoles-card", "Authentication failed")
772+
break
773+
except testlib.Error:
774+
if "Failed to connect" in b.text(".consoles-card"):
775+
print("Unexpected error message. Retrying...")
776+
b.click(".consoles-card button:contains(Retry)")
777+
else:
778+
raise
779+
780+
b.wait_in_text(".consoles-card", "Authentication failed")
781+
782+
@testlib.skipImage("cockpit-ws too old",
783+
"rhel-9-*", "centos-9-*", "ubuntu-*", "debian-trixie", "opensuse-tumbleweed")
784+
def testNonSharedExternal(self):
785+
b = self.browser
786+
m = self.machine
787+
788+
name = "subVmTest1"
789+
self.createVm(name, graphics="vnc")
790+
791+
self.login_and_go("/machines")
792+
self.waitPageInit()
793+
self.waitVmRow(name)
794+
self.goToVmPage(name)
795+
796+
# VNC viewer should be connected
797+
b.wait_visible(".vm-console-vnc canvas")
798+
799+
# Start external non-shared viewer. We use Cockpit itself, in
800+
# "Remote resizing" mode.
801+
b2 = self.new_browser()
802+
cookie = b.cookie("cockpit")
803+
b2.open(f"/cockpit/@localhost/machines/index.html#/vm/vnc?name={name}&connection=system", cookie=cookie)
804+
805+
# Allow extra time for the page to initialize itself. It's a
806+
# whole instance of cockpit-machines that has to list all VMs
807+
# and all node devices...
808+
#
809+
# See https://issues.redhat.com/browse/COCKPIT-1302
810+
with b2.wait_timeout(60):
811+
b2.select_PF("#vm-console-vnc-scaling", "Remote resizing")
812+
813+
# Our viewer should disconnect cleanly
814+
b.wait_in_text(".consoles-card", "Disconnected")
815+
816+
def count_vnc_connections():
817+
# Two open ports on localhost per connection
818+
return int(m.execute("ss -tn | grep 5900 | wc -l")) / 2
819+
820+
# There should be a single VNC connection (for the detached viewer)
821+
testlib.wait(lambda: count_vnc_connections() == 1)
822+
823+
# Trying to reconnect should fail while the external viewer is running
824+
b.click('.consoles-card button:contains("Connect")')
825+
b.wait_in_text(".consoles-card", "Failed to connect")
826+
827+
# Stopping the external viewer should allow us to connect again
828+
b2.go("#/")
829+
testlib.wait(lambda: count_vnc_connections() == 0)
830+
831+
b.click('.consoles-card button:contains("Retry")')
832+
b.wait_visible(".vm-console-vnc canvas")
833+
testlib.wait(lambda: count_vnc_connections() == 1)
834+
835+
@testlib.skipImage("No sscg", "arch", "opensuse-tumbleweed")
836+
def testVNCTLS(self):
837+
b = self.browser
838+
m = self.machine
839+
840+
# Make some random certs
841+
m.execute("""
842+
mkdir -p /etc/pki/libvirt
843+
cd /etc/pki/libvirt/
844+
sscg --force --ca-file=ca-cert.pem --cert-file=server-cert.pem --cert-key-file=server-key.pem
845+
chmod a+r /etc/pki/libvirt/*
846+
""")
847+
848+
# Configure VNC to use TLS
849+
self.write_file("/etc/libvirt/qemu.conf", """
850+
vnc_tls = 1
851+
vnc_tls_x509_cert_dir = "/etc/pki/libvirt"
852+
""", append=True)
853+
854+
m.execute(f"systemctl restart {self.getLibvirtServiceName()}")
855+
856+
name = "subVmTest1"
857+
self.createVm(name, graphics="vnc")
858+
859+
self.login_and_go("/machines")
860+
self.waitPageInit()
861+
self.waitVmRow(name)
862+
self.goToVmPage(name)
863+
864+
# VNC viewer should not be connected, with a helpful message
865+
b.wait_in_text(".consoles-card", "VNC with TLS is not supported")
866+
747867

748868
if __name__ == '__main__':
749869
testlib.test_main()

0 commit comments

Comments
 (0)