From 3a560e9f8544a23b39e04dcee93ebb1851aa2757 Mon Sep 17 00:00:00 2001 From: catborise Date: Tue, 27 Oct 2026 00:00:42 +0300 Subject: [PATCH] fix(console): a console that cannot open says why and starts no client A stopped VM, an unreachable or unsupported console and a disabled serial console rendered the lite VNC page under the error: it connected to novncd anyway, showed "Something went wrong" and offered keys, Scale and Send CtrlAltDel for a console that was not there. A refused console was a bare text 403 inside the popup. - console-error.html: the console shell with the VM's name and the reason, no menu and no script; a refused or unknown VM gets it with 403 and without the VM's name, as before for everyone (no superuser exception). - The view signs the console token only for a page that connects. - console-menu.html: the Send key(s) menu and Fullscreen, included by the console pages; the serial page lists only the keys it sends (the VNC groups did nothing there). - Every console window is titled " - Console". - The lite page no longer takes a VNC password from its link. - The shell lost its 850px x 600px corner radius (a grey arc on the serial and error pages) and spans the window (container-fluid). --- console/templates/console-base.html | 59 ++--------------------- console/templates/console-error.html | 7 +++ console/templates/console-menu.html | 53 +++++++++++++++++++++ console/templates/console-vnc-full.html | 3 +- console/templates/console-vnc-lite.html | 37 +++------------ console/templates/console-xterm.html | 3 +- console/tests/test_views.py | 47 ++++++++++++++---- console/views.py | 63 ++++++++++--------------- 8 files changed, 139 insertions(+), 133 deletions(-) create mode 100644 console/templates/console-error.html create mode 100644 console/templates/console-menu.html diff --git a/console/templates/console-base.html b/console/templates/console-base.html index 583a1793..6f76096c 100644 --- a/console/templates/console-base.html +++ b/console/templates/console-base.html @@ -7,6 +7,7 @@ + {% if instance %}{{ instance.name }} - {% endif %}{% trans "Console" %} {# error pages render without app_settings: they get zephyr #} {% with theme=app_settings.BOOTSTRAP_THEME|default:"zephyr" %}{% endwith %} @@ -24,8 +25,6 @@ display: flex; flex-direction: column; background-color: #494949; - border-bottom-right-radius: 850px 600px; - } #main_container canvas { @@ -42,10 +41,11 @@ -
+
- {% if console_error %} - - {% endif %} {% block content %}{% endblock %}
diff --git a/console/templates/console-error.html b/console/templates/console-error.html new file mode 100644 index 00000000..57422223 --- /dev/null +++ b/console/templates/console-error.html @@ -0,0 +1,7 @@ +{% extends "console-base.html" %} +{# a console that cannot open: why, and no console client #} +{% block navbar %}{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/console/templates/console-menu.html b/console/templates/console-menu.html new file mode 100644 index 00000000..1bad4d35 --- /dev/null +++ b/console/templates/console-menu.html @@ -0,0 +1,53 @@ +{% load i18n %} +{# the console pages' Send key(s) menu and Fullscreen; serial: only the keys a terminal takes #} + + diff --git a/console/templates/console-vnc-full.html b/console/templates/console-vnc-full.html index 44a8979a..785a716a 100755 --- a/console/templates/console-vnc-full.html +++ b/console/templates/console-vnc-full.html @@ -13,7 +13,6 @@ or the fragment: http://example.com/#host=HOST&port=PORT&encrypt=1 --> -WebVirtCloud - noVNC @@ -96,6 +95,8 @@ {% endblock %} +{% block navbarmenu %}{% include "console-menu.html" %}{% endblock %} + {% block content %}
diff --git a/console/templates/console-vnc-lite.html b/console/templates/console-vnc-lite.html index 7da37b2a..63d10f70 100755 --- a/console/templates/console-vnc-lite.html +++ b/console/templates/console-vnc-lite.html @@ -12,14 +12,6 @@ Connect parameters are provided in query string: http://example.com/?scale=true (the proxy host, port and path come from the server) --> -WebVirtCloud - noVNC - Lite - - - - - - @@ -114,26 +106,6 @@ document.getElementById('noVNC_status').textContent = text; } - // This function extracts the value of one variable from the - // query string. If the variable isn't defined in the URL - // it returns the default value instead. - function readQueryVariable(name, defaultValue) { - // A URL with a query parameter can look like this: - // https://www.example.com?myqueryparam=myvalue - // - // Note that we use location.href instead of location.search - // because Firefox < 53 has a bug w.r.t location.search - const re = new RegExp('.*[?&]' + name + '=([^&#]*)'), - match = document.location.href.match(re); - - if (match) { - // We have to decode the URL since want the cleartext value - return decodeURIComponent(match[1]); - } - - return defaultValue; - } - bindKeyAction('machineShutdownButton', machineShutdown); bindKeyAction('machineRebootButton', machineReboot); bindKeyAction('machineResetButton', machineReset); @@ -153,10 +125,12 @@ const host = window.location.hostname; let port = window.location.port; + // the VNC password, for a user who may open the console without it; + // otherwise the server asks for it (credentialsrequired) {% if perms.instances.passwordless_console and console_passwd %} - const password = '{{ console_passwd|escapejs }}'; + const credentials = { password: '{{ console_passwd|escapejs }}' }; {% else %} - const password = readQueryVariable('password'); + const credentials = {}; {% endif %} const path = 'novncd/'; @@ -190,7 +164,7 @@ // Creating a new RFB object will start a new connection rfb = new RFB(document.getElementById('noVNC_container'), url, - { credentials: { password: password } }); + { credentials: credentials }); // Add listeners to important events from the RFB module rfb.addEventListener("connect", connectedToServer); @@ -208,6 +182,7 @@ {% endblock %} {% block navbarmenu %} +{% include "console-menu.html" %} diff --git a/console/templates/console-xterm.html b/console/templates/console-xterm.html index 8bac9384..ec3047c8 100644 --- a/console/templates/console-xterm.html +++ b/console/templates/console-xterm.html @@ -3,7 +3,6 @@ {% load static %} {% block head %} -WebVirtCloud - XTerm @@ -14,6 +13,8 @@ {% endblock %} +{% block navbarmenu %}{% include "console-menu.html" with serial=True %}{% endblock %} + {% block content %}
Status: connecting... diff --git a/console/tests/test_views.py b/console/tests/test_views.py index 10c08dec..9be41e6b 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -131,7 +131,7 @@ class ConsoleViewsTestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-lite.html") + self.assertTemplateUsed(response, "console-error.html") self.assertIn("'rdp' is not supported", str(response.context["console_error"])) @override_settings(SERIAL_CONSOLE_ENABLED=True) @@ -204,9 +204,32 @@ class ConsoleViewsTestCase(TestCase): url = self.url response = self.client.get(url) - # Non-permitted user attempting to view other user's instance + # Non-permitted user attempting to view other user's instance: the + # console's own error page, without the VM's name or a console token self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "console-error.html") self.assertIn("permission", response.content.decode("utf-8").lower()) + self.assertNotContains(response, "vm-alpha", status_code=403) + self.assertNotIn("token", response.context) + + @override_settings(SERIAL_CONSOLE_ENABLED=False) + @patch("console.views.wvmInstance") + def test_a_console_that_cannot_open_gets_no_console_client(self, mock_wvm): + # the page says why; it neither connects to novncd nor offers keys + conn = mock_wvm.return_value + conn.instance.isActive.return_value = 1 + self.client.force_login(self.admin_user) + for console_type, running in (("vnc", 0), ("pty", 1), ("rdp", 1), (None, 1)): + with self.subTest(console_type=console_type, running=running): + conn.get_console_type.return_value = console_type + conn.instance.isActive.return_value = running + response = self.client.get(self.url) + self.assertTemplateUsed(response, "console-error.html") + self.assertNotIn("token", response.context) + page = html.fromstring(response.content) + self.assertEqual(page.xpath("//script[@type='module'] | //*[@id='dropdownMenuLink']"), []) + self.assertEqual(len(page.xpath("//div[@role='alert']")), 1) + self.assertEqual(page.findtext(".//title"), "vm-alpha - Console") @patch("console.views.wvmInstance") def test_console_lite_uses_the_signed_token(self, mock_wvm): @@ -220,7 +243,6 @@ class ConsoleViewsTestCase(TestCase): signed = response.context["token"] self.assertEqual(TimestampSigner(salt="console.novnc").unsign(signed), f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}") - self.assertNotContains(response, "readQueryVariable('token'") self.assertContains(response, f"const token = '{escapejs(signed)}'") @patch("console.views.wvmInstance") @@ -238,8 +260,8 @@ class ConsoleViewsTestCase(TestCase): self.client.force_login(self.alice) response = self.client.get(self.url + "?host=attacker.example&port=443&path=x") self.assertNotContains(response, "attacker.example") - for name in ("host", "port", "path"): - self.assertNotContains(response, f"readQueryVariable('{name}'") + # nor a password from it: a link would leave it in history and logs + self.assertNotContains(response, "readQueryVariable") self.assertContains(response, "const host = window.location.hostname;") @patch("console.views.wvmInstance") @@ -394,7 +416,7 @@ class ConsoleViewsTestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-lite.html") + self.assertTemplateUsed(response, "console-error.html") self.assertIsNotNone(response.context["console_error"]) def test_an_unknown_vm_gives_the_superuser_an_error_page(self): @@ -402,10 +424,19 @@ class ConsoleViewsTestCase(TestCase): url = reverse("console", args=[self.compute.id, "no-such-uuid"]) response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "console-vnc-lite.html") + self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "console-error.html") self.assertIsNotNone(response.context["console_error"]) + @override_settings(SERIAL_CONSOLE_ENABLED=True) + @patch("console.views.wvmInstance") + def test_the_serial_console_offers_only_the_keys_a_terminal_takes(self, mock_wvm): + mock_wvm.return_value.get_console_type.return_value = "pty" + self.client.force_login(self.admin_user) + page = html.fromstring(self.client.get(self.url).content) + keys = page.xpath("//*[@aria-labelledby='dropdownMenuLink']//a/@id") + self.assertEqual(sorted(keys), ["ctrlbackslash", "ctrlc", "ctrld", "ctrlz", "sendesc", "sendtab"]) + @patch("console.views.wvmInstance") def test_console_send_keys_menu_elements(self, mock_wvm): mock_conn = MagicMock() diff --git a/console/views.py b/console/views.py index 007bf093..a3f7a223 100644 --- a/console/views.py +++ b/console/views.py @@ -3,7 +3,6 @@ from appsettings.settings import app_settings from console import tokens from django.conf import settings -from django.http.response import HttpResponseForbidden from django.shortcuts import render from django.utils.translation import gettext_lazy as _ from instances.models import Instance @@ -22,30 +21,22 @@ def to_bool(val, default=False): def console(request, compute_id, uuid): """The console page of the VM with this UUID on this compute. The page's - query gives the view (lite or full) and its display options.""" - console_error = None - token = "" + query gives the view (lite or full) and its display options. A console + that cannot open gets a page that says why, and no console client.""" view_type = "full" if request.GET.get("view", "").strip().lower() == "full" else "lite" view_only = to_bool(request.GET.get("view_only"), default=to_bool(app_settings.CONSOLE_VIEW_ONLY)) scale = to_bool(request.GET.get("scale"), default=to_bool(app_settings.CONSOLE_SCALE)) resize_session = to_bool(request.GET.get("resize_session"), default=to_bool(app_settings.CONSOLE_RESIZE_SESSION)) clip_viewport = to_bool(request.GET.get("clip_viewport"), default=to_bool(app_settings.CONSOLE_CLIP_VIEWPORT)) + instance = Instance.objects.filter(compute_id=compute_id, uuid=uuid).first() + # Same answer for "no such VM" and "not allowed", so the response + # does not reveal whether a VM exists. + if instance is None or not can_open_console(request.user, instance): + console_error = _("User does not have permission to access console or host/instance not exist") + return render(request, "console-error.html", {"console_error": console_error}, status=403) + try: - instance = Instance.objects.filter(compute_id=compute_id, uuid=uuid).first() - if instance is None and request.user.is_superuser: - raise Instance.DoesNotExist - # Same answer for "no such VM" and "not allowed", so the response - # does not reveal whether a VM exists. - if instance is None or not can_open_console(request.user, instance): - console_error = _( - "User does not have permission to access console or host/instance not exist" - ) - return HttpResponseForbidden(console_error) - - # Generate secure signed token for noVNC daemon - token = tokens.SIGNER.sign(f"{compute_id}:{uuid}:{request.user.id}") - conn = wvmInstance( instance.compute.hostname, instance.compute.login, @@ -62,40 +53,36 @@ def console(request, compute_id, uuid): unreachable = instance.compute.type in (CONN_TCP, CONN_TLS) and bool( conn.get_console_socket() or is_loopback(conn.get_console_listener_addr()) ) - except (libvirtError, ValueError, IndexError, Instance.DoesNotExist): + except (libvirtError, ValueError, IndexError): running = unreachable = False console_type = None console_passwd = None - if console_type and not running: # a shut-off VM has no console to connect to + if console_type is None: + console_error = _("Fail to get console. Please check the console configuration of your VM.") + elif 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()) elif console_type == "pty" and not settings.SERIAL_CONSOLE_ENABLED: console_error = _( "Serial console is disabled on this server. " "Please switch VM graphics to VNC or ask an administrator." ) - response = render(request, "console-vnc-lite.html", locals()) - elif console_type == "pty": - response = render(request, "console-xterm.html", locals()) + elif console_type not in ("vnc", "pty"): + console_error = _( + "Console type '%(type)s' is not supported. Please switch the VM graphics to VNC." + ) % {"type": console_type} else: - if console_type is None: - console_error = _( - "Fail to get console. Please check the console configuration of your VM." - ) - else: - console_error = _( - "Console type '%(type)s' is not supported. Please switch the VM graphics to VNC." - ) % {"type": console_type} - response = render(request, "console-vnc-lite.html", locals()) - return response + console_error = None + if console_error: + return render(request, "console-error.html", {"instance": instance, "console_error": console_error}) + + # the console daemons' credential for this page + token = tokens.SIGNER.sign(f"{compute_id}:{uuid}:{request.user.id}") + console_page = "console-xterm.html" if console_type == "pty" else "console-vnc-" + view_type + ".html" + return render(request, console_page, locals())