fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user