diff --git a/README.md b/README.md index 2239f0cc..52c4fe9f 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ pip install -r conf/requirements.txt python3 manage.py migrate python3 manage.py collectstatic --noinput -# 6. Configure Supervisor (gunicorn, novncd and socketiod, running as nginx) +# 6. Configure Supervisor (gunicorn and novncd, running as nginx; socketiod is disabled by default) sed 's/^user=.*/user=nginx/' conf/supervisor/webvirtcloud.conf | sudo tee /etc/supervisord.d/webvirtcloud.ini > /dev/null # 7. Configure Nginx @@ -180,7 +180,6 @@ sudo setsebool -P httpd_can_network_connect on 2>/dev/null || true sudo firewall-cmd --add-service=http --permanent 2>/dev/null || true sudo firewall-cmd --add-port=6080/tcp --permanent 2>/dev/null || true -sudo firewall-cmd --add-port=6081/tcp --permanent 2>/dev/null || true sudo firewall-cmd --reload 2>/dev/null || true # 9. Start and enable services @@ -217,7 +216,7 @@ python3 manage.py collectstatic --noinput # 6. Configure Nginx sudo cp conf/nginx/suse_nginx.conf /etc/nginx/vhosts.d/webvirtcloud.conf 2>/dev/null || sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -# 7. Configure Supervisor (gunicorn, novncd and socketiod, running as nginx) +# 7. Configure Supervisor (gunicorn and novncd, running as nginx; socketiod is disabled by default) sudo zypper --non-interactive install -y python3-supervisor || sudo zypper --non-interactive install -y supervisor sudo mkdir -p /etc/supervisord.d sed 's/^user=.*/user=nginx/' conf/supervisor/webvirtcloud.conf | sudo tee /etc/supervisord.d/webvirtcloud.ini > /dev/null @@ -279,7 +278,7 @@ python manage.py runserver 0.0.0.0:8000 --settings=webvirtcloud.settings-dev --n Open `http://127.0.0.1:8000`. `settings-dev` enables `DEBUG` and the Django Debug Toolbar. `--nostatic` is required: the CSS/JS live in `static/` (`STATIC_ROOT`), which WhiteNoise serves but runserver's own static handler does not. -For the browser consoles, start the websocket proxies in separate terminals: `python console/novncd` (VNC, port 6080) and `python console/socketiod` (serial/xterm.js, port 6081). +For the browser console, start the websocket proxy in a separate terminal: `python console/novncd` (VNC, port 6080). The serial console (`python console/socketiod`, port 6081) is disabled by default; see [Serial Console](#serial-console-disabled-by-default). ## Compute Node (Hypervisor) Setup @@ -380,6 +379,15 @@ datasource: metadata_urls: [ "http://webvirtcloud.domain.com/datasource" ] ``` +### Serial Console (disabled by default) + +The xterm.js serial console is served by `console/socketiod`, which does **not authenticate connections yet**: anyone who can reach its port (6081) can read and write a serial console that someone has opened. Until this is fixed, the serial console is disabled: + +- the console page shows "Serial console is disabled" for VMs with a `pty` console, and +- `socketiod` refuses to start, and is not started by the bundled supervisor/systemd configs. + +To enable it anyway on a trusted network, set `SERIAL_CONSOLE_ENABLED = True` in `webvirtcloud/settings.py`, set `autostart=true` for `[program:socketiod]` in the supervisor config (or `systemctl enable --now webvirt-socketiod`), and restrict access to port 6081 with a firewall. Existing installs whose `settings.py` predates this setting are treated as disabled. + ### Reverse-Proxy & Port Forwarding If WebVirtCloud runs behind a reverse proxy terminating SSL or forwarding port 80/443, set the public console ports in `webvirtcloud/settings.py` (defaults: 6080 for noVNC, 6081 for Socket.IO). The bundled nginx config already proxies `/novncd/` and `/socket.io/`: diff --git a/conf/supervisor/webvirtcloud.conf b/conf/supervisor/webvirtcloud.conf index a554f0dd..d0e4c6ca 100644 --- a/conf/supervisor/webvirtcloud.conf +++ b/conf/supervisor/webvirtcloud.conf @@ -14,10 +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. [program:socketiod] -command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod -d +command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/socketiod directory=/srv/webvirtcloud user=www-data -autostart=true +autostart=false autorestart=true redirect_stderr=true diff --git a/conf/systemd/install.sh b/conf/systemd/install.sh index 5fc9d07a..72624876 100755 --- a/conf/systemd/install.sh +++ b/conf/systemd/install.sh @@ -4,4 +4,7 @@ SCRIPTPATH=$(dirname "$FILEPATH"); cd "$SCRIPTPATH" cp webvirt-* /lib/systemd/system/ cp webvirt /etc/default/ -echo Run to start services \"systemctl daemon-reload\; systemctl enable --now $(ls webvirt-* | tr "\n" " ")\" +# 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" " ")\" diff --git a/conf/systemd/webvirt-socketiod.service b/conf/systemd/webvirt-socketiod.service index 741f0dce..d2e525a8 100644 --- a/conf/systemd/webvirt-socketiod.service +++ b/conf/systemd/webvirt-socketiod.service @@ -1,5 +1,5 @@ [Unit] -Description=Webvirt Cloud NOVNCD +Description=Webvirt Cloud serial console (socketiod; requires SERIAL_CONSOLE_ENABLED = True) Wants=network-online.target After=network-online.target @@ -13,7 +13,7 @@ LimitNOFILE=64000 AmbientCapabilities=CAP_NET_BIND_SERVICE EnvironmentFile=/etc/default/webvirt -ExecStart=/bin/bash -c "cd ${WEBVIRT_PATH};${WEBVIRT_PATH}/venv/bin/python3 ${WEBVIRT_PATH}/console/socketiod -d" +ExecStart=/bin/bash -c "cd ${WEBVIRT_PATH};${WEBVIRT_PATH}/venv/bin/python3 ${WEBVIRT_PATH}/console/socketiod" # Make sure stderr/stdout is captured in the systemd journal. StandardOutput=journal diff --git a/console/socketiod b/console/socketiod index 18895861..ecc93fdb 100755 --- a/console/socketiod +++ b/console/socketiod @@ -17,6 +17,19 @@ if ROOT_PATH not in sys.path: django.setup() +from django.conf import settings as django_settings + +# socketiod does not authenticate connections yet (ROADMAP S-01): 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) + import re import tempfile import io diff --git a/console/test_socketiod_disabled.py b/console/test_socketiod_disabled.py new file mode 100644 index 00000000..6f4fc591 --- /dev/null +++ b/console/test_socketiod_disabled.py @@ -0,0 +1,57 @@ +import os +import signal +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOCKETIOD = ROOT / "console" / "socketiod" + + +class SocketiodDisabledTestCase(unittest.TestCase): + """ROADMAP S-01: socketiod must refuse to start unless explicitly enabled.""" + + def test_socketiod_exits_when_serial_console_disabled(self): + settings_dir = ROOT / "console" / "_test_settings" + settings_dir.mkdir(exist_ok=True) + try: + (settings_dir / "__init__.py").write_text("") + (settings_dir / "disabled.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="console._test_settings.disabled", + PYTHONPATH=str(ROOT), + ) + # Other test modules import novncd, which sets SIGCHLD to SIG_IGN in + # this process; with that, the child's exit status is lost (always 0). + previous_sigchld = signal.signal(signal.SIGCHLD, signal.SIG_DFL) + 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") + finally: + signal.signal(signal.SIGCHLD, previous_sigchld) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("SERIAL_CONSOLE_ENABLED", result.stderr) + finally: + for f in settings_dir.glob("*"): + if f.is_file(): + f.unlink() + for d in settings_dir.glob("__pycache__"): + for f in d.glob("*"): + f.unlink() + d.rmdir() + settings_dir.rmdir() diff --git a/console/tests.py b/console/tests.py index 5cdc4e45..e5abae1a 100644 --- a/console/tests.py +++ b/console/tests.py @@ -121,6 +121,7 @@ class ConsoleViewsTestCase(TestCase): self.assertTemplateUsed(response, "console-vnc-lite.html") self.assertIn("SPICE", str(response.context["console_error"])) + @override_settings(SERIAL_CONSOLE_ENABLED=True) @patch("console.views.wvmInstance") def test_console_superuser_pty(self, mock_wvm): mock_conn = MagicMock() @@ -134,6 +135,23 @@ class ConsoleViewsTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "console-xterm.html") + @override_settings(SERIAL_CONSOLE_ENABLED=False) + @patch("console.views.wvmInstance") + def test_console_pty_refused_when_serial_console_disabled(self, mock_wvm): + # ROADMAP S-01: 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 + + self.client.force_login(self.admin_user) + url = reverse("console") + f"?token={self.token}" + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateNotUsed(response, "console-xterm.html") + self.assertIn("Serial console is disabled", str(response.context["console_error"])) + @patch("console.views.wvmInstance") def test_console_user_assigned_instance_access(self, mock_wvm): mock_conn = MagicMock() diff --git a/console/views.py b/console/views.py index a4350bd0..40ccce3f 100644 --- a/console/views.py +++ b/console/views.py @@ -133,6 +133,16 @@ 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 (ROADMAP S-01), so + # the serial console stays off unless an admin explicitly enables it. + 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) diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index c4c3e16c..5c5b3a61 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -218,6 +218,12 @@ 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 + SOCKETIO_PORT = 6081 SOCKETIO_HOST = "0.0.0.0"