1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 04:06:47 +00:00

feat(console): a working serial console

The xterm console never connected: socketiod expected a different token
format, its worker had no compute credentials, one global console was
shared by every client and its output broadcast to all of them, and the
Docker image did not run socketiod at all.

- console/tokens.py: one authorize() for noVNC and socketiod; the signed
  token is the only credential, checked at connect time.
- socketiod: one pty worker per client (consolecallback with the
  instance id), one session per VM, a session cap, input and resize
  checked; the parent never opens libvirt. The engine.io same-origin
  check is on.
- consolecallback: opens the console with the compute's credentials and
  exits with a message when it cannot.
- Docker runs socketiod as a runit service; nginx proxies /socket.io/
  with Host and the scheme for the origin check. Supervisor and systemd
  start it as well; WEBVIRTCLOUD_SERIAL_CONSOLE=1 enables it.
- Tests: token and session unit tests, socketiod as a process, and a
  live test that reads SeaBIOS from a wvc-test VM on the TCP and SSH
  hosts.
This commit is contained in:
catborise 2026-10-17 16:48:05 +03:00
parent 9b0c6e2d8e
commit d37aa42392
20 changed files with 592 additions and 569 deletions

View file

@ -1,245 +1,145 @@
#!/usr/bin/env python3
import os
import sys
import logging
import django
DIR_PATH = os.path.dirname(os.path.abspath(__file__))
ROOT_PATH = os.path.abspath(os.path.join(DIR_PATH, "..", ""))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webvirtcloud.settings")
CERT = DIR_PATH + "/cert.pem"
if ROOT_PATH not in sys.path:
sys.path.append(ROOT_PATH)
django.setup()
from django.conf import settings as django_settings
# socketiod does not authenticate connections yet: any client
# that can reach the port can read and write an open serial console. Refuse
# to start unless an admin explicitly opts in.
if not getattr(django_settings, "SERIAL_CONSOLE_ENABLED", False):
sys.stderr.write(
"socketiod: the serial console is disabled (SERIAL_CONSOLE_ENABLED is not "
"True in settings). It is unauthenticated; only enable it on a trusted "
"network.\n"
)
sys.exit(1)
# 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 os
import pty
import select
import struct
import fcntl
import termios
import signal
import socketio
import eventlet
import struct
import sys
import termios
# from six.moves import http_cookies as Cookie
from http import cookies as Cookie
from webvirtcloud.settings import SOCKETIO_PORT, SOCKETIO_HOST
import argparse
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()
def get_parser(default_host=SOCKETIO_HOST, default_port=SOCKETIO_PORT or 6081):
parser = argparse.ArgumentParser(description="WebVirtCloud Socket.IO daemon")
parser.add_argument(
"-v",
"--verbose",
dest="verbose",
action="store_true",
help="Verbose mode",
default=False,
)
parser.add_argument(
"-d", "--debug", dest="debug", action="store_true", help="Debug mode", default=False
)
parser.add_argument(
"-H",
"--host",
dest="host",
action="store",
help="Listen host",
default=default_host,
)
parser.add_argument(
"-p",
"--port",
dest="port",
action="store",
type=int,
help="Listen port",
default=default_port,
)
return parser
import socketio # noqa: E402
from django import db # noqa: E402
from django.conf import settings # noqa: E402
from console.tokens import authorize # noqa: E402
options = get_parser().parse_args()
CONSOLECALLBACK = os.path.join(ROOT_PATH, "conf", "daemon", "consolecallback")
MAX_SESSIONS = 20
FORMAT = "%(asctime)s - %(name)s - %(levelname)s : %(message)s"
if options.debug:
logging.basicConfig(level=logging.DEBUG, format=FORMAT)
options.verbose = True
elif options.verbose:
logging.basicConfig(level=logging.INFO, format=FORMAT)
else:
logging.basicConfig(level=logging.WARNING, format=FORMAT)
async_mode = "eventlet"
sio = socketio.Server(async_mode=async_mode, cors_allowed_origins=[])
fd = None
child_pid = None
def get_connection_infos(token):
from instances.models import Instance
from vrtManager.instance import wvmInstance
try:
temptoken = token.split("-", 1)
host = int(temptoken[0])
uuid = temptoken[1]
instance = Instance.objects.get(compute_id=host, uuid=uuid)
conn = wvmInstance(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
instance.name,
)
except Exception as e:
logging.error(
"Fail to retrieve console connection infos for token %s : %s" % (token, e)
)
raise
return (instance, conn)
def set_winsize(fd, row, col, xpix=0, ypix=0):
winsize = struct.pack("HHHH", row, col, xpix, ypix)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def stop_console():
"""Stop the console process (if still running), reap it and close its pty."""
global fd
global child_pid
if child_pid:
try:
os.kill(child_pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.waitpid(child_pid, 0) # only the child this daemon started
except ChildProcessError:
pass
if fd is not None:
os.close(fd)
fd = None
child_pid = None
def read_and_forward_pty_output():
max_read_bytes = 1024 * 20
# a character may be split between two reads
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# The pty number may be reused by the next console; the pid identifies
# this one. Every yield (sleep, emit) may end it.
console_pid, console_fd = child_pid, fd
while True:
sio.sleep(0.01)
if child_pid != console_pid:
return
(data_ready, _, _) = select.select([console_fd], [], [], 0)
if not data_ready:
continue
try:
output = os.read(console_fd, max_read_bytes)
except OSError: # EIO: the console process ended
output = b""
if not output:
sio.emit("pty_output", {"output": decoder.decode(b"", final=True) + "\r\n[console closed]\r\n"})
stop_console()
for sid, _ in list(sio.manager.get_participants("/", None)):
sio.disconnect(sid)
return
sio.emit("pty_output", {"output": decoder.decode(output)})
sio = socketio.Server(async_mode="eventlet") # the same origin only
sessions = {} # sid -> {"pid", "fd", "vm"}
@sio.event
def resize(sid, message):
global fd
if fd:
set_winsize(fd, message["rows"], message["cols"])
def connect(sid, environ, auth):
if not settings.SERIAL_CONSOLE_ENABLED:
return False
try:
instance = authorize((auth or {}).get("token"))
except PermissionError:
return False
finally:
db.close_old_connections()
if len(sessions) >= MAX_SESSIONS or any(s["vm"] == instance.pk for s in sessions.values()):
return False
pid, fd = pty.fork()
if pid == 0:
try:
os.execv(sys.executable, [sys.executable, CONSOLECALLBACK, str(instance.pk)])
finally:
os._exit(1)
sessions[sid] = session = {"pid": pid, "fd": fd, "vm": instance.pk}
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 sessions.get(sid) is session:
sio.sleep(0.01)
if not select.select([session["fd"]], [], [], 0)[0]:
continue
try:
output = os.read(session["fd"], 20480)
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."""
session = sessions.pop(sid, None)
if session is None:
return
try:
os.close(session["fd"])
except OSError:
pass
try:
os.kill(session["pid"], signal.SIGKILL)
except ProcessLookupError:
pass
sio.start_background_task(reap, session["pid"])
def reap(pid):
while True:
try:
if os.waitpid(pid, os.WNOHANG)[0]:
return
except ChildProcessError:
return
sio.sleep(0.1)
@sio.event
def pty_input(sid, message):
global fd
if fd:
os.write(fd, message["input"].encode())
@sio.event
def disconnect_request(sid):
sio.disconnect(sid)
@sio.event
def connect(sid, environ):
global fd
global child_pid
token = None
hcookie = environ.get("HTTP_COOKIE")
if hcookie:
cookie = Cookie.SimpleCookie()
for hcookie_part in hcookie.split(";"):
hcookie_part = hcookie_part.lstrip()
try:
cookie.load(hcookie_part)
except Cookie.CookieError:
logging.warning("Found malformed cookie")
else:
if "token" in cookie:
token = cookie["token"].value
if child_pid:
# already started child process, don't start another
# write a new line so that when a client refresh the shell prompt is printed
os.write(fd, str.encode("\n"))
return
# create child process attached to a pty we can read from and write to
(child_pid, fd) = pty.fork()
if child_pid == 0:
# Become the console process; never return, which would run this
# server's code in the child as well. Its output goes to the client.
session = sessions.get(sid)
if session and isinstance(message, dict) and isinstance(message.get("input"), str):
try:
if token is None:
sys.stderr.write("No console token\n")
else:
(instance, conn) = get_connection_infos(token)
os.execv("conf/daemon/consolecallback", ["consolecallback", conn.wvm.getURI(), conn.get_uuid()])
except BaseException:
pass # get_connection_infos logged it
finally:
os._exit(1)
else:
# this is the parent process fork.
sio.start_background_task(target=read_and_forward_pty_output)
os.write(session["fd"], message["input"].encode())
except OSError:
pass
@sio.event
def disconnect(sid):
stop_console()
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)):
fcntl.ioctl(session["fd"], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
app = socketio.WSGIApp(sio)
@sio.event
def disconnect(sid, reason=None):
stop(sid)
eventlet.wsgi.server(eventlet.listen((options.host, int(options.port))), app)
def shutdown(signum, frame):
for sid in list(sessions):
stop(sid)
sys.exit(0)
if __name__ == "__main__":
import eventlet
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()
signal.signal(signal.SIGTERM, shutdown)
eventlet.wsgi.server(eventlet.listen((options.host, options.port)), socketio.WSGIApp(sio))