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

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 <name>" 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).
This commit is contained in:
catborise 2026-10-27 00:31:36 +03:00
parent 0532471759
commit 2b54198965
7 changed files with 125 additions and 98 deletions

View file

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

View file

@ -6,100 +6,28 @@
<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>
<script src="{% static 'js/socket.io.js' %}"></script>
<style>
#terminal { flex: 1; min-height: 0; }
#terminal { flex: 1 1 auto; min-height: 0; background: #000; }
</style>
{{ token|json_script:"serial-token" }}
<script type="module" src="{% static "js/console_serial.js" %}"></script>
{% endblock %}
{% block navbarstatus %}
<span id="status" class="badge text-bg-secondary" role="status"
data-connected="{% trans "Connected" %}" data-disconnected="{% trans "Disconnected" %}"
data-taken-over="{% trans "Taken over by %(name)s" %}"
data-hint="{% trans "Connected. If the VM shows nothing, press Enter." %}">{% trans "Connecting" %}</span>
{% 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>
<button id="button" type="button" onclick="myFunction();">Connect</button>
</div>
<div id="terminal"></div>
<script>
// socketiod is behind this page's own /socket.io/ location; the page's
// signed token is the credential. It lasts two minutes: a new
// connection takes a new page (Reconnect), not an automatic retry.
var socket = io({transports: ["websocket", "polling"], auth: {token: "{{ token|escapejs }}"}, reconnection: false});
const status = document.getElementById("status");
const button = document.getElementById("button");
var term = new Terminal({
cursorBlink: true,
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
// typed and pasted input
term.onData((data) => {
socket.emit("pty_input", {"input": data})
});
socket.on("pty_output", function(output){
term.write(output["output"])
})
function showStatus(text, color) {
status.textContent = text;
status.style.backgroundColor = color;
button.textContent = socket.connected ? "{% trans 'Disconnect'|escapejs %}" : "{% trans 'Reconnect'|escapejs %}";
}
socket.on("connect", () => showStatus("{% trans 'connected'|escapejs %}", "lightgreen"));
socket.on("disconnect", () => showStatus("{% trans 'disconnected'|escapejs %}", "#ff8383"));
// the server says why: serial console off, an expired link, the console open elsewhere
socket.on("connect_error", (err) => showStatus(err.message, "#ff8383"));
function myFunction(){
if (socket.connected) {
socket.disconnect();
} else {
location.reload();
}
}
function resize(){
fitAddon.fit()
socket.emit("resize", {"cols": term.cols, "rows": term.rows})
}
function bindTerminalKey(id, str) {
const el = document.getElementById(id);
if (el) {
el.addEventListener('click', (e) => {
e.preventDefault();
socket.emit("pty_input", {"input": str});
});
}
}
bindTerminalKey('ctrlc', '\x03');
bindTerminalKey('ctrld', '\x04');
bindTerminalKey('ctrlz', '\x1a');
bindTerminalKey('ctrlbackslash', '\x1c');
bindTerminalKey('sendtab', '\t');
bindTerminalKey('sendesc', '\x1b');
const fsBtn = document.getElementById('fullscreen_button');
if (fsBtn) {
fsBtn.addEventListener('click', (e) => {
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
document.documentElement.requestFullscreen().catch(() => {});
}
});
}
window.onresize = resize;
window.onload = resize;
</script>
{% block navbarend %}
<button type="button" class="btn btn-sm btn-light" id="connection_button"
data-disconnect="{% trans "Disconnect" %}" data-reconnect="{% trans "Reconnect" %}">{% trans "Disconnect" %}</button>
{% endblock %}
{% block content %}
<div id="terminal"></div>
{% endblock %}

View file

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

View file

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

View file

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

View file

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

View file

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