summaryrefslogtreecommitdiff
path: root/tests/vm_guard_acceptance.py
diff options
context:
space:
mode:
authorAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
committerAndrew Guschin <guschin@altlinux.org>2026-10-30 03:10:41 +0400
commitd608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch)
tree3824ec9da5ca0adac913cc47e3e20054c3dc843a /tests/vm_guard_acceptance.py
parent40cf3276cdc74bf6f1795990ed600891a637d1fe (diff)
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'tests/vm_guard_acceptance.py')
-rw-r--r--tests/vm_guard_acceptance.py158
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()