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'));