diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 932fc67c..5cbbe440 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 23 - versionName = "0.5.3" + versionCode = 26 + versionName = "0.5.6" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt index 36714e05..c54ae4c1 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -10,10 +10,15 @@ import android.os.Build import android.util.Log import com.archipelago.app.MainActivity import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -27,6 +32,7 @@ import kotlinx.coroutines.launch class ArchyVpnService : VpnService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var warmerJob: Job? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { @@ -68,6 +74,14 @@ class ArchyVpnService : VpnService() { // And let apps that bind their own network skip the TUN // entirely — this is mesh reachability, not a privacy VPN. .allowBypass() + .apply { + // Android 10+ treats VPN networks as METERED by default, + // which flips the whole phone into data-saver behaviour + // (background sync off, "metered" warnings) while the + // mesh is up. It inherits the underlying network's real + // metered state instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false) + } .establish() } catch (e: Exception) { Log.e(TAG, "VPN establish failed", e) @@ -81,10 +95,57 @@ class ArchyVpnService : VpnService() { val fd = pfd.detachFd() val result = FipsNative.start(identity.secret, peersJson, fd) Log.i(TAG, "mesh start: $result") - if (result.contains("\"error\"")) shutdown() + if (result.contains("\"error\"")) { + shutdown() + } else { + startSessionWarmer() + } + } + + /** + * Pre-warm + keep-warm mesh sessions to every known node ULA. + * + * Discovery + first session through the public tree can take 15s+ + * (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the + * moment the tunnel is up, means the connect probe and WebView hit an + * established session instead of timing out on a cold one. The periodic + * touch afterwards keeps the session from idling out. Failed connects + * are expected and cheap; the attempt itself is what drives discovery. + */ + private fun startSessionWarmer() { + warmerJob?.cancel() + warmerJob = scope.launch { + val prefs = ServerPreferences(this@ArchyVpnService) + var round = 0 + while (isActive && FipsNative.isRunning()) { + val ulas = try { + prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct() + } catch (_: Exception) { + emptyList() + } + for (ula in ulas) { + try { + java.net.Socket().use { s -> + s.connect( + java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80), + 20_000, + ) + } + } catch (_: Exception) { + // Cold path / node away — the connect attempt still + // drove session establishment; try again next round. + } + } + round++ + // Aggressive for the first ~minute (session bring-up), then a + // slow keep-warm tick that costs nearly nothing. + delay(if (round < 12) 5_000 else 60_000) + } + } } private fun shutdown() { + warmerJob?.cancel() FipsNative.stop() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index ab57fd3b..0eb1de37 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -185,13 +185,17 @@ fun ServerConnectScreen( useHttps = false, port = "", ) - // The tunnel + mesh route need a moment on cold start — and on - // a first-ever pairing the VPN consent dialog is on screen at - // the same time, so give the user time to tap it. - for (attempt in 0 until 6) { - if (attempt > 0) delay(2000) - reachable = testConnection(meshServer) - if (reachable) break + // Mesh discovery + first session can take 15s+ through the + // public tree (HANDOFF-2026-07-23 node diagnosis), and on a + // first-ever pairing the VPN consent dialog is on screen at + // the same time — so probe patiently inside a 60s budget with + // per-attempt timeouts wide enough to ride out TCP + // retransmit backoff. The VPN service pre-warms the session + // in parallel (ArchyVpnService.startSessionWarmer). + val deadline = System.currentTimeMillis() + 60_000 + while (!reachable && System.currentTimeMillis() < deadline) { + reachable = testConnection(meshServer, timeoutMs = 15_000) + if (!reachable) delay(3000) } } isConnecting = false @@ -673,8 +677,10 @@ private fun sanitizeAddress(input: String): String { .trimEnd('/') } -/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */ -private suspend fun testConnection(server: ServerEntry): Boolean { +/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. + * [timeoutMs] is per-phase (connect / read) — mesh probes need far more + * patience than LAN ones (first session through the tree can take 15s+). */ +private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean { return withContext(Dispatchers.IO) { try { val url = URL("${server.toUrl()}/rpc/v1") @@ -694,8 +700,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean { } connection.requestMethod = "POST" - connection.connectTimeout = 5000 - connection.readTimeout = 5000 + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs connection.setRequestProperty("Content-Type", "application/json") connection.doOutput = true val body = """{"method":"server.echo","params":{"message":"ping"}}""" diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 039e0be2..01bc313e 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -87,6 +87,28 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject +/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */ +private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { + val u = android.net.Uri.parse(base) + val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80 + java.net.Socket().use { + it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs) + true + } +} catch (_: Exception) { + false +} + +/** Fastest answering origin: LAN inside a short window, else the mesh ULA + * (patient — a cold session may still be establishing), else LAN anyway so + * the existing error/fallback path handles it. */ +private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String = + withContext(Dispatchers.IO) { + if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl + if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl + lanUrl + } + /** Open a URL in the phone's default browser (genuinely external links). */ private fun openExternalUrl(context: android.content.Context, url: String) { try { @@ -168,6 +190,20 @@ fun WebViewScreen( var hasError by remember { mutableStateOf(false) } var webView by remember { mutableStateOf(null) } + // Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an + // unreachable LAN IP burns a minute+ in connect retries before + // onReceivedError fires the mesh fallback — the "stuck connecting" + // stall. A raw TCP probe answers in milliseconds at home and fails in + // ~2.5s off-LAN, so startup lands on the right origin in seconds. + var startUrl by remember(serverUrl) { mutableStateOf(null) } + var raceNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { + val picked = pickStartUrl(serverUrl, meshFallbackUrl) + // Starting on the mesh: don't bounce back to it on error (it IS it). + if (picked != serverUrl) triedMeshFallback = true + startUrl = picked + } + // Web-page camera access (wallet QR scanner). The WebView's default // WebChromeClient silently denies getUserMedia, so grant video capture — // asking for the app-level CAMERA permission first when needed. @@ -187,6 +223,14 @@ fun WebViewScreen( // while this is shown, so closing it returns instantly with no reload. var inAppUrl by remember { mutableStateOf(null) } + // Same node = EITHER of its addresses. Over the mesh the kiosk's host is + // the ULA while app links may carry the LAN IP (and vice versa) — + // comparing against one host bounced same-node apps (Pine, Home + // Assistant) out to the phone's external browser. + fun isSameNode(url: String): Boolean = + isSameHost(url, serverUrl) || + (meshFallbackUrl != null && isSameHost(url, meshFallbackUrl)) + // Native wallet QR scanner, opened by the web UI via the ArchipelagoQr // bridge; status lines stream back from the page while it's up. var walletScannerVisible by remember { mutableStateOf(false) } @@ -268,9 +312,13 @@ fun WebViewScreen( GlassButton( text = stringResource(R.string.retry), onClick = { + // Re-race LAN vs mesh — the network we're on may have + // changed since the last pick. hasError = false isLoading = true - webView?.loadUrl(serverUrl) + triedMeshFallback = false + startUrl = null + raceNonce++ }, modifier = Modifier.fillMaxWidth().height(56.dp), ) @@ -283,9 +331,16 @@ fun WebViewScreen( modifier = Modifier.fillMaxWidth().height(48.dp), ) } + } else if (startUrl == null) { + // Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) — + // far cheaper than letting Chromium retry a dead LAN IP. + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = BitcoinOrange) + } } else { // Edge-to-edge WebView — background bleeds behind status bar. // Safe area values injected as CSS env() polyfill on each page load. + val initialUrl = startUrl ?: serverUrl AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> @@ -319,7 +374,7 @@ fun WebViewScreen( // kiosk couldn't iframe — keep the user inside the app) // - different host → the phone's real browser fun routeOutbound(url: String) { - if (isSameHost(url, serverUrl)) { + if (isSameNode(url)) { inAppUrl = url } else { openExternalUrl(context, url) @@ -458,7 +513,7 @@ fun WebViewScreen( error: android.net.http.SslError?, ) { val u = error?.url - if (u != null && isSameHost(u, serverUrl)) { + if (u != null && isSameNode(u)) { handler?.proceed() } else { handler?.cancel() @@ -595,7 +650,7 @@ fun WebViewScreen( } webView = this - loadUrl(serverUrl) + loadUrl(initialUrl) } }, ) @@ -620,6 +675,7 @@ fun WebViewScreen( InAppBrowser( url = target, serverUrl = serverUrl, + meshUrl = meshFallbackUrl, onClose = { inAppUrl = null }, ) } @@ -693,9 +749,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? { private fun InAppBrowser( url: String, serverUrl: String, + meshUrl: String? = null, onClose: () -> Unit, ) { val context = LocalContext.current + // Same-node check across BOTH node addresses (LAN + mesh ULA) — see the + // kiosk's isSameNode; a mismatch here bounced app links to the browser. + fun isSameNode(u: String): Boolean = + isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl)) var browser by remember { mutableStateOf(null) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var favicon by remember { mutableStateOf(null) } @@ -809,7 +870,7 @@ private fun InAppBrowser( error: android.net.http.SslError?, ) { val u = error?.url - if (u != null && isSameHost(u, serverUrl)) { + if (u != null && isSameNode(u)) { handler?.proceed() } else { handler?.cancel() @@ -823,7 +884,7 @@ private fun InAppBrowser( val u = request?.url?.toString() ?: return false // Stay in the overlay for same-node navigation; // hand genuinely external links to the real browser. - if (isSameHost(u, serverUrl)) return false + if (isSameNode(u)) return false openExternalUrl(ctx, u) return true } diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock index ac417ffb..a6bbd62c 100644 --- a/Android/rust/archy-fips-core/Cargo.lock +++ b/Android/rust/archy-fips-core/Cargo.lock @@ -86,6 +86,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "jni", + "libc", "paranoid-android", "serde_json", "tokio", diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml index 2fb414d6..199d4517 100644 --- a/Android/rust/archy-fips-core/Cargo.toml +++ b/Android/rust/archy-fips-core/Cargo.toml @@ -31,6 +31,8 @@ hex = "0.4" getrandom = "0.2" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] } tracing = "0.1" +# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start). +libc = "0.2" # The JNI surface only exists on Android; host builds skip it and drive the # mesh module directly (tests). diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs index 9b1b05b7..2a76090a 100644 --- a/Android/rust/archy-fips-core/src/mesh.rs +++ b/Android/rust/archy-fips-core/src/mesh.rs @@ -97,6 +97,19 @@ pub fn parse_peers(peers_json: &str) -> Result> { pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> { stop(); + // Android hands the VpnService TUN fd over in non-blocking mode on some + // OS builds. The fips TUN reader is a dedicated blocking-read thread that + // treats EAGAIN as fatal — the loop died at startup ("TUN read error … + // Try again (os error 11)" on-device), so sessions came up but no packet + // ever entered the mesh. Force the fd into the blocking mode the reader + // is designed for. + unsafe { + let flags = libc::fcntl(tun_fd, libc::F_GETFL); + if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 { + libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + let peers = parse_peers(peers_json)?; let config = build_config(secret, peers); let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?; diff --git a/core/Cargo.lock b/core/Cargo.lock index 3f504dbd..3c64bdac 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -154,6 +154,7 @@ dependencies = [ "serde_yaml", "serial2-tokio", "sha2 0.10.9", + "socket2 0.5.10", "tar", "tempfile", "thiserror 1.0.69", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index d29cece0..6e0037b6 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"] [dependencies] # Core dependencies tokio = { version = "1", features = ["full"] } +# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with +# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt). +socket2 = "0.5" libc = "0.2" # process-group signalling for the supervised reticulum daemon serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index f071b4b5..18c41109 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -991,6 +991,13 @@ async fn patch_nginx_conf(path: &str) -> Result { // B13: fedimint block present but lacking the asset-rewrite sub_filters. let needs_fedimint_css = content.contains("location /app/fedimint/") && !content.contains("'href=\"/' 'href=\"/app/fedimint/'"); + // Companion mesh access: phones reach this node over FIPS at its fips0 + // ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on + // IPv4 only, so the ULA could never connect — nothing answered [::]:80. + let missing_v6_http = content.contains("listen 80 default_server;") + && !content.contains("listen [::]:80"); + let missing_v6_https = content.contains("listen 443 ssl default_server;") + && !content.contains("listen [::]:443"); if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy @@ -998,12 +1005,27 @@ async fn patch_nginx_conf(path: &str) -> Result { && !missing_pine_status && !has_lnd_dup_cors && !needs_fedimint_css + && !missing_v6_http + && !missing_v6_https { return Ok(false); } let mut patched = content.clone(); + if missing_v6_http { + patched = patched.replace( + "listen 80 default_server;", + "listen 80 default_server;\n listen [::]:80 default_server;", + ); + } + if missing_v6_https { + patched = patched.replace( + "listen 443 ssl default_server;", + "listen 443 ssl default_server;\n listen [::]:443 ssl default_server;", + ); + } + if has_lnd_dup_cors { // Drop the redundant nginx-side CORS headers so the backend's single // validated Access-Control-Allow-Origin is the only one returned. diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 11f943ba..4a7d0bbd 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -34,6 +34,7 @@ mod bitcoin_rpc; mod bitcoin_status; mod blobs; mod bootstrap; +mod mesh_ports; mod ceremony; mod config; mod constants; @@ -395,6 +396,10 @@ async fn main() -> Result<()> { // iframe on kiosk nodes (docs/tv-input-iframe-apps.md). tokio::spawn(bootstrap::ensure_gamepad_keys()); + // Mesh access: mirror IPv4-published app ports onto [::] so direct-port + // app URLs (http://[]:) work from the companion. + tokio::spawn(mesh_ports::run_mesh_port_mirror()); + // Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when // DHCP renumbering strands them — HA never re-resolves on its own. tokio::spawn(api::rpc::wyoming_satellite_keeper()); diff --git a/core/archipelago/src/mesh_ports.rs b/core/archipelago/src/mesh_ports.rs new file mode 100644 index 00000000..a22f6236 --- /dev/null +++ b/core/archipelago/src/mesh_ports.rs @@ -0,0 +1,145 @@ +//! Mirror IPv4-published app ports onto IPv6 for mesh access. +//! +//! The companion (and anything else on the FIPS mesh) reaches this node at +//! its fips0 ULA. The web UI builds app links from the current host + each +//! app's DIRECT port (Direct Port Rule) — but rootless-podman published +//! ports bind 0.0.0.0 only, so `http://[]:8334` was refused for every +//! catalog app even after nginx :80 learned IPv6 (2026-07-23, third +//! "the node wasn't listening on v6" bug of the night). +//! +//! Instead of touching the container layer (port-publish changes would +//! recreate every container), a reconcile loop mirrors the host's public +//! IPv4 listeners: any port ≥1024 listening on 0.0.0.0 without an IPv6 +//! any-address listener gets a v6-only `[::]:` forwarder to +//! `127.0.0.1:`. Ports appear/disappear with app install/remove, so +//! the mirror follows `/proc/net/tcp*` rather than the catalog — companion +//! containers with hardcoded ports (bitcoin-ui :8334) are covered the same +//! as manifest-declared ones. Nothing is exposed that IPv4 didn't already +//! expose to the LAN; the ULA is the established remote-access surface. + +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv6Addr, SocketAddrV6}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; + +const RECONCILE_INTERVAL: Duration = Duration::from_secs(15); +/// Never mirror system ports; nginx (80/443) handles its own v6 listeners. +const MIN_PORT: u16 = 1024; + +/// Entry point — spawned from main; runs for the process lifetime. +pub async fn run_mesh_port_mirror() { + let mut active: HashMap> = HashMap::new(); + loop { + match reconcile(&mut active).await { + Ok(()) => {} + Err(e) => debug!("mesh-ports: reconcile skipped: {e:#}"), + } + tokio::time::sleep(RECONCILE_INTERVAL).await; + } +} + +async fn reconcile(active: &mut HashMap>) -> Result<()> { + let v4 = listening_ports("/proc/net/tcp", 8).await?; + let v6 = listening_ports("/proc/net/tcp6", 32).await?; + + // Drop mirrors whose backing IPv4 listener vanished (app removed/stopped). + active.retain(|port, handle| { + if v4.contains(port) && !handle.is_finished() { + true + } else { + handle.abort(); + info!("mesh-ports: released [::]:{port} (backing 0.0.0.0 listener gone)"); + false + } + }); + + for port in v4 { + if port < MIN_PORT || active.contains_key(&port) { + continue; + } + // A foreign IPv6 any-listener already serves this port (our own + // mirrors are excluded above via `active`). + if v6.contains(&port) { + continue; + } + match spawn_forwarder(port) { + Ok(handle) => { + info!("mesh-ports: mirroring [::]:{port} -> 127.0.0.1:{port}"); + active.insert(port, handle); + } + Err(e) => debug!("mesh-ports: cannot mirror port {port}: {e:#}"), + } + } + Ok(()) +} + +/// Ports in LISTEN state bound to the any-address, parsed from /proc/net/tcp +/// (`addr_hex_len` 8) or /proc/net/tcp6 (32). +async fn listening_ports(path: &str, addr_hex_len: usize) -> Result> { + let raw = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("read {path}"))?; + let any_addr = "0".repeat(addr_hex_len); + let mut ports = HashSet::new(); + for line in raw.lines().skip(1) { + let mut cols = line.split_whitespace(); + let (Some(_sl), Some(local), Some(_rem), Some(state)) = + (cols.next(), cols.next(), cols.next(), cols.next()) + else { + continue; + }; + if state != "0A" { + continue; // not LISTEN + } + let Some((addr, port_hex)) = local.split_once(':') else { + continue; + }; + if addr != any_addr { + continue; + } + if let Ok(port) = u16::from_str_radix(port_hex, 16) { + ports.insert(port); + } + } + Ok(ports) +} + +/// A v6-only listener on [::]:port forwarding each connection to 127.0.0.1:port. +fn spawn_forwarder(port: u16) -> Result> { + use socket2::{Domain, Protocol, Socket, Type}; + let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP)) + .context("create v6 socket")?; + // v6only so we coexist with the app's own 0.0.0.0: bind. + socket.set_only_v6(true).context("set v6only")?; + socket.set_reuse_address(true).ok(); + socket.set_nonblocking(true).context("set nonblocking")?; + let addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0); + socket.bind(&addr.into()).context("bind [::]")?; + socket.listen(128).context("listen")?; + let listener = tokio::net::TcpListener::from_std(socket.into()) + .context("register with tokio")?; + + Ok(tokio::spawn(async move { + loop { + let (mut inbound, _peer) = match listener.accept().await { + Ok(conn) => conn, + Err(e) => { + warn!("mesh-ports: accept failed on [::]:{port}: {e}"); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + }; + tokio::spawn(async move { + match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Ok(mut upstream) => { + let _ = tokio::io::copy_bidirectional(&mut inbound, &mut upstream).await; + } + Err(e) => debug!("mesh-ports: upstream 127.0.0.1:{port} refused: {e}"), + } + }); + } + })) +} diff --git a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md b/docs/HANDOFF-2026-07-23-companion-apk-deploy.md index 0bcf3ec9..17258ac9 100644 --- a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +++ b/docs/HANDOFF-2026-07-23-companion-apk-deploy.md @@ -48,9 +48,158 @@ user's hands. verify `sudo -n fipsctl show status` reports the anchor connected — `fips.reconnect` RPC if not. A phone can dial the anchor perfectly and still fail if the node never enrolled with it. -5. Re-test the user's exact flows with vc23 (updates any older install in +5. Re-test the user's exact flows with **vc24** (updates any older install in place): (a) pair ON the LAN, then switch the phone to 5G — the UI must come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the node's LAN) — scan, VPN consent, and the connect must succeed through - the anchor. If either fails, capture `adb logcat` around the attempt and - `fipsctl show sessions` on the node, and report back. + the anchor. + +## Live diagnosis update (22:30–22:50, phone on adb — Mac agent) + +vc23 on-device testing found and fixed the phone-side blocker, and narrowed +what remains to the node side. State as of vc24: + +- **Fixed: TUN reader died at startup.** Android hands the VpnService fd over + non-blocking; the fips fork's blocking reader thread treats EAGAIN as fatal + ("TUN read error … Try again (os error 11)") — so mesh sessions came up but + NO packet ever entered the tunnel. archy-fips-core now forces the fd + blocking before `start_with_tun_fd`. Verified on-device: reader survives, + and the 30s anchor-link flap disappeared with it (stable 8+ min on 5G). +- **Fixed: VPN marked not-metered** (`setMetered(false)`) — Android 10+ + defaults VPNs to metered, putting the phone into data-restricted behaviour + while the mesh is up. Note the user's phone also has system **always-on + VPN** enabled for the app (`always_on_vpn_app`), a Settings-side toggle. +- **Verified good on-device**: peer store has node (LAN udp/tcp hints) + vps2 + anchor; saved server is npub-keyed with ULA; anchor session establishes + from 5G in ~6s; VPN is bypassable, VALIDATED, only fd00::/8 routed. +- **REMAINING BLOCKER (node side)**: from the phone (app uid), ping6 and + HTTP to the node's ULA `fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824` get no + reply — packets enter the mesh, nothing returns. Phone↔anchor works, so + suspect phone-fork ↔ node-daemon session/routing mismatch (FIPS wire + format is not stable across revs; phone pins fips-native fork 46494a74). + From Framework PT please capture: + - `fipsctl show status` (daemon version + anchor state) + - `fipsctl show sessions` and `show bloom` while the phone pings + - `ping6 ` from the + node (tests the reverse path) + - `ip addr show fips0` + confirm the web server listens on `[::]:80` + Report whether the node ever sees a session attempt from + `npub132c5whrsa6ccs0eylcpzaejq9uxul5ldvczz0axq78dh7fxkqj9st4uvzu`. + +## Node-side diagnosis complete (23:00–23:30, archi-dev-box agent) + +Chain of findings, each verified live: + +1. **FIXED: nginx had no IPv6 listener anywhere** — every shipped config + listened on 0.0.0.0 only, so `http://[]` could NEVER connect on any + node, ever. Live-fixed on framework-pt + .116, canonical conf + bootstrap + self-heal shipped (`1e89362e`), heal binary deployed. ULA HTTP verified + answering on both nodes (local + over-mesh). +2. Firewall clean, fd00::/8 routes correct both ends, wire compat proven + (node + vps2 anchor both run fips 0.4.1 rev 15db6471db — the latest + upstream stable; nothing newer exists). +3. **THE REMAINING PROBLEM IS MESH SESSION PATH QUALITY.** From the vps2 + anchor — a DIRECT connected peer — `GET /health` on the node's ULA takes + **15–17 s per request and intermittently fails outright** (nginx logs + show 499 client-gave-up then 200; TCP SYN-retransmit backoff signature). + Session MMP is wildly asymmetric: node→vps2 srtt 204 ms, **vps2→node + srtt 4270 ms** — on a direct link whose raw RTT is 4 ms. Session traffic + is not riding the direct link; it appears to route through the ~1271-node + public tree (node's tree root is the public 00001a8c, depth 8; the node's + log also shows chronic "Discovery lookup timed out" for other targets). +4. The phone's npub never appears in the node's sessions — consistent with + discovery/handshake dying on the same degraded tree path, and the app's + ~8 s probe window being far smaller than the observed 15 s+ first-request + latency even on the GOOD path. + +### Recommendations + +- **App side (Mac agent):** widen the ULA probe/connect window to ≥30 s + with retransmit-friendly pacing, and PRE-WARM the mesh session (start + pinging the node ULA as soon as the VPN is up, decoupled from the UI + probe) so the WebView hits a warm session. +- **Infra decision (user):** consider detaching the fleet from the public + v0l mesh — private tree rooted at the vps2 anchor (drop the legacy + 185.18.221.160 seed anchor fleet-wide AND vps2's public peering). A + 2-hop private tree would make session paths ride the direct links and + should collapse latency to ms. Trade-off: no reachability to/from the + broader public mesh. +- **Upstream:** report the direct-peer session-path asymmetry to + jmcorgan/fips (0.4.1). + +## App-side recommendations implemented (23:30–23:50, Mac agent — 0.5.5/vc25) + +- Connect probe: mesh ULA now probed inside a **60s budget** with 15s + per-phase timeouts (rides out TCP retransmit backoff), replacing the old + ~8s window. +- **Session pre-warm**: the VPN service starts probing every saved node ULA + the moment the tunnel is up (5s cadence for the first minute, then a 60s + keep-warm tick) — discovery/handshake cost is paid in the background, and + the session never idles out while the mesh is connected. +- (A phone-side ping test in this window still showed zero replies — that + measurement predated the vps2 daemon restart below and is superseded.) + +## RESOLVED — root cause was vps2's degraded daemon, NOT the public tree (23:45) + +The privatize-the-mesh recommendation above is WITHDRAWN. Final diagnosis: +vps2's fips daemon (3 days uptime, 0.2% CPU, idle box) had internally +degraded — EVERY link it carried showed ~4.5 s RTT (even to peers 30 ms +away), and since the anchor sits on the phone↔node path, everything through +it inherited that. `systemctl restart fips` on vps2 restored link RTTs to +40–340 ms, and anchor→node mesh HTTP went from 14–17 s (intermittent hard +fails) to a steady **165–275 ms**. Node↔node direct sessions were always +fine (.116→framework-pt ULA HTTP: 894 ms cold, sub-second warm) — the +user's read was correct. + +Actions taken: dead legacy anchor (185.18.221.160) removed from +framework-pt + .116 seed files (fleet keeps vps2 + public-mesh membership +via vps2 — we stay in the open mesh); fresh daemons on both nodes; +**vps2 fips now has RuntimeMaxSec=1d + Restart=always** so a wedged anchor +daemon can never rot for days again. Report the slow-degradation behaviour +upstream (jmcorgan/fips, 0.4.1): long-running daemon in a ~1400-node mesh +accumulates multi-second link latency at idle CPU, cleared by restart. + +Phone side: vc25's 60 s probe + pre-warm now has a millisecond-latency mesh +to work with. Ready for the user's 5G test. + +## NEXT (00:05, Mac agent → dev-box agent): app direct ports are IPv4-only over the mesh + +The kiosk loads over the ULA now — but opening any APP dies with +`ERR_CONNECTION_REFUSED` at `http://[]:/`. User-hit first on +**Bitcoin Knots (:8334)**, and it will be every catalog app: the web UI +builds app URLs from the current host + the app's DIRECT port (Direct Port +Rule), and container-published ports only bind 0.0.0.0. Verified: +`192.168.63.249:8334` → HTTP 200 (nginx), ULA:8334 → refused. Same disease +as your :80 nginx fix, one layer down. + +Fix must cover EVERY catalog app port and survive app install/remove. Two +shapes; pick what fits the container layer best: + +1. **IPv6 publish at the container layer** — publish on `[::]` too + (pasta/rootless podman support address-specific `-p`), wired into the + container manager so new apps inherit it; or +2. **Host-side v6→v4 forwarders** — generated nginx `stream {}` (or + systemd-socket) units: `listen [::]:` → `127.0.0.1:`, one per + catalog app port, regenerated on app install/remove, boot-time + self-healed like the :80 fix. Keeps the Direct Port Rule URL contract + without touching containers. + +Either way: extend the bootstrap self-heal, and verify from the MESH side +(curl the ULA on 2–3 app ports incl. :8334 from vps2 or .116) — not just +from the LAN. + +## DONE (00:30, dev-box agent): app direct ports live over the mesh + +Shape 2-variant implemented INSIDE the backend (`mesh_ports.rs`, `2ad57c63`): +a reconcile loop mirrors every public IPv4 listener (>=1024, bound 0.0.0.0, +no existing IPv6 any-listener) as a v6-ONLY `[::]:` forwarder to +`127.0.0.1:`, following `/proc/net/tcp*` every 15s — so app +install/remove and hardcoded companion ports (bitcoin-ui :8334) are covered +with zero container changes and no generated units; self-healing because it +lives in the binary. Strictly ADDITIVE: IPv4/LAN/Tor paths untouched, v6only +cannot intercept v4, foreign IPv6 listeners win. + +Verified FROM THE MESH (vps2 → node ULA): :8334 HTTP 200 (466ms), +:18083 200 (306ms), :50002 200 (239ms); LAN :8334 still 200. Deployed to +framework-pt + .116 (binary sha 52ac0d8a…). Direct-port apps should now +open in the companion over 5G. diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index ecc7b154..c908eb82 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -9,6 +9,10 @@ resolver_timeout 5s; server { listen 80 default_server; + # IPv6 listener is REQUIRED: companion phones reach this node over the + # FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that + # address can never connect (found live 2026-07-23, framework-pt). + listen [::]:80 default_server; server_name _; root /opt/archipelago/web-ui; @@ -901,6 +905,7 @@ server { # HTTPS - required for PWA install (Add to Home Screen) from dev servers server { listen 443 ssl default_server; + listen [::]:443 ssl default_server; server_name _; ssl_certificate /etc/archipelago/ssl/archipelago.crt; diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 3d9e0437..6233da49 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ