mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
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.
This commit is contained in:
parent
d37aa42392
commit
0f9bbd3e86
6 changed files with 53 additions and 3 deletions
|
|
@ -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 `<graphics>` device, a `<console type='pty'>`) 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue