From 4db1b85fc50fddf6f1faf3ec430040398a619921 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 26 Jul 2026 21:48:38 +0100 Subject: [PATCH] =?UTF-8?q?fix(server):=20web=20UI=20listens=20on=20IPv6?= =?UTF-8?q?=20=E2=80=94=20the=20mesh=20could=20never=20load=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh is IPv6-only; the main web listener bound 0.0.0.0 only, so a phone reaching a node over its fips0 ULA got RST on :80 (ERR_CONNECTION_ABORTED) — UI over mesh was structurally impossible. Confirmed 2026-07-26 on framework-pt: v4:80 = 200, v6:80 = refused. Mirror an IPv4-any main listener with a V6ONLY [::] socket on the same port; V6ONLY so it coexists with the v4 listener regardless of net.ipv6.bindv6only. Also: fips.yaml generator carries the fast-reconnect profile (validated live on framework-pt since 2026-07-24; snapshot tests updated). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/fips/config.rs | 60 +++++++++++++++++++++++++++++ core/archipelago/src/server.rs | 58 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/core/archipelago/src/fips/config.rs b/core/archipelago/src/fips/config.rs index d25c92b2..d682277c 100644 --- a/core/archipelago/src/fips/config.rs +++ b/core/archipelago/src/fips/config.rs @@ -48,6 +48,30 @@ pub struct FipsConfig { pub struct NodeSection { pub identity: IdentitySection, pub discovery: DiscoverySection, + pub retry: RetrySection, + pub rate_limit: RateLimitSection, +} + +/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base +/// doubling to 300s) are tuned for stable always-on links; a node redialing +/// a recycled anchor sat off-mesh for ~90s. Field names verified live on +/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RetrySection { + pub base_interval_secs: u64, + pub max_backoff_secs: u64, + pub max_retries: u32, +} + +/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0 +/// gaps out to 8-16s between resends exactly when a route has just +/// appeared; 400ms x1.5 keeps continuous coverage through the connect +/// window (phone measured session-after-route: 225ms). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RateLimitSection { + pub handshake_resend_interval_ms: u64, + pub handshake_resend_backoff: f64, + pub handshake_max_resends: u32, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -60,6 +84,14 @@ pub struct IdentitySection { #[derive(Debug, Clone, PartialEq, Serialize)] pub struct DiscoverySection { + /// Lookup completion timeout. Lookups fired while the tree position is + /// still settling are doomed; failing them fast (5s, not 10s) lets the + /// 1s-backoff retry find the route the moment it exists. + pub timeout_secs: u64, + pub backoff_base_secs: u64, + pub backoff_max_secs: u64, + pub retry_interval_secs: u64, + pub max_attempts: u8, pub lan: LanDiscoverySection, } @@ -123,8 +155,23 @@ impl Default for FipsConfig { node: NodeSection { identity: IdentitySection { persistent: true }, discovery: DiscoverySection { + timeout_secs: 5, + backoff_base_secs: 1, + backoff_max_secs: 30, + retry_interval_secs: 2, + max_attempts: 3, lan: LanDiscoverySection { enabled: true }, }, + retry: RetrySection { + base_interval_secs: 1, + max_backoff_secs: 30, + max_retries: 30, + }, + rate_limit: RateLimitSection { + handshake_resend_interval_ms: 400, + handshake_resend_backoff: 1.5, + handshake_max_resends: 10, + }, }, tun: TunSection { enabled: true, @@ -318,8 +365,21 @@ node: identity: persistent: true discovery: + timeout_secs: 5 + backoff_base_secs: 1 + backoff_max_secs: 30 + retry_interval_secs: 2 + max_attempts: 3 lan: enabled: true + retry: + base_interval_secs: 1 + max_backoff_secs: 30 + max_retries: 30 + rate_limit: + handshake_resend_interval_ms: 400 + handshake_resend_backoff: 1.5 + handshake_max_resends: 10 tun: enabled: true name: fips0 diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 67d58a17..c5f3b6ee 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -988,6 +988,43 @@ impl Server { main_addr, )); + // The mesh is IPv6-only: a phone reaching the node over its fips0 + // ULA lands on port 80 over v6, where a 0.0.0.0 listener never + // answers — the UI was structurally unreachable over the mesh + // (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200, + // v6:80 = refused). Mirror an IPv4-any main listener with a + // V6ONLY [::] socket on the same port — v6-only so it coexists + // with the v4 listener regardless of net.ipv6.bindv6only. + let v4_any_port = match main_addr { + SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()), + _ => None, + }; + let v6_task = if let Some(port) = v4_any_port { + let v6_addr = SocketAddr::new( + std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + port, + ); + match bind_v6_only(v6_addr) { + Ok(listener) => { + info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr); + Some(tokio::spawn(accept_loop( + self.api_handler.clone(), + listener, + active_connections.clone(), + false, // same semantics as the main listener + tx.subscribe(), + v6_addr, + ))) + } + Err(e) => { + warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e); + None + } + } + } else { + None + }; + // Peer listener: late-binding so we don't need an archipelago // restart when fips0 comes up after onboarding. let peer_task = tokio::spawn(peer_late_bind_loop( @@ -1012,6 +1049,9 @@ impl Server { } let _ = main_task.await; + if let Some(t) = v6_task { + let _ = t.await; + } let _ = peer_task.await; info!("Shutdown complete"); @@ -1019,6 +1059,24 @@ impl Server { } } +/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the +/// socket never claims the IPv4 side (which the main listener owns) — +/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with +/// EADDRINUSE. +fn bind_v6_only(addr: SocketAddr) -> std::io::Result { + let socket = socket2::Socket::new( + socket2::Domain::IPV6, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + socket.set_only_v6(true)?; + socket.set_reuse_address(true)?; + socket.set_nonblocking(true)?; + socket.bind(&addr.into())?; + socket.listen(1024)?; + tokio::net::TcpListener::from_std(socket.into()) +} + /// 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