diff options
Diffstat (limited to 'tests/vm_boot_acceptance.py')
| -rw-r--r-- | tests/vm_boot_acceptance.py | 129 |
1 files changed, 129 insertions, 0 deletions
diff --git a/tests/vm_boot_acceptance.py b/tests/vm_boot_acceptance.py new file mode 100644 index 0000000..9b7b1d9 --- /dev/null +++ b/tests/vm_boot_acceptance.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""CLI/systemd acceptance on the supplied ALT VM. No successful TUN core is assumed.""" +import argparse +import contextlib +import json +import os +from pathlib import Path +import pwd +import signal +import socket +import sqlite3 +import subprocess +import time + +ROOT = Path('/var/tmp/roentgen-implementation') +BIN = str(ROOT / 'roentgen') + + +def run(*args, timeout=30, check=True): + result = subprocess.run(args, check=check, text=True, capture_output=True, timeout=timeout) + return result.stdout.strip() + + +def require_vm(): + assert os.geteuid() == 0 and socket.gethostname() == 'altctlrs' + assert (ROOT / 'allow-network-tests').exists() + + +def stage(): + require_vm() + for directory in ['/etc/roentgen', '/var/lib/roentgen']: + Path(directory).mkdir(mode=0o700, exist_ok=True) + try: + pwd.getpwnam('roentgen-xray') + except KeyError: + run('useradd', '--system', '--user-group', '--no-create-home', '--shell', '/sbin/nologin', 'roentgen-xray') + paths = { + 'roentgen.service': '/etc/systemd/system/roentgen.service', + 'roentgen-guard.service': '/etc/systemd/system/roentgen-guard.service', + 'roentgen-xray.service': '/etc/systemd/system/roentgen-xray.service', + 'org.roentgen.Controller1.conf': '/etc/dbus-1/system.d/org.roentgen.Controller1.conf', + 'org.roentgen.policy': '/usr/share/polkit-1/actions/org.roentgen.policy', + } + for name, destination in paths.items(): + Path(destination).write_bytes((ROOT / 'assets' / name).read_bytes()) + Path(destination).chmod(0o644) + for unit in ['NetworkManager', 'systemd-networkd', 'network']: + target = Path(f'/etc/systemd/system/{unit}.service.d/50-roentgen-guard.conf') + target.parent.mkdir(exist_ok=True) + target.write_bytes((ROOT / 'assets/roentgen-network-guard.conf').read_bytes()) + binary = Path('/usr/local/bin/roentgen') + binary.write_bytes(Path(BIN).read_bytes()); binary.chmod(0o755) + guard = Path('/etc/roentgen/guard.toml') + if not guard.exists(): guard.write_text('version = 1\nenabled = false\n') + run('systemctl', 'daemon-reload') + run('busctl', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig') + run('systemctl', 'start', 'roentgen-guard.service', 'roentgen.service') + + +def enabled_fixture(): + run('systemctl', 'stop', 'roentgen.service') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + database.execute('UPDATE state SET enabled=1,selected=NULL WHERE singleton=1') + Path('/etc/roentgen/guard.toml').write_text('version = 1\nenabled = true\n') + run(BIN, 'guard') + run('systemctl', 'start', 'roentgen.service') + status = json.loads(run(BIN, 'status', '--json')) + assert status['requested_enabled'] and status['routing'] == 'blocked' + + +def hang_recovery(): + stage(); enabled_fixture() + pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + os.kill(pid, signal.SIGSTOP) + try: + rules = run('nft', 'list', 'table', 'inet', 'roentgen') + assert 'policy drop' in rules and 'oifname "roentgen0" accept' not in rules + started = time.monotonic() + run(BIN, 'recover', '--disable', timeout=25) + elapsed = time.monotonic() - started + assert elapsed < 20, f'Hung controller recovery took {elapsed:.1f}s' + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + assert database.execute('SELECT enabled FROM state').fetchone()[0] == 0 + assert 'policy drop' not in run('nft', 'list', 'table', 'inet', 'roentgen') + print(json.dumps({'passed':['guard remains armed while controller is stopped', 'explicit recovery terminates a hung controller and persists disabled intent before releasing protection'], 'recovery_seconds':round(elapsed, 2)}, indent=2)) + finally: + with contextlib.suppress(ProcessLookupError): os.kill(pid, signal.SIGCONT) + + +def prepare_boot(): + stage(); enabled_fixture() + run('systemctl', 'enable', 'roentgen-guard.service', 'roentgen.service') + (ROOT / 'before-boot-id').write_text(Path('/proc/sys/kernel/random/boot_id').read_text()) + print('Enabled/blocked boot fixture prepared; reboot the VM next.') + + +def verify_boot(): + require_vm() + assert Path('/proc/sys/kernel/random/boot_id').read_text() != (ROOT / 'before-boot-id').read_text() + status = json.loads(run(BIN, 'status', '--json')) + assert status['requested_enabled'] and status['selected_configuration'] is None and status['routing'] == 'blocked' + def timestamp(unit): + return int(run('systemctl', 'show', '--value', '-p', 'ActiveEnterTimestampMonotonic', unit)) + guard, manager = timestamp('roentgen-guard.service'), timestamp('NetworkManager.service') + assert 0 < guard < manager, f'Guard/NetworkManager ordering violated: {guard}, {manager}' + rules = run('nft', 'list', 'table', 'inet', 'roentgen') + assert 'policy drop' in rules and 'oifname "roentgen0" accept' not in rules + old_pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + os.kill(old_pid, signal.SIGKILL) + assert 'policy drop' in run('nft', 'list', 'table', 'inet', 'roentgen') + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + pid = int(run('systemctl', 'show', '--value', '-p', 'MainPID', 'roentgen.service')) + if pid and pid != old_pid: + result = subprocess.run([BIN, 'status', '--json'], text=True, capture_output=True, timeout=2) + if result.returncode == 0: + assert json.loads(result.stdout)['requested_enabled'] + break + time.sleep(0.2) + else: raise AssertionError('Controller did not recover from SIGKILL') + run(BIN, 'recover', '--disable') + print(json.dumps({'passed':['normal reboot restores enabled/blocked intent', 'guard becomes active before NetworkManager', 'SIGKILL does not release guard protection', 'systemd restarts the controller without disabling intent'], 'guard_active_us':guard, 'network_manager_active_us':manager}, indent=2)) + + +if __name__ == '__main__': + parser=argparse.ArgumentParser() + parser.add_argument('mode', choices=['hang', 'prepare-boot', 'verify-boot']) + mode=parser.parse_args().mode + {'hang':hang_recovery, 'prepare-boot':prepare_boot, 'verify-boot':verify_boot}[mode]() |