From 2b5419896584212e4d54640893bcca393cefa4d3 Mon Sep 17 00:00:00 2001 From: catborise Date: Tue, 27 Oct 2026 00:31:36 +0300 Subject: [PATCH] feat(console): the serial console page on the panel's bar; a taken-over user learns by whom The serial page had a white strip of inline styles with an untranslated status and a browser-default button above the terminal, sent input while disconnected, sized the terminal only on window load and resize, and left the keyboard elsewhere after the key menu. When a superuser opened a console open in another window, its user saw "[An administrator opened this console]" and a plain "disconnected". - console-xterm.html shares the VNC page's bar: status badge (Connecting, Connected, Disconnected, the server's refusal), Disconnect, then Reconnect (a new page for a new token). The script is static/js/console_serial.js; the token reaches it as json_script data. - The terminal takes the keyboard on connect and after the key menu; input is sent only while connected; it fits its box on connect and whenever the box changes size (ResizeObserver). - A console that writes nothing yet gets a dim local hint: press Enter. - socketiod sends the displaced client "taken_over" with the superuser's username; the page shows "Taken over by " in the bar and the terminal. The superuser is not asked first (owner's decision). - Fullscreen uses the shared console_keys.js handler (keyboard lock). --- console/socketiod | 7 +- console/templates/console-xterm.html | 108 +++++--------------------- console/tests/test_live_serial.py | 4 +- console/tests/test_socketiod.py | 8 +- console/tests/test_socketiod_child.py | 3 +- console/tests/test_views.py | 4 + static/js/console_serial.js | 89 +++++++++++++++++++++ 7 files changed, 125 insertions(+), 98 deletions(-) create mode 100644 static/js/console_serial.js diff --git a/console/socketiod b/console/socketiod index 1529f5e9..bf37e009 100755 --- a/console/socketiod +++ b/console/socketiod @@ -75,6 +75,9 @@ def connect(sid, environ, auth): raise refused("Too many consoles are open on this server: try again later") if open_sid is not None: # a superuser takes over a console left open taken = open_sid, sessions.pop(open_sid) + # the page tells its user who took the console; first, as its + # forward ends the connection as soon as the worker is gone + sio.emit("taken_over", {"by": user.username}, to=open_sid) # gone before the new worker starts: libvirt gives a console to one at a time end(taken[1], wait=True) pid, fd = pty.fork() @@ -96,9 +99,7 @@ def connect(sid, environ, auth): logger.info("serial console of VM %s opened by %s%s", instance.name, user.username, " (taken over)" if taken else "") if taken: - old_sid, _ = taken - sio.emit("pty_output", {"output": "\r\n[An administrator opened this console]\r\n"}, to=old_sid) - sio.disconnect(old_sid) + sio.disconnect(taken[0]) sio.start_background_task(forward, sid, session) diff --git a/console/templates/console-xterm.html b/console/templates/console-xterm.html index ec3047c8..13476f5e 100644 --- a/console/templates/console-xterm.html +++ b/console/templates/console-xterm.html @@ -6,100 +6,28 @@ - - +{{ token|json_script:"serial-token" }} + {% endblock %} + +{% block navbarstatus %} +{% trans "Connecting" %} +{% endblock %} + {% block navbarmenu %}{% include "console-menu.html" with serial=True %}{% endblock %} -{% block content %} -
- Status: connecting... - -
- -
- +{% block navbarend %} + +{% endblock %} + +{% block content %} +
{% endblock %} diff --git a/console/tests/test_live_serial.py b/console/tests/test_live_serial.py index 926da97b..f2fff5b3 100644 --- a/console/tests/test_live_serial.py +++ b/console/tests/test_live_serial.py @@ -83,8 +83,8 @@ class LiveSerialConsoleTestCase(TransactionTestCase): with self.assertRaises(socketio.exceptions.ConnectionError): console(self.port, token) admin_client, admin_received = console(self.port, self.admin_token(token)) - wait_for(lambda: "An administrator opened this console" in "".join(received["output"])) - self.assertIn("An administrator opened this console", "".join(received["output"])) + wait_for(lambda: received["taken_over"]) + self.assertEqual(received["taken_over"], ["serial-live-admin"]) client.disconnect() # the VM stops: the terminal says so dom.destroy() diff --git a/console/tests/test_socketiod.py b/console/tests/test_socketiod.py index f6665812..7e593b36 100644 --- a/console/tests/test_socketiod.py +++ b/console/tests/test_socketiod.py @@ -154,13 +154,17 @@ class SocketiodTestCase(Base): admin = get_user_model().objects.create_superuser("root", "root@example.com", "pw") self.d.connect("owner-tab", {}, {"token": self.token()}) old = self.d.sessions["owner-tab"] - with patch.object(self.d.sio, "emit") as emit, patch.object(self.d.sio, "disconnect") as disconnect: + killed_at_emit = [] + with patch.object(self.d.sio, "emit", side_effect=lambda *a, **k: killed_at_emit.append(self.d.os.kill.call_count)) as emit, \ + patch.object(self.d.sio, "disconnect") as disconnect: self.assertIsNone(self.d.connect("admin-tab", {}, {"token": self.token(admin)})) self.assertEqual(list(self.d.sessions), ["admin-tab"]) self.assertFalse(old["open"]) # the old worker was killed before the new one started: libvirt gives the console to one self.assertEqual(self.killed_before_fork, [0, 1]) - self.assertIn("An administrator opened this console", emit.call_args.args[1]["output"]) + emit.assert_called_once_with("taken_over", {"by": "root"}, to="owner-tab") + # told before its worker was killed: the old page's forward disconnects it then + self.assertEqual(killed_at_emit, [0]) disconnect.assert_called_once_with("owner-tab") @override_settings(SERIAL_CONSOLE_ENABLED=True) diff --git a/console/tests/test_socketiod_child.py b/console/tests/test_socketiod_child.py index 148c4bb4..752caf3c 100644 --- a/console/tests/test_socketiod_child.py +++ b/console/tests/test_socketiod_child.py @@ -87,8 +87,9 @@ def start_socketiod(test): def console(port, token): """A connected client and what it receives until the server disconnects it.""" client = socketio.Client(reconnection=False) - received = {"output": [], "gone": threading.Event()} + received = {"output": [], "taken_over": [], "gone": threading.Event()} client.on("pty_output", lambda data: received["output"].append(data["output"])) + client.on("taken_over", lambda data: received["taken_over"].append(data["by"])) client.on("disconnect", lambda *args: received["gone"].set()) # the same origin as the server, as the page through nginx url = "http://127.0.0.1:%d" % port diff --git a/console/tests/test_views.py b/console/tests/test_views.py index be51a733..118ff17a 100644 --- a/console/tests/test_views.py +++ b/console/tests/test_views.py @@ -126,6 +126,10 @@ class ConsoleViewsTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "console-xterm.html") + # the page's credential for socketiod, as data + signed = json.loads(html.fromstring(response.content).get_element_by_id("serial-token").text) + self.assertEqual(TimestampSigner(salt="console.novnc").unsign(signed), + f"{self.compute.id}:{self.instance.uuid}:{self.admin_user.id}") @override_settings(SERIAL_CONSOLE_ENABLED=False) @patch("console.views.wvmInstance") diff --git a/static/js/console_serial.js b/static/js/console_serial.js new file mode 100644 index 00000000..4a0e34bc --- /dev/null +++ b/static/js/console_serial.js @@ -0,0 +1,89 @@ +// The serial console page: xterm.js on socketiod (Socket.IO behind nginx's +// /socket.io/), driven from the panel's bar: status, keys, reconnect. +import { bindFullscreen, bindKeyAction } from './console_keys.js'; + +const status = document.getElementById('status'); +const connectionButton = document.getElementById('connection_button'); + +// the page's signed token is the credential, good for two minutes: a new +// connection takes a new page (Reconnect), not an automatic retry +const token = JSON.parse(document.getElementById('serial-token').textContent); +const socket = io({ transports: ['websocket', 'polling'], auth: { token }, reconnection: false }); + +const term = new Terminal({ cursorBlink: true }); +const fitAddon = new FitAddon.FitAddon(); +term.loadAddon(fitAddon); +term.open(document.getElementById('terminal')); + +function send(input) { + if (socket.connected) { + socket.emit('pty_input', { input }); + } +} + +function resize() { + fitAddon.fit(); + if (socket.connected) { + socket.emit('resize', { cols: term.cols, rows: term.rows }); + } +} + +function showStatus(text, color) { + status.textContent = text; + status.className = 'badge text-bg-' + color; + connectionButton.textContent = socket.connected ? connectionButton.dataset.disconnect : connectionButton.dataset.reconnect; +} + +let output = false; +let takenBy = null; + +// typed and pasted input +term.onData(send); +socket.on('pty_output', (data) => { + output = true; + term.write(data.output); +}); +socket.on('connect', () => { + showStatus(status.dataset.connected, 'success'); + resize(); + term.focus(); + // a serial console shows nothing until the VM writes + setTimeout(() => { + if (!output && socket.connected) { + term.write('\x1b[2m' + status.dataset.hint + '\x1b[0m\r\n'); + } + }, 1500); +}); +// a superuser opened this console: the server ends this session next +socket.on('taken_over', (data) => { + takenBy = data.by; +}); +socket.on('disconnect', () => { + if (takenBy) { + const text = status.dataset.takenOver.replace('%(name)s', takenBy); + term.write('\r\n[' + text + ']\r\n'); + showStatus(text, 'danger'); + } else { + showStatus(status.dataset.disconnected, 'danger'); + } +}); +// the server says why: serial console off, an expired link, the console open elsewhere +socket.on('connect_error', (err) => showStatus(err.message, 'danger')); + +connectionButton.addEventListener('click', () => { + if (socket.connected) { + socket.disconnect(); + } else { + window.location.reload(); + } +}); + +const keys = { ctrlc: '\x03', ctrld: '\x04', ctrlz: '\x1a', ctrlbackslash: '\x1c', sendtab: '\t', sendesc: '\x1b' }; +for (const [id, input] of Object.entries(keys)) { + bindKeyAction(id, () => send(input)); +} +bindFullscreen(); + +// back to the terminal after the menu +document.addEventListener('hidden.bs.dropdown', () => term.focus()); +new ResizeObserver(resize).observe(document.getElementById('terminal'));