From 77ee39a3dfd88c900564e905aea85faef2101a01 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 26 Jul 2026 22:48:16 +0100 Subject: [PATCH] =?UTF-8?q?fix(server+quadlet):=20app=20UIs=20work=20over?= =?UTF-8?q?=20the=20mesh=20=E2=80=94=20v6=20relay=20+=20v4-pinned=20publis?= =?UTF-8?q?hes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/container/quadlet.rs | 27 ++++++---- core/archipelago/src/server.rs | 64 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index d3ce9bfe..7771b328 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -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")); diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index c5f3b6ee..17806321 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1027,6 +1027,15 @@ impl Server { // Peer listener: late-binding so we don't need an archipelago // restart when fips0 comes up after onboarding. + // App UIs over the mesh: rootless podman's port forwarder binds + // IPv4 only for most apps, so a phone dialing [ULA]:8123 got + // nothing even with the firewall open (HA/FileBrowser/Gitea/ + // Portainer/Pine all v4-only on 2026-07-26; a few bind [::] + // themselves). Bridge each catalog launch port to its v4 loopback + // listener with a V6ONLY relay — self-selecting: where the app + // already answers on v6 the bind fails and the app wins. + let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); + let peer_task = tokio::spawn(peer_late_bind_loop( self.api_handler.clone(), active_connections.clone(), @@ -1052,6 +1061,7 @@ impl Server { if let Some(t) = v6_task { let _ = t.await; } + relay_task.abort(); let _ = peer_task.await; info!("Shutdown complete"); @@ -1077,6 +1087,60 @@ fn bind_v6_only(addr: SocketAddr) -> std::io::Result { tokio::net::TcpListener::from_std(socket.into()) } +/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for +/// why). Rescans every 60s so ports of freshly installed apps get bridged +/// without a daemon restart. Each relay is a V6ONLY listener forwarding +/// raw TCP to the same port on IPv4 loopback. +async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver) { + use std::collections::HashSet; + let mut bridged: HashSet = HashSet::new(); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = interval.tick() => { + for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { + if bridged.contains(&port) { + continue; + } + let addr = SocketAddr::new( + std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + port, + ); + // EADDRINUSE = the app itself (or a prior relay) already + // answers on v6 — exactly when we must stay out of the way. + let Ok(listener) = bind_v6_only(addr) else { continue }; + bridged.insert(port); + debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}"); + let mut rx = shutdown_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + accepted = listener.accept() => { + let Ok((mut inbound, _)) = accepted else { break }; + tokio::spawn(async move { + let Ok(mut outbound) = tokio::net::TcpStream::connect( + ("127.0.0.1", port), + ) + .await else { return }; + let _ = tokio::io::copy_bidirectional( + &mut inbound, + &mut outbound, + ) + .await; + }); + } + _ = rx.changed() => break, + } + } + }); + } + } + _ = shutdown_rx.changed() => return, + } + } +} + /// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer /// listener and run the normal accept loop. If the bind fails (port /// already taken, permissions), log and keep retrying. Returns on