From fc3c1d51b1be9db6ba6ad286584fbc34bb305f38 Mon Sep 17 00:00:00 2001 From: Andrew Guschin Date: Fri, 30 Oct 2026 03:32:00 +0400 Subject: Fix protected activation ordering and diagnostic review findings --- tests/vm_controller_acceptance.py | 114 +++++++++++++++++++++++++++++++++++++- tests/vm_guard_acceptance.py | 16 ++++++ 2 files changed, 128 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/vm_controller_acceptance.py b/tests/vm_controller_acceptance.py index d39b16f..1d3405a 100644 --- a/tests/vm_controller_acceptance.py +++ b/tests/vm_controller_acceptance.py @@ -6,6 +6,13 @@ import json import os from pathlib import Path import socket +import sqlite3 +import pty +import select +import termios +import fcntl +import struct +import pwd import subprocess import threading import time @@ -126,19 +133,122 @@ def main(): results.append('service restart retains explicit selection and disabled intent') editor = ROOT / 'check-editor.py' editor.write_text('import os,pathlib,sys\np=pathlib.Path(sys.argv[-1])\nassert p.suffix==".txt" and (p.stat().st_mode & 0o777)==0o600\nassert p.stat().st_uid==os.getuid()\nassert sys.argv[1:3]==["--literal","two words"]\npathlib.Path("/var/tmp/roentgen-editor-path").write_text(str(p))\n') - env = dict(os.environ, EDITOR=f'python3 {editor} --literal "two words"') + env = dict(os.environ, TMPDIR='/var/tmp', EDITOR=f'python3 {editor} --literal "two words"') cli('logs', '--editor', user='rustacean', env=env) snapshot = Path('/var/tmp/roentgen-editor-path').read_text() assert not Path(snapshot).exists() results.append('editor receives literal arguments and a user-owned private snapshot that is removed afterward') + before = status() + process, terminal = pty.fork() + if process == 0: + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack('HHHH', 32, 120, 0, 0)) + user = pwd.getpwnam('rustacean') + os.initgroups(user.pw_name, user.pw_gid) + os.setgid(user.pw_gid); os.setuid(user.pw_uid) + os.environ['TMPDIR'] = '/var/tmp' + os.execv(BIN, [BIN, 'tui']) + output = b'' + deadline = time.monotonic() + 10 + while b'Connections' not in output and time.monotonic() < deadline: + if select.select([terminal], [], [], 0.2)[0]: output += os.read(terminal, 65536) + assert b'Connections' in output, 'TUI did not render under the invoking ordinary user' + os.write(terminal, b'l?') + time.sleep(0.3) + os.write(terminal, b'\x1b') + time.sleep(0.1) + os.write(terminal, b'q') + done = False + while time.monotonic() < deadline: + if select.select([terminal], [], [], 0.1)[0]: + with contextlib.suppress(OSError): output += os.read(terminal, 65536) + pid, exit_status = os.waitpid(process, os.WNOHANG) + if pid: + assert os.waitstatus_to_exitcode(exit_status) == 0 + done = True + break + if not done: + os.kill(process, 9); os.waitpid(process, 0) + raise AssertionError('TUI did not exit after q') + attributes = termios.tcgetattr(terminal) + assert attributes[3] & termios.ICANON and attributes[3] & termios.ECHO + assert b'\x1b[?1049l' in output + os.close(terminal) + assert status()['selected_configuration'] == before['selected_configuration'] + assert status()['requested_enabled'] == before['requested_enabled'] + results.append('ordinary-user TUI renders, accepts navigation/help/quit, restores terminal modes, and preserves service intent') + # Persisted enabled intent must not bypass a rejected preflight on retry. + command('systemctl', 'stop', 'roentgen.service') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + database.execute('UPDATE state SET enabled=1 WHERE singleton=1') + Path('/etc/roentgen/guard.toml').write_text('version = 1\nenabled = true\n') + cli('guard') + command('systemctl', 'start', 'roentgen.service') + time.sleep(6) + current = status() + assert current['requested_enabled'] and current['routing'] == 'blocked' + assert 'Unsupported Xray release' in current['last_error'] + assert 'roentgen0' not in command('ip', 'link', 'show').stdout + results.append('restart and scheduled retry preserve blocked intent after failed compatibility checks') + # An unset selection must discard stale capture even if a TUN survived. + command('systemctl', 'stop', 'roentgen.service') + command('ip', 'tuntap', 'add', 'dev', 'roentgen0', 'mode', 'tun') + command('ip', 'link', 'set', 'roentgen0', 'up') + command('ip', 'route', 'add', 'default', 'dev', 'roentgen0', 'table', '20260', 'proto', '186') + command('ip', 'rule', 'add', 'priority', '10260', 'fwmark', '0x02000000/0x02000000', 'lookup', '20260') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + database.execute('UPDATE state SET selected=NULL WHERE singleton=1') + command('systemctl', 'start', 'roentgen.service') + assert status()['routing'] == 'blocked' and status()['selected_configuration'] is None + assert '10260' not in command('ip', 'rule', 'show').stdout + assert 'oifname "roentgen0" accept' not in command('nft', 'list', 'table', 'inet', 'roentgen').stdout + command('ip', 'link', 'del', 'roentgen0') + results.append('restart with unset selection removes stale capture and keeps TUN forwarding blocked') + # Recovery does not depend on parsing a broken editable definition file. + config = Path('/etc/roentgen/config.toml') + saved = config.read_bytes() + config.write_text('broken = [') + cli('recover', '--disable') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + assert database.execute('SELECT enabled FROM state').fetchone()[0] == 0 + config.write_bytes(saved) + command('systemctl', 'start', 'roentgen.service') + assert not status()['requested_enabled'] and status()['routing'] == 'direct' + results.append('explicit recovery persists disabled intent even with malformed editable TOML') cli('subscription', 'delete', 'Personal') assert status()['selected_configuration'] is None results.append('subscription deletion clears selection without replacement') + command('systemctl', 'stop', 'roentgen.service') + with sqlite3.connect('/var/lib/roentgen/state.sqlite3') as database: + database.execute('UPDATE state SET enabled=1 WHERE singleton=1') + Path('/etc/roentgen/guard.toml').write_text('version = 1\nenabled = true\n') + cli('guard') + database = Path('/var/lib/roentgen/state.sqlite3') + database.replace('/var/lib/roentgen/state.sqlite3.missing-fixture') + command('systemctl', 'start', 'roentgen.service', ok=False) + assert not database.exists() + assert 'policy drop' in command('nft', 'list', 'table', 'inet', 'roentgen').stdout + cli('recover', '--disable') + with sqlite3.connect(database) as connection: + assert connection.execute('SELECT enabled FROM state').fetchone()[0] == 0 + results.append('missing durable enabled state fails closed; only explicit recovery initializes disabled state') + cli('uninstall') + assert Path('/etc/roentgen/config.toml').exists() and Path('/var/lib/roentgen/state.sqlite3').exists() + assert not target.exists() and not Path('/etc/systemd/system/roentgen.service').exists() + assert 'roentgen' not in command('nft', 'list', 'tables').stdout + # Purge is exercised after re-staging the service assets as a VM fixture. + for source, destination in destinations.items(): + Path(destination).write_bytes((assets / source).read_bytes()) + target.write_bytes(Path(BIN).read_bytes()); target.chmod(0o755) + command('systemctl', 'daemon-reload') + cli('uninstall', '--purge') + assert not Path('/etc/roentgen').exists() and not Path('/var/lib/roentgen').exists() + assert command('/usr/bin/xray', 'version').stdout.startswith('Xray 26.3.27') + results.append('uninstall retains saved data by default, explicit purge removes it, and system Xray remains installed') print(json.dumps({'passed': results}, indent=2)) finally: server.shutdown() if policy.exists(): policy.unlink() - command('systemctl', 'stop', 'roentgen.service', ok=True) + subprocess.run(['systemctl', 'stop', 'roentgen.service'], capture_output=True) if __name__ == '__main__': main() diff --git a/tests/vm_guard_acceptance.py b/tests/vm_guard_acceptance.py index ff99c33..8886d7b 100644 --- a/tests/vm_guard_acceptance.py +++ b/tests/vm_guard_acceptance.py @@ -80,8 +80,13 @@ def serve(host,port): c.sendall(data) while True: c,_=s.accept();threading.Thread(target=handle,args=(c,),daemon=True).start() +def udp(host): + s=socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET,socket.SOCK_DGRAM);s.bind((host,53)) + while True: + data,peer=s.recvfrom(1024);s.sendto(data,peer) for host in ['198.51.100.2','10.254.0.2','2001:db8:726f::2','fded:726f::2']: for port in [53,18080]:threading.Thread(target=serve,args=(host,port),daemon=True).start() + threading.Thread(target=udp,args=(host,),daemon=True).start() time.sleep(120) ''' children.append(subprocess.Popen(['ip', 'netns', 'exec', 'roentgen-peer', 'python3', '-u', '-c', server], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)) @@ -123,6 +128,17 @@ for line in sys.stdin: else: raise AssertionError(f'Public direct fallback leaked to {host}:{port}') results.append('public IPv4/IPv6 destinations are blocked on ports 53 and 18080') + for host, allowed in [('10.254.0.2', True), ('fded:726f::2', True), ('198.51.100.2', False), ('2001:db8:726f::2', False)]: + with socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET, socket.SOCK_DGRAM) as udp: + udp.settimeout(1) + try: + udp.sendto(b'dns-port-observation', (host, 53)) + data, _ = udp.recvfrom(1024) + except OSError: + assert not allowed, f'Private UDP DNS was blocked: {host}' + else: + assert allowed and data == b'dns-port-observation', f'Public UDP DNS leaked: {host}' + results.append('UDP DNS follows the same IPv4/IPv6 private/public destination rule') incoming.stdin.write('enabled\n'); incoming.stdin.flush() assert incoming.stdout.readline().strip() == 'ok' guard(False) -- cgit v1.2.3