mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
pty.fork returns an inheritable master descriptor, so each new console worker, and the ssh it may start, held the terminals of every other open session until it ended. A worker now closes every descriptor above its own terminal before it starts.
162 lines
7.2 KiB
Python
162 lines
7.2 KiB
Python
"""socketiod: who may connect, and each session's console kept to itself."""
|
|
|
|
import os
|
|
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)
|
|
|
|
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)
|
|
|
|
|
|
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 = b""
|
|
while b"]" not in output:
|
|
output += os.read(d.sessions["second"]["fd"], 1024)
|
|
# 0-2: its terminal, 3: the listing itself
|
|
self.assertEqual(output.decode().strip(), "['0', '1', '2', '3']")
|
|
|
|
|
|
def _open(fd):
|
|
try:
|
|
os.fstat(fd)
|
|
except OSError:
|
|
return False
|
|
return True
|