mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +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:
parent
9b0c6e2d8e
commit
d37aa42392
20 changed files with 592 additions and 569 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
23
README.md
23
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 `<graphics>` device, a `<console type='pty'>`) 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.
|
||||
|
|
|
|||
|
|
@ -1,100 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
# consolecallback - provide a persistent console that survives guest reboots
|
||||
# consolecallback <instance id> - 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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
conf/runit/socketiod.sh
Executable file
5
conf/runit/socketiod.sh
Executable file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" " ")\"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -18,25 +18,10 @@
|
|||
</div>
|
||||
|
||||
<div style="width: 100%; height:100%;" id="terminal"></div>
|
||||
<script crossorigin="anonymous">
|
||||
let host = readQueryVariable('host', '{{ socketio_host|escapejs }}');
|
||||
let port = readQueryVariable('port', '{{ socketio_port|escapejs }}');
|
||||
let path = readQueryVariable('path', '{{ socketio_path|escapejs }}');
|
||||
|
||||
|
||||
// Build the websocket URL used to connect
|
||||
let url;
|
||||
if (window.location.protocol === "https:") {
|
||||
url = 'wss';
|
||||
} else {
|
||||
url = 'ws';
|
||||
}
|
||||
url += '://' + host;
|
||||
if (port) {
|
||||
url += ':' + port;
|
||||
}
|
||||
|
||||
var socket = io.connect(url, {transports: ["websocket", "polling"]});
|
||||
<script>
|
||||
// socketiod is behind this page's own /socket.io/ location; the page's
|
||||
// signed token is the credential (the cookie holds the last console's)
|
||||
var socket = io({transports: ["websocket", "polling"], auth: {token: "{{ token|escapejs }}"}});
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const button = document.getElementById("button");
|
||||
|
|
@ -58,23 +43,21 @@
|
|||
term.write(output["output"])
|
||||
})
|
||||
|
||||
socket.on("connect", () => {
|
||||
status.innerHTML = '<span style="background-color: lightgreen;">connected</span>'
|
||||
button.innerHTML = 'Disconnect'
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
status.innerHTML = '<span style="background-color: #ff8383;">disconnected</span>'
|
||||
button.innerHTML = 'Connect'
|
||||
})
|
||||
function showStatus(text, color) {
|
||||
status.textContent = text;
|
||||
status.style.backgroundColor = color;
|
||||
button.textContent = socket.connected ? "Disconnect" : "Connect";
|
||||
}
|
||||
socket.on("connect", () => showStatus("connected", "lightgreen"));
|
||||
socket.on("disconnect", () => showStatus("disconnected", "#ff8383"));
|
||||
// refused: serial console off, an expired token, or the VM's console already open
|
||||
socket.on("connect_error", () => showStatus("refused", "#ff8383"));
|
||||
|
||||
function myFunction(){
|
||||
if (button.innerHTML =='Connect'){
|
||||
location.reload();
|
||||
}
|
||||
|
||||
else if (button.innerHTML == "Disconnect"){
|
||||
socket.emit("disconnect_request")
|
||||
if (socket.connected) {
|
||||
socket.disconnect();
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,26 +66,6 @@
|
|||
socket.emit("resize", {"cols": term.cols, "rows": term.rows})
|
||||
}
|
||||
|
||||
// This function extracts the value of one variable from the
|
||||
// query string. If the variable isn't defined in the URL
|
||||
// it returns the default value instead.
|
||||
function readQueryVariable(name, defaultValue) {
|
||||
// A URL with a query parameter can look like this:
|
||||
// https://www.example.com?myqueryparam=myvalue
|
||||
//
|
||||
// Note that we use location.href instead of location.search
|
||||
// because Firefox < 53 has a bug w.r.t location.search
|
||||
const re = new RegExp('.*[?&]' + name + '=([^&#]*)'),
|
||||
match = document.location.href.match(re);
|
||||
|
||||
if (match) {
|
||||
// We have to decode the URL since want the cleartext value
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function bindTerminalKey(id, str) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
|
|
|
|||
79
console/tests/test_live_serial.py
Normal file
79
console/tests/test_live_serial.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Live serial console: socketiod opens a running wvc-test VM's console on the
|
||||
TCP host (TEST_LIBVIRT_HOST, with its password) and on the SSH host
|
||||
(TEST_LIBVIRT_SSH_HOST). The VM has no graphics and no OS; SeaBIOS writes its
|
||||
banner to the serial port. Runs only with TEST_LIBVIRT_HOST set."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import libvirt
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TransactionTestCase
|
||||
from vrtManager.connection import connection_manager
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from console import tokens
|
||||
from console.tests.test_socketiod_child import children, console, start_socketiod, socketio
|
||||
from instances.models import Instance
|
||||
from instances.tests import livetest
|
||||
|
||||
P = livetest.PREFIX
|
||||
|
||||
|
||||
def serial_vm(conn, name):
|
||||
"""A paused VM without graphics whose BIOS writes to the serial console once resumed."""
|
||||
dom = conn.defineXML(
|
||||
f"<domain type='kvm'><name>{name}</name><memory unit='MiB'>64</memory><vcpu>1</vcpu>"
|
||||
"<os><type arch='x86_64' machine='q35'>hvm</type><bios useserial='yes'/></os>"
|
||||
"<devices><serial type='pty'><target port='0'/></serial><console type='pty'/></devices></domain>"
|
||||
)
|
||||
dom.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED)
|
||||
return dom
|
||||
|
||||
|
||||
@unittest.skipIf(socketio is None, "python-socketio is not installed")
|
||||
class LiveSerialConsoleTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
if not livetest.enabled():
|
||||
self.skipTest("Set TEST_LIBVIRT_HOST to run the live libvirt tests")
|
||||
self.user = get_user_model().objects.create_user("serial-live", password="pw")
|
||||
self.hosts = [(os.environ["TEST_LIBVIRT_HOST"], os.environ.get("TEST_LIBVIRT_LOGIN", ""),
|
||||
os.environ.get("TEST_LIBVIRT_PASSWORD", ""), int(os.environ.get("TEST_LIBVIRT_TYPE", 4)))]
|
||||
if os.environ.get("TEST_LIBVIRT_SSH_HOST"):
|
||||
self.hosts.append((os.environ["TEST_LIBVIRT_SSH_HOST"], os.environ.get("TEST_LIBVIRT_SSH_LOGIN", "root"), "", 2))
|
||||
self.server, self.port = start_socketiod(self)
|
||||
|
||||
def token(self, host, login, password, conn_type):
|
||||
compute = Compute.objects.create(name=P + host, hostname=host, login=login, password=password, type=conn_type)
|
||||
conn = connection_manager.get_connection(host, login, password, conn_type)
|
||||
before = livetest.inventory(conn)
|
||||
self.addCleanup(lambda: self.assertEqual(livetest.inventory(conn), before))
|
||||
self.addCleanup(livetest.cleanup, conn)
|
||||
dom = serial_vm(conn, P + "serial")
|
||||
vm = Instance.objects.create(compute=compute, name=dom.name(), uuid=dom.UUIDString())
|
||||
UserInstance.objects.create(user=self.user, instance=vm)
|
||||
return tokens.SIGNER.sign(f"{compute.id}:{vm.uuid}:{self.user.id}"), dom
|
||||
|
||||
def test_the_console_of_a_running_vm_opens_once(self):
|
||||
for args in self.hosts:
|
||||
with self.subTest(host=args[0], type=args[3]):
|
||||
token, dom = self.token(*args)
|
||||
client, received = console(self.port, token)
|
||||
time.sleep(1) # the console is open on the paused VM; its BIOS writes once it runs
|
||||
dom.resume()
|
||||
deadline = time.monotonic() + 30
|
||||
while "SeaBIOS" not in "".join(received["output"]) and time.monotonic() < deadline:
|
||||
time.sleep(0.2)
|
||||
self.assertIn("SeaBIOS", "".join(received["output"]))
|
||||
# one client per VM
|
||||
with self.assertRaises(socketio.exceptions.ConnectionError):
|
||||
console(self.port, token)
|
||||
client.disconnect()
|
||||
deadline = time.monotonic() + 5
|
||||
while children(self.server.pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(children(self.server.pid), [])
|
||||
dom.destroy()
|
||||
137
console/tests/test_socketiod.py
Normal file
137
console/tests/test_socketiod.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""socketiod: who may connect, and each session's console kept to itself."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from importlib.util import module_from_spec, spec_from_loader
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from console import tokens
|
||||
from instances.models import Instance
|
||||
|
||||
SOCKETIOD = Path(__file__).resolve().parent.parent / "socketiod"
|
||||
|
||||
|
||||
def load_socketiod():
|
||||
loader = SourceFileLoader("socketiod_under_test", str(SOCKETIOD))
|
||||
module = module_from_spec(spec_from_loader(loader.name, loader))
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class Base(TestCase):
|
||||
def setUp(self):
|
||||
self.compute = Compute.objects.create(name="c", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=self.compute, name="vm-a", uuid="11111111-0000-0000-0000-000000000001")
|
||||
self.user = get_user_model().objects.create_user("owner", password="pw")
|
||||
UserInstance.objects.create(user=self.user, instance=self.vm)
|
||||
|
||||
def token(self, user=None, vm=None):
|
||||
vm = vm or self.vm
|
||||
return tokens.SIGNER.sign(f"{vm.compute_id}:{vm.uuid}:{(user or self.user).id}")
|
||||
|
||||
|
||||
class TokenTestCase(Base):
|
||||
def test_a_token_opens_its_vm_for_a_user_who_may_open_it(self):
|
||||
self.assertEqual(tokens.authorize(self.token()), self.vm)
|
||||
|
||||
def test_refused_tokens(self):
|
||||
stranger = get_user_model().objects.create_user("stranger", password="pw")
|
||||
inactive = get_user_model().objects.create_user("inactive", password="pw", is_active=False)
|
||||
UserInstance.objects.create(user=inactive, instance=self.vm)
|
||||
cases = {
|
||||
"none": None,
|
||||
"legacy host-uuid": f"{self.compute.id}-{self.vm.uuid}",
|
||||
"tampered": self.token()[:-2] + "xx",
|
||||
"no access": self.token(stranger),
|
||||
"inactive user": self.token(inactive),
|
||||
}
|
||||
for name, token in cases.items():
|
||||
with self.subTest(name), self.assertRaises(PermissionError):
|
||||
tokens.authorize(token)
|
||||
token = self.token()
|
||||
with patch("django.core.signing.time.time", return_value=time.time() + tokens.MAX_AGE + 1):
|
||||
with self.assertRaises(PermissionError):
|
||||
tokens.authorize(token)
|
||||
|
||||
|
||||
class SocketiodTestCase(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.d = load_socketiod()
|
||||
self.started = []
|
||||
self.forks = iter(range(1000, 2000))
|
||||
patches = [
|
||||
patch.object(self.d.pty, "fork", side_effect=self.fork),
|
||||
patch.object(self.d.sio, "start_background_task", side_effect=lambda *a: self.started.append(a)),
|
||||
patch.object(self.d.os, "kill"),
|
||||
# the daemon closes its connection after each check; the test's transaction keeps it
|
||||
patch.object(self.d.db, "close_old_connections"),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def fork(self):
|
||||
read, write = os.pipe()
|
||||
self.addCleanup(lambda: [os.close(fd) for fd in (read, write) if _open(fd)])
|
||||
return next(self.forks), read
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_valid_token_starts_a_console_for_its_session(self):
|
||||
self.assertIsNone(self.d.connect("sid-1", {}, {"token": self.token()}))
|
||||
self.assertEqual(self.d.sessions["sid-1"]["vm"], self.vm.pk)
|
||||
self.assertEqual(self.started[0][0], self.d.forward)
|
||||
|
||||
def test_refused_connections_start_nothing(self):
|
||||
other = Instance.objects.create(compute=self.compute, name="vm-b", uuid="11111111-0000-0000-0000-000000000002")
|
||||
UserInstance.objects.create(user=self.user, instance=other)
|
||||
with override_settings(SERIAL_CONSOLE_ENABLED=False):
|
||||
self.assertIs(self.d.connect("off", {}, {"token": self.token()}), False)
|
||||
with override_settings(SERIAL_CONSOLE_ENABLED=True):
|
||||
self.assertIs(self.d.connect("no-auth", {}, None), False)
|
||||
self.assertIs(self.d.connect("bad", {}, {"token": "x"}), False)
|
||||
self.assertIsNone(self.d.connect("first", {}, {"token": self.token()}))
|
||||
# one session per VM
|
||||
self.assertIs(self.d.connect("second", {}, {"token": self.token()}), False)
|
||||
with patch.object(self.d, "MAX_SESSIONS", 1):
|
||||
self.assertIs(self.d.connect("over", {}, {"token": self.token(vm=other)}), False)
|
||||
self.assertEqual(list(self.d.sessions), ["first"])
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_input_and_resize_go_to_the_session_and_are_checked(self):
|
||||
self.d.connect("sid-1", {}, {"token": self.token()})
|
||||
fd = self.d.sessions["sid-1"]["fd"]
|
||||
with patch.object(self.d.os, "write") as write, patch.object(self.d.fcntl, "ioctl") as ioctl:
|
||||
self.d.pty_input("sid-1", {"input": "ls\r"})
|
||||
for bad in (None, "ls", {"input": 7}):
|
||||
self.d.pty_input("sid-1", bad)
|
||||
self.d.pty_input("unknown", {"input": "x"})
|
||||
self.d.resize("sid-1", {"rows": 24, "cols": 80})
|
||||
for bad in ({"rows": 0, "cols": 80}, {"rows": 24, "cols": 501}, {"rows": "24", "cols": 80}, "x"):
|
||||
self.d.resize("sid-1", bad)
|
||||
write.assert_called_once_with(fd, b"ls\r")
|
||||
self.assertEqual(ioctl.call_count, 1)
|
||||
|
||||
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
||||
def test_a_session_is_stopped_once(self):
|
||||
self.d.connect("sid-1", {}, {"token": self.token()})
|
||||
pid = self.d.sessions["sid-1"]["pid"]
|
||||
self.d.disconnect("sid-1")
|
||||
self.d.stop("sid-1")
|
||||
self.assertNotIn("sid-1", self.d.sessions)
|
||||
self.d.os.kill.assert_called_once_with(pid, self.d.signal.SIGKILL)
|
||||
|
||||
|
||||
def _open(fd):
|
||||
try:
|
||||
os.fstat(fd)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
"""socketiod's console process ending on its own: the clients are told and
|
||||
disconnected, the process is reaped and the next connection starts a new one."""
|
||||
"""socketiod as a process: each client gets the output of its own console
|
||||
worker, the worker is reaped, and a client without a valid token is refused.
|
||||
The workers cannot reach libvirt here (the VMs' compute is the local socket),
|
||||
so each ends with "Cannot open the console of <its VM>"."""
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
|
@ -11,6 +13,15 @@ import time
|
|||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from console import tokens
|
||||
from instances.models import Instance
|
||||
|
||||
try:
|
||||
import socketio
|
||||
except ImportError: # pragma: no cover
|
||||
|
|
@ -40,79 +51,78 @@ def children(pid):
|
|||
return found
|
||||
|
||||
|
||||
def start_socketiod(test):
|
||||
"""socketiod on a free port, reading test's database; stopped at test's cleanup."""
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
test.addCleanup(tmp.cleanup)
|
||||
db = {k: connection.settings_dict[k] for k in ("ENGINE", "USER", "PASSWORD", "HOST", "PORT", "OPTIONS")}
|
||||
db["NAME"] = str(connection.settings_dict["NAME"])
|
||||
(Path(tmp.name) / "socketiod_test_settings.py").write_text(
|
||||
"from webvirtcloud.settings import * # noqa\n"
|
||||
"SERIAL_CONSOLE_ENABLED = True\n"
|
||||
"DATABASES = {'default': %r}\n" % db
|
||||
)
|
||||
port = free_port()
|
||||
env = dict(os.environ, DJANGO_SETTINGS_MODULE="socketiod_test_settings",
|
||||
PYTHONPATH=os.pathsep.join([tmp.name, str(ROOT)]))
|
||||
server = subprocess.Popen([sys.executable, str(SOCKETIOD), "-H", "127.0.0.1", "-p", str(port)],
|
||||
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
test.addCleanup(lambda: (server.kill(), server.wait()))
|
||||
end = time.monotonic() + 20
|
||||
while time.monotonic() < end:
|
||||
with socket.socket() as s:
|
||||
if s.connect_ex(("127.0.0.1", port)) == 0:
|
||||
return server, port
|
||||
test.assertIsNone(server.poll(), "socketiod exited at start")
|
||||
time.sleep(0.1)
|
||||
test.fail("socketiod did not listen within 20 s")
|
||||
|
||||
|
||||
def console(port, token):
|
||||
"""A connected client and what it receives until the server disconnects it."""
|
||||
client = socketio.Client(reconnection=False)
|
||||
received = {"output": [], "gone": threading.Event()}
|
||||
client.on("pty_output", lambda data: received["output"].append(data["output"]))
|
||||
client.on("disconnect", lambda *args: received["gone"].set())
|
||||
# the same origin as the server, as the page through nginx
|
||||
url = "http://127.0.0.1:%d" % port
|
||||
client.connect(url, auth={"token": token}, transports=["polling"], headers={"Origin": url})
|
||||
return client, received
|
||||
|
||||
|
||||
@unittest.skipIf(socketio is None, "python-socketio is not installed")
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "reads /proc")
|
||||
class SocketiodChildExitTestCase(unittest.TestCase):
|
||||
class SocketiodProcessTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
(Path(tmp.name) / "socketiod_child_settings.py").write_text(
|
||||
"from webvirtcloud.settings import * # noqa\n"
|
||||
"SERIAL_CONSOLE_ENABLED = True\n"
|
||||
"DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': %r}}\n"
|
||||
% str(Path(tmp.name) / "db.sqlite3")
|
||||
)
|
||||
self.port = free_port()
|
||||
env = dict(
|
||||
os.environ,
|
||||
DJANGO_SETTINGS_MODULE="socketiod_child_settings",
|
||||
PYTHONPATH=os.pathsep.join([tmp.name, str(ROOT)]),
|
||||
)
|
||||
self.server = subprocess.Popen(
|
||||
[sys.executable, str(SOCKETIOD), "-H", "127.0.0.1", "-p", str(self.port)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.addCleanup(self.stop_server)
|
||||
end = time.monotonic() + 20
|
||||
while time.monotonic() < end:
|
||||
with socket.socket() as s:
|
||||
if s.connect_ex(("127.0.0.1", self.port)) == 0:
|
||||
return
|
||||
self.assertIsNone(self.server.poll(), "socketiod exited at start")
|
||||
time.sleep(0.1)
|
||||
self.fail("socketiod did not listen within 20 s")
|
||||
compute = Compute.objects.create(name="local", hostname="localhost", login="", password="", type=4)
|
||||
user = get_user_model().objects.create_user("serial-owner", password="pw")
|
||||
self.tokens = {}
|
||||
for n, name in enumerate(("vm-a", "vm-b"), 1):
|
||||
vm = Instance.objects.create(compute=compute, name=name, uuid=f"22222222-0000-0000-0000-00000000000{n}")
|
||||
UserInstance.objects.create(user=user, instance=vm)
|
||||
self.tokens[name] = tokens.SIGNER.sign(f"{compute.id}:{vm.uuid}:{user.id}")
|
||||
self.server, self.port = start_socketiod(self)
|
||||
|
||||
def stop_server(self):
|
||||
self.server.kill()
|
||||
self.server.wait()
|
||||
def console(self, token):
|
||||
return console(self.port, token)
|
||||
|
||||
def open_console(self):
|
||||
"""Connect with a malformed token, so the console process fails at once;
|
||||
returns the output and whether the server disconnected the client."""
|
||||
client = socketio.Client(reconnection=False)
|
||||
output = []
|
||||
gone = threading.Event()
|
||||
client.on("pty_output", lambda data: output.append(data["output"]))
|
||||
client.on("disconnect", lambda *args: gone.set())
|
||||
client.connect(
|
||||
"http://127.0.0.1:%d" % self.port,
|
||||
headers={"Cookie": "token=no-such-vm"},
|
||||
transports=["polling"],
|
||||
)
|
||||
try:
|
||||
gone.wait(15)
|
||||
finally:
|
||||
if not gone.is_set():
|
||||
client.disconnect()
|
||||
return "".join(output), gone.is_set()
|
||||
|
||||
def assert_no_child_left(self):
|
||||
def test_each_client_gets_its_own_console(self):
|
||||
sessions = {name: self.console(token) for name, token in self.tokens.items()}
|
||||
for name, (client, received) in sessions.items():
|
||||
with self.subTest(name):
|
||||
received["gone"].wait(20)
|
||||
output = "".join(received["output"])
|
||||
self.assertIn("Cannot open the console of %s" % name, output)
|
||||
self.assertNotIn("vm-a" if name == "vm-b" else "vm-b", output)
|
||||
self.assertIn("console closed", output)
|
||||
end = time.monotonic() + 5
|
||||
while children(self.server.pid) and time.monotonic() < end:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(children(self.server.pid), [])
|
||||
self.assertIsNone(self.server.poll())
|
||||
|
||||
def test_the_client_is_told_and_the_next_connection_starts_again(self):
|
||||
for attempt in (1, 2):
|
||||
with self.subTest(attempt=attempt):
|
||||
output, disconnected = self.open_console()
|
||||
self.assertIn("console closed", output)
|
||||
# no traceback of socketiod's own code (on Python 3.13 eventlet
|
||||
# prints an ignored _after_fork error in every forked child)
|
||||
self.assertNotIn('socketiod", line', output)
|
||||
self.assertTrue(disconnected)
|
||||
self.assert_no_child_left()
|
||||
self.assertIsNone(self.server.poll())
|
||||
def test_a_client_without_a_valid_token_is_refused(self):
|
||||
for token in ("", "22-no-such-vm"):
|
||||
with self.subTest(token=token), self.assertRaises(socketio.exceptions.ConnectionError):
|
||||
self.console(token)
|
||||
self.assertEqual(children(self.server.pid), [])
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SOCKETIOD = ROOT / "console" / "socketiod"
|
||||
|
||||
|
||||
class SocketiodDisabledTestCase(unittest.TestCase):
|
||||
"""socketiod must refuse to start unless explicitly enabled."""
|
||||
|
||||
def test_socketiod_exits_when_serial_console_disabled(self):
|
||||
# The settings module lives in a temporary directory, so the test does
|
||||
# not write into the source tree (read-only checkouts, --parallel).
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "socketiod_disabled_settings.py").write_text(
|
||||
"from webvirtcloud.settings import * # noqa\n"
|
||||
"SERIAL_CONSOLE_ENABLED = False\n"
|
||||
"SOCKETIO_HOST = '127.0.0.1'\n"
|
||||
"SOCKETIO_PORT = 0\n"
|
||||
)
|
||||
env = dict(
|
||||
os.environ,
|
||||
DJANGO_SETTINGS_MODULE="socketiod_disabled_settings",
|
||||
PYTHONPATH=os.pathsep.join([tmp, str(ROOT)]),
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SOCKETIOD)],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.fail("socketiod kept running although SERIAL_CONSOLE_ENABLED=False")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("SERIAL_CONSOLE_ENABLED", result.stderr)
|
||||
|
|
@ -140,8 +140,6 @@ class ConsoleViewsTestCase(TestCase):
|
|||
@override_settings(SERIAL_CONSOLE_ENABLED=False)
|
||||
@patch("console.views.wvmInstance")
|
||||
def test_console_pty_refused_when_serial_console_disabled(self, mock_wvm):
|
||||
# socketiod is unauthenticated, so the serial console is
|
||||
# disabled unless an admin explicitly enables it.
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.get_console_type.return_value = "pty"
|
||||
mock_wvm.return_value = mock_conn
|
||||
|
|
@ -519,7 +517,7 @@ class NovncdDaemonLogicTestCase(TestCase):
|
|||
"No console token provided in cookie or query parameters"
|
||||
)
|
||||
|
||||
@patch("instances.models.Instance.objects.get")
|
||||
@patch("console.tokens.authorize")
|
||||
@patch("vrtManager.instance.wvmInstance")
|
||||
def test_get_connection_infos_valid_signed_token(self, mock_wvm, mock_inst_get):
|
||||
User = get_user_model()
|
||||
|
|
|
|||
27
console/tokens.py
Normal file
27
console/tokens.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""The console token: the console view signs "host:uuid:user_id" for the VM
|
||||
whose console it shows; the console daemons (novncd, socketiod) accept it for
|
||||
120 s if that user may still open that VM's console. A bearer token: whoever
|
||||
holds it within that time gets the console."""
|
||||
|
||||
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
|
||||
|
||||
SIGNER = TimestampSigner(salt="console.novnc")
|
||||
MAX_AGE = 120
|
||||
|
||||
|
||||
def authorize(token):
|
||||
"""The Instance the token opens; PermissionError otherwise."""
|
||||
try:
|
||||
host, uuid, user_id = SIGNER.unsign(token, max_age=MAX_AGE).split(":")
|
||||
host, user_id = int(host), int(user_id)
|
||||
except (BadSignature, SignatureExpired, ValueError, TypeError):
|
||||
raise PermissionError("Invalid or expired console token")
|
||||
instance = Instance.objects.filter(compute_id=host, uuid=uuid).select_related("compute").first()
|
||||
user = get_user_model().objects.filter(id=user_id, is_active=True).first()
|
||||
if instance is None or user is None or not can_open_console(user, instance):
|
||||
raise PermissionError("No access to this console")
|
||||
return instance
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
import re
|
||||
|
||||
from appsettings.settings import app_settings
|
||||
from console import tokens
|
||||
from django.conf import settings
|
||||
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
||||
from django.core.signing import BadSignature, SignatureExpired
|
||||
from django.http.response import HttpResponseForbidden
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
|
@ -52,7 +53,7 @@ def console(request):
|
|||
request.GET.get("clip_viewport"), default=clip_viewport
|
||||
)
|
||||
|
||||
signer = TimestampSigner(salt="console.novnc")
|
||||
signer = tokens.SIGNER
|
||||
host = None
|
||||
uuid = None
|
||||
|
||||
|
|
@ -126,30 +127,13 @@ def console(request):
|
|||
if console_type == "vnc":
|
||||
console_page = "console-vnc-" + view_type + ".html"
|
||||
response = render(request, console_page, locals())
|
||||
elif console_type == "pty" and not getattr(
|
||||
settings, "SERIAL_CONSOLE_ENABLED", False
|
||||
):
|
||||
# socketiod does not authenticate connections yet, so
|
||||
# the serial console stays off unless an admin explicitly enables it.
|
||||
elif console_type == "pty" and not settings.SERIAL_CONSOLE_ENABLED:
|
||||
console_error = _(
|
||||
"Serial console is disabled on this server. "
|
||||
"Please switch VM graphics to VNC or ask an administrator."
|
||||
)
|
||||
response = render(request, "console-vnc-lite.html", locals())
|
||||
elif console_type == "pty":
|
||||
socketio_public_host = getattr(settings, "SOCKETIO_PUBLIC_HOST", None)
|
||||
socketio_public_port = getattr(settings, "SOCKETIO_PUBLIC_PORT", 6081)
|
||||
socketio_public_path = getattr(settings, "SOCKETIO_PUBLIC_PATH", "socket.io/")
|
||||
|
||||
socketio_host = (
|
||||
socketio_public_host if socketio_public_host else request.get_host()
|
||||
)
|
||||
socketio_port = socketio_public_port if socketio_public_port else 6081
|
||||
socketio_path = socketio_public_path if socketio_public_path else "/"
|
||||
|
||||
if ":" in socketio_host:
|
||||
socketio_host = re.sub(":[0-9]+", "", socketio_host)
|
||||
|
||||
response = render(request, "console-xterm.html", locals())
|
||||
else:
|
||||
if console_type is None:
|
||||
|
|
|
|||
|
|
@ -137,12 +137,6 @@ HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
|
|||
ALLOWED_HTML_SINKS = {
|
||||
("accounts/templates/login.html", "$btn.html("): "static spinner markup",
|
||||
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
|
||||
("console/templates/console-xterm.html", "status.innerHTML = '<span style=\"background-color: lightgreen;\">connected</span>'"): "static text",
|
||||
("console/templates/console-xterm.html", "button.innerHTML = 'Disconnect'"): "static text",
|
||||
("console/templates/console-xterm.html", "status.innerHTML = '<span style=\"background-color: #ff8383;\">disconnected</span>'"): "static text",
|
||||
("console/templates/console-xterm.html", "button.innerHTML = 'Connect'"): "static text",
|
||||
("console/templates/console-xterm.html", "if (button.innerHTML =='Connect'){"): "comparison, not a write",
|
||||
("console/templates/console-xterm.html", 'else if (button.innerHTML == "Disconnect"){'): "comparison, not a write",
|
||||
("instances/templates/create_instance_w2.html", "$('#img-list').html(selected_list_html);"): "superuser-only; tracked as U-04",
|
||||
("instances/templates/create_instance_w2.html", "$('#net-list').html(selected_list_html);"): "superuser-only; tracked as U-04",
|
||||
("instances/templates/instance.html", "//sto_input.innerHTML = pool;"): "comment",
|
||||
|
|
|
|||
|
|
@ -288,26 +288,16 @@ WS_PUBLIC_PATH = "novncd/"
|
|||
# Websock Certificate for SSL
|
||||
WS_CERT = None
|
||||
|
||||
# Serial console (xterm.js over socketiod). Disabled by default: socketiod does
|
||||
# not authenticate connections yet, so anyone who can reach SOCKETIO_PORT can
|
||||
# read and write an open serial console. Only enable it on a trusted network,
|
||||
# and also start the socketiod service (autostart=false in the supervisor config).
|
||||
SERIAL_CONSOLE_ENABLED = False
|
||||
# Serial console (xterm.js over socketiod) for VMs without graphics. socketiod
|
||||
# accepts only the console page's signed token (as noVNC does); while this is
|
||||
# False it refuses every connection. Restart socketiod after changing it.
|
||||
SERIAL_CONSOLE_ENABLED = os.getenv("WEBVIRTCLOUD_SERIAL_CONSOLE") == "1"
|
||||
|
||||
SOCKETIO_PORT = 6081
|
||||
# Listen on localhost only and reach socketiod through the nginx /socket.io/
|
||||
# location (set SOCKETIO_PUBLIC_PORT to 80 or 443 then).
|
||||
# Listen on localhost only; the console page reaches socketiod through the
|
||||
# nginx /socket.io/ location.
|
||||
SOCKETIO_HOST = "127.0.0.1"
|
||||
|
||||
# Socketio public host
|
||||
SOCKETIO_PUBLIC_HOST = None
|
||||
|
||||
# Socketio public port - 80 or 443 if reverse-proxy, else 6081
|
||||
SOCKETIO_PUBLIC_PORT = 6081
|
||||
|
||||
# Socketio public path
|
||||
SOCKETIO_PUBLIC_PATH = "socket.io/"
|
||||
|
||||
# List of console listen addresses
|
||||
QEMU_CONSOLE_LISTENER_ADDRESSES = (
|
||||
("127.0.0.1", _("Localhost")),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue