diff options
Diffstat (limited to 'tests/vm_guard_acceptance.py')
| -rw-r--r-- | tests/vm_guard_acceptance.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/tests/vm_guard_acceptance.py b/tests/vm_guard_acceptance.py new file mode 100644 index 0000000..ff99c33 --- /dev/null +++ b/tests/vm_guard_acceptance.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""CLI acceptance checks. Run as root inside the authorized disposable ALT VM.""" +import contextlib +import json +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + +ROOT = Path('/var/tmp/roentgen-implementation') +BINARY = ROOT / 'roentgen' + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=20, **kwargs) + + +def guard(enabled): + temporary = Path('/etc/roentgen/guard.toml.tmp') + temporary.write_text(f'version = 1\nenabled = {str(enabled).lower()}\n') + temporary.chmod(0o600) + temporary.replace('/etc/roentgen/guard.toml') + run(str(BINARY), 'guard') + + +def echo(listener): + while True: + try: + connection, _ = listener.accept() + except OSError: + return + threading.Thread(target=stream, args=(connection,), daemon=True).start() + + +def stream(connection): + with connection: + while data := connection.recv(1024): + connection.sendall(data) + + +def exchange(connection): + connection.sendall(b'roentgen-acceptance') + assert connection.recv(1024) == b'roentgen-acceptance' + + +def connect(host, port): + return socket.create_connection((host, port), timeout=1) + + +def main(): + assert os.geteuid() == 0 + assert socket.gethostname() == 'altctlrs', 'This test is restricted to the supplied VM' + assert (ROOT / 'allow-network-tests').exists(), 'Missing VM-only authorization marker' + Path('/etc/roentgen').mkdir(mode=0o700, exist_ok=True) + run('id', 'roentgen-xray') + run('ip', 'netns', 'add', 'roentgen-peer') + children = [] + listeners = [] + results = [] + try: + run('ip', 'link', 'add', 'rgtest0', 'type', 'veth', 'peer', 'name', 'rgpeer0', 'netns', 'roentgen-peer') + for address in ['198.51.100.1/24', '10.254.0.1/24', '2001:db8:726f::1/64', 'fded:726f::1/64']: + run('ip', 'address', 'add', address, 'dev', 'rgtest0', *(['nodad'] if ':' in address else [])) + run('ip', 'link', 'set', 'rgtest0', 'up') + for address in ['198.51.100.2/24', '10.254.0.2/24', '2001:db8:726f::2/64', 'fded:726f::2/64']: + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'address', 'add', address, 'dev', 'rgpeer0', *(['nodad'] if ':' in address else [])) + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'link', 'set', 'rgpeer0', 'up') + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'link', 'set', 'lo', 'up') + server = '''import socket,threading,time + +def serve(host,port): + s=socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((host,port));s.listen() + def handle(c): + with c: + while True: + data=c.recv(1024) + if not data:return + c.sendall(data) + while True: + c,_=s.accept();threading.Thread(target=handle,args=(c,),daemon=True).start() +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() +time.sleep(120) +''' + children.append(subprocess.Popen(['ip', 'netns', 'exec', 'roentgen-peer', 'python3', '-u', '-c', server], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)) + time.sleep(2) + guard(False) + outgoing = connect('198.51.100.2', 18080) + exchange(outgoing) + listener = socket.socket() + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(('198.51.100.1', 18081)) + listener.listen() + listeners.append(listener) + threading.Thread(target=echo, args=(listener,), daemon=True).start() + client = '''import socket,sys +s=socket.create_connection(('198.51.100.1',18081),timeout=3) +print('ready',flush=True) +for line in sys.stdin: + s.sendall(b'keep-incoming');assert s.recv(1024)==b'keep-incoming';print('ok',flush=True) +''' + incoming = subprocess.Popen(['ip', 'netns', 'exec', 'roentgen-peer', 'python3', '-u', '-c', client], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + children.append(incoming) + assert incoming.stdout.readline().strip() == 'ready' + incoming.stdin.write('before\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + guard(True) + for host in ['10.254.0.2', 'fded:726f::2']: + for port in [53,18080]: + try: + with connect(host, port) as connection: exchange(connection) + except Exception as error: + raise AssertionError(f'Private direct connection failed: {host}:{port}') from error + results.append('private IPv4/IPv6 destinations stay direct on ports 53 and 18080') + for host in ['198.51.100.2', '2001:db8:726f::2']: + for port in [53,18080]: + try: + with connect(host, port) as connection: exchange(connection) + except (TimeoutError, OSError): + pass + else: + raise AssertionError(f'Public direct fallback leaked to {host}:{port}') + results.append('public IPv4/IPv6 destinations are blocked on ports 53 and 18080') + incoming.stdin.write('enabled\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + guard(False) + guard(True) + incoming.stdin.write('after-toggle\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + results.append('classified incoming TCP connection survives guard replacement and toggles') + try: + exchange(outgoing) + except (TimeoutError, OSError): + pass + else: + raise AssertionError('A preexisting direct outgoing connection bypassed the guard') + outgoing.close() + results.append('preexisting outgoing TCP connection receives no established-flow exemption') + guard(False) + with connect('198.51.100.2', 18080) as connection: exchange(connection) + results.append('explicit disabled guard restores ordinary direct routing') + print(json.dumps({'passed': results}, indent=2)) + finally: + import sys + if sys.exc_info()[0]: print(run('nft', 'list', 'table', 'inet', 'roentgen').stdout, flush=True) + with contextlib.suppress(Exception): guard(False) + for child in children: + child.terminate() + with contextlib.suppress(Exception): child.wait(timeout=3) + if child.poll() is None: child.kill() + for listener in listeners: listener.close() + subprocess.run(['ip', 'link', 'del', 'rgtest0'], capture_output=True) + subprocess.run(['ip', 'netns', 'del', 'roentgen-peer'], capture_output=True) + + +if __name__ == '__main__': main() |