summaryrefslogtreecommitdiff
path: root/tests/vm_boot_acceptance.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/vm_boot_acceptance.py')
-rw-r--r--tests/vm_boot_acceptance.py129
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]()