1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/console/socketiod
catborise 4f7bca9754 fix(console): socketiod refuses a request without a signed-in session
Engine.IO accepts a connection, and holds one of socketiod's 40 threads
for it, from its first request; the console token is checked only in
Socket.IO's connect(), which a client need never send. Anyone who could
reach the panel, without an account, could open 40 websockets, answer
their pings, and no serial console would open for anybody (checked: the
41st handshake got no answer until they were closed).

The daemon's WSGI app now refuses a request whose session cookie names
no signed-in user, with 403, before Engine.IO sees it; the page always
sends that cookie. A token without its session no longer connects either,
as for novncd. Checked again: 60 such websockets are each answered 403 at
once and hold nothing.

The process tests sign their client in with a session cookie, as a
browser; python-socketio sends a custom Cookie header only with its first
request, so the client gets a cookie jar instead.
2026-11-06 20:00:43 +03:00

248 lines
9.6 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, session_user_id # 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"), "pty")
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)
def signed_in_only(app):
"""Engine.IO holds a connection, and one of the threads, from its first
request; Socket.IO's connect() checks the token only after that. A request
without a signed-in session is refused before it takes a thread: without
this, anyone could hold every thread and no console would open."""
def wsgi(environ, start_response):
try:
signed_in = session_user_id(environ.get("HTTP_COOKIE")) is not None
finally:
db.close_old_connections()
if not signed_in:
start_response("403 Forbidden", [("Content-Type", "text/plain")])
return [b"Sign in to open a serial console\n"]
return app(environ, start_response)
return wsgi
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 signed_in_only(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()