1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 20:26:48 +00:00

fix(console): a serial session follows its user's access; a superuser takes over

- The console worker learns whose session it is and checks every ten
  seconds that the user may still open the console: an owner removed or a
  user deactivated meanwhile loses it, with a line in the terminal. Access
  was checked only when the session started, which could last for days.
- One console per VM: a console left open in a forgotten tab locked
  everyone out, superusers included. A superuser's connection now takes
  the console over, and the other window is told so.
This commit is contained in:
catborise 2026-10-26 19:27:31 +03:00
parent 1d48cc6881
commit 82af555d45
8 changed files with 104 additions and 18 deletions

View file

@ -7,6 +7,13 @@ from pathlib import Path
from unittest import TestCase
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.test import TestCase as DjangoTestCase
from accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
CONSOLECALLBACK = Path(__file__).resolve().parent.parent.parent / "conf" / "daemon" / "consolecallback"
@ -57,3 +64,19 @@ class ConsoleWorkerTests(TestCase):
self.cb.from_client(None, 0, None, self.console)
self.assertFalse(self.console.running)
self.console.stream.send.assert_not_called()
class AccessTests(DjangoTestCase):
"""The worker ends a session whose user may no longer open the console."""
def test_an_owner_removed_meanwhile_loses_the_console(self):
cb = load()
compute = Compute.objects.create(name="c", hostname="192.0.2.1", login="u", password="p", type=1)
vm = Instance.objects.create(compute=compute, name="vm-a", uuid="11111111-0000-0000-0000-000000000009")
owner = get_user_model().objects.create_user("owner", password="pw")
ownership = UserInstance.objects.create(user=owner, instance=vm)
with patch.object(cb.db.connection, "close"): # the test's transaction keeps its connection
self.assertTrue(cb.may_still_open(owner.pk, vm))
ownership.delete()
self.assertFalse(cb.may_still_open(owner.pk, vm))
self.assertFalse(cb.may_still_open(999999, vm))

View file

@ -23,6 +23,12 @@ from instances.tests import livetest
P = livetest.PREFIX
def wait_for(condition, seconds=30):
deadline = time.monotonic() + seconds
while not condition() and time.monotonic() < deadline:
time.sleep(0.2)
def serial_vm(conn, name):
"""A paused VM without graphics whose BIOS writes to the serial console once resumed."""
dom = conn.defineXML(
@ -57,6 +63,11 @@ class LiveSerialConsoleTestCase(TransactionTestCase):
UserInstance.objects.create(user=self.user, instance=vm)
return tokens.SIGNER.sign(f"{compute.id}:{vm.uuid}:{self.user.id}"), dom
def admin_token(self, token):
host, uuid, _user = tokens.SIGNER.unsign(token).split(":")
admin, _ = get_user_model().objects.get_or_create(username="serial-live-admin", is_superuser=True)
return tokens.SIGNER.sign(f"{host}:{uuid}:{admin.id}")
def test_the_console_of_a_running_vm_opens_once(self):
for args in self.hosts:
with self.subTest(host=args[0], type=args[3]):
@ -68,12 +79,17 @@ class LiveSerialConsoleTestCase(TransactionTestCase):
while "SeaBIOS" not in "".join(received["output"]) and time.monotonic() < deadline:
time.sleep(0.2)
self.assertIn("SeaBIOS", "".join(received["output"]))
# one client per VM
# one client per VM; a superuser takes a console left open over
with self.assertRaises(socketio.exceptions.ConnectionError):
console(self.port, token)
admin_client, admin_received = console(self.port, self.admin_token(token))
wait_for(lambda: "An administrator opened this console" in "".join(received["output"]))
self.assertIn("An administrator opened this console", "".join(received["output"]))
client.disconnect()
deadline = time.monotonic() + 5
while consoles(self.server.pid) and time.monotonic() < deadline:
time.sleep(0.05)
self.assertEqual(consoles(self.server.pid), [])
# the VM stops: the terminal says so
dom.destroy()
wait_for(lambda: "not running" in "".join(admin_received["output"]))
self.assertIn("The VM is not running", "".join(admin_received["output"]))
admin_client.disconnect()
wait_for(lambda: not consoles(self.server.pid))
self.assertEqual(consoles(self.server.pid), [])

View file

@ -40,7 +40,7 @@ class Base(TestCase):
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.assertEqual(tokens.authorize(self.token()), (self.vm, self.user))
def test_refused_tokens(self):
stranger = get_user_model().objects.create_user("stranger", password="pw")
@ -142,6 +142,18 @@ class SocketiodTestCase(Base):
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"])
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
@ -157,7 +169,7 @@ class SocketiodTestCase(Base):
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):
with patch.object(self.d, "authorize", return_value=(self.vm, self.user)):
for t in threads:
t.start()
for t in threads:

View file

@ -606,7 +606,7 @@ class NovncdDaemonLogicTestCase(TestCase):
mock_instance.compute.password = ""
mock_instance.compute.type = 1
mock_instance.name = "test-vm"
mock_inst_get.return_value = mock_instance
mock_inst_get.return_value = (mock_instance, user)
mock_conn = MagicMock()
mock_conn.get_console_listener_addr.return_value = "127.0.0.1"