From 9c4c525800bb6230c6a8dee806f2197ea8eda2c1 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 26 Oct 2026 19:58:42 +0300 Subject: [PATCH] fix(console): novncd reaches a VNC console where it listens - A console listening on a given address is reached on that address; the compute's host name is used only for a wildcard listener (0.0.0.0, ::, none). It was always the host name, which may resolve to another interface. - ::1 and localhost count as loopback like 127.0.0.0/8: such a console on a TCP or TLS host is refused instead of being tried on the host's name. - The console page says why such a console cannot open and what to change (listen on all interfaces, or add the host over SSH) instead of a generic connection error. --- console/novncd | 22 ++++++++++++++---- console/tests/test_views.py | 46 +++++++++++++++++++++++++++++++++++-- console/views.py | 13 ++++++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/console/novncd b/console/novncd index 73f72a0b..a4271c86 100755 --- a/console/novncd +++ b/console/novncd @@ -15,7 +15,7 @@ if ROOT_PATH not in sys.path: django.setup() -import re +import ipaddress import socket from urllib.parse import parse_qs, urlparse @@ -105,6 +105,17 @@ def get_connection_infos(token): return compute.hostname, compute.login, compute.type, console_host, console_port, console_socket +WILDCARD = (None, "", "0.0.0.0", "::") + + +def is_loopback(address): + """127.0.0.0/8, ::1 or localhost: reachable only on the compute itself.""" + try: + return ipaddress.ip_address(address).is_loopback + except ValueError: + return address == "localhost" + + class CompatibilityMixIn(object): def _new_client(self, daemon, socket_factory): # The page puts its own console's token on its websocket URL: a cookie @@ -125,7 +136,7 @@ class CompatibilityMixIn(object): ) tunnel = None - loopback = re.match(r"^127\.", console_host or "") + loopback = is_loopback(console_host) if conntype == CONN_SSH: # the console travels inside the SSH connection, encrypted, # whatever address it listens on @@ -146,9 +157,10 @@ class CompatibilityMixIn(object): self.msg(message) raise Exception(message) else: - # Direct access - self.msg("connecting to: %s:%s" % (connhost, console_port)) - tsock = socket_factory(connhost, console_port, connect=True) + # the address the console listens on, or the compute's for a wildcard + address = connhost if console_host in WILDCARD else console_host + self.msg("connecting to: %s:%s" % (address, console_port)) + tsock = socket_factory(address, console_port, connect=True) if self.verbose and not daemon: print(self.traffic_legend) diff --git a/console/tests/test_views.py b/console/tests/test_views.py index 10a882aa..801674b1 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -48,12 +48,13 @@ class ConsoleViewsTestCase(TestCase): username="bob", email="bob@example.com", password="bobpassword" ) + # SSH: novncd reaches any console listener (a TCP host's loopback one it cannot) cls.compute = Compute.objects.create( name="test-compute", hostname="127.0.0.1", login="root", password="", - type=1, + type=2, ) cls.instance = Instance.objects.create( @@ -275,6 +276,15 @@ class ConsoleViewsTestCase(TestCase): self.assertEqual(response.status_code, 403) self.assertTemplateNotUsed(response, "console-vnc-lite.html") + @patch("console.views.wvmInstance") + def test_a_tcp_hosts_loopback_console_says_why_it_cannot_open(self, mock_wvm): + Compute.objects.filter(pk=self.compute.pk).update(type=1) + mock_wvm.return_value.get_console_type.return_value = "vnc" + mock_wvm.return_value.console_listens_locally.return_value = True + self.client.force_login(self.admin_user) + response = self.client.get(reverse("console") + f"?token={self.token}") + self.assertContains(response, "This console listens only on its host") + @patch("console.views.wvmInstance") def test_both_pages_connect_to_novncd_behind_the_panels_own_address(self, mock_wvm): # nginx proxies /novncd/ on the panel's own scheme, host and port: no @@ -494,7 +504,8 @@ class NovncdDaemonLogicTestCase(TestCase): handler._new_client(daemon=False, socket_factory=socket_factory) mock_get_info.assert_called_once_with("1-uuid-from-query") - socket_factory.assert_called_once_with("host2", 5901, connect=True) + # the address the console listens on + socket_factory.assert_called_once_with("192.168.1.50", 5901, connect=True) @patch.object(novncd_mod, "get_connection_infos") def test_a_local_socket_host_reaches_a_console_on_its_loopback(self, mock_get_info): @@ -536,6 +547,37 @@ class NovncdDaemonLogicTestCase(TestCase): # the tunnel ends with the session close_tunnel.assert_called_once_with(open_tunnel.return_value[1]) + def test_a_tcp_hosts_console_is_reached_where_it_listens(self): + for console_host, reached in (("0.0.0.0", "kvm1.example.com"), ("::", "kvm1.example.com"), + (None, "kvm1.example.com"), ("192.0.2.40", "192.0.2.40")): + with self.subTest(console_host=console_host), patch.object( + novncd_mod, "get_connection_infos", + return_value=("kvm1.example.com", "", 1, + console_host, 5900, None), + ): + handler = self.CompatibilityMixIn() + handler.path = "/novncd/?token=1-uuid" + handler.msg = MagicMock() + handler.do_proxy = MagicMock() + handler.verbose = False + socket_factory = MagicMock() + handler._new_client(daemon=False, socket_factory=socket_factory) + socket_factory.assert_called_once_with(reached, 5900, connect=True) + + def test_a_tcp_hosts_loopback_console_is_refused(self): + for console_host in ("127.0.0.1", "::1", "localhost"): + with self.subTest(console_host=console_host), patch.object( + novncd_mod, "get_connection_infos", return_value=("kvm1.example.com", "", 1, console_host, 5900, None), + ): + handler = self.CompatibilityMixIn() + handler.path = "/novncd/?token=1-uuid" + handler.msg = MagicMock() + handler.verbose = False + socket_factory = MagicMock() + with self.assertRaises(Exception): + handler._new_client(daemon=False, socket_factory=socket_factory) + socket_factory.assert_not_called() + @patch.object(novncd_mod, "get_connection_infos") def test_compatibility_mixin_missing_token_safe_exit(self, mock_get_info): handler = self.CompatibilityMixIn() diff --git a/console/views.py b/console/views.py index a3248cd0..cb3a1183 100644 --- a/console/views.py +++ b/console/views.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ from instances.models import Instance from instances.utils import can_open_console from libvirt import libvirtError +from vrtManager.connection import CONN_TCP, CONN_TLS from vrtManager.instance import wvmInstance @@ -105,8 +106,11 @@ def console(request): console_websocket_port = conn.get_console_websocket_port() console_passwd = conn.get_console_passwd() running = conn.instance.isActive() + # novncd reaches a console that listens on its host's loopback only + # through SSH (or on this host) + unreachable = instance.compute.type in (CONN_TCP, CONN_TLS) and conn.console_listens_locally() except (libvirtError, ValueError, IndexError, Instance.DoesNotExist): - running = False + running = unreachable = False console_type = None console_websocket_port = None console_passwd = None @@ -114,6 +118,13 @@ def console(request): if console_type and not running: # a shut-off VM has no console to connect to console_error = _("The VM is not running: start it to open its console.") response = render(request, "console-vnc-lite.html", locals()) + elif console_type == "vnc" and unreachable: + console_error = _( + "This console listens only on its host, which the panel reaches over TCP or TLS. " + "Let the console listen on all interfaces in the VM's console settings, " + "or add the host as an SSH host." + ) + response = render(request, "console-vnc-lite.html", locals()) elif console_type == "vnc": console_page = "console-vnc-" + view_type + ".html" response = render(request, console_page, locals())