fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
@@ -0,0 +1,134 @@
#!/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"; }}
}}
}}
'''
# The reusable guard is an http-context include; apply to the inner block.
start = source.index('http {') + len('http {')
source = source[:start] + '\n' + guard.guarded(source[start:])
source = bridge.dashboard_acme_root(source, tmp)
config = tmp / 'nginx.conf'
config.write_text(source)
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\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n'
stream.sendall(f'{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: {connection}\r\nContent-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\nX-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\nX-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
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)