From 0f9bbd3e8621144a47e01a180cabb3f3d9891164 Mon Sep 17 00:00:00 2001 From: catborise Date: Sat, 17 Oct 2026 18:43:39 +0300 Subject: [PATCH] feat(instances): choose VNC or the serial console on the VM page Settings > Console gets a Console type field. Serial removes the VM's graphics device (adding a pty console when there is none); VNC adds the graphics device new VMs get, on localhost, and then applies the form's VNC fields, so an administrator can open it to all interfaces for a TCP compute. Serial is offered only while SERIAL_CONSOLE_ENABLED is on. --- README.md | 1 + instances/forms.py | 6 ++++++ instances/tests/test_live_xml_effects.py | 10 ++++++++++ instances/tests/test_update_console.py | 17 ++++++++++++++++- instances/views.py | 9 +++++++-- vrtManager/instance.py | 13 +++++++++++++ 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7bc4e273..3c6c1ccb 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,7 @@ Set `OTP_ENABLED = True` in `settings.py` to ask for a one-time code at login. I A VM without graphics (no `` device, a ``) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set `WEBVIRTCLOUD_SERIAL_CONSOLE=1` (Docker: in `docker-compose.override.yml`, then `docker compose -p webvirtcloud up -d`) or `SERIAL_CONSOLE_ENABLED = True` in `settings.py` and restart socketiod. +- The VM page's Settings → Console switches a shut-off VM between VNC and serial (Console type). A new VNC console takes the form's other fields; on a TCP or TLS compute it needs Listen on: All interfaces (only SSH computes reach a localhost console). - The guest must use its serial port: a login prompt needs `systemctl enable --now serial-getty@ttyS0`, boot messages need the kernel parameter `console=ttyS0,115200`. The console shows only what the guest writes after it opens; press Enter for a new prompt. - socketiod listens on 127.0.0.1:6081; the page reaches it through nginx's `/socket.io/` location on the panel's own address. - It accepts the console page's signed token, as noVNC does: whoever gets that token within 120 s gets the console, and an open console stays open after its user loses access to the VM. diff --git a/instances/forms.py b/instances/forms.py index fa1e37be..5b0ddd1b 100644 --- a/instances/forms.py +++ b/instances/forms.py @@ -1,6 +1,7 @@ import re from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from webvirtcloud.settings import QEMU_CONSOLE_LISTENER_ADDRESSES, QEMU_KEYMAPS @@ -14,6 +15,7 @@ class FlavorForm(forms.ModelForm): class ConsoleForm(forms.Form): + console_type = forms.ChoiceField(label=_("Console type"), required=False) listen_on = forms.ChoiceField(label=_("Listen on")) generate_password = forms.BooleanField(label=_("Generate password"), required=False) clear_password = forms.BooleanField(label=_("Clear password"), required=False) @@ -27,6 +29,10 @@ class ConsoleForm(forms.Form): def __init__(self, *args, can_set_listener=True, **kwargs): super(ConsoleForm, self).__init__(*args, **kwargs) + types = [("vnc", "VNC")] + if settings.SERIAL_CONSOLE_ENABLED: + types.append(("pty", _("Serial (xterm; the guest needs a getty on ttyS0)"))) + self.fields["console_type"].choices = types keymap_choices = [("auto", _("Auto"))] + list((c, c) for c in QEMU_KEYMAPS) # Only administrators choose the listen address: beyond localhost the # console is reachable from the network. A disabled field ignores diff --git a/instances/tests/test_live_xml_effects.py b/instances/tests/test_live_xml_effects.py index 2e283195..834f95df 100644 --- a/instances/tests/test_live_xml_effects.py +++ b/instances/tests/test_live_xml_effects.py @@ -105,6 +105,16 @@ EDITS = { lambda t: first(t, "devices/graphics/listen/@address") == "127.0.0.1", ["devices/graphics/@listen", "devices/graphics/listen/@address"], True, False, ), + "set_console_type pty": ( + lambda vm: vm.set_console_type("pty"), + lambda t: not t.xpath("devices/graphics") and first(t, "devices/console/@type") == "pty", + ["devices/graphics", "devices/console"], True, False, + ), + "set_console_type vnc": ( + lambda vm: vm.set_console_type("vnc"), + lambda t: t.xpath("devices/graphics/@type") == ["vnc"] and first(t, "devices/graphics/@listen") == "127.0.0.1", + ["devices/graphics"], True, False, + ), "set_console_passwd": ( lambda vm: vm.set_console_passwd("new12345"), lambda t: first(t, "devices/graphics/@passwd") == "new12345", diff --git a/instances/tests/test_update_console.py b/instances/tests/test_update_console.py index c36de08d..9e626d7b 100644 --- a/instances/tests/test_update_console.py +++ b/instances/tests/test_update_console.py @@ -2,7 +2,7 @@ from unittest.mock import patch from computes.models import Compute from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from instances.models import Instance @@ -25,3 +25,18 @@ class UpdateConsoleTestCase(TestCase): proxy = self.post({"listen_on": "127.0.0.1", "password": "", "keymap": "de"}) proxy.set_console_keymap.assert_called_once_with("de") proxy.set_console_listener_addr.assert_called_once_with("127.0.0.1") + + @override_settings(SERIAL_CONSOLE_ENABLED=True) + def test_a_new_console_type_and_its_vnc_settings(self): + data = {"listen_on": "0.0.0.0", "keymap": "de"} + for old, new, vnc_settings in (("vnc", "pty", False), ("pty", "vnc", True)): + with self.subTest(new), patch("instances.models.wvmInstance") as wvm: + wvm.return_value.get_console_type.return_value = old + self.client.post(reverse("instances:update_console", args=[self.instance.id]), dict(data, console_type=new)) + wvm.return_value.set_console_type.assert_called_once_with(new) + self.assertEqual(wvm.return_value.set_console_listener_addr.called, vnc_settings) + + @override_settings(SERIAL_CONSOLE_ENABLED=False) + def test_serial_is_not_offered_while_the_serial_console_is_off(self): + proxy = self.post({"console_type": "pty", "listen_on": "127.0.0.1", "keymap": "de"}) + proxy.set_console_type.assert_not_called() diff --git a/instances/views.py b/instances/views.py index 6c9bbe18..2e89c5cb 100755 --- a/instances/views.py +++ b/instances/views.py @@ -122,6 +122,7 @@ def _instance_page(request, pk, instance, compute): if can_manage_console: console_form = ConsoleForm( initial={ + "console_type": instance.console_type, "listen_on": instance.config_console_listener_address, "password": instance.config_console_passwd, "keymap": instance.config_console_keymap, @@ -1806,7 +1807,11 @@ def update_console(request, pk): if not utils.can_manage_console(request.user, instance): raise PermissionDenied form = ConsoleForm(request.POST or None, can_set_listener=request.user.is_superuser) - if form.is_valid(): + if form.is_valid() and form.cleaned_data["console_type"] not in ("", instance.console_type): + instance.proxy.set_console_type(form.cleaned_data["console_type"]) + addlogmsg(request.user.username, instance.compute.name, instance.name, _("Set console type")) + # the other fields are VNC settings; a new VNC console gets them too + if form.is_valid() and form.cleaned_data["console_type"] != "pty": if ( "generate_password" in form.changed_data or "clear_password" in form.changed_data @@ -1860,7 +1865,7 @@ def update_console(request, pk): addlogmsg( request.user.username, instance.compute.name, instance.name, msg ) - else: + if not form.is_valid(): messages.error(request, _("Console settings were not saved: %(errors)s") % {"errors": form.errors.as_text()}) return get_safe_redirect( diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 2ed7ee38..59183cc2 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -1341,6 +1341,19 @@ class wvmInstance(wvmConnect): newxml = etree.tostring(root).decode() return self._defineXML(newxml) + def set_console_type(self, console_type): + """VNC: a VNC graphics device on localhost; "pty": no graphics, so the + serial console opens (libvirt adds the serial port for the console).""" + root = etree.fromstring(self._XMLDesc(PERSISTENT_XML)) + devices = root.find("devices") + for graphic in devices.findall("graphics"): + devices.remove(graphic) + if console_type == "vnc": + etree.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1") + elif devices.find("console") is None: + etree.SubElement(devices, "console", type="pty") + return self._defineXML(etree.tostring(root).decode()) + def get_console_socket(self): socket = util.get_xml_path(self._XMLDesc(0), "/domain/devices/graphics/@socket") return socket