1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/console/socketiod
catborise 2b54198965 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).
2026-10-27 08:12:33 +03:00

231 lines
8.8 KiB
Python
Executable file

#!/usr/bin/env python3
# The serial console daemon. The page connects with its signed console token
# (Socket.IO auth); each accepted connection gets its own console worker
# (conf/daemon/consolecallback) in a pty, which opens the VM's console with the
# compute's credentials. This process never opens libvirt. Connections are
# refused while SERIAL_CONSOLE_ENABLED is off.
import argparse
import codecs
import fcntl
import logging
import os
import pty
import select
import signal
import struct
import sys
import termios
import threading
import tty
import django
ROOT_PATH = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
if ROOT_PATH not in sys.path:
sys.path.append(ROOT_PATH)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webvirtcloud.settings")
django.setup()
import socketio # noqa: E402
from gunicorn.app.base import BaseApplication # noqa: E402
from django import db # noqa: E402
from django.conf import settings # noqa: E402
from console.tokens import authorize # noqa: E402
CONSOLECALLBACK = os.path.join(ROOT_PATH, "conf", "daemon", "consolecallback")
LOG_FD = os.dup(2) # this daemon's stderr: a worker logs there on its fd 3
MAX_SESSIONS = 20
logger = logging.getLogger("socketiod")
sio = socketio.Server(async_mode="threading") # the same origin only
# Handlers run on their own threads. sessions (sid -> {"pid", "fd", "vm",
# "lock", "open"}) changes only under sessions_lock; a session's fd is used
# and closed only under its own lock, while it is open, so a closed fd's
# number, reused by a new session, never reaches another console. The fd
# is non-blocking: no one waits on it while holding the lock (a paste and
# the guest's echo of it would wait on each other).
sessions = {}
sessions_lock = threading.Lock()
def refused(reason):
"""The refusal the page shows; it is logged too."""
logger.info("serial console refused: %s", reason)
return socketio.exceptions.ConnectionRefusedError(reason)
@sio.event
def connect(sid, environ, auth):
# the page shows the reason
if not settings.SERIAL_CONSOLE_ENABLED:
raise refused("The serial console is turned off on this server")
try:
instance, user = authorize((auth or {}).get("token"))
except PermissionError:
raise refused("The console link has expired, or you may no longer open this console: reload the page")
finally:
db.close_old_connections()
taken = None
with sessions_lock:
open_sid = next((s for s, session in sessions.items() if session["vm"] == instance.pk), None)
if open_sid is not None and not user.is_superuser:
raise refused("The console of this VM is open in another window")
if open_sid is None and len(sessions) >= MAX_SESSIONS:
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()
if pid == 0:
try:
# pty.fork's master is inheritable: without this each worker would
# hold the other sessions' consoles open
os.dup2(LOG_FD, 3)
os.closerange(4, os.sysconf("SC_OPEN_MAX"))
os.environ["CONSOLECALLBACK_LOG_FD"] = "3"
os.execv(sys.executable, [sys.executable, CONSOLECALLBACK, str(instance.pk), str(user.pk)])
finally:
os._exit(1)
os.set_blocking(fd, False)
# raw at once, not when the worker gets to it: until then the terminal
# keeps at most one 4 KiB line of a paste
tty.setraw(fd)
sessions[sid] = session = {"pid": pid, "fd": fd, "vm": instance.pk, "lock": threading.Lock(), "open": True}
logger.info("serial console of VM %s opened by %s%s", instance.name, user.username,
" (taken over)" if taken else "")
if taken:
sio.disconnect(taken[0])
sio.start_background_task(forward, sid, session)
def forward(sid, session):
"""The worker's output to its client, until the worker ends."""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") # a character may be split
while session["open"]:
try:
# a fd closed meanwhile may wake this up: the session is closed then
ready = select.select([session["fd"]], [], [], 0.1)[0]
except (OSError, ValueError):
ready = True
if not ready:
continue
with session["lock"]:
if not session["open"]:
return
try:
output = os.read(session["fd"], 20480)
except BlockingIOError: # nothing after all
continue
except OSError: # EIO: the worker ended
output = b""
if not output:
sio.emit("pty_output", {"output": decoder.decode(b"", final=True) + "\r\n[console closed]\r\n"}, to=sid)
stop(sid)
sio.disconnect(sid)
return
sio.emit("pty_output", {"output": decoder.decode(output)}, to=sid)
def stop(sid):
"""End the sid's worker; once, whoever calls it first."""
with sessions_lock:
session = sessions.pop(sid, None)
if session is not None:
end(session)
def end(session, wait=False):
"""End a session taken out of sessions; with wait, once its worker is gone."""
try:
os.kill(session["pid"], signal.SIGKILL) # first: a write blocked on its terminal ends
except ProcessLookupError:
pass
with session["lock"]:
session["open"] = False
try:
os.close(session["fd"])
except OSError:
pass
if wait:
try:
os.waitpid(session["pid"], 0)
except ChildProcessError: # reaped already
pass
else:
sio.start_background_task(os.waitpid, session["pid"], 0)
@sio.event
def pty_input(sid, message):
session = sessions.get(sid)
if session and isinstance(message, dict) and isinstance(message.get("input"), str):
data = memoryview(message["input"].encode(errors="replace")) # a lone surrogate from the browser
while data:
with session["lock"]:
if not session["open"]:
return
try:
data = data[os.write(session["fd"], data):]
continue
except BlockingIOError: # the worker has not read the rest yet
pass
except OSError:
return
try:
select.select([], [session["fd"]], [], 0.1) # outside the lock: output goes on meanwhile
except (OSError, ValueError): # closed meanwhile: the loop sees it
pass
@sio.event
def resize(sid, message):
session = sessions.get(sid)
if not (session and isinstance(message, dict)):
return
rows, cols = message.get("rows"), message.get("cols")
if all(isinstance(v, int) and 1 <= v <= 500 for v in (rows, cols)):
with session["lock"]:
if session["open"]:
try:
fcntl.ioctl(session["fd"], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
except OSError: # the worker has just ended
pass
@sio.event
def disconnect(sid, reason=None):
stop(sid)
class Server(BaseApplication):
"""gunicorn with one worker (the sessions live in it); a thread per connection."""
def __init__(self, host, port):
self.bind = "%s:%d" % (host, port)
super().__init__()
def load_config(self):
self.cfg.set("bind", self.bind)
self.cfg.set("worker_class", "gthread")
self.cfg.set("threads", 2 * MAX_SESSIONS)
self.cfg.set("graceful_timeout", 1) # open consoles never finish; their workers end with the pty
self.cfg.set("control_socket_disable", True)
def load(self):
return socketio.WSGIApp(sio)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="WebVirtCloud serial console daemon")
parser.add_argument("-H", "--host", default=settings.SOCKETIO_HOST, help="Listen host")
parser.add_argument("-p", "--port", type=int, default=settings.SOCKETIO_PORT, help="Listen port")
options = parser.parse_args()
# to stderr (the journal, docker logs), not the panel's own log file:
# each process rotating that file would lose lines
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s", force=True)
Server(options.host, options.port).run()