mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
English-only texts users read, now in their language: - the serial console's four refusals, which socketiod sends in the language LocaleMiddleware would choose (cookie, then browser) and logs in English; the texts live in console/refusals.py, as makemessages reads only .py files; - a submission key reused for another request, a malformed MAC address, the moved-VM refusal of a script's request, the API's "Templates cannot be started" (now the page's sentence), "Pool is not active" and the ISO upload's checks; - "Problem occurred with host" was a fragment with the host's name and error after it: one sentence with both.
271 lines
13 KiB
Python
271 lines
13 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.sign(vm.compute_id, vm.uuid, (user or self.user).id, "pty")
|
||
|
||
|
||
class TokenTestCase(Base):
|
||
def test_a_token_opens_its_vm_for_a_user_who_may_open_it(self):
|
||
self.assertEqual(tokens.authorize(self.token(), "pty"), (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),
|
||
"a VNC page's token": tokens.sign(self.vm.compute_id, self.vm.uuid, self.user.id, "vnc"),
|
||
}
|
||
for name, token in cases.items():
|
||
with self.subTest(name), self.assertRaises(PermissionError):
|
||
tokens.authorize(token, "pty")
|
||
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, "pty")
|
||
|
||
|
||
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=False)
|
||
def test_a_refusal_is_in_the_page_s_language_and_logged_in_english(self):
|
||
# as LocaleMiddleware: the language cookie first, then the browser's
|
||
for environ, shown in (({"HTTP_COOKIE": "django_language=tr", "HTTP_ACCEPT_LANGUAGE": "zh-hans"}, "Seri konsol bu sunucuda kapalı"),
|
||
({"HTTP_ACCEPT_LANGUAGE": "zh-CN,zh;q=0.9"}, "此服务器已关闭串行控制台"),
|
||
({}, "The serial console is turned off on this server")):
|
||
with self.subTest(shown), self.assertRaises(self.d.socketio.exceptions.ConnectionRefusedError) as refusal, \
|
||
self.assertLogs("socketiod", level="INFO") as log:
|
||
self.d.connect("sid", environ, {"token": self.token()})
|
||
self.assertEqual(refusal.exception.error_args["message"], shown)
|
||
self.assertIn("refused: The serial console is turned off on this server", log.output[0])
|
||
|
||
@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"]
|
||
killed_at_emit = []
|
||
with patch.object(self.d.sio, "emit", side_effect=lambda *a, **k: killed_at_emit.append(self.d.os.kill.call_count)) 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])
|
||
emit.assert_called_once_with("taken_over", {"by": "root"}, to="owner-tab")
|
||
# told before its worker was killed: the old page's forward disconnects it then
|
||
self.assertEqual(killed_at_emit, [0])
|
||
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
|