#!/usr/bin/env python3 """Resolve NPM's existing storage and bridge public requests through NPM itself. Never move a database or reimplement NPM access lists/custom locations. The host terminates public TLS, then forwards to NPM's loopback-only listeners. """ import argparse import contextlib import fcntl import hashlib import ipaddress import json import os from pathlib import Path import pwd import re import shutil import sqlite3 import subprocess import tempfile import time BASE = Path('/var/lib/archipelago/nginx-proxy-manager') STATE = Path('/var/lib/archipelago/npm-public-bridge') OUTPUT = Path('/etc/nginx/conf.d/public-npm-proxy-hosts.conf') def active_dashboard(nginx_root=Path('/etc/nginx')): enabled = nginx_root / 'sites-enabled/archipelago' selected = enabled if enabled.exists() or enabled.is_symlink() else nginx_root / 'sites-available/archipelago' return selected.resolve() DASHBOARD = active_dashboard() def run(args, **kwargs): result = subprocess.run(args, capture_output=True, timeout=45, **kwargs) if result.returncode: raise RuntimeError(f'{args[0]} failed (exit {result.returncode}); previous configuration retained') return result.stdout def podman_args(): if os.geteuid() == 0: account = pwd.getpwnam('archipelago') return ['sudo', '-n', '-u', account.pw_name, 'env', f'XDG_RUNTIME_DIR=/run/user/{account.pw_uid}', 'podman'] return ['podman'] def inspect_runtime(): command = podman_args() exists = subprocess.run(command + ['container', 'exists', 'nginx-proxy-manager'], capture_output=True, timeout=20) if exists.returncode == 1: return None if exists.returncode: raise RuntimeError('Cannot inspect NPM runtime; refusing to guess its data directory') info = json.loads(run(command + ['inspect', 'nginx-proxy-manager'])) if len(info) != 1: raise RuntimeError('Ambiguous NPM runtime') return info[0] def checked_path(value): path = Path(value) if not path.is_absolute() or any(c in str(path) for c in '\r\n\x00'): raise ValueError('NPM mount must be an absolute path without control characters') return path def resolve_paths(base=BASE, runtime=None): """Keep the active mount; without one, reuse the single existing database.""" base = checked_path(base) remembered = {} layout_file = base / '.archy-storage.json' if layout_file.exists(): saved = json.loads(layout_file.read_text()) remembered = {name: checked_path(saved[name]) for name in ('data', 'certificates')} mounts = {} for mount in [] if runtime is None else runtime.get('Mounts', []): destination = mount.get('Destination') if destination not in ('/data', '/etc/letsencrypt'): continue if destination in mounts: raise ValueError('Duplicate NPM persistent mount; refusing ambiguous runtime configuration') mounts[destination] = checked_path(mount['Source']) if runtime is not None and set(mounts) != {'/data', '/etc/letsencrypt'}: raise ValueError('NPM must have explicit /data and /etc/letsencrypt mounts; review before recreation') candidates = {base, base / 'data'} if remembered: candidates.add(remembered['data']) if '/data' in mounts: candidates.add(mounts['/data']) databases = {p.resolve() for p in candidates if (p / 'database.sqlite').exists()} # A live, explicit mount (or our previously validated receipt after a # managed stop) identifies the database without guessing. Older installers # can leave an unused second database behind; preserve it, never merge it # or let its mere existence displace the database NPM actually uses. if len(databases) > 1 and '/data' not in mounts and not remembered: raise ValueError('Multiple NPM databases found; choose the active layout explicitly before upgrading') data = mounts.get('/data', remembered.get('data', next(iter(databases), base))) if databases and data.resolve() not in databases: raise ValueError('NPM active mount differs from the saved database; refusing an empty replacement') db = data / 'database.sqlite' if remembered and not db.exists(): raise ValueError('Previously initialized NPM database is missing; refusing an empty replacement') if db.exists(): # Read-only opening never creates an empty replacement database. con = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True, timeout=5) try: if con.execute('PRAGMA quick_check').fetchone()[0] != 'ok': raise ValueError('NPM database integrity check failed') tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} if not {'proxy_host', 'certificate'} <= tables: raise ValueError('NPM database schema is not initialized; retry after NPM startup') finally: con.close() certs = mounts.get('/etc/letsencrypt', remembered.get('certificates', base / 'letsencrypt')) return {'data': str(data), 'certificates': str(certs)} def quote(value): value = str(value) if any(ord(c) < 32 for c in value): raise ValueError('Control character in nginx configuration value') return '"' + value.replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$') + '"' def prepare_realip(paths): """Append one managed block through NPM's supported custom HTTP include. A read-only mount in /etc/nginx/conf.d breaks NPM's startup ownership pass. Preserve existing custom directives and a private copy before adding trust for rootlessport's single forwarding source on our legacy subnet. """ data = Path(paths['data']) path = data / 'nginx/custom/http_top.conf' for candidate in [path, path.parent, path.parent.parent]: if candidate.is_symlink(): raise ValueError('NPM custom configuration is a symlink; preserved for review') source = path.read_bytes() if path.exists() else b'' begin = b'# BEGIN ARCHY HOST BRIDGE\n' end = b'# END ARCHY HOST BRIDGE\n' block = begin + b'set_real_ip_from 169.254.1.100;\n' + end if begin.strip() in source or end.strip() in source: if source.count(begin) != 1 or source.count(end) != 1 or block not in source: raise ValueError('NPM managed trust block has an operator override; preserved for review') return path if source: backups = data / '.archy-http-top-backups' backups.mkdir(exist_ok=True, mode=0o700) backup = backups / hashlib.sha256(source).hexdigest() if not backup.exists(): atomic(backup, source, 0o600) content = source + (b'\n' if source and not source.endswith(b'\n') else b'') + block mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 atomic(path, content, mode) return path def domains(value): names = json.loads(value) if not isinstance(names, list) or not names: raise ValueError('NPM host has no valid domain names') result = [] for name in names: if not isinstance(name, str): raise ValueError('Invalid NPM domain name') name = name.lower().rstrip('.') plain = name[2:] if name.startswith('*.') else name if len(name) > 253 or not plain or any( not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label) for label in plain.split('.') ): raise ValueError('Invalid NPM domain name; no nginx configuration was generated') result.append(name) return sorted(set(result)) def hosts(data): db = Path(data) / 'database.sqlite' con = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True, timeout=5) con.row_factory = sqlite3.Row try: # Avoid reading credentials, access-list passwords or advanced snippets. tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} rows = [] for table in ('proxy_host', 'redirection_host', 'dead_host'): if table not in tables: continue # Table names come solely from this fixed allowlist, never DB data. rows.extend(dict(r) for r in con.execute(f''' SELECT p.id, p.domain_names, p.certificate_id, c.provider, COALESCE(c.is_deleted, 0) AS certificate_deleted FROM {table} p LEFT JOIN certificate c ON c.id = p.certificate_id WHERE p.enabled = 1 AND p.is_deleted = 0 ORDER BY p.id ''')) return rows finally: con.close() def managed_tunnel_listener(binding, container_port): """Recognize the existing private WireGuard web ingress, never a LAN bind. This does not select the tunnel as an upstream: the host bridge still requires a separate loopback listener. NPM's admin port has no exception. """ expected_port = {80: '18081', 443: '18443'}.get(container_port) if expected_port is None or str(binding.get('HostPort')) != expected_port: return False try: address = ipaddress.IPv4Address(binding.get('HostIp', '')) if not any(address in ipaddress.IPv4Network(network) for network in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')): return False interfaces = json.loads(run(['ip', '-d', '-j', 'address', 'show', 'dev', 'wg-web'])) return any(item.get('ifname') == 'wg-web' and item.get('linkinfo', {}).get('info_kind') == 'wireguard' and any(entry.get('family') == 'inet' and entry.get('local') == str(address) for entry in item.get('addr_info', [])) for item in interfaces) except (ValueError, RuntimeError, OSError): return False def local_port(runtime, container_port): bindings = runtime.get('NetworkSettings', {}).get('Ports', {}).get(f'{container_port}/tcp') or [] # A loopback mapping alongside a wildcard mapping is still public exposure. if any(binding.get('HostIp') not in ('127.0.0.1', '::1') and not managed_tunnel_listener(binding, container_port) for binding in bindings): raise ValueError(f'NPM container port {container_port} has a non-loopback published listener') for binding in bindings: if binding.get('HostIp') in ('127.0.0.1', '::1'): port = int(binding['HostPort']) if 1 <= port <= 65535: host = '[::1]' if binding['HostIp'] == '::1' else '127.0.0.1' return f'{host}:{port}' raise ValueError(f'NPM container port {container_port} needs a loopback-only published listener') def certificate_paths(paths, row): number = row['certificate_id'] if not isinstance(number, int) or number < 0: raise ValueError('Invalid NPM certificate ID') if number == 0 or row['certificate_deleted']: return None parent = (Path(paths['certificates']) / 'live' if row['provider'] == 'letsencrypt' else Path(paths['data']) / 'custom_ssl') / f'npm-{number}' cert, key = parent / 'fullchain.pem', parent / 'privkey.pem' if not cert.is_file() or not key.is_file(): raise ValueError(f'NPM certificate {number} files are missing; inspect certificate issuance') return cert, key def render(rows, paths, http_address, https_address, acme_root, trust_file): """Only route through NPM; never bypass its authentication or custom routes.""" for address in (http_address, https_address): host, port = address.rsplit(':', 1) if not ipaddress.ip_address(host.strip('[]')).is_loopback or not 1 <= int(port) <= 65535: raise ValueError('NPM upstream must be loopback') chunks = ['# Generated by npm-public-bridge.py; routes and access control remain owned by NPM.\n'] fingerprints = [] trust = Path('/etc/ssl/certs/ca-certificates.crt').read_bytes() seen = set() for row in rows: names = domains(row['domain_names']) if seen.intersection(names): raise ValueError('Duplicate public NPM domain; resolve conflicting hosts first') seen.update(names) cert = certificate_paths(paths, row) common = f''' location ^~ /.well-known/acme-challenge/ {{ default_type text/plain; root {quote(acme_root)}; try_files $uri =404; }} ''' def proxy(address, scheme): tls = '' if scheme == 'http' else f''' proxy_ssl_server_name on; proxy_ssl_name $host; proxy_ssl_verify on; proxy_ssl_verify_depth 5; proxy_ssl_trusted_certificate {quote(trust_file)};''' return f''' location / {{ proxy_pass {scheme}://{address}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Archipelago-Public-Ingress 1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; proxy_request_buffering off; # NPM/app configuration owns upload limits; do not impose a second cap. client_max_body_size 0;{tls} }} ''' chunks.append(f'''server {{ listen 80; listen [::]:80; server_name {' '.join(names)}; {common}{proxy(http_address, 'http')} }} ''') if cert: chain, key = cert cert_bytes = chain.read_bytes() trust += b'\n' + cert_bytes fingerprints.append(hashlib.sha256(cert_bytes).hexdigest()) # Including the key fingerprint detects replacement without logging keys. fingerprints.append(hashlib.sha256(key.read_bytes()).hexdigest()) chunks.append(f'''server {{ listen 443 ssl; listen [::]:443 ssl; server_name {' '.join(names)}; ssl_certificate {quote(chain)}; ssl_certificate_key {quote(key)}; {common}{proxy(https_address, 'https')} }} ''') return ''.join(chunks).encode(), trust, fingerprints def atomic(path, content, mode=0o600): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream: temporary = Path(stream.name) os.fchmod(stream.fileno(), mode) stream.write(content) stream.flush() os.fsync(stream.fileno()) try: os.replace(temporary, path) fd = os.open(path.parent, os.O_DIRECTORY) try: os.fsync(fd) finally: os.close(fd) finally: temporary.unlink(missing_ok=True) def recover_pending(state=STATE, command=subprocess.run): """Caller holds the nginx lock; recover BEFORE reading candidate input files.""" journal = Path(state) / 'pending.json' if not journal.exists(): return False saved = json.loads(journal.read_text()) for entry in saved['files']: target = Path(entry['path']) if entry['backup'] is None: target.unlink(missing_ok=True) else: atomic(target, Path(entry['backup']).read_bytes(), entry['mode']) for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']): result = command(args, capture_output=True, timeout=30) if result.returncode: raise RuntimeError('NPM bridge pending transaction recovery failed; journal retained') journal.unlink() return True def apply_files(files, state=STATE, command=subprocess.run, lock_path=Path('/run/lock/archy-nginx-config.lock'), renewal_fingerprint='', locked=False, certificate_reload=None): """Commit managed files together, with durable rollback before nginx reload. The journal contains file paths and backups, never private key contents. A interrupted transaction is rolled back before attempting the next one. """ state = Path(state) state.mkdir(parents=True, exist_ok=True, mode=0o700) os.chmod(state, 0o700) journal = state / 'pending.json' receipt = state / 'applied.json' def reload_nginx(): for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']): result = command(args, capture_output=True, timeout=30) if result.returncode: raise RuntimeError('NPM bridge nginx validation/reload failed') def restore(saved): for entry in saved['files']: target = Path(entry['path']) if entry['backup'] is None: target.unlink(missing_ok=True) else: atomic(target, Path(entry['backup']).read_bytes(), entry['mode']) reload_nginx() journal.unlink() with contextlib.nullcontext() if locked else Path(lock_path).open('a+b') as lock: if not locked: fcntl.flock(lock, fcntl.LOCK_EX) if journal.exists(): restore(json.loads(journal.read_text())) current = {} if receipt.exists(): current = json.loads(receipt.read_text()) changed = [(Path(path), content, mode) for path, content, mode in files if not Path(path).is_file() or Path(path).read_bytes() != content or Path(path).stat().st_mode & 0o777 != mode] if not changed and current.get('renewal_fingerprint') == renewal_fingerprint: return False backup_dir = state / ('backup-' + str(time.time_ns())) backup_dir.mkdir(mode=0o700) entries = [] for index, (target, _, _) in enumerate(changed): if target.is_symlink(): raise ValueError('Managed nginx output is a symlink; review operator override before updating') backup = None mode = 0o600 if target.exists(): backup = backup_dir / str(index) mode = target.stat().st_mode & 0o777 atomic(backup, target.read_bytes()) entries.append({'path': str(target), 'backup': None if backup is None else str(backup), 'mode': mode}) saved = {'files': entries} atomic(journal, json.dumps(saved).encode()) try: for target, content, mode in changed: atomic(target, content, mode) if certificate_reload and current.get('renewal_fingerprint') != renewal_fingerprint: # Replacing a custom certificate through NPM's API can update # files without reloading its running TLS listener. Ensure both # TLS endpoints pick up the replacement, not just host nginx. certificate_reload() reload_nginx() atomic(receipt, json.dumps({'renewal_fingerprint': renewal_fingerprint}).encode()) journal.unlink() except Exception as failure: try: restore(saved) except Exception as rollback: raise RuntimeError('NPM bridge failed; rollback incomplete, durable recovery journal retained') from rollback raise failure return True def dashboard_acme_root(source, data): """Update only known managed ACME roots, without changing custom locations.""" root = Path(data) / 'letsencrypt-acme-challenge' pattern = re.compile(r'(location\s+\^~\s+/\.well-known/acme-challenge/\s*\{)([^{}]*)(\})', re.S) count = 0 def replace(found): nonlocal count body = found[2] existing = re.findall(r'\broot\s+([^;]+);', body) allowed = {str(BASE / 'letsencrypt-acme-challenge'), str(BASE / 'data/letsencrypt-acme-challenge'), str(root)} if len(existing) != 1 or existing[0].strip().strip('"') not in allowed: raise ValueError('Custom dashboard ACME location requires review; configuration preserved') count += 1 body = re.sub(r'\broot\s+[^;]+;', lambda _: 'root ' + quote(root) + ';', body) return found[1] + body + found[3] result = pattern.sub(replace, source) if count == 1: # Older shipped dashboard templates omitted HTTPS ACME entirely. A # public challenge exception must never fall through to the SPA there. # Recognize only our canonical default servers; preserve custom layouts. prefix = r'server\s*\{\s*(?:if\s*\(\$archy_management_denied\)\s*\{\s*return 404;\s*\}\s*)?' http = list(re.finditer(prefix + r'listen 80 default_server;', result)) https = list(re.finditer(prefix + r'listen 443 ssl default_server;', result)) challenge = list(pattern.finditer(result)) if (len(http) == len(https) == 1 and http[0].end() < challenge[0].start() < https[0].start() and result.count('/.well-known/acme-challenge/') == 1): at = https[0].end() location = ('\n\n location ^~ /.well-known/acme-challenge/ {\n' ' default_type text/plain;\n' ' root ' + quote(root) + ';\n' ' try_files $uri =404;\n }') result = result[:at] + location + result[at:] count += 1 if count != 2: raise ValueError('Expected HTTP and HTTPS dashboard ACME locations; refusing partial migration') return result def legacy_angor_route(source, paths, relay=False): """Recognize only the exact temporary routes recorded in the live handoff. Operator edits must fail recognition, not be overwritten by migration. Domain and certificate IDs are extracted, then the entire configuration is compared to the known template; no deployment hostname is hardcoded. """ marker = ('# Live repair: NPM relay host, certificate11. Retire through release migration.' if relay else '# Shorty repair 2026-10-01: NPM host 2, certificate 8.') if not source.startswith(marker + '\n'): return False names = re.findall(r'\bserver_name\s+([^;]+);', source) if len(names) != 2 or names[0] != names[1]: return False try: name = domains(json.dumps([names[0].strip()]))[0] except ValueError: return False certificate_id = 11 if relay else 8 parent = Path(paths['certificates']) / 'live' / f'npm-{certificate_id}' extra = '''proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s;''' if relay else '' limit = '' if relay else 'client_max_body_size 4m;' expected = f''' server {{ listen 80; listen [::]:80; server_name {name}; location ^~ /.well-known/acme-challenge/ {{ default_type text/plain; root {Path(paths['data']) / 'letsencrypt-acme-challenge'}; try_files $uri =404; }} location / {{ return 301 https://$host$request_uri; }} }} server {{ listen 443 ssl; listen [::]:443 ssl; server_name {name}; ssl_certificate {parent / 'fullchain.pem'}; ssl_certificate_key {parent / 'privkey.pem'}; ssl_protocols TLSv1.2 TLSv1.3; {limit} location / {{ proxy_pass http://127.0.0.1:{8091 if relay else 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; {extra} }} }} ''' def normalized(text): return ''.join(re.sub(r'#[^\n]*', '', text).split()) return normalized(source) == normalized(expected) def legacy_shop_route(source, paths): """Recognize the exact BTCPay emergency route; preserve any operator edits.""" marker = re.match(r'# ([a-z0-9.-]+) — BTCPay Server\. LetsEncrypt cert \(npm-([0-9]+)\) obtained via NPM webroot\.\n', source) if not marker: return False try: name = domains(json.dumps([marker[1]]))[0] except ValueError: return False parent = Path(paths['certificates']) / 'live' / f'npm-{marker[2]}' expected = f''' server {{ listen 80; listen [::]:80; server_name {name} www.{name}; location ^~ /.well-known/acme-challenge/ {{ default_type text/plain; root {Path(paths['data']) / 'letsencrypt-acme-challenge'}; try_files $uri =404; }} location / {{ return 301 https://$host$request_uri; }} }} server {{ listen 443 ssl; listen [::]:443 ssl; server_name {name} www.{name}; ssl_certificate {parent / 'fullchain.pem'}; ssl_certificate_key {parent / '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; }} }} ''' def normalized(text): return ''.join(re.sub(r'#[^\n]*', '', text).split()) return normalized(source) == normalized(expected) def existing_route_changes(rows, paths, output=OUTPUT, directories=None): """Retire exact managed emergency routes and refuse other duplicate hosts.""" if directories is None: directories = [Path('/etc/nginx/conf.d'), Path('/etc/nginx/sites-enabled')] claimed = {name for row in rows for name in domains(row['domain_names'])} tls_claimed = {name for row in rows if row.get('certificate_id') and not row.get('certificate_deleted') for name in domains(row['domain_names'])} changes = [] for directory in directories: if not directory.exists(): continue for path in directory.iterdir(): if not path.is_file() or path.resolve() == Path(output).resolve(): continue if directory.name == 'conf.d' and path.suffix != '.conf': continue source = path.read_text() uncommented = re.sub(r'#[^\n]*', '', source) existing = {name for group in re.findall(r'\bserver_name\s+([^;]+);', uncommented) for name in group.split()} recognized = (path.name in ('angor-indexer-npm.conf', 'angor-relay-npm.conf') and legacy_angor_route( source, paths, relay=path.name == 'angor-relay-npm.conf' )) or (path.name == 'shop-btcpay.conf' and legacy_shop_route(source, paths)) # Never retire a working emergency endpoint without a complete # replacement, including every alias and its HTTPS listener. if recognized and existing and existing.issubset(tls_claimed): changes.append((path, b'# Temporary managed route retired; NPM owns routing through public-npm-proxy-hosts.conf.\n', 0o644)) continue if claimed.intersection(existing): raise ValueError(f'Existing public nginx route conflicts with NPM in {path.name}; custom configuration preserved for review') return changes def build_files(runtime, paths, dashboard=DASHBOARD, output=OUTPUT, state=STATE): """Create a reviewable candidate without altering database, keys or services.""" trust_file = Path(state) / 'upstream-trust.pem' rows = hosts(paths['data']) configuration, trust, fingerprints = render( rows, paths, local_port(runtime, 80), local_port(runtime, 443), Path(paths['data']) / 'letsencrypt-acme-challenge', trust_file) dashboard = Path(dashboard) default_site = dashboard_acme_root(dashboard.read_text(), paths['data']) files = [(Path(output), configuration, 0o644), (trust_file, trust, 0o644), (dashboard, default_site.encode(), dashboard.stat().st_mode & 0o777)] files.extend(existing_route_changes(rows, paths, output)) digest = hashlib.sha256(json.dumps(fingerprints).encode()).hexdigest() return files, digest def main(): parser = argparse.ArgumentParser() parser.add_argument('--resolve', action='store_true') parser.add_argument('--remember', action='store_true') parser.add_argument('--prepare-realip', action='store_true') parser.add_argument('--acme-only', action='store_true') args = parser.parse_args() runtime = inspect_runtime() if args.resolve: paths = resolve_paths(runtime=runtime) if args.prepare_realip: prepare_realip(paths) if args.remember and runtime is not None and (Path(paths['data']) / 'database.sqlite').is_file(): # Capture authoritative mounts BEFORE lifecycle code removes the # inspect record. Subsequent recreation must reuse custom storage. atomic(BASE / '.archy-storage.json', json.dumps(paths).encode()) print(json.dumps(paths)) return if args.acme_only: with Path('/run/lock/archy-nginx-config.lock').open('a+b') as lock: fcntl.flock(lock, fcntl.LOCK_EX) recover_pending(STATE / 'acme') recover_pending(STATE) paths = resolve_paths(runtime=runtime) candidate = dashboard_acme_root(DASHBOARD.read_text(), paths['data']).encode() apply_files([(DASHBOARD, candidate, DASHBOARD.stat().st_mode & 0o777)], state=STATE / 'acme', locked=True) print('NPM default ACME root verified') return with Path('/run/lock/archy-nginx-config.lock').open('a+b') as lock: fcntl.flock(lock, fcntl.LOCK_EX) recover_pending(STATE / 'acme') recover_pending(STATE) if runtime is None: # A saved DB does not authorize resurrecting an uninstalled app's routes. files = existing_route_changes([], resolve_paths(runtime=None)) if OUTPUT.exists(): files.append((OUTPUT, b'# NPM is not installed; public bridge disabled.\n', 0o644)) if files: apply_files(files, locked=True) print('NPM absent; no public routes installed') return paths = resolve_paths(runtime=runtime) # Certificate issuance must not wait for the optional HTTP/TLS bridge # listeners to be migrated or become ready. acme_candidate = dashboard_acme_root(DASHBOARD.read_text(), paths['data']).encode() apply_files([(DASHBOARD, acme_candidate, DASHBOARD.stat().st_mode & 0o777)], state=STATE / 'acme', locked=True) files, fingerprint = build_files(runtime, paths) def reload_certificate(): for args in (['nginx', '-t'], ['nginx', '-s', 'reload']): run(podman_args() + ['exec', 'nginx-proxy-manager'] + args) changed = apply_files(files, renewal_fingerprint=fingerprint, locked=True, certificate_reload=reload_certificate) print('NPM public bridge updated' if changed else 'NPM public bridge unchanged') if __name__ == '__main__': try: main() except Exception as error: # Do not expose DB values, private keys, command output or account data. print(f'NPM public bridge: {type(error).__name__}: {error}', file=__import__('sys').stderr) raise SystemExit(1)