fix(server+quadlet): app UIs work over the mesh — v6 relay + v4-pinned publishes

Rootless podman's wildcard publish claims [::] but BLACK-HOLES inbound
v6 (accepts, forwards nothing — vaultwarden 'empty response' from the
phone, 2026-07-26), while most apps got no v6 listener at all. Two
halves: quadlets now publish unbound ports on 0.0.0.0 explicitly
(frees the v6 side; the one-time drift/recreate at upgrade is the
deploy vehicle), and the daemon runs a self-selecting V6ONLY relay on
every catalog launch port forwarding raw TCP to the v4 loopback
listener. Rescans every 60s so new installs bridge without a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-26 22:48:16 +01:00
co-authored by Claude Fable 5
parent 02917cc22c
commit 77ee39a3df
2 changed files with 81 additions and 10 deletions
+17 -10
View File
@@ -248,10 +248,17 @@ impl QuadletUnit {
} else {
proto.as_str()
};
// Keep the rendered directive byte-identical for unbound
// ports so existing units don't read as drifted.
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
// (accepts, forwards nothing — "empty response" from the
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
// which forwards to the v4 loopback listener that works.
// NOTE: this changes the rendered directive for unbound
// ports on purpose — the one-time drift/recreate at upgrade
// is the deploy vehicle for the fix.
if bind.is_empty() {
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
} else {
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
}
@@ -1018,7 +1025,7 @@ mod tests {
u.network = NetworkMode::Bridge("archy-net".into());
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
let s = u.render();
assert!(s.contains("PublishPort=3000:3000/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
}
#[test]
@@ -1167,8 +1174,8 @@ mod tests {
let s = u.render();
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(!s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
}
#[test]
@@ -1200,8 +1207,8 @@ mod tests {
..QuadletUnit::default()
};
let s = u.render();
assert!(s.contains("PublishPort=8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
@@ -1317,7 +1324,7 @@ app:
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "searxng").render();
assert!(s.contains("PublishPort=8888:8080/tcp"));
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
assert!(!s.contains("Network=host"));
}
@@ -1746,7 +1753,7 @@ app:
assert!(body.contains("Network=archy-net"));
assert!(body.contains("NetworkAlias=lnd"));
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
assert!(body.contains("PublishPort=10009:10009/tcp"));
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
assert!(body.contains("PodmanArgs=--memory=1024m"));