Files
archy/scripts/tests/test_npm_public_bridge.py
T

430 lines
25 KiB
Python
Raw Normal View History

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')
def test_distinct_sni_certificates_do_not_share_upstream_tls_sessions(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
rows = []
for number, name in [(1, 'first.example'), (2, 'second.example')]:
directory = root / 'custom_ssl' / f'npm-{number}'
directory.mkdir(parents=True)
(directory / 'fullchain.pem').write_text('fixture certificate ' + name)
(directory / 'privkey.pem').write_text('fixture key ' + name)
rows.append({'id': number, 'domain_names': json.dumps([name]),
'certificate_id': number, 'certificate_deleted': 0, 'provider': 'other'})
config, _, _ = bridge.render(rows, {'data': str(root)}, '127.0.0.1:8088',
'127.0.0.1:8444', '/acme', '/trust.pem')
self.assertEqual(config.count(b'proxy_ssl_session_reuse off;'), 2)
self.assertEqual(config.count(b'proxy_ssl_name $host;'), 2)
self.assertEqual(config.count(b'proxy_ssl_verify on;'), 2)
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()