mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
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.
145 lines
4.4 KiB
Python
Executable file
145 lines
4.4 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 os
|
|
import pty
|
|
import select
|
|
import signal
|
|
import struct
|
|
import sys
|
|
import termios
|
|
|
|
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 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
|
|
|
|
sio = socketio.Server(async_mode="eventlet") # the same origin only
|
|
sessions = {} # sid -> {"pid", "fd", "vm"}
|
|
|
|
|
|
@sio.event
|
|
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):
|
|
session = sessions.get(sid)
|
|
if session and isinstance(message, dict) and isinstance(message.get("input"), str):
|
|
try:
|
|
os.write(session["fd"], message["input"].encode())
|
|
except OSError:
|
|
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)):
|
|
fcntl.ioctl(session["fd"], termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
|
|
|
|
@sio.event
|
|
def disconnect(sid, reason=None):
|
|
stop(sid)
|
|
|
|
|
|
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))
|