2026-10-05 12:43:49 -04:00
#!/usr/bin/env python3
"""Actual nginx HTTP/TLS source-boundary tests in a disposable network namespace."""
import importlib.util
import os
from pathlib import Path
import socket
import ssl
import subprocess
import tempfile
import time
assert os . geteuid () == 0 , 'Run through the isolated systemd test unit'
assert os . readlink ( '/proc/self/ns/net' ) != os . readlink ( '/proc/1/ns/net' ), 'Refusing host network namespace'
root = Path ( __file__ ) . resolve () . parents [ 2 ]
spec = importlib . util . spec_from_file_location ( 'guard' , root / 'scripts/dashboard-public-guard.py' )
guard = importlib . util . module_from_spec ( spec )
spec . loader . exec_module ( guard )
bridge_spec = importlib . util . spec_from_file_location ( 'bridge' , root / 'scripts/npm-public-bridge.py' )
bridge = importlib . util . module_from_spec ( bridge_spec )
bridge_spec . loader . exec_module ( bridge )
subprocess . run ([ 'ip' , 'link' , 'set' , 'lo' , 'up' ], check = True )
for address in [ '198.18.0.1/32' , '198.18.0.2/32' , '192.168.10.2/32' , '100.64.123.2/32' ,
'2001:db8:1::1/128' , '2001:db8:1::2/128' , 'fd00:1::2/128' ]:
subprocess . run ([ 'ip' , 'addr' , 'add' , address , 'dev' , 'lo' ], check = True )
with tempfile . TemporaryDirectory ( prefix = 'archy-guard-network-' ) as tmp :
tmp = Path ( tmp )
tmp . chmod ( 0o755 )
challenge = tmp / 'letsencrypt-acme-challenge/.well-known/acme-challenge'
challenge . mkdir ( parents = True )
( challenge / 'test-token' ) . write_text ( 'exact-acme-token' )
cert , key = tmp / 'cert.pem' , tmp / 'key.pem'
subprocess . run ([ 'openssl' , 'req' , '-x509' , '-newkey' , 'rsa:2048' , '-nodes' , '-days' , '1' ,
'-subj' , '/CN=fixture.example' , '-keyout' , str ( key ), '-out' , str ( cert )],
check = True , stdout = subprocess . DEVNULL , stderr = subprocess . DEVNULL )
source = f '''pid { tmp } /nginx.pid;
error_log { tmp } /error.log;
events {{ worker_connections 128; }}
http {{
access_log off;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
real_ip_header X-Real-IP;
server {{
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ {{ root { bridge . BASE } /data/letsencrypt-acme-challenge; try_files $uri =404; }}
location / {{ return 200 "dashboard-or-rpc"; }}
}}
server {{
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_certificate { cert } ; ssl_certificate_key { key } ;
server_name _;
# Legacy shipped HTTPS template omitted the ACME location.
location / {{ return 200 "dashboard-or-rpc"; }}
}}
server {{ listen 80; listen [::]:80; server_name public.example;
location / {{ return 200 "public-app"; }}
}}
}}
'''
2026-10-05 15:08:42 -04:00
# Use the same http-context site include as a real appliance, so the
# guarded runtime installer is exercised against actual nginx reloads.
2026-10-05 12:43:49 -04:00
start = source . index ( 'http {' ) + len ( 'http {' )
2026-10-05 15:08:42 -04:00
legacy = tmp / 'legacy-runtime.conf'
legacy . write_text ( bridge . dashboard_acme_root ( source [ start : source . rfind ( '}' )], tmp ))
site = tmp / 'site.conf'
site . write_text ( guard . guarded ( legacy . read_text ()))
2026-10-05 12:43:49 -04:00
config = tmp / 'nginx.conf'
2026-10-05 15:08:42 -04:00
config . write_text ( source [: start ] + f ' \n include { site } ; \n}}\n ' )
2026-10-05 12:43:49 -04:00
command = [ 'nginx' , '-p' , str ( tmp ), '-c' , str ( config )]
subprocess . run ( command + [ '-t' ], check = True , capture_output = True )
subprocess . run ( command , check = True , capture_output = True )
context = ssl . create_default_context ( cafile = str ( cert ))
context . check_hostname = False # Unknown-SNI routing probe; certificate chain still verified.
def request ( src , path = '/' , tls = False , host = 'unknown.example' , sni = 'unknown.example' , extra = '' , method = 'GET' , websocket = False ):
family = socket . AF_INET6 if ':' in src else socket . AF_INET
target = '2001:db8:1::1' if family == socket . AF_INET6 else '198.18.0.1'
stream = socket . socket ( family )
stream . settimeout ( 4 )
stream . bind (( src , 0 ))
stream . connect (( target , 443 if tls else 80 ))
if tls :
stream = context . wrap_socket ( stream , server_hostname = sni )
with stream :
connection = 'Upgrade' if websocket else 'close'
if websocket :
extra += 'Upgrade: websocket \r\n Sec-WebSocket-Version: 13 \r\n Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== \r\n '
stream . sendall ( f ' { method } { path } HTTP/1.1 \r\n Host: { host } \r\n Connection: { connection } \r\n Content-Length: 0 \r\n { extra } \r\n ' . encode ())
body = b ''
while data := stream . recv ( 65536 ):
body += data
# Upgrade requests can leave a rejected connection persistent.
if websocket and b ' \r\n\r\n ' in body :
headers , payload = body . split ( b ' \r\n\r\n ' , 1 )
lengths = [ int ( line . split ( b ':' , 1 )[ 1 ]) for line in headers . split ( b ' \r\n ' )
if line . lower () . startswith ( b 'content-length:' )]
if lengths and len ( payload ) >= lengths [ 0 ]:
break
return int ( body . split ( b ' ' , 2 )[ 1 ]), body
try :
count = 0
for src in [ '198.18.0.2' , '2001:db8:1::2' ]:
for tls in [ False , True ]:
for host in [ 'unknown.example' , '127.0.0.1' , 'archipelago.local' , '198.18.0.1' , '[2001:db8:1::1]' ]:
for path in [ '/' , '/login' , '/assets/dashboard.js' , '/rpc/v1' , '/ws' , '/.well-known/acme-challenge/../rpc/v1' ]:
status , body = request ( src , path , tls , host , None if host in [ '198.18.0.1' , '[2001:db8:1::1]' ] else host ,
'X-Forwarded-For: 127.0.0.1 \r\n X-Real-IP: 192.168.1.2 \r\n ' ,
method = 'POST' if path == '/rpc/v1' else 'GET' , websocket = path == '/ws' )
assert status == 404 and b 'dashboard-or-rpc' not in body , ( src , tls , host , path , status )
count += 1
status , body = request ( src , '/.well-known/acme-challenge/test-token' , tls )
assert status == 200 and body . endswith ( b 'exact-acme-token' ), ( status , body )
assert request ( src , host = 'public.example' )[ 1 ] . endswith ( b 'public-app' )
for src in [ '127.0.0.1' , '192.168.10.2' , '100.64.123.2' , '::1' , 'fd00:1::2' ]:
for tls in [ False , True ]:
assert request ( src , '/rpc/v1' , tls )[ 0 ] == 200
for src in [ '127.0.0.1' , '::1' ]:
for tls in [ False , True ]:
for client in [ '198.18.0.2' , '2001:db8:1::2' ]:
assert request ( src , '/rpc/v1' , tls , extra = f 'X-Real-IP: { client } \r\n ' , method = 'POST' )[ 0 ] == 404
assert request ( src , '/rpc/v1' , tls , extra = 'X-Real-IP: 192.168.10.2 \r\n ' )[ 0 ] == 200
assert request ( src , '/rpc/v1' , tls , extra = 'X-Archipelago-Public-Ingress: 1 \r\n ' , method = 'POST' )[ 0 ] == 404
status , body = request ( src , '/.well-known/acme-challenge/test-token' , tls ,
extra = 'X-Real-IP: 198.18.0.2 \r\n X-Archipelago-Public-Ingress: 1 \r\n ' )
assert status == 200 and body . endswith ( b 'exact-acme-token' )
subprocess . run ( command + [ '-s' , 'reload' ], check = True , capture_output = True )
assert request ( '198.18.0.2' )[ 0 ] == 404
assert request ( 'fd00:1::2' , tls = True )[ 0 ] == 200
2026-10-05 15:08:42 -04:00
def fixture_command ( args , ** kwargs ):
assert site . read_text () . count ( guard . CHECK ) == 2
actual = command + ([ '-t' ] if args [ 0 ] == 'nginx' else [ '-s' , 'reload' ])
return subprocess . run ( actual , ** kwargs )
assert guard . apply ( site , fixture_command , tmp / 'lock' , legacy ) is False
legacy . write_text ( legacy . read_text () + '# old OTA payload with no guard \n ' )
assert guard . apply ( site , fixture_command , tmp / 'lock' , legacy )
for src in [ '198.18.0.2' , '2001:db8:1::2' ]:
for tls in [ False , True ]:
for endpoint in [ '/' , '/assets/main.js' , '/rpc/v1' , '/ws' ]:
assert request ( src , endpoint , tls , extra = 'X-Forwarded-For: 127.0.0.1 \r\n X-Real-IP: 192.168.1.2 \r\n ' )[ 0 ] == 404
assert request ( src , '/.well-known/acme-challenge/test-token' , tls )[ 0 ] == 200
assert request ( 'fd00:1::2' , tls = True )[ 0 ] == 200
# Emulate the actual legacy rollback: its old binary copies the runtime
# template verbatim. Protect the payload before that old code can run.
assert guard . protect_template ( legacy )
site . write_bytes ( legacy . read_bytes ())
subprocess . run ( command + [ '-t' ], check = True , capture_output = True )
subprocess . run ( command + [ '-s' , 'reload' ], check = True , capture_output = True )
assert request ( '198.18.0.2' , '/rpc/v1' )[ 0 ] == 404
assert request ( '2001:db8:1::2' , '/rpc/v1' , tls = True )[ 0 ] == 404
previous = site . read_bytes ()
legacy . write_text ( legacy . read_text () + 'invalid_nginx_directive; \n ' )
try :
guard . apply ( site , fixture_command , tmp / 'lock' , legacy )
raise AssertionError ( 'Invalid runtime configuration was accepted' )
except RuntimeError :
pass
assert site . read_bytes () == previous
assert request ( '198.18.0.2' )[ 0 ] == 404
assert request ( 'fd00:1::2' , tls = True )[ 0 ] == 200
print ( 'PASS real legacy runtime installation and invalid-template rollback: guarded before reload, public IPv4/IPv6 blocked, ACME/private access preserved' )
2026-10-05 12:43:49 -04:00
print ( f 'PASS { count } public HTTP/TLS negative cases: IPv4/IPv6, unknown/raw/forged Host/SNI, forwarded headers, UI/assets/RPC/WS; ACME/private access and reload' )
finally :
subprocess . run ( command + [ '-s' , 'quit' ], check = True , capture_output = True )
for _ in range ( 40 ):
if not ( tmp / 'nginx.pid' ) . exists ():
break
time . sleep ( .05 )