fix: prevent NPM tunnel collisions and false app health restarts

This commit is contained in:
archipelago
2026-09-30 16:30:40 -04:00
parent 2992443d5d
commit c82c1eee98
9 changed files with 485 additions and 9 deletions
+3
View File
@@ -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.
+4 -2
View File
@@ -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
+20
View File
@@ -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")
+64 -7
View File
@@ -501,12 +501,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
out
}
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<std::net::SocketAddr> {
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out: Vec<u16> = ports
let mut out: Vec<std::net::SocketAddr> = ports
.iter()
.filter(|p| {
p.get("protocol")
@@ -515,9 +515,19 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
.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<u16> {
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);
}
}
+45
View File
@@ -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.
+158
View File
@@ -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
+137
View File
@@ -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()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Real nftables routing check. Run: sudo unshare --net python3 <this file>.
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)
+1
View File
@@ -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