1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

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 "<VM> - 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).
This commit is contained in:
catborise 2026-10-27 00:00:42 +03:00
parent 976955fff8
commit 3a560e9f85
8 changed files with 139 additions and 133 deletions

View file

@ -7,6 +7,7 @@
<meta charset="utf-8">
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if instance %}{{ instance.name }} - {% endif %}{% trans "Console" %}</title>
{# error pages render without app_settings: they get zephyr #}
{% with theme=app_settings.BOOTSTRAP_THEME|default:"zephyr" %}<link rel="stylesheet" href="{% static "css/themes/"|add:theme|add:".min.css" %}" id="wvc_css">{% endwith %}
<link href="{% static "css/webvirtcloud.css" %}" rel="stylesheet">
@ -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 @@
</head>
<body>
<div id='main_container' class="container">
<div id="main_container">
<nav class="navbar sticky-top navbar-expand-md navbar-dark bg-primary" aria-label="console navbar">
<div class="container">
<div class="container-fluid">
<a class="navbar-brand">{{ instance.name }}</a>
{% block navbar %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -53,61 +53,12 @@
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto my-2 my-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button"
id="dropdownMenuLink" aria-haspopup="true" aria-expanded="false">
{% trans "Send key(s)" %}
</a>
<div class="dropdown-menu shadow" aria-labelledby="dropdownMenuLink" style="min-width: 250px;">
<h6 class="dropdown-header">{% trans "System" %}</h6>
<a class="dropdown-item" id="ctrlaltdel" href="#">Ctrl+Alt+Del</a>
<a class="dropdown-item" id="ctrlaltbackspace" href="#">Ctrl+Alt+Backspace</a>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Navigation & Desktop" %}</h6>
<a class="dropdown-item" id="alttab" href="#">Alt+Tab</a>
<a class="dropdown-item" id="superkey" href="#">Windows / Super Key</a>
<a class="dropdown-item" id="sendesc" href="#">Escape</a>
<a class="dropdown-item" id="sendtab" href="#">Tab</a>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Terminal Control" %}</h6>
<a class="dropdown-item" id="ctrlc" href="#">Ctrl+C</a>
<a class="dropdown-item" id="ctrlz" href="#">Ctrl+Z</a>
<a class="dropdown-item" id="ctrld" href="#">Ctrl+D</a>
<a class="dropdown-item" id="ctrlbackslash" href="#">Ctrl+\</a>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Virtual Consoles (Ctrl+Alt+F#)" %}</h6>
<div class="px-3 py-1">
<div class="row g-1 text-center">
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf1" href="#">F1</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf2" href="#">F2</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf3" href="#">F3</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf4" href="#">F4</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf5" href="#">F5</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf6" href="#">F6</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf7" href="#">F7</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf8" href="#">F8</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf9" href="#">F9</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf10" href="#">F10</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf11" href="#">F11</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf12" href="#">F12</a></div>
</div>
</div>
</div>
</li>
<li class="nav-item" id="fullscreen_button">
<a class="nav-link" href="#">{% trans "Fullscreen" %}</a>
</li>
{% block navbarmenu %}{% endblock %}
</ul>
</div>
{% endblock %}
</div>
</nav>
{% if console_error %}
<div class="alert alert-danger alert-dismissible rounded-0 m-0" role="alert">
{{ console_error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="{% trans "Close" %}"></button>
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
<script src="{% static "js/jquery.js" %}"></script>

View file

@ -0,0 +1,7 @@
{% extends "console-base.html" %}
{# a console that cannot open: why, and no console client #}
{% block navbar %}{% endblock %}
{% block content %}
<div class="alert alert-danger rounded-0 m-0" role="alert">{{ console_error }}</div>
{% endblock %}

View file

@ -0,0 +1,53 @@
{% load i18n %}
{# the console pages' Send key(s) menu and Fullscreen; serial: only the keys a terminal takes #}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button"
id="dropdownMenuLink" aria-haspopup="true" aria-expanded="false">
{% trans "Send key(s)" %}
</a>
<div class="dropdown-menu shadow" aria-labelledby="dropdownMenuLink" style="min-width: 250px;">
{% if not serial %}
<h6 class="dropdown-header">{% trans "System" %}</h6>
<a class="dropdown-item" id="ctrlaltdel" href="#">Ctrl+Alt+Del</a>
<a class="dropdown-item" id="ctrlaltbackspace" href="#">Ctrl+Alt+Backspace</a>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Navigation & Desktop" %}</h6>
<a class="dropdown-item" id="alttab" href="#">Alt+Tab</a>
<a class="dropdown-item" id="superkey" href="#">Windows / Super Key</a>
<a class="dropdown-item" id="sendesc" href="#">Escape</a>
<a class="dropdown-item" id="sendtab" href="#">Tab</a>
<div class="dropdown-divider"></div>
{% endif %}
<h6 class="dropdown-header">{% trans "Terminal Control" %}</h6>
<a class="dropdown-item" id="ctrlc" href="#">Ctrl+C</a>
<a class="dropdown-item" id="ctrlz" href="#">Ctrl+Z</a>
<a class="dropdown-item" id="ctrld" href="#">Ctrl+D</a>
<a class="dropdown-item" id="ctrlbackslash" href="#">Ctrl+\</a>
{% if serial %}
<a class="dropdown-item" id="sendesc" href="#">Escape</a>
<a class="dropdown-item" id="sendtab" href="#">Tab</a>
{% else %}
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">{% trans "Virtual Consoles (Ctrl+Alt+F#)" %}</h6>
<div class="px-3 py-1">
<div class="row g-1 text-center">
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf1" href="#">F1</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf2" href="#">F2</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf3" href="#">F3</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf4" href="#">F4</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf5" href="#">F5</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf6" href="#">F6</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf7" href="#">F7</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf8" href="#">F8</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf9" href="#">F9</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf10" href="#">F10</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf11" href="#">F11</a></div>
<div class="col-3"><a class="btn btn-sm btn-outline-secondary w-100 py-1" id="ctrlaltf12" href="#">F12</a></div>
</div>
</div>
{% endif %}
</div>
</li>
<li class="nav-item" id="fullscreen_button">
<a class="nav-link" href="#">{% trans "Fullscreen" %}</a>
</li>

View file

@ -13,7 +13,6 @@
or the fragment:
http://example.com/#host=HOST&port=PORT&encrypt=1
-->
<title>WebVirtCloud - noVNC</title>
<meta charset="utf-8" />
@ -96,6 +95,8 @@
<!-- end scripts -->
{% endblock %}
{% block navbarmenu %}{% include "console-menu.html" %}{% endblock %}
{% block content %}
<div id="noVNC_fallback_error" class="noVNC_center">
<div>

View file

@ -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)
-->
<title>WebVirtCloud - noVNC - Lite</title>
<meta charset="utf-8">
<!-- Always force latest IE rendering engine (even in intranet) &
Chrome Frame. Remove this if you use the .htaccess -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!-- Stylesheets -->
<link rel="stylesheet" href='{% static "js/novnc/app/styles/lite.css" %}'>
@ -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" %}
<li class="nav-item" id="scale_button">
<a class="nav-link{% if scale %} active{% endif %}" href="#" role="button" aria-pressed="{{ scale|yesno:"true,false" }}">{% trans "Scale" %}</a>
</li>

View file

@ -3,7 +3,6 @@
{% load static %}
{% block head %}
<title>WebVirtCloud - XTerm</title>
<link rel="stylesheet" href="{% static 'css/xterm.css' %}"/>
<script src="{% static 'js/xterm/xterm.js' %}"></script>
<script src="{% static 'js/xterm/addon-fit.js' %}"></script>
@ -14,6 +13,8 @@
</style>
{% endblock %}
{% block navbarmenu %}{% include "console-menu.html" with serial=True %}{% endblock %}
{% block content %}
<div style="background: white; padding-bottom: 5px;">
<span style="font-size: small;">Status: <span style="font-size: small;" id="status">connecting...</span></span>

View file

@ -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()

View file

@ -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())