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.py
catborise 6d22a18ba7 fix(i18n): refusals and checks users read are translated
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.
2026-11-06 23:48:53 +03:00

271 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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