summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/vm_controller_acceptance.py114
-rw-r--r--tests/vm_guard_acceptance.py16
2 files changed, 128 insertions, 2 deletions
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)