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:
parent
0532471759
commit
2b54198965
7 changed files with 125 additions and 98 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
89
static/js/console_serial.js
Normal file
89
static/js/console_serial.js
Normal 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'));
|
||||
Loading…
Add table
Add a link
Reference in a new issue