1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/console/tests/test_socketiod_child.py
catborise 4f7bca9754 fix(console): socketiod refuses a request without a signed-in session
Engine.IO accepts a connection, and holds one of socketiod's 40 threads
for it, from its first request; the console token is checked only in
Socket.IO's connect(), which a client need never send. Anyone who could
reach the panel, without an account, could open 40 websockets, answer
their pings, and no serial console would open for anybody (checked: the
41st handshake got no answer until they were closed).

The daemon's WSGI app now refuses a request whose session cookie names
no signed-in user, with 403, before Engine.IO sees it; the page always
sends that cookie. A token without its session no longer connects either,
as for novncd. Checked again: 60 such websockets are each answered 403 at
once and hold nothing.

The process tests sign their client in with a session cookie, as a
browser; python-socketio sends a custom Cookie header only with its first
request, so the client gets a cookie jar instead.
2026-11-06 20:00:43 +03:00

167 lines
7.4 KiB
Python

"""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
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import connection
from django.test import Client, TransactionTestCase
from accounts.models import UserInstance
from computes.models import Compute
from console import tokens
from instances.models import Instance
try:
import requests
import socketio
except ImportError: # pragma: no cover
socketio = None
ROOT = Path(__file__).resolve().parent.parent.parent
SOCKETIOD = ROOT / "console" / "socketiod"
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def children(pid):
"""(pid, state) of the processes whose parent is pid."""
found = []
for entry in os.listdir("/proc"):
try:
with open("/proc/%s/stat" % entry) as f:
fields = f.read().rsplit(")", 1)[1].split()
except (OSError, IndexError):
continue
if int(fields[1]) == pid:
found.append((int(entry), fields[0]))
return found
def consoles(pid):
"""(pid, state) of the console workers: children of socketiod's gunicorn worker."""
return [c for worker, _ in children(pid) for c in children(worker)]
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)]))
test.log = Path(tmp.name) / "socketiod.log" # its stderr, as the journal would have it
server = subprocess.Popen([sys.executable, str(SOCKETIOD), "-H", "127.0.0.1", "-p", str(port)],
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=test.log.open("w"))
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 session_key(user):
"""The session cookie value of user signed in, as the console page's browser has it."""
browser = Client()
browser.force_login(user)
return browser.cookies[settings.SESSION_COOKIE_NAME].value
def console(port, token, session_key):
"""A connected client, signed in with session_key as a browser would be,
and what it receives until the server disconnects it."""
browser = requests.Session() # sends the cookie with each request, not only the first
browser.cookies.set(settings.SESSION_COOKIE_NAME, session_key)
client = socketio.Client(reconnection=False, http_session=browser)
received = {"output": [], "taken_over": [], "gone": threading.Event()}
client.on("pty_output", lambda data: received["output"].append(data["output"]))
client.on("taken_over", lambda data: received["taken_over"].append(data["by"]))
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 SocketiodProcessTestCase(TransactionTestCase):
def setUp(self):
compute = Compute.objects.create(name="local", hostname="localhost", login="", password="", type=4)
user = get_user_model().objects.create_user("serial-owner", password="pw")
self.session_key = session_key(user)
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.sign(compute.id, vm.uuid, user.id, "pty")
self.server, self.port = start_socketiod(self)
def console(self, token, session_key=None):
return console(self.port, token, self.session_key if session_key is None else session_key)
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 consoles(self.server.pid) and time.monotonic() < end:
time.sleep(0.05)
self.assertEqual(consoles(self.server.pid), [])
self.assertIsNone(self.server.poll())
# the operator finds why in socketiod's log, with the details the user is not shown
log = self.log.read_text()
for name in self.tokens:
self.assertIn("serial console of VM %s did not open" % name, log)
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(consoles(self.server.pid), [])
def test_a_request_without_a_session_is_refused_before_it_takes_a_thread(self):
# Engine.IO would hold the connection (and a thread) before connect() checks the token
handshake = "http://127.0.0.1:%d/socket.io/?EIO=4&transport=polling" % self.port
name = settings.SESSION_COOKIE_NAME
for cookie in ("", f"{name}=not-a-session"):
with self.subTest(cookie=cookie), self.assertRaises(HTTPError) as refusal:
urlopen(Request(handshake, headers={"Cookie": cookie}), timeout=10)
self.assertEqual(refusal.exception.code, 403)
self.assertEqual(urlopen(Request(handshake, headers={"Cookie": f"{name}={self.session_key}"}), timeout=10).status, 200)
with self.assertRaises(socketio.exceptions.ConnectionError): # a valid token is no use without the session
self.console(self.tokens["vm-a"], session_key="not-a-session")