summaryrefslogtreecommitdiff
path: root/tests/vm_boot_acceptance.py
blob: 9b7b1d995198f808856c3c4eb6666d1850729031 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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]()