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
+30
View File
@@ -0,0 +1,30 @@
# Shorty repair 2026-10-01: NPM host 2, certificate 8.
# Replace through durable NPM integration migration once released.
server {
listen 80;
listen [::]:80;
server_name fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-8/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-8/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 4m;
location / {
proxy_pass http://127.0.0.1:8998;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+32
View File
@@ -0,0 +1,32 @@
# Live repair: NPM relay host, certificate11. Retire through release migration.
server {
listen 80;
listen [::]:80;
server_name fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-11/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-11/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+32
View File
@@ -0,0 +1,32 @@
# fixture.example — BTCPay Server. LetsEncrypt cert (npm-10) obtained via NPM webroot.
server {
listen 80;
listen [::]:80;
server_name fixture.example www.fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example www.fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-10/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-10/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:23000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}
@@ -0,0 +1,29 @@
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('catalog_drift', ROOT / 'scripts/check-app-catalog-drift.py')
drift = importlib.util.module_from_spec(spec)
spec.loader.exec_module(drift)
class CatalogCapabilities(unittest.TestCase):
def test_generated_npm_migration_requires_gateway_support_and_preserves_old_manifest(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / 'catalog.json'
subprocess.run(['bash', str(ROOT / 'scripts/generate-app-catalog.sh'), str(target)], check=True, capture_output=True)
catalog = json.loads(target.read_text())
baseline = json.loads((ROOT / 'releases/app-catalog.json').read_text())
entry = catalog['apps']['nginx-proxy-manager']
self.assertEqual(entry['manifest'], baseline['apps']['nginx-proxy-manager']['manifest'])
self.assertEqual(set(entry['manifest_variants'][0]['requires']), {'runtime-migration-backup-v1', 'npm-legacy-host-gateway-v1'})
selected = drift.load_catalog(target)['nginx-proxy-manager']
self.assertEqual(selected['container']['network'], 'slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24')
entry['manifest_variants'][0]['requires'].append('future-unknown-capability')
target.write_text(json.dumps(catalog))
self.assertEqual(drift.load_catalog(target)['nginx-proxy-manager']['container'], entry['manifest']['app']['container'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,96 @@
import importlib.util
from pathlib import Path
import subprocess
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location('guard', Path(__file__).parents[1] / 'dashboard-public-guard.py')
guard = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(guard)
SOURCE = '''# quoted braces must not confuse the parser
server { listen 80 default_server; server_name _; location / { return 200 "{}"; } }
server { listen 443 ssl default_server; server_name _; location / { return 200 "a}"; } }
server { listen 80; server_name public.example; location / { return 200 "app"; } }
'''
class GuardTests(unittest.TestCase):
def test_active_site_copy_and_symlink_target_are_resolved(self):
for symlink in [False, True]:
with self.subTest(symlink=symlink), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
available = root / 'sites-available/archipelago'
enabled = root / 'sites-enabled/archipelago'
available.parent.mkdir(); enabled.parent.mkdir()
available.write_text(SOURCE)
if symlink:
enabled.symlink_to(available)
else:
enabled.write_text(SOURCE + '# active custom copy\n')
active = guard.active_dashboard(root)
self.assertEqual(active, available if symlink else enabled)
before = available.read_bytes()
def command(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
guard.apply(active, command, root / 'lock')
self.assertIn(guard.BEGIN, enabled.read_text())
self.assertEqual(enabled.is_symlink(), symlink)
if not symlink:
self.assertEqual(available.read_bytes(), before)
def test_all_defaults_guarded_named_apps_untouched_idempotent(self):
updated = guard.guarded(SOURCE)
self.assertEqual(updated.count(guard.CHECK), 2)
self.assertIn(SOURCE.splitlines()[-1], updated)
self.assertEqual(guard.guarded(updated), updated)
self.assertIn('geo $realip_remote_addr', updated)
def test_incomplete_and_ambiguous_config_rejected(self):
for source in [SOURCE.replace('listen 443 ssl default_server;', 'listen 8443 ssl;'),
SOURCE + '\n' + guard.BEGIN, SOURCE + '\nserver {',
guard.END + '\n' + guard.BEGIN + '\n' + SOURCE]:
with self.assertRaises(ValueError):
guard.guarded(source)
def test_legacy_address_specific_https_dashboard(self):
source = SOURCE.replace('listen 443 ssl default_server;', 'listen 192.168.1.10:443 ssl;')
updated = guard.guarded(source)
self.assertEqual(updated.count(guard.CHECK), 2)
self.assertEqual(guard.guarded(updated), updated)
self.assertIn(SOURCE.splitlines()[-1], updated)
def test_syntax_and_reload_failure_restore_exact_previous_bytes(self):
for failure in ['nginx', 'systemctl']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'archipelago'
path.write_text(SOURCE)
calls = []
def command(args, **kwargs):
calls.append(args)
# Only the candidate's first matching command fails.
fail = args[0] == failure and sum(x[0] == failure for x in calls) == 1
return subprocess.CompletedProcess(args, int(fail))
with self.assertRaises(RuntimeError):
guard.apply(path, command, Path(tmp) / 'nginx.lock')
self.assertEqual(path.read_text(), SOURCE)
backups = list(Path(tmp).glob('*.before-management-guard-*'))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), SOURCE)
self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600)
self.assertEqual(calls[-1], ['systemctl', 'reload', 'nginx'])
def test_second_application_does_not_reload(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'archipelago'
path.write_text(SOURCE)
calls = []
def command(args, **kwargs):
calls.append(args)
return subprocess.CompletedProcess(args, 0)
self.assertTrue(guard.apply(path, command, Path(tmp) / 'nginx.lock'))
self.assertFalse(guard.apply(path, command, Path(tmp) / 'nginx.lock'))
self.assertEqual(len(calls), 2)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,61 @@
import importlib.util
from pathlib import Path
import json
import os
import tempfile
import unittest
spec = importlib.util.spec_from_file_location('filebrowser_credentials', Path(__file__).resolve().parents[1] / 'filebrowser-credentials.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class CredentialTests(unittest.TestCase):
def test_record_validation_rejects_defaults_and_malformed_credentials(self):
valid = {'schema': 1, 'username': 'archy-' + 'a' * 32, 'password': 'b' * 64}
self.assertEqual(module.credentials(valid), valid)
for values in [{**valid, 'username': 'admin'}, {**valid, 'password': 'admin'}, {**valid, 'schema': 2}, {**valid, 'password': 'x' * 64}, None]:
with self.assertRaises(module.ProvisionError):
module.credentials(values)
def test_atomic_secret_is_private_and_refuses_symlinks(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'credential.json'
module.atomic_json(path, {'value': 'test'})
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(json.loads(path.read_text()), {'value': 'test'})
target = Path(tmp) / 'target'
target.write_text('preserved')
path.unlink()
path.symlink_to(target)
with self.assertRaises(module.ProvisionError):
module.atomic_json(path, {})
self.assertEqual(target.read_text(), 'preserved')
def test_database_selection_preserves_legacy_and_configured_locations(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.assertEqual(module.database_path(root), '/data/filebrowser.db')
(root / 'database.db').touch()
self.assertEqual(module.database_path(root), '/data/database.db')
(root / 'filebrowser.db').touch()
with self.assertRaises(module.ProvisionError):
module.database_path(root)
(root / '.filebrowser.json').write_text(json.dumps({'database': '/data/database.db'}))
self.assertEqual(module.database_path(root), '/data/database.db')
def test_database_paths_fail_closed_on_traversal_or_symlinks(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for database in ['/data/../outside.db', '/outside.db', None, '/data/a.db\n--flag']:
(root / '.filebrowser.json').write_text(json.dumps({'database': database}))
with self.assertRaises(module.ProvisionError):
module.database_path(root)
(root / '.filebrowser.json').write_text(json.dumps({'database': '/data/filebrowser.db'}))
(root / 'filebrowser.db').symlink_to(root / 'elsewhere')
with self.assertRaises(module.ProvisionError):
module.database_path(root)
if __name__ == '__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""QEMU runner argument/error handling; this is not an actual ISO boot test."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
RUNNER = Path(__file__).resolve().parents[2] / 'image-recipe/_archived/test-iso-qemu.sh'
class QemuRunnerTests(unittest.TestCase):
def test_command_survives_timeout_and_stale_logs_cannot_pass(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
iso = root / 'candidate with spaces.iso'
iso.touch()
executable = root / 'qemu-system-x86_64'
executable.write_text('''#!/usr/bin/python3
import os,pathlib,sys,time
args=sys.argv[1:]
assert args[args.index('-cdrom')+1]==os.environ['FIXTURE_ISO']
assert args[args.index('-machine')+1]=='pc'
assert 'hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:8100-:80' in args[args.index('-serial')-1]
log=pathlib.Path(args[args.index('-serial')+1].removeprefix('file:'))
mode=os.environ['FIXTURE_MODE']
if mode=='login':log.write_text('Debian GNU/Linux 13 archipelago-installer ttyS0\\n')
elif mode!='silent':log.write_text('systemd[1]: boot fixture marker\\n')
if mode=='crash':sys.exit(5)
if mode=='timeout':time.sleep(10)
''')
executable.chmod(0o755)
# A dedicated empty fixture disk avoids requiring qemu-img here.
(root / 'archipelago-test-disk.qcow2').touch()
env = dict(os.environ, PATH=str(root)+':'+os.environ['PATH'],
TMPDIR=str(root), FIXTURE_ISO=str(iso))
for mode, expected in [('timeout', 0), ('crash', 5), ('silent', 1), ('login', 0)]:
with self.subTest(mode=mode):
(root / 'archipelago-qemu-serial.log').write_text('systemd[1]: stale pass\n')
result = subprocess.run(['bash', str(RUNNER), str(iso), '--bios', '1'],
env=dict(env, FIXTURE_MODE=mode),
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, expected, result.stdout+result.stderr)
if __name__ == '__main__':
unittest.main()
+412
View File
@@ -0,0 +1,412 @@
import contextlib
import importlib.util
import json
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location('bridge', Path(__file__).parents[1] / 'npm-public-bridge.py')
bridge = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(bridge)
def database(path):
path.mkdir(parents=True, exist_ok=True)
with contextlib.closing(sqlite3.connect(path / 'database.sqlite')) as con:
con.executescript('''
CREATE TABLE proxy_host(id INTEGER, domain_names TEXT, certificate_id INTEGER,
enabled INTEGER, is_deleted INTEGER);
CREATE TABLE certificate(id INTEGER, provider TEXT, is_deleted INTEGER);
''')
def runtime(data, certs):
return {'Mounts': [{'Destination': '/data', 'Source': str(data)},
{'Destination': '/etc/letsencrypt', 'Source': str(certs)}]}
class StorageTests(unittest.TestCase):
def test_managed_realip_file_is_idempotent_and_preserves_operator_snippets(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
custom = data / 'nginx/custom/http_top.conf'
custom.parent.mkdir(parents=True)
custom.write_text('# Operator configuration\n')
original_mode = custom.stat().st_mode & 0o777
path = bridge.prepare_realip({'data': str(data)})
self.assertEqual(path.stat().st_mode & 0o777, original_mode)
self.assertEqual(path.read_text().count('set_real_ip_from'), 1)
self.assertIn('set_real_ip_from 169.254.1.100;', path.read_text())
before = path.stat().st_mtime_ns
self.assertEqual(bridge.prepare_realip({'data': str(data)}), path)
self.assertEqual(path.stat().st_mtime_ns, before)
self.assertTrue(custom.read_text().startswith('# Operator configuration\n'))
path.write_text('# BEGIN ARCHY HOST BRIDGE\n# Operator override\n# END ARCHY HOST BRIDGE\n')
with self.assertRaisesRegex(ValueError, 'operator override'):
bridge.prepare_realip({'data': str(data)})
self.assertIn('# Operator override', path.read_text())
original = data / 'operator.conf'
original.write_text('# Preserved\n')
path.unlink(); path.symlink_to(original)
with self.assertRaisesRegex(ValueError, 'symlink'):
bridge.prepare_realip({'data': str(data)})
self.assertEqual(original.read_text(), '# Preserved\n')
def test_recorded_custom_mount_survives_missing_container_record(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / 'npm'
base.mkdir()
data = Path(tmp) / 'custom-data'
database(data)
expected = {'data': str(data), 'certificates': str(Path(tmp) / 'custom-certs')}
bridge.atomic(base / '.archy-storage.json', json.dumps(expected).encode())
self.assertEqual(bridge.resolve_paths(base), expected)
(data / 'database.sqlite').unlink()
with self.assertRaisesRegex(ValueError, 'Previously initialized'):
bridge.resolve_paths(base)
def test_fresh_flat_nested_and_custom_mount_without_mutation(self):
for layout in ['fresh', 'flat', 'nested', 'custom']:
with self.subTest(layout=layout), tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / 'npm'
data = base / 'data' if layout == 'nested' else base
if layout == 'custom':
data = Path(tmp) / 'operator-data'
certs = Path(tmp) / 'operator-certs' if layout == 'custom' else base / 'letsencrypt'
if layout != 'fresh':
database(data)
before = {p: p.read_bytes() for p in Path(tmp).rglob('*') if p.is_file()}
result = bridge.resolve_paths(base, runtime(data, certs))
self.assertEqual(result, {'data': str(data), 'certificates': str(certs)})
after = {p: p.read_bytes() for p in Path(tmp).rglob('*') if p.is_file()}
self.assertEqual(before, after)
if layout != 'custom':
self.assertEqual(bridge.resolve_paths(base)['data'], str(data))
def test_ambiguity_and_wrong_active_mount_fail_without_replacing_data(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
database(base)
original = (base / 'database.sqlite').read_bytes()
with self.assertRaisesRegex(ValueError, 'active mount differs'):
bridge.resolve_paths(base, runtime(base / 'empty', base / 'certs'))
database(base / 'data')
with self.assertRaisesRegex(ValueError, 'Multiple NPM databases'):
bridge.resolve_paths(base)
before = {path: path.read_bytes() for path in base.rglob('database.sqlite')}
for selected in [base, base / 'data']:
self.assertEqual(bridge.resolve_paths(base, runtime(selected, base / 'certs'))['data'], str(selected))
bridge.atomic(base / '.archy-storage.json', json.dumps({
'data': str(base), 'certificates': str(base / 'certs')}).encode())
self.assertEqual(bridge.resolve_paths(base)['data'], str(base))
self.assertEqual({path: path.read_bytes() for path in base.rglob('database.sqlite')}, before)
self.assertEqual((base / 'database.sqlite').read_bytes(), original)
def test_corrupt_or_uninitialized_database_is_not_recreated(self):
for value in [b'not a sqlite database', b'']:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
db = base / 'database.sqlite'
db.write_bytes(value)
with self.assertRaises((sqlite3.DatabaseError, ValueError)):
bridge.resolve_paths(base)
self.assertEqual(db.read_bytes(), value)
def test_duplicate_and_missing_mounts_rejected(self):
info = runtime(Path('/data/npm'), Path('/data/certs'))
for mounts in [info['Mounts'][:1], info['Mounts'] + info['Mounts'][:1]]:
with self.assertRaises(ValueError):
bridge.resolve_paths(Path('/nonexistent-fixture'), {'Mounts': mounts})
def test_deleted_and_disabled_hosts_are_excluded(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
database(data)
with contextlib.closing(sqlite3.connect(data / 'database.sqlite')) as con:
con.executemany('INSERT INTO proxy_host VALUES (?, ?, 0, ?, ?)', [
(1, '["active.example"]', 1, 0),
(2, '["disabled.example"]', 0, 0),
(3, '["deleted.example"]', 1, 1)])
con.commit()
self.assertEqual([row['id'] for row in bridge.hosts(data)], [1])
def test_redirect_and_dead_hosts_are_also_routed_through_npm(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
database(data)
with contextlib.closing(sqlite3.connect(data / 'database.sqlite')) as con:
for table, domain in [('redirection_host', 'redirect.example'), ('dead_host', 'gone.example')]:
con.execute(f'CREATE TABLE {table} AS SELECT * FROM proxy_host')
con.execute(f'INSERT INTO {table} VALUES (1, ?, 0, 1, 0)', (json.dumps([domain]),))
con.commit()
self.assertEqual({r['domain_names'] for r in bridge.hosts(data)},
{'["redirect.example"]', '["gone.example"]'})
class RoutingTests(unittest.TestCase):
def test_active_dashboard_uses_enabled_copy_or_symlink_target(self):
for symlink in [False, True]:
with self.subTest(symlink=symlink), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
enabled = root / 'sites-enabled/archipelago'
available = root / 'sites-available/archipelago'
enabled.parent.mkdir(); available.parent.mkdir()
available.write_text('available')
if symlink:
enabled.symlink_to(available)
else:
enabled.write_text('active copy')
self.assertEqual(bridge.active_dashboard(root), available if symlink else enabled)
def test_acme_flat_nested_upgrade_and_custom_override_preservation(self):
for legacy in [str(bridge.BASE), str(bridge.BASE / 'data')]:
source = ('location ^~ /.well-known/acme-challenge/ {\n'
f' root {legacy}/letsencrypt-acme-challenge; try_files $uri =404;\n' + '}\n') * 2
result = bridge.dashboard_acme_root(source, '/operator/npm-data')
self.assertEqual(result.count('root "/operator/npm-data/letsencrypt-acme-challenge";'), 2)
self.assertEqual(bridge.dashboard_acme_root(result, '/operator/npm-data'), result)
with self.assertRaisesRegex(ValueError, 'Custom dashboard ACME'):
bridge.dashboard_acme_root(source.replace(legacy, '/unrecognized'), '/operator/npm-data')
with self.assertRaisesRegex(ValueError, 'HTTP and HTTPS'):
bridge.dashboard_acme_root(source.split('}\n')[0] + '}\n', '/operator/npm-data')
def test_shipped_template_and_legacy_missing_https_acme(self):
template = (Path(__file__).parents[2] / 'image-recipe/configs/nginx-archipelago.conf').read_text()
import re
pattern = re.compile(r'location\s+\^~\s+/\.well-known/acme-challenge/\s*\{[^{}]*\}', re.S)
locations = list(pattern.finditer(template))
self.assertEqual(len(locations), 2)
legacy = template[:locations[1].start()] + template[locations[1].end():]
for source in (template, legacy):
result = bridge.dashboard_acme_root(source, '/operator/npm-data')
self.assertEqual(result.count('root "/operator/npm-data/letsencrypt-acme-challenge";'), 2)
self.assertEqual(bridge.dashboard_acme_root(result, '/operator/npm-data'), result)
self.assertEqual(result.count('try_files $uri =404;'), template.count('try_files $uri =404;'))
with self.assertRaisesRegex(ValueError, 'HTTP and HTTPS'):
bridge.dashboard_acme_root(legacy.replace('listen 443 ssl default_server;', 'listen 444 ssl;'), '/operator/npm-data')
def test_custom_duplicate_routes_block_replacement(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / 'conf.d'
directory.mkdir()
config = directory / 'operator.conf'
original = 'server { listen 80; server_name public.example; return 200 "operator"; }'
config.write_text(original)
rows = [{'domain_names': '["public.example"]'}]
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes(rows, {}, output=directory / 'generated.conf', directories=[directory])
self.assertEqual(config.read_text(), original)
config.write_text('server { listen 80; server_name other.example; return 200 "operator"; }')
self.assertEqual(bridge.existing_route_changes(rows, {}, directories=[directory]), [])
def test_emergency_routes_retire_transactionally_and_preserve_operator_edits(self):
paths = {'data': str(bridge.BASE), 'certificates': str(bridge.BASE / 'letsencrypt')}
fixtures = Path(__file__).parent / 'fixtures'
for app in ['indexer', 'relay', 'shop']:
with self.subTest(app=app), tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / 'conf.d'
directory.mkdir()
route = directory / ('shop-btcpay.conf' if app == 'shop' else f'angor-{app}-npm.conf')
original = (fixtures / f'npm-emergency-{app}.conf').read_bytes()
route.write_bytes(original)
self.assertTrue(bridge.legacy_shop_route(original.decode(), paths) if app == 'shop'
else bridge.legacy_angor_route(original.decode(), paths, relay=app == 'relay'))
names = ['fixture.example', 'www.fixture.example'] if app == 'shop' else ['fixture.example']
rows = [{'domain_names': json.dumps(names), 'certificate_id': 12}]
self.assertEqual(bridge.existing_route_changes([], paths, directories=[directory]), [])
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes([{**rows[0], 'certificate_id': 0}], paths, directories=[directory])
if app == 'shop':
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes([{**rows[0], 'domain_names': '["fixture.example"]'}], paths, directories=[directory])
changes = bridge.existing_route_changes(rows, paths, directories=[directory])
self.assertEqual(len(changes), 1)
def fail(args, **kwargs):
return subprocess.CompletedProcess(args, 1 if route.read_bytes() != original else 0)
with self.assertRaisesRegex(RuntimeError, 'validation/reload failed'):
bridge.apply_files(changes, Path(tmp) / 'state', fail, Path(tmp) / 'lock')
self.assertEqual(route.read_bytes(), original)
def succeed(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.apply_files(changes, Path(tmp) / 'state', succeed, Path(tmp) / 'lock'))
self.assertEqual(bridge.existing_route_changes(rows, paths, directories=[directory]), [])
modified = original.replace(b'proxy_http_version 1.1;', b'proxy_http_version 1.1; proxy_read_timeout 42s;')
route.write_bytes(modified)
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes(rows, paths, directories=[directory])
self.assertEqual(route.read_bytes(), modified)
def test_domain_injection_rejected(self):
for name in ['_', 'x; return 200;', 'x\ninclude bad;', '$host', 'a/b', '.example', 'a..b', '-a.example']:
with self.subTest(name=name), self.assertRaises(ValueError):
bridge.domains(json.dumps([name]))
self.assertEqual(bridge.domains('["EXAMPLE.COM.", "*.example.com"]'),
['*.example.com', 'example.com'])
def test_mixed_wildcard_and_loopback_publication_rejected(self):
info = {'NetworkSettings': {'Ports': {'80/tcp': [
{'HostIp': '127.0.0.1', 'HostPort': '8088'},
{'HostIp': '0.0.0.0', 'HostPort': '8088'}]}}}
with self.assertRaisesRegex(ValueError, 'non-loopback'):
bridge.local_port(info, 80)
info['NetworkSettings']['Ports']['80/tcp'].pop()
self.assertEqual(bridge.local_port(info, 80), '127.0.0.1:8088')
def test_wireguard_web_listener_preserved_but_loopback_still_required(self):
tunnel = {'HostIp': '10.55.0.2', 'HostPort': '18081'}
info = {'NetworkSettings': {'Ports': {'80/tcp': [
tunnel, {'HostIp': '127.0.0.1', 'HostPort': '8088'}]}}}
interface = [{'ifname': 'wg-web', 'linkinfo': {'info_kind': 'wireguard'},
'addr_info': [{'family': 'inet', 'local': '10.55.0.2'}]}]
with patch.object(bridge, 'run', return_value=json.dumps(interface)):
self.assertEqual(bridge.local_port(info, 80), '127.0.0.1:8088')
info['NetworkSettings']['Ports']['80/tcp'].pop()
with self.assertRaisesRegex(ValueError, 'needs a loopback'):
bridge.local_port(info, 80)
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 81))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='0.0.0.0'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='203.0.113.1'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='10.55.0.3'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostPort='18080'), 80))
interface[0]['linkinfo']['info_kind'] = 'dummy'
with patch.object(bridge, 'run', return_value=json.dumps(interface)):
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 80))
with patch.object(bridge, 'run', side_effect=RuntimeError('interface missing')):
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 80))
def test_bridge_routes_through_npm_without_copying_upstream(self):
rows = [{'id': 1, 'domain_names': '["public.example"]', 'certificate_id': 0,
'certificate_deleted': 0, 'provider': None, 'forward_host': 'private-backend'}]
config, trust, fingerprints = bridge.render(rows, {}, '127.0.0.1:8088', '127.0.0.1:8444',
'/acme', '/trust.pem')
self.assertIn(b'proxy_pass http://127.0.0.1:8088;', config)
self.assertNotIn(b'private-backend', config)
self.assertNotIn(b'listen 443', config)
self.assertIn(b'proxy_set_header X-Forwarded-For $remote_addr;', config)
self.assertEqual(fingerprints, [])
self.assertTrue(trust)
with self.assertRaisesRegex(ValueError, 'Duplicate'):
bridge.render(rows + rows, {}, '127.0.0.1:8088', '127.0.0.1:8444', '/acme', '/trust.pem')
class TransactionTests(unittest.TestCase):
def test_idempotent_sync_and_certificate_renewal_reload(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
files = [(base / 'hosts.conf', b'new routes', 0o644), (base / 'trust.pem', b'chain', 0o600)]
calls = []
def command(args, **kwargs):
calls.append(args)
return subprocess.CompletedProcess(args, 0)
args = (files, base / 'state', command, base / 'lock')
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='first'))
self.assertEqual(len(calls), 2)
self.assertFalse(bridge.apply_files(*args, renewal_fingerprint='first'))
self.assertEqual(len(calls), 2)
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='renewed'))
self.assertEqual(len(calls), 4)
self.assertEqual((base / 'hosts.conf').stat().st_mode & 0o777, 0o644)
self.assertEqual((base / 'trust.pem').stat().st_mode & 0o777, 0o600)
def test_certificate_reload_failure_rolls_back_and_retry_keeps_obligation(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
output = base / 'hosts.conf'
output.write_bytes(b'previous')
def command(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
def failure():
raise RuntimeError('fixture NPM reload failure')
args = ([(output, b'candidate', 0o644)], base / 'state', command, base / 'lock')
with self.assertRaisesRegex(RuntimeError, 'fixture NPM reload failure'):
bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=failure)
self.assertEqual(output.read_bytes(), b'previous')
self.assertFalse((base / 'state/applied.json').exists())
reloaded = []
callback = lambda: reloaded.append(True)
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=callback))
self.assertFalse(bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=callback))
self.assertEqual(reloaded, [True])
def test_validation_or_reload_failure_restores_files_and_modes(self):
for failure in ['nginx', 'systemctl']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
original = base / 'hosts.conf'
original.write_bytes(b'operator previous routes')
original.chmod(0o640)
trust = base / 'trust.pem'
calls = []
def command(args, **kwargs):
calls.append(args)
fail = args[0] == failure and sum(c[0] == failure for c in calls) == 1
return subprocess.CompletedProcess(args, int(fail))
with self.assertRaisesRegex(RuntimeError, 'validation/reload failed'):
bridge.apply_files([(original, b'candidate', 0o644), (trust, b'new trust', 0o600)],
base / 'state', command, base / 'lock')
self.assertEqual(original.read_bytes(), b'operator previous routes')
self.assertEqual(original.stat().st_mode & 0o777, 0o640)
self.assertFalse(trust.exists())
self.assertFalse((base / 'state/pending.json').exists())
backup = next((base / 'state').glob('backup-*/0'))
self.assertEqual(backup.read_bytes(), original.read_bytes())
self.assertEqual(backup.stat().st_mode & 0o777, 0o600)
def test_failed_rollback_is_recovered_before_next_sync(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
original = base / 'hosts.conf'
original.write_bytes(b'previous')
files = [(original, b'candidate', 0o644)]
def fail(args, **kwargs):
return subprocess.CompletedProcess(args, 1)
with self.assertRaisesRegex(RuntimeError, 'rollback incomplete'):
bridge.apply_files(files, base / 'state', fail, base / 'lock')
self.assertTrue((base / 'state/pending.json').exists())
seen = []
def succeed(args, **kwargs):
seen.append(original.read_bytes())
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.apply_files(files, base / 'state', succeed, base / 'lock'))
self.assertEqual(seen, [b'previous', b'previous', b'candidate', b'candidate'])
self.assertFalse((base / 'state/pending.json').exists())
def test_pending_recovery_restores_inputs_before_render_and_is_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
target = root / 'active.conf'
target.write_bytes(b'partially written candidate')
backup = root / 'backup'
backup.write_bytes(b'original active config')
state = root / 'state'
state.mkdir()
journal = state / 'pending.json'
journal.write_text(json.dumps({'files': [{'path': str(target), 'backup': str(backup), 'mode': 0o640}]}))
calls = []
def command(args, **kwargs):
calls.append(args)
self.assertEqual(target.read_bytes(), b'original active config')
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.recover_pending(state, command))
self.assertFalse(bridge.recover_pending(state, command))
self.assertEqual(len(calls), 2)
self.assertEqual(target.stat().st_mode & 0o777, 0o640)
def test_operator_symlink_not_replaced(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
custom = base / 'custom.conf'
custom.write_bytes(b'operator')
output = base / 'hosts.conf'
output.symlink_to(custom)
with self.assertRaisesRegex(ValueError, 'symlink'):
bridge.apply_files([(output, b'new', 0o644)], base / 'state', lock_path=base / 'lock')
self.assertTrue(output.is_symlink())
self.assertEqual(custom.read_bytes(), b'operator')
if __name__ == '__main__':
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
import contextlib
import importlib.util
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('release_notes', Path(__file__).resolve().parents[1] / 'sync-whats-new.py')
notes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(notes)
class ReleaseNotesTests(unittest.TestCase):
def test_check_rejects_stale_content_without_modifying_the_modal(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
changelog = root / 'CHANGELOG.md'
modal = root / 'notes.vue'
changelog.write_text('## v1.9.0 (2026-10-05)\n\n- Keep uploads on their original screen.\n')
stale = notes.render_block({'ver': 'v1.9.0', 'date': 'October 2, 2026', 'bullets': ['Keep uploads across screens.']})
modal.write_text('<template>\n' + stale + '</template>\n')
with patch.object(notes, 'CHANGELOG', changelog), patch.object(notes, 'MODAL', modal):
original = modal.read_bytes()
with patch('sys.argv', ['sync-whats-new.py', '--check']), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(notes.main(), 1)
self.assertEqual(modal.read_bytes(), original)
with patch('sys.argv', ['sync-whats-new.py']), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(notes.main(), 0)
self.assertIn('original screen', modal.read_text())
self.assertIn('October 5, 2026', modal.read_text())
with patch('sys.argv', ['sync-whats-new.py', '--check']), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(notes.main(), 0)
if __name__ == '__main__':
unittest.main()