159 lines
7.3 KiB
Python
159 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Migrate the known NPM/LND tunnel collision, without touching wallet services.
|
||
|
|
|
||
|
|
Runs as the rootless app owner before orchestrator startup. Only the narrow
|
||
|
|
legacy web-tunnel profile is accepted. Unknown custom routing fails closed.
|
||
|
|
"""
|
||
|
|
import ipaddress
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import socket
|
||
|
|
import subprocess
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
|
||
|
|
def command(*args, input=None):
|
||
|
|
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=45)
|
||
|
|
if result.returncode:
|
||
|
|
# Commands may read private files. Never print their captured output.
|
||
|
|
raise RuntimeError(f'{args[0]} operation failed (exit {result.returncode})')
|
||
|
|
return result.stdout
|
||
|
|
|
||
|
|
|
||
|
|
def plan(drop, rules):
|
||
|
|
"""Return a conservative migration, or None for absent/already fixed mapping."""
|
||
|
|
matches = re.findall(r'^PublishPort=([0-9.]+):18080:80/tcp$', drop, re.M)
|
||
|
|
if not matches:
|
||
|
|
return None
|
||
|
|
if len(matches) != 1:
|
||
|
|
raise ValueError('ambiguous NPM tunnel mapping')
|
||
|
|
destination = str(ipaddress.IPv4Address(matches[0]))
|
||
|
|
peer_match = re.search(r'ip saddr ([0-9.]+) ip daddr ' + re.escape(destination)
|
||
|
|
+ r' tcp dport \{ 18080, 18443 \} accept', rules)
|
||
|
|
if not peer_match:
|
||
|
|
raise ValueError('unrecognized NPM tunnel firewall; manual review required')
|
||
|
|
peer = str(ipaddress.IPv4Address(peer_match[1]))
|
||
|
|
# Match the entire old profile, not just a substring in an arbitrary firewall.
|
||
|
|
old = f'''table inet web_tunnel {{
|
||
|
|
chain input {{
|
||
|
|
type filter hook input priority -10; policy accept;
|
||
|
|
iifname != "wg-web" return
|
||
|
|
ct state established,related accept
|
||
|
|
ip saddr {peer} icmp type echo-request accept
|
||
|
|
ip saddr {peer} ip daddr {destination} tcp dport {{ 18080, 18443 }} accept
|
||
|
|
counter drop
|
||
|
|
}}
|
||
|
|
chain forward {{
|
||
|
|
type filter hook forward priority -10; policy accept;
|
||
|
|
iifname "wg-web" counter drop
|
||
|
|
oifname "wg-web" counter drop
|
||
|
|
}}
|
||
|
|
}}'''
|
||
|
|
if rules.split() != old.split():
|
||
|
|
raise ValueError('custom NPM tunnel firewall differs; manual review required')
|
||
|
|
if 'PublishPort='+destination+':18081:' in drop:
|
||
|
|
raise ValueError('replacement port already configured')
|
||
|
|
new_rules = rules.replace('table inet web_tunnel {', f'''table inet web_tunnel {{
|
||
|
|
# Preserve incoming HTTP while keeping LND REST's port free.
|
||
|
|
chain prerouting {{
|
||
|
|
type nat hook prerouting priority dstnat; policy accept;
|
||
|
|
iifname "wg-web" ip saddr {peer} ip daddr {destination} tcp dport 18080 redirect to :18081
|
||
|
|
}}''', 1).replace('tcp dport { 18080, 18443 } accept',
|
||
|
|
'tcp dport { 18081, 18443 } accept')
|
||
|
|
return (drop.replace(f'PublishPort={destination}:18080:80/tcp',
|
||
|
|
f'PublishPort={destination}:18081:80/tcp'), new_rules, destination)
|
||
|
|
|
||
|
|
|
||
|
|
def atomic_user(path, content):
|
||
|
|
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False) as f:
|
||
|
|
tmp = Path(f.name)
|
||
|
|
os.fchmod(f.fileno(), 0o600)
|
||
|
|
f.write(content)
|
||
|
|
f.flush()
|
||
|
|
os.fsync(f.fileno())
|
||
|
|
os.replace(tmp, path)
|
||
|
|
|
||
|
|
|
||
|
|
def root_write(path, content):
|
||
|
|
# Stage next to the destination; rename makes the config update atomic.
|
||
|
|
staged = str(path)+'.archy-npm-migration'
|
||
|
|
command('sudo', '-n', 'tee', staged, input=content)
|
||
|
|
command('sudo', '-n', 'chmod', '600', staged)
|
||
|
|
command('sudo', '-n', 'mv', '--', staged, str(path))
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
drop = Path.home()/'.config/containers/systemd/nginx-proxy-manager.container.d/web-tunnel.conf'
|
||
|
|
rules_path = Path('/etc/wireguard/wg-web.nft')
|
||
|
|
state = Path.home()/'.local/state/archipelago/npm-tunnel-migration'
|
||
|
|
journal = state/'pending.json'
|
||
|
|
recovered_active = None
|
||
|
|
# Interrupted migrations are completed/rolled back before normal startup.
|
||
|
|
if journal.exists():
|
||
|
|
saved = json.loads(journal.read_text())
|
||
|
|
command('systemctl', '--user', 'stop', 'nginx-proxy-manager.service')
|
||
|
|
atomic_user(drop, saved['drop'])
|
||
|
|
root_write(rules_path, saved['rules'])
|
||
|
|
command('sudo', '-n', 'nft', '-f', '-', input='delete table inet web_tunnel\n'+saved['rules'])
|
||
|
|
command('systemctl', '--user', 'daemon-reload')
|
||
|
|
# Do not restart the colliding old configuration before reapplying.
|
||
|
|
recovered_active = saved.get('was_active')
|
||
|
|
journal.unlink()
|
||
|
|
if not drop.exists():
|
||
|
|
return
|
||
|
|
old_drop = drop.read_text()
|
||
|
|
if not re.search(r'^PublishPort=[0-9.]+:18080:80/tcp$', old_drop, re.M):
|
||
|
|
return
|
||
|
|
old_rules = command('sudo', '-n', 'cat', str(rules_path))
|
||
|
|
new_drop, new_rules, destination = plan(old_drop, old_rules)
|
||
|
|
# A free, assigned replacement is required; do not guess another port.
|
||
|
|
with socket.socket() as probe:
|
||
|
|
probe.bind((destination, 18081))
|
||
|
|
# The route must be persistent and loaded by the tunnel's startup contract.
|
||
|
|
wg = command('sudo', '-n', 'grep', '-E', r'^(PreUp|PostDown)\s*=', '/etc/wireguard/wg-web.conf')
|
||
|
|
if 'PreUp = nft -f /etc/wireguard/wg-web.nft' not in wg or 'PostDown = nft delete table inet web_tunnel' not in wg:
|
||
|
|
raise ValueError('unrecognized tunnel lifecycle; manual review required')
|
||
|
|
active = command('sudo', '-n', 'nft', 'list', 'table', 'inet', 'web_tunnel')
|
||
|
|
# Reject live-only rule changes instead of silently discarding them. nft
|
||
|
|
# canonicalizes priority names and adds counter values when listing rules.
|
||
|
|
def normalized(text):
|
||
|
|
text = re.sub(r'counter packets \d+ bytes \d+', 'counter', text)
|
||
|
|
return text.replace('priority filter - 10', 'priority -10').split()
|
||
|
|
if normalized(active) != normalized(old_rules):
|
||
|
|
raise ValueError('live tunnel rules differ from persistent config; review required')
|
||
|
|
transaction = 'delete table inet web_tunnel\n'+new_rules
|
||
|
|
command('sudo', '-n', 'nft', '--check', '-f', '-', input=transaction)
|
||
|
|
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
|
|
os.chmod(state, 0o700)
|
||
|
|
was_active = recovered_active or command('systemctl', '--user', 'show', 'nginx-proxy-manager.service', '--property=ActiveState', '--value').strip()
|
||
|
|
saved = json.dumps({'drop': old_drop, 'rules': old_rules, 'was_active': was_active})
|
||
|
|
atomic_user(state/'before.json', saved)
|
||
|
|
atomic_user(journal, saved)
|
||
|
|
try:
|
||
|
|
command('systemctl', '--user', 'stop', 'nginx-proxy-manager.service')
|
||
|
|
atomic_user(drop, new_drop)
|
||
|
|
root_write(rules_path, new_rules)
|
||
|
|
command('sudo', '-n', 'nft', '-f', '-', input=transaction)
|
||
|
|
command('systemctl', '--user', 'daemon-reload')
|
||
|
|
if was_active in ('active', 'activating', 'reloading', 'failed'):
|
||
|
|
command('systemctl', '--user', 'restart', 'nginx-proxy-manager.service')
|
||
|
|
journal.unlink()
|
||
|
|
except Exception:
|
||
|
|
atomic_user(drop, old_drop)
|
||
|
|
root_write(rules_path, old_rules)
|
||
|
|
command('sudo', '-n', 'nft', '-f', '-', input='delete table inet web_tunnel\n'+old_rules)
|
||
|
|
command('systemctl', '--user', 'daemon-reload')
|
||
|
|
# Keep the journal if rollback fails so the next startup retries it.
|
||
|
|
journal.unlink()
|
||
|
|
raise
|
||
|
|
print('NPM tunnel port repaired; original configuration backed up; native services unchanged')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
try:
|
||
|
|
main()
|
||
|
|
except Exception as error:
|
||
|
|
raise SystemExit('NPM tunnel migration requires attention: '+str(error)) from None
|