mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
- The worker told why a console did not open or closed only in the user's terminal (as users see errors: a host that cannot be reached is not named). It now also logs the full reason to socketiod's own stderr, the journal or docker logs, which socketiod hands it as a descriptor. - A refused token says the link has expired or the user may no longer open the console: a valid link for a user who lost access was told only that it had expired. - A send that takes nothing (0 bytes) waits like a busy stream instead of spinning.
254 lines
12 KiB
Python
254 lines
12 KiB
Python
"""socketiod: who may connect, and each session's console kept to itself."""
|
|
|
|
import os
|
|
import select
|
|
import tempfile
|
|
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, self.user))
|
|
|
|
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.killed_before_fork = []
|
|
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):
|
|
self.killed_before_fork.append(self.d.os.kill.call_count)
|
|
master, worker_side = os.openpty()
|
|
self.addCleanup(lambda: [os.close(fd) for fd in (master, worker_side) if _open(fd)])
|
|
return next(self.forks), master
|
|
|
|
@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 refused(self, sid, auth):
|
|
with self.assertRaises(self.d.socketio.exceptions.ConnectionRefusedError) as refusal, \
|
|
self.assertLogs("socketiod", level="INFO") as log: # the reason is logged too
|
|
self.d.connect(sid, {}, auth)
|
|
self.assertIn(refusal.exception.error_args["message"], log.output[0])
|
|
return refusal.exception.error_args["message"]
|
|
|
|
def test_refused_connections_start_nothing_and_say_why(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.assertIn("turned off", self.refused("off", {"token": self.token()}))
|
|
with override_settings(SERIAL_CONSOLE_ENABLED=True):
|
|
self.assertIn("expired", self.refused("no-auth", None))
|
|
self.assertIn("expired", self.refused("bad", {"token": "x"}))
|
|
self.assertIsNone(self.d.connect("first", {}, {"token": self.token()}))
|
|
# one session per VM
|
|
self.assertIn("another window", self.refused("second", {"token": self.token()}))
|
|
with patch.object(self.d, "MAX_SESSIONS", 1):
|
|
self.assertIn("Too many", self.refused("over", {"token": self.token(vm=other)}))
|
|
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", side_effect=lambda fd, data: len(data)) 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)
|
|
self.assertEqual(write.call_count, 1)
|
|
self.assertEqual((write.call_args.args[0], bytes(write.call_args.args[1])), (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"]
|
|
session = self.d.sessions["sid-1"]
|
|
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)
|
|
# a closed session's fd number may belong to a new session: nothing uses it
|
|
self.assertFalse(session["open"])
|
|
with patch.object(self.d.os, "write") as write:
|
|
self.d.pty_input("sid-1", {"input": "x"})
|
|
session_input = self.d.sessions.get("sid-1")
|
|
write.assert_not_called()
|
|
self.assertIsNone(session_input)
|
|
|
|
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
|
def test_a_superuser_takes_over_a_console_left_open(self):
|
|
admin = get_user_model().objects.create_superuser("root", "root@example.com", "pw")
|
|
self.d.connect("owner-tab", {}, {"token": self.token()})
|
|
old = self.d.sessions["owner-tab"]
|
|
with patch.object(self.d.sio, "emit") as emit, patch.object(self.d.sio, "disconnect") as disconnect:
|
|
self.assertIsNone(self.d.connect("admin-tab", {}, {"token": self.token(admin)}))
|
|
self.assertEqual(list(self.d.sessions), ["admin-tab"])
|
|
self.assertFalse(old["open"])
|
|
# the old worker was killed before the new one started: libvirt gives the console to one
|
|
self.assertEqual(self.killed_before_fork, [0, 1])
|
|
self.assertIn("An administrator opened this console", emit.call_args.args[1]["output"])
|
|
disconnect.assert_called_once_with("owner-tab")
|
|
|
|
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
|
def test_two_connections_at_once_start_one_console(self):
|
|
import threading
|
|
|
|
barrier, results = threading.Barrier(8), []
|
|
|
|
def connect(n):
|
|
barrier.wait()
|
|
try:
|
|
results.append(self.d.connect(f"sid-{n}", {}, {"token": self.token()}))
|
|
except self.d.socketio.exceptions.ConnectionRefusedError:
|
|
results.append("refused")
|
|
|
|
threads = [threading.Thread(target=connect, args=(n,)) for n in range(8)]
|
|
# the threads have their own database connections, outside this test's transaction
|
|
with patch.object(self.d, "authorize", return_value=(self.vm, self.user)):
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
self.assertEqual(sorted(results, key=str), [None] + ["refused"] * 7)
|
|
self.assertEqual(len(self.d.sessions), 1)
|
|
|
|
|
|
class PasteTestCase(Base):
|
|
"""A real worker that echoes every byte twice: a paste far larger than the
|
|
terminal's buffers goes through while its echo comes back. With the
|
|
session lock held during a blocking write, the paste and its echo
|
|
waited on each other for ever."""
|
|
|
|
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
|
def test_a_large_paste_and_its_echo_do_not_wait_on_each_other(self):
|
|
import threading
|
|
|
|
d = load_socketiod()
|
|
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as script:
|
|
# raw already: socketiod made the terminal raw before starting it
|
|
script.write("import os\nwhile (data := os.read(0, 4096)):\n os.write(1, data * 2)\n")
|
|
self.addCleanup(os.unlink, script.name)
|
|
echoed = []
|
|
patches = [patch.object(d, "CONSOLECALLBACK", script.name), patch.object(d.db, "close_old_connections"),
|
|
patch.object(d.sio, "emit", side_effect=lambda event, data, to: echoed.append(len(data["output"]))),
|
|
patch.object(d.sio, "disconnect")]
|
|
for p in patches:
|
|
p.start()
|
|
self.addCleanup(p.stop)
|
|
d.connect("sid-1", {}, {"token": self.token()})
|
|
self.addCleanup(d.stop, "sid-1")
|
|
paste = threading.Thread(target=d.pty_input, args=("sid-1", {"input": "x" * 1_000_000}), daemon=True)
|
|
paste.start()
|
|
paste.join(30)
|
|
self.assertFalse(paste.is_alive(), "the paste never finished")
|
|
deadline = time.monotonic() + 30
|
|
while sum(echoed) < 2_000_000 and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
self.assertEqual(sum(echoed), 2_000_000)
|
|
|
|
|
|
class WorkerIsolationTestCase(Base):
|
|
"""A real fork: a session's worker holds no other session's console."""
|
|
|
|
@override_settings(SERIAL_CONSOLE_ENABLED=True)
|
|
def test_a_worker_has_only_its_own_terminal(self):
|
|
d = load_socketiod()
|
|
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 tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as script:
|
|
# stands in for consolecallback: prints the descriptors it holds
|
|
script.write("import os, time\nprint(sorted(os.listdir('/proc/self/fd')))\ntime.sleep(5)\n")
|
|
self.addCleanup(os.unlink, script.name)
|
|
with patch.object(d, "CONSOLECALLBACK", script.name), patch.object(d.sio, "start_background_task"), \
|
|
patch.object(d.db, "close_old_connections"):
|
|
d.connect("first", {}, {"token": self.token()})
|
|
d.connect("second", {}, {"token": self.token(vm=other)})
|
|
self.addCleanup(lambda: [d.stop(sid) for sid in list(d.sessions)])
|
|
output, fd = b"", d.sessions["second"]["fd"]
|
|
while b"]" not in output:
|
|
select.select([fd], [], [], 5)
|
|
output += os.read(fd, 1024)
|
|
# 0-2: its terminal, 3: socketiod's log, 4: the listing itself
|
|
self.assertEqual(output.decode().strip(), "['0', '1', '2', '3', '4']")
|
|
|
|
|
|
def _open(fd):
|
|
try:
|
|
os.fstat(fd)
|
|
except OSError:
|
|
return False
|
|
return True
|