diff --git a/CHANGELOG.md b/CHANGELOG.md index baedea20..90fd6359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API. +- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths. + - Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync. - Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps. diff --git a/apps/nginx-proxy-manager/manifest.yml b/apps/nginx-proxy-manager/manifest.yml index 42f18e97..9935bd4c 100644 --- a/apps/nginx-proxy-manager/manifest.yml +++ b/apps/nginx-proxy-manager/manifest.yml @@ -64,9 +64,11 @@ app: environment: [] + # Probe the admin API inside the container, independent of optional + # tunnel listeners. This also verifies the Node backend is ready. health_check: - type: tcp - endpoint: localhost:81 + type: http + endpoint: http://127.0.0.1:81/api/ interval: 30s timeout: 5s retries: 3 diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 875259fe..4ea1381e 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -147,6 +147,26 @@ pub async fn ensure_runtime_assets_ready() { Ok(_) => debug!("No OTA runtime payload to synchronize"), Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e), } + // Repair the narrowly recognized legacy NPM tunnel override before app + // reconciliation. The embedded script ships in both OTA and ISO binaries. + // It preserves native wallet services and refuses unknown custom routing. + match tokio::process::Command::new("python3") + .arg("-c") + .arg(include_str!("../../../scripts/repair-npm-tunnel.py")) + .output() + .await + { + Ok(output) if output.status.success() => { + if !output.stdout.is_empty() { + info!("{}", String::from_utf8_lossy(&output.stdout).trim()); + } + } + Ok(output) => warn!( + "NPM tunnel migration needs attention: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + Err(error) => warn!("NPM tunnel migration could not run: {error}"), + } match run_apps_dir_repair().await { Ok(true) => { info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps") diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index a203e138..b11e10f2 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -501,12 +501,12 @@ async fn check_containers() -> Vec { out } -fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec { +fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec { let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else { return Vec::new(); }; - let mut out: Vec = ports + let mut out: Vec = ports .iter() .filter(|p| { p.get("protocol") @@ -515,9 +515,19 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec { .eq_ignore_ascii_case("tcp") }) .filter_map(|p| { - p.get("host_port") - .and_then(|v| v.as_u64()) - .and_then(|port| u16::try_from(port).ok()) + let port = p.get("host_port")?.as_u64()?; + let port = u16::try_from(port).ok().filter(|port| *port != 0)?; + let bind = p.get("host_ip").and_then(|v| v.as_str()).unwrap_or(""); + // Wildcard listeners are reachable through the corresponding + // loopback family. Explicit binds must be probed at that address: + // probing a WireGuard-only port on 127.0.0.1 creates false failures + // and endlessly restarts an otherwise healthy app. + let address: std::net::IpAddr = match bind { + "" | "0.0.0.0" => "127.0.0.1".parse().ok()?, + "::" => "::1".parse().ok()?, + explicit => explicit.parse().ok()?, + }; + Some(std::net::SocketAddr::new(address, port)) }) .collect(); out.sort_unstable(); @@ -525,11 +535,11 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec { out } -async fn host_ports_ready(ports: &[u16]) -> bool { +async fn host_ports_ready(ports: &[std::net::SocketAddr]) -> bool { for port in ports { let ready = tokio::time::timeout( std::time::Duration::from_secs(2), - tokio::net::TcpStream::connect(("127.0.0.1", *port)), + tokio::net::TcpStream::connect(*port), ) .await .is_ok_and(|r| r.is_ok()); @@ -1662,4 +1672,51 @@ mod tests { "Prefetcher:catching up to daemon height 953,480" )); } + #[test] + fn published_port_probes_preserve_explicit_bind_addresses() { + let c = serde_json::json!({"Ports": [ + {"host_ip":"127.0.0.1","host_port":8081,"protocol":"tcp"}, + {"host_ip":"10.77.0.2","host_port":18081,"protocol":"tcp"}, + {"host_ip":"10.77.0.2","host_port":18443,"protocol":"tcp"}, + {"host_ip":"::1","host_port":8082,"protocol":"tcp"} + ]}); + let targets = host_tcp_ports_from_container(&c); + for target in [ + "127.0.0.1:8081", + "10.77.0.2:18081", + "10.77.0.2:18443", + "[::1]:8082", + ] { + assert!(targets.contains(&target.parse().unwrap())); + } + assert!(!targets.contains(&"127.0.0.1:18081".parse().unwrap())); + } + + #[test] + fn published_port_probes_normalize_wildcards_and_ignore_invalid_entries() { + let c = serde_json::json!({"Ports": [ + {"host_ip":"0.0.0.0","host_port":8080}, + {"host_ip":"","host_port":8080}, + {"host_ip":"::","host_port":8080}, + {"host_ip":"10.0.0.1","host_port":53,"protocol":"udp"}, + {"host_ip":"bad","host_port":8080}, + {"host_port":0}, {"host_port":65536}, {"container_port":80} + ]}); + let targets = host_tcp_ports_from_container(&c); + assert_eq!(targets.len(), 2); + assert!(targets.contains(&"127.0.0.1:8080".parse().unwrap())); + assert!(targets.contains(&"[::1]:8080".parse().unwrap())); + } + + #[tokio::test] + async fn health_probe_reaches_non_default_loopback_and_detects_closed_port() { + let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + assert!(host_ports_ready(&[address]).await); + // Same port, wrong local address reproduces the former false failure. + let wrong = std::net::SocketAddr::new("127.0.0.1".parse().unwrap(), address.port()); + assert!(!host_ports_ready(&[wrong]).await); + drop(listener); + assert!(!host_ports_ready(&[address]).await); + } } diff --git a/docs/next-release-20260930.md b/docs/next-release-20260930.md index ba9c4acf..ce957829 100644 --- a/docs/next-release-20260930.md +++ b/docs/next-release-20260930.md @@ -257,3 +257,48 @@ Bitcoin, LND and the production site container identities/start times were unchanged by the port repair. Rollback copies and the data archive are retained in the node's private support directory. No global OTA or ISO was published by this repair; the remaining release gates above still apply. + +#### Follow-up: fleet delivery and false health failures + +A longer observation exposed a second, generic defect after the port conflict +was repaired: the health monitor probed all published ports at `127.0.0.1`, +including NPM's tunnel-only listeners. Every monitor interval could therefore +restart a healthy app. The short initial restart check did not catch this. + +The next backend now probes the actual `host_ip` from Podman; only wildcard +addresses map to the corresponding loopback family. Regression tests cover +explicit IPv4/IPv6 binds, wildcards, UDP/unpublished/invalid entries, and a real +listener on a different loopback address. NPM's manifest now checks its internal +admin HTTP API. The same check is deployed as a persistent Quadlet drop-in on +the affected node so its older backend stops making false recovery attempts. + +The backend embeds `scripts/repair-npm-tunnel.py` and runs it before app +reconciliation, after runtime asset promotion. This makes the targeted legacy +port migration available to both OTA and ISO installations without relying on +an independently installed script. Standard fresh installs are a no-op. Only +the recognized legacy tunnel/firewall profile is migrated; unknown operator +routing, occupied replacement ports and live-only firewall changes fail closed +with a startup warning. Configuration backups, an interrupted-migration journal, +atomic nft transactions and rollback protect the existing routing. Native wallet +services and certificate databases are never modified by this fleet migration. + +The Python migration tests run in the release gate. The unsigned next catalog +was regenerated successfully with the new NPM HTTP health check. These changes +are prepared for the next release; existing published OTA/ISO artifacts remain +unchanged and the new signed artifacts still require the release gates above. + +Verification for this follow-up: 18 migration tests passed; 43 health-monitor +backend tests passed through the isolated runner. A disposable network-namespace +regression exercised actual peer traffic through the nft redirect while a +separate simulated LND listener retained port 18080. The generated rules also +passed nft validation and atomic replacement. Run that regression with +`sudo unshare --net python3 tests/regression/npm-tunnel-network.py`; it refuses +to run in the host network namespace. The migration is a verified no-op on the +already repaired node and on a standard development install without the override. + +After deploying the API health check, a 270-second live observation crossed +multiple health-monitor intervals: NPM stayed healthy with the same container +ID/start time, every API probe returned success, and Bitcoin/LND/production-site +container IDs/start times were unchanged. This supersedes the initial short +restart-only acceptance recorded above. The generic backend fix is committed +for release, while the live node uses the equivalent internal NPM health check. diff --git a/scripts/repair-npm-tunnel.py b/scripts/repair-npm-tunnel.py new file mode 100644 index 00000000..ac548842 --- /dev/null +++ b/scripts/repair-npm-tunnel.py @@ -0,0 +1,158 @@ +#!/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 diff --git a/scripts/tests/test_repair_npm_tunnel.py b/scripts/tests/test_repair_npm_tunnel.py new file mode 100644 index 00000000..25e9f896 --- /dev/null +++ b/scripts/tests/test_repair_npm_tunnel.py @@ -0,0 +1,137 @@ +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch, MagicMock +import tempfile +import json + +spec=importlib.util.spec_from_file_location('repair', Path(__file__).parents[1]/'repair-npm-tunnel.py') +m=importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +DROP='[Container]\nPublishPort=10.77.0.2:18080:80/tcp\nPublishPort=10.77.0.2:18443:443/tcp\n' +RULES='''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 10.77.0.1 icmp type echo-request accept + ip saddr 10.77.0.1 ip daddr 10.77.0.2 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 + } +}''' + +class Plan(unittest.TestCase): + def test_preserves_peer_https_and_restricts_redirect(self): + drop,rules,dst=m.plan(DROP,RULES) + self.assertEqual(dst,'10.77.0.2') + self.assertIn('PublishPort=10.77.0.2:18081:80/tcp',drop) + self.assertIn('PublishPort=10.77.0.2:18443:443/tcp',drop) + self.assertIn('iifname "wg-web" ip saddr 10.77.0.1 ip daddr 10.77.0.2 tcp dport 18080 redirect to :18081',rules) + self.assertNotIn('tcp dport { 18080, 18443 } accept',rules) + self.assertIn('iifname "wg-web" counter drop',rules) + def test_idempotent(self): + d,r,_=m.plan(DROP,RULES) + self.assertIsNone(m.plan(d,r)) + def test_standard_fresh_install_untouched(self): + self.assertIsNone(m.plan('[Container]\nPublishPort=127.0.0.1:8081:81/tcp','')) + def test_no_hardcoded_deployment_address(self): + d,r,dst=m.plan(DROP.replace('10.77.0.','10.55.0.'),RULES.replace('10.77.0.','10.55.0.')) + self.assertEqual(dst,'10.55.0.2');self.assertIn('ip saddr 10.55.0.1',r) + def test_custom_firewall_preserved(self): + for r in [RULES+'\ntable inet extra {}',RULES.replace('counter drop','accept'),RULES.replace('wg-web','wg-custom')]: + with self.assertRaises(ValueError): m.plan(DROP,r) + def test_ambiguous_mapping(self): + with self.assertRaises(ValueError): m.plan(DROP+DROP,RULES) + def test_wrong_destination(self): + with self.assertRaises(ValueError): m.plan(DROP.replace('10.77.0.2','10.77.0.3'),RULES) + def test_already_used_mapping(self): + with self.assertRaises(ValueError): m.plan(DROP+'PublishPort=10.77.0.2:18081:80/tcp\n',RULES) + +class Migration(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home=Path(self.temp.name) + self.drop=self.home/'.config/containers/systemd/nginx-proxy-manager.container.d/web-tunnel.conf' + self.drop.parent.mkdir(parents=True) + self.drop.write_text(DROP) + self.calls=[];self.rules=RULES;self.fail=None + self.state=self.home/'.local/state/archipelago/npm-tunnel-migration' + def command(self,*args,input=None): + self.calls.append((args,input)) + if self.fail and self.fail(args): + self.fail=None + raise RuntimeError('injected failure') + if 'cat' in args or ('list' in args and 'nft' in args): return self.rules + if 'grep' in args: return 'PreUp = nft -f /etc/wireguard/wg-web.nft\nPostDown = nft delete table inet web_tunnel' + if 'show' in args: return 'active' + return '' + def run_migration(self): + with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m,'root_write') as write,patch.object(m.socket,'socket'): + m.main() + return write + def test_success_and_second_run_noop(self): + write=self.run_migration() + self.assertIn(':18081:80/tcp',self.drop.read_text()) + self.assertEqual(json.loads((self.state/'before.json').read_text())['drop'],DROP) + self.assertFalse((self.state/'pending.json').exists()) + self.assertEqual(write.call_count,1) + self.calls.clear();self.run_migration();self.assertEqual(self.calls,[]) + def test_no_native_service_commands(self): + self.run_migration() + for args,_ in self.calls: + self.assertNotIn('lnd.service',args);self.assertNotIn('bitcoin-core.service',args) + def test_validation_failure_does_not_stop_or_write(self): + self.fail=lambda a:'--check' in a + with self.assertRaises(RuntimeError):self.run_migration() + self.assertEqual(self.drop.read_text(),DROP) + self.assertFalse(self.state.exists()) + self.assertFalse(any('stop' in a for a,_ in self.calls)) + def test_apply_failure_restores_files_and_firewall(self): + self.fail=lambda a:'nft' in a and '-f' in a and '--check' not in a + with self.assertRaises(RuntimeError):self.run_migration() + self.assertEqual(self.drop.read_text(),DROP) + self.assertFalse((self.state/'pending.json').exists()) + self.assertTrue(any(v=='delete table inet web_tunnel\n'+RULES for _,v in self.calls)) + def test_crash_journal_recovers_and_retries(self): + self.state.mkdir(parents=True) + (self.state/'pending.json').write_text(json.dumps({'drop':DROP,'rules':RULES,'was_active':'active'})) + self.drop.write_text(m.plan(DROP,RULES)[0]) + self.run_migration() + self.assertIn(':18081:80/tcp',self.drop.read_text()) + self.assertFalse((self.state/'pending.json').exists()) + def test_fresh_install_executes_no_commands(self): + self.drop.unlink();self.run_migration();self.assertEqual(self.calls,[]) + def test_busy_replacement_port_does_not_mutate(self): + with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m.socket,'socket') as socket: + socket.return_value.__enter__.return_value.bind.side_effect=OSError('in use') + with self.assertRaises(OSError):m.main() + self.assertFalse(self.state.exists()) + self.assertFalse(any('stop' in a for a,_ in self.calls)) + def test_failed_rollback_keeps_recovery_journal(self): + with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=self.command),patch.object(m,'root_write',side_effect=RuntimeError('write failed')),patch.object(m.socket,'socket'): + with self.assertRaises(RuntimeError):m.main() + self.assertTrue((self.state/'pending.json').exists()) + self.assertEqual(self.drop.read_text(),DROP) + def test_stopped_app_is_not_started(self): + original=self.command + def stopped(*args,input=None): + return 'inactive' if 'show' in args else original(*args,input=input) + with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=stopped),patch.object(m,'root_write'),patch.object(m.socket,'socket'): + m.main() + self.assertFalse(any('restart' in a for a,_ in self.calls)) + def test_live_only_firewall_changes_are_not_discarded(self): + original=self.command + def different(*args,input=None): + result=original(*args,input=input) + return result+' table inet custom {}' if 'list' in args else result + with patch.object(Path,'home',return_value=self.home),patch.object(m,'command',side_effect=different),patch.object(m.socket,'socket'): + with self.assertRaises(ValueError):m.main() + self.assertEqual(self.drop.read_text(),DROP) + +if __name__=='__main__': unittest.main() diff --git a/tests/regression/npm-tunnel-network.py b/tests/regression/npm-tunnel-network.py new file mode 100644 index 00000000..a908538d --- /dev/null +++ b/tests/regression/npm-tunnel-network.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Real nftables routing check. Run: sudo unshare --net python3 . +Never runs in the host network namespace; creates no persistent namespaces. +""" +import importlib.util +import os +from pathlib import Path +import socket +import subprocess +import threading + +assert os.geteuid() == 0 +assert os.readlink('/proc/self/ns/net') != os.readlink('/proc/1/ns/net'), 'requires isolated network namespace' +repo=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location('fixture',repo/'scripts/tests/test_repair_npm_tunnel.py') +f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f) +def run(*args,input=None): + return subprocess.run(args,input=input,text=True,capture_output=True,check=True,timeout=10).stdout +run('ip','link','set','lo','up') +peer=subprocess.Popen(['unshare','--net','sleep','60']) +try: + import time + for _ in range(100): + if os.readlink(f'/proc/{peer.pid}/ns/net')!=os.readlink('/proc/self/ns/net'): break + time.sleep(.02) + else: raise AssertionError('peer namespace did not start') + run('ip','link','add','wg-web','type','veth','peer','name','wgpeer') + run('ip','link','set','wgpeer','netns',str(peer.pid)) + run('ip','addr','add','10.77.0.2/30','dev','wg-web') + run('ip','link','set','wg-web','up') + prefix=('nsenter','-t',str(peer.pid),'-n') + run(*prefix,'ip','addr','add','10.77.0.1/30','dev','wgpeer') + run(*prefix,'ip','link','set','wgpeer','up') + run(*prefix,'ip','link','set','lo','up') + listeners=[] + for address,reply in [(('10.77.0.2',18081),b'NPM'),(('0.0.0.0',18080),b'LND')]: + listener=socket.socket();listener.bind(address);listener.listen();listeners.append(listener) + def serve(sock=listener,data=reply): + connection,_=sock.accept() + with connection: connection.sendall(data) + threading.Thread(target=serve,daemon=True).start() + run('nft','-f','-',input=f.RULES) + _,rules,_=f.m.plan(f.DROP,f.RULES) + transaction='delete table inet web_tunnel\n'+rules + run('nft','--check','-f','-',input=transaction) + run('nft','-f','-',input=transaction) + result=run(*prefix,'python3','-c',"import socket; s=socket.create_connection(('10.77.0.2',18080),3); print(s.recv(10).decode())") + assert result.strip()=='NPM',result + with socket.create_connection(('127.0.0.1',18080),3) as connection: + assert connection.recv(10)==b'LND' + print('PASS: original peer HTTP port reaches NPM; local LND REST port remains separate') +finally: + peer.terminate();peer.wait(timeout=5) diff --git a/tests/release/run.sh b/tests/release/run.sh index b0912870..3ea8ff68 100755 --- a/tests/release/run.sh +++ b/tests/release/run.sh @@ -73,6 +73,7 @@ stage "git-diff-check" git diff --check stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check stage "app-build-contexts" python3 tests/regression/app-build-contexts.py stage "manifest-shell" python3 scripts/check-manifest-shell.py +stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py stage "doctor-ports" bash tests/regression/container-doctor-ports.sh stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs