fix: prevent NPM tunnel collisions and false app health restarts
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user