diff --git a/Dockerfile b/Dockerfile index 70210674..30635cc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,17 +72,20 @@ RUN mkdir -p /etc/my_init.d \ /etc/service/nginx-log-forwarder \ /etc/service/webvirtcloud \ /etc/service/webvirtcloud-worker \ - /etc/service/novnc + /etc/service/novnc \ + /etc/service/socketiod COPY conf/runit/10_webvirtcloud_init.sh /etc/my_init.d/10_webvirtcloud_init.sh COPY conf/runit/nginx /etc/service/nginx/run COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run COPY conf/runit/novncd.sh /etc/service/novnc/run +COPY conf/runit/socketiod.sh /etc/service/socketiod/run COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run COPY conf/runit/webvirtcloud-worker.sh /etc/service/webvirtcloud-worker/run RUN chmod +x /etc/my_init.d/10_webvirtcloud_init.sh \ /etc/service/nginx/run \ /etc/service/nginx-log-forwarder/run \ /etc/service/novnc/run \ + /etc/service/socketiod/run \ /etc/service/webvirtcloud/run \ /etc/service/webvirtcloud-worker/run diff --git a/README.md b/README.md index cd515be4..7bc4e273 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,7 @@ If libvirt warns `Host SMBIOS information is not available`, install `dmidecode` | `ALLOWED_HOSTS` | extra host names/IPs the panel answers to (comma-separated, `*` for any); other hosts get 400 | | `CSRF_TRUSTED_ORIGINS` | extra trusted origins, e.g. `https://wvc.example.com` | | `WEBVIRTCLOUD_HTTPS=1` | HTTPS profile, see [HTTPS and reverse proxy](#https-and-reverse-proxy) | +| `WEBVIRTCLOUD_SERIAL_CONSOLE=1` | serial console, see [Serial console](#serial-console) | | `WEBVIRTCLOUD_DB_HOST` | use PostgreSQL instead of sqlite (new installs); `WEBVIRTCLOUD_DB_PORT`/`_NAME`/`_USER`/`_PASSWORD` default to 5432/webvirtcloud/webvirtcloud/empty | | `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS` | SMTP for e-mail OTP | | `WEBVIRTCLOUD_LOG_FILE` | log file (default `data/webvirtcloud.log`, rotated at 10 MB × 5) | @@ -390,9 +391,14 @@ An LDAP user is authenticated by LDAP and authorized through the WebVirtCloud pe Set `OTP_ENABLED = True` in `settings.py` to ask for a one-time code at login. It is per user: a user without a confirmed device signs in with the password alone. -### Serial console (disabled) +### Serial console -The serial (xterm.js) console is unavailable until `console/socketiod` is rewritten with authentication. Installs from before this change: set `autostart=false` for `[program:socketiod]`, comment out `location /socket.io/` in nginx, and set `SOCKETIO_HOST = "127.0.0.1"` in `settings.py`. +A VM without graphics (no `` device, a ``) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set `WEBVIRTCLOUD_SERIAL_CONSOLE=1` (Docker: in `docker-compose.override.yml`, then `docker compose -p webvirtcloud up -d`) or `SERIAL_CONSOLE_ENABLED = True` in `settings.py` and restart socketiod. + +- The guest must use its serial port: a login prompt needs `systemctl enable --now serial-getty@ttyS0`, boot messages need the kernel parameter `console=ttyS0,115200`. The console shows only what the guest writes after it opens; press Enter for a new prompt. +- socketiod listens on 127.0.0.1:6081; the page reaches it through nginx's `/socket.io/` location on the panel's own address. +- It accepts the console page's signed token, as noVNC does: whoever gets that token within 120 s gets the console, and an open console stays open after its user loses access to the VM. +- One console per VM, at most 20 at a time; a second one shows "refused". ### Running novncd with runit (Debian) @@ -434,12 +440,12 @@ docker compose up -d 1. Stop the panel, back up and pull: ```bash - supervisorctl stop webvirtcloud novncd webvirt-worker + supervisorctl stop webvirtcloud novncd socketiod webvirt-worker # once: root may use the repo, which belongs to the service user git config --global --add safe.directory /srv/webvirtcloud b=/root/webvirtcloud-backup-$(date +%Y%m%d-%H%M%S) cd /srv/webvirtcloud && - ! supervisorctl status webvirtcloud novncd webvirt-worker | grep RUNNING && + ! supervisorctl status webvirtcloud novncd socketiod webvirt-worker | grep RUNNING && mkdir -m 700 "$b" && venv/bin/python3 -c "import sqlite3,sys; sqlite3.connect('db.sqlite3').backup(sqlite3.connect(sys.argv[1]))" "$b/db.sqlite3" && cp -p webvirtcloud/settings.py /etc/nginx/conf.d/webvirtcloud.conf "$b"/ && @@ -503,7 +509,7 @@ docker compose up -d Put back your own lines (`server_name`, TLS, the noVNC port, supervisor `environment=` settings), then reload nginx and start the panel: ```bash - nginx -t && systemctl reload nginx && supervisorctl update && supervisorctl start webvirtcloud novncd webvirt-worker + nginx -t && systemctl reload nginx && supervisorctl update && supervisorctl start webvirtcloud novncd socketiod webvirt-worker ``` Older versions collected static files into `static/`; `git clean -n static/` lists the leftovers, which can be deleted. @@ -529,9 +535,9 @@ Installer and manual installation, from a root shell (`nginx:nginx` on RHEL / op ```bash b=/root/webvirtcloud-backup-YYYYMMDD-HHMMSS -supervisorctl stop webvirtcloud novncd webvirt-worker +supervisorctl stop webvirtcloud novncd socketiod webvirt-worker cd /srv/webvirtcloud && -! supervisorctl status webvirtcloud novncd webvirt-worker | grep RUNNING && +! supervisorctl status webvirtcloud novncd socketiod webvirt-worker | grep RUNNING && git checkout -- static/css/wvc-main.min.css dev/scss/wvc-main.scss && git checkout "$(cat "$b/commit")" && venv/bin/python3 -c "import os,sqlite3,sys; [os.remove(p) for p in ('db.sqlite3-journal','db.sqlite3-wal','db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(sys.argv[1]).backup(sqlite3.connect('db.sqlite3'))" "$b/db.sqlite3" && @@ -559,7 +565,7 @@ Then reload nginx and start the panel: ```bash nginx -t && systemctl reload nginx && -supervisorctl update && supervisorctl start webvirtcloud novncd webvirt-worker +supervisorctl update && supervisorctl start webvirtcloud novncd socketiod webvirt-worker ``` When the restored version predates the worker, `supervisorctl` reports that `webvirt-worker` does not exist; the other programs start. A rollback leaves the clone on a detached HEAD at the restored commit; the update steps record that commit before they switch back to `master`. @@ -600,6 +606,7 @@ When the restored version predates the worker, `supervisorctl` reports that `web - External snapshots use libvirt's snapshot API and need libvirt 9.9 or newer on the compute hosts. They work on UEFI VMs and running, paused or shut-off VMs, can save memory too or, on a running VM with a connected QEMU guest agent, freeze the guest's file systems (the default there), and a revert keeps disks added later (it detaches them). A snapshot is refused when a storage pool with the VM's disks has less free space than a share of those disks (10%, at least 1 GiB; both on the Settings page) plus, for the pool of the memory file, the VM's memory; this is checked before the snapshot only, nothing is reserved. A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. Snapshot names no longer get an `s1.` prefix; existing ones keep theirs and can still be reverted and deleted, except that a snapshot an older version took of a UEFI VM can only be deleted. On NFS, libvirt removes merged snapshot files as the qemu user, who needs write access to the image directory; files it could not remove are named in a warning after the delete or revert. Deleting a VM with its data removes its snapshot files too, except a template it grew from and files another VM uses. - A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is no longer deleted from the storage page or the API; the delete is refused with the reason. Only VMs on the same compute are checked. - Docker no longer publishes port 6080; the console goes through nginx on the panel's port (`WS_PUBLIC_PORT`, default 80). +- The serial console works (off by default, see [Serial console](#serial-console)); `SOCKETIO_PUBLIC_HOST`/`_PORT`/`_PATH` are gone, the page uses the panel's own address. - Long operations run as jobs in a worker process (`manage.py run_worker`); follow them on the VM page and on the Jobs page. - Cloning a VM or a volume and creating a VM run as jobs: the page returns at once and the job panel follows them. The API's VM create answers 202 with the job and needs a new `submission_key` per request. The create form no longer takes a console password; set it on the VM page. - Taking and reverting a snapshot run as jobs, like deleting an external snapshot: the page returns at once and the job panel follows them. Deleting an internal snapshot stays immediate. diff --git a/conf/daemon/consolecallback b/conf/daemon/consolecallback index a510fdf7..db9fa923 100755 --- a/conf/daemon/consolecallback +++ b/conf/daemon/consolecallback @@ -1,100 +1,94 @@ #!/usr/bin/env python3 -# consolecallback - provide a persistent console that survives guest reboots +# consolecallback - the serial console of one VM on stdin/stdout +# (socketiod runs it in a pty, one per client). It opens the console through +# the compute's connection (its stored credentials); the connection's event +# loop thread runs the stream callbacks. The console survives guest reboots. import os -import logging -import libvirt +import sys +import time import tty -import termios -import atexit -from argparse import ArgumentParser -from typing import Optional # noqa F401 + +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 libvirt # noqa: E402 + +from instances.models import Instance # noqa: E402 +from vrtManager.connection import connection_manager # noqa: E402 -def reset_term() -> None: - termios.tcsetattr(0, termios.TCSADRAIN, attrs) +class Console: + def __init__(self, connection, uuid): + self.domain = connection.lookupByUUIDString(uuid) + self.connection = connection + self.stream = None + self.running = True + + def check(self): + """Open the stream while the VM runs, drop it while it does not.""" + active = self.domain.state()[0] in (libvirt.VIR_DOMAIN_RUNNING, libvirt.VIR_DOMAIN_PAUSED) + if active and self.stream is None: + self.stream = self.connection.newStream(libvirt.VIR_STREAM_NONBLOCK) + self.domain.openConsole(None, self.stream, 0) # a console in use is refused + self.stream.eventAddCallback(libvirt.VIR_STREAM_EVENT_READABLE, from_vm, self) + elif not active and self.stream is not None: + self.close_stream() + + def close_stream(self): + stream, self.stream = self.stream, None + try: + stream.eventRemoveCallback() + stream.abort() + except libvirt.libvirtError: + pass -def error_handler(unused, error) -> None: - # The console stream errors on VM shutdown; we don't care - if error[0] == libvirt.VIR_ERR_RPC and error[1] == libvirt.VIR_FROM_STREAMS: - return - logging.warning(error) - - -class Console(object): - def __init__(self, uri: str, uuid: str) -> None: - self.uri = uri - self.uuid = uuid - self.connection = libvirt.open(uri) - self.domain = self.connection.lookupByUUIDString(uuid) - self.state = self.domain.state(0) - self.connection.domainEventRegister(lifecycle_callback, self) - self.stream = None # type: Optional[libvirt.virStream] - self.run_console = True - self.stdin_watch = -1 - logging.info("%s initial state %d, reason %d", - self.uuid, self.state[0], self.state[1]) - - -def check_console(console: Console) -> bool: - if (console.state[0] == libvirt.VIR_DOMAIN_RUNNING or console.state[0] == libvirt.VIR_DOMAIN_PAUSED): - if console.stream is None: - console.stream = console.connection.newStream(libvirt.VIR_STREAM_NONBLOCK) - console.domain.openConsole(None, console.stream, 0) - console.stream.eventAddCallback(libvirt.VIR_STREAM_EVENT_READABLE, stream_callback, console) - else: - if console.stream: - console.stream.eventRemoveCallback() - console.stream = None - - return console.run_console - - -def stdin_callback(watch: int, fd: int, events: int, console: Console) -> None: - readbuf = os.read(fd, 1024) - if readbuf.startswith(b""): - console.run_console = False - return - if console.stream: - console.stream.send(readbuf) - - -def stream_callback(stream: libvirt.virStream, events: int, console: Console) -> None: +def from_client(watch, fd, events, console): try: - assert console.stream - received_data = console.stream.recv(1024) - except Exception: + data = os.read(fd, 1024) + except OSError: + data = b"" + if not data: # the client is gone + console.running = False + elif console.stream is not None: + try: + console.stream.send(data) + except libvirt.libvirtError: + console.close_stream() + + +def from_vm(stream, events, console): + try: + data = stream.recv(1024) + except libvirt.libvirtError: # the VM shut down + data = None + if not data or data == -2: # -2: nothing to read now + if data != -2: + console.close_stream() return - os.write(0, received_data) + os.write(1, data) -def lifecycle_callback(connection: libvirt.virConnect, domain: libvirt.virDomain, event: int, detail: int, console: Console) -> None: - console.state = console.domain.state(0) - logging.info("%s transitioned to state %d, reason %d", - console.uuid, console.state[0], console.state[1]) +def main(): + instance = Instance.objects.select_related("compute").get(pk=int(sys.argv[1])) + c = instance.compute + try: + console = Console(connection_manager.get_connection(c.hostname, c.login, c.password, c.type), instance.uuid) + console.check() + except libvirt.libvirtError as err: + sys.stdout.write("Cannot open the console of %s: %s\r\n" % (instance.name, err.get_error_message())) + sys.exit(1) + tty.setraw(0) + libvirt.virEventAddHandle(0, libvirt.VIR_EVENT_HANDLE_READABLE, from_client, console) + while console.running: + time.sleep(0.2) + console.check() -# main -parser = ArgumentParser(epilog="Example: %(prog)s 'qemu:///system' '32ad945f-7e78-c33a-e96d-39f25e025d81'") -parser.add_argument("uri") -parser.add_argument("uuid") -args = parser.parse_args() - -print("Escape character is ^]") -logging.basicConfig(filename='msg.log', level=logging.DEBUG) -logging.info("URI: %s", args.uri) -logging.info("UUID: %s", args.uuid) - -libvirt.virEventRegisterDefaultImpl() -libvirt.registerErrorHandler(error_handler, None) - -atexit.register(reset_term) -attrs = termios.tcgetattr(0) -tty.setraw(0) - -console = Console(args.uri, args.uuid) -console.stdin_watch = libvirt.virEventAddHandle(0, libvirt.VIR_EVENT_HANDLE_READABLE, stdin_callback, console) - -while check_console(console): - libvirt.virEventRunDefaultImpl() +main() diff --git a/conf/nginx/webvirtcloud.conf b/conf/nginx/webvirtcloud.conf index 83f0b223..4cd91a78 100644 --- a/conf/nginx/webvirtcloud.conf +++ b/conf/nginx/webvirtcloud.conf @@ -37,15 +37,18 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } - # Serial console (socketiod). Disabled by default: socketiod does not - # authenticate connections yet. Uncomment only together with - # SERIAL_CONSOLE_ENABLED = True, and only on a trusted network. - #location /socket.io/ { - # proxy_pass http://wssocketiod; - # proxy_http_version 1.1; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection "upgrade"; - #} + # Serial console (socketiod); it refuses connections while + # SERIAL_CONSOLE_ENABLED is off. Host and the scheme go on for its + # same-origin check. + location /socket.io/ { + proxy_pass http://wssocketiod; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $wvc_forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; + } location /websockify { proxy_pass http://wsnovncd; proxy_http_version 1.1; diff --git a/conf/runit/socketiod.sh b/conf/runit/socketiod.sh new file mode 100755 index 00000000..ff77d8e5 --- /dev/null +++ b/conf/runit/socketiod.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# The serial console daemon; it refuses connections while SERIAL_CONSOLE_ENABLED is off. + +cd /srv/webvirtcloud || exit 1 +exec /sbin/setuser www-data /srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod >> /var/log/socketiod.log 2>&1 diff --git a/conf/supervisor/webvirtcloud.conf b/conf/supervisor/webvirtcloud.conf index 08823d3d..7d04a357 100644 --- a/conf/supervisor/webvirtcloud.conf +++ b/conf/supervisor/webvirtcloud.conf @@ -14,14 +14,13 @@ autostart=true autorestart=true redirect_stderr=true -# Serial console daemon. Disabled by default because it does not authenticate -# connections yet; set SERIAL_CONSOLE_ENABLED = True in settings.py and -# autostart=true here only on a trusted network. +# Serial console daemon; it refuses connections while SERIAL_CONSOLE_ENABLED +# is off in settings.py. [program:socketiod] command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod directory=/srv/webvirtcloud user=www-data -autostart=false +autostart=true autorestart=true redirect_stderr=true diff --git a/conf/systemd/install.sh b/conf/systemd/install.sh index 72624876..5fc9d07a 100755 --- a/conf/systemd/install.sh +++ b/conf/systemd/install.sh @@ -4,7 +4,4 @@ SCRIPTPATH=$(dirname "$FILEPATH"); cd "$SCRIPTPATH" cp webvirt-* /lib/systemd/system/ cp webvirt /etc/default/ -# webvirt-socketiod (serial console) is not enabled by default: it does not -# authenticate connections yet and refuses to start unless -# SERIAL_CONSOLE_ENABLED = True is set in settings.py. -echo Run to start services \"systemctl daemon-reload\; systemctl enable --now $(ls webvirt-* | grep -v socketiod | tr "\n" " ")\" +echo Run to start services \"systemctl daemon-reload\; systemctl enable --now $(ls webvirt-* | tr "\n" " ")\" diff --git a/conf/systemd/webvirt-socketiod.service b/conf/systemd/webvirt-socketiod.service index d2e525a8..eb097bc5 100644 --- a/conf/systemd/webvirt-socketiod.service +++ b/conf/systemd/webvirt-socketiod.service @@ -1,5 +1,5 @@ [Unit] -Description=Webvirt Cloud serial console (socketiod; requires SERIAL_CONSOLE_ENABLED = True) +Description=Webvirt Cloud serial console (socketiod; refuses connections unless SERIAL_CONSOLE_ENABLED = True) Wants=network-online.target After=network-online.target diff --git a/console/novncd b/console/novncd index b8802fd0..c4f3a9a1 100755 --- a/console/novncd +++ b/console/novncd @@ -83,36 +83,11 @@ except ImportError: def get_connection_infos(token): - from django.contrib.auth import get_user_model - from django.core.signing import BadSignature, SignatureExpired, TimestampSigner - from instances.models import Instance - from instances.utils import can_open_console + from console.tokens import authorize from vrtManager.instance import wvmInstance - # The console view signs exactly "host:uuid:user_id". Anything else - # (unsigned, expired, or without the user part) is rejected, because the - # per-user access check below depends on user_id. - signer = TimestampSigner(salt="console.novnc") try: - unsigned = signer.unsign(token, max_age=120) - host, uuid, user_id = unsigned.split(":") - host = int(host) - user_id = int(user_id) - except (BadSignature, SignatureExpired, ValueError, TypeError): - raise PermissionError("Invalid or expired console token") - - try: - instance = Instance.objects.get(compute_id=host, uuid=uuid) - User = get_user_model() - try: - user = User.objects.get(id=user_id) - except User.DoesNotExist: - raise PermissionError("User not found") - if not user.is_active: - raise PermissionError("User account is inactive or disabled") - if not can_open_console(user, instance): - raise PermissionError("User no longer has access to this instance") - + instance = authorize(token) conn = wvmInstance( instance.compute.hostname, instance.compute.login, diff --git a/console/socketiod b/console/socketiod index 43b56e80..b24a33ac 100755 --- a/console/socketiod +++ b/console/socketiod @@ -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)) diff --git a/console/templates/console-xterm.html b/console/templates/console-xterm.html index 389f58e7..090a5d21 100644 --- a/console/templates/console-xterm.html +++ b/console/templates/console-xterm.html @@ -18,25 +18,10 @@
-