1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/console/socketiod
catborise 060a3cc86b fix(console): a serial paste and its echo no longer wait on each other
- socketiod wrote input to the worker's terminal while holding the
  session's lock, and the write could wait for the worker; the output
  forwarder needs that lock. A large paste into a guest that echoes it
  could stop both for good. The terminal is now non-blocking: input is
  written as far as the worker takes it, and the wait for room is outside
  the lock.
- The terminal is raw from the start. Until the worker made it raw, it
  kept at most one 4 KiB line of a paste, and the worker's own setraw
  dropped what was typed meanwhile; the worker no longer sets it.
- A superuser's takeover ends the old worker, and waits for it, before
  starting the new one: libvirt gives a VM's console to one at a time.
- A resize while the worker ends no longer raises.
2026-10-26 23:52:37 +03:00

227 lines
8.5 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")
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: 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)
# 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.closerange(3, os.sysconf("SC_OPEN_MAX"))
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:
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.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()