From e282c05911a93ce765dd585666ab0df3014bbd39 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 15 Aug 2026 09:52:03 -0400 Subject: [PATCH] fix(openwrt): unbounded blocking SSH connect no longer stalls the whole API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router::connect/connect_password did a blocking std TcpStream::connect with no timeout, inline on the tokio runtime. Against a router that stayed behind when its node moved networks (framework-pt, 2026-08-15), every dashboard poll of openwrt.get-status parked a worker thread for the OS connect timeout (~2 min) — overlapping polls stalled unrelated RPCs for 25s+ at a time, sessions timed out, and TOTP codes expired before the backend verified them. - bounded_tcp(): 5s connect timeout + 30s read/write timeouts on the session socket, shared by both connect paths. - openwrt.get-status runs its SSH exchange on spawn_blocking, so even a slow router can only slow its own tile, never the API. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/openwrt.rs | 135 +++++++++++++----------- core/openwrt/src/router.rs | 27 ++++- 2 files changed, 99 insertions(+), 63 deletions(-) diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index 0c30a0ca..a506b02d 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -1,6 +1,6 @@ use super::RpcHandler; use crate::network::router as net_router; -use anyhow::Result; +use anyhow::{Context, Result}; use archipelago_openwrt::{ detect, router::Router, @@ -87,8 +87,80 @@ impl RpcHandler { .or_else(|| saved.password.clone()) .unwrap_or_default(); - let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; - router.verify_openwrt()?; + // The SSH session is blocking (ssh2 over std TcpStream). Run it on the + // blocking pool: inline it used to park a tokio worker for the whole + // exchange, and against an unreachable router (the gateway that stayed + // behind after a node moved networks) the periodic dashboard poll + // stalled unrelated RPCs for tens of seconds — long enough that TOTP + // codes expired in flight (framework-pt, 2026-08-15). + let status = { + let host = host.clone(); + let ssh_user = ssh_user.clone(); + let ssh_password = ssh_password.clone(); + tokio::task::spawn_blocking(move || -> Result { + let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; + router.verify_openwrt()?; + + // System info + let release = router + .run_ok("cat /etc/openwrt_release") + .unwrap_or_default(); + let hostname = router + .uci_get("system.@system[0].hostname") + .unwrap_or_else(|_| "unknown".into()); + let uptime_secs: u64 = router + .run_ok("cat /proc/uptime") + .unwrap_or_default() + .split_whitespace() + .next() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + // TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native). + // The service binary is /usr/bin/tollgate-wrt (per its init.d script), + // not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk + // *package* name, never an on-disk filename. + let tollgate_installed = router + .run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \ + test -f /usr/bin/tollgate-wrt 2>/dev/null") + .map(|(_, code)| code == 0) + .unwrap_or(false); + + let tollgate = if tollgate_installed { + serde_json::json!({ + "installed": true, + "enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false), + "metric": router.uci_get("tollgate.main.metric").unwrap_or_default(), + "step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), + "price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), + "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::().ok()).unwrap_or(1), + "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), + "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), + }) + } else { + serde_json::json!({ "installed": false }) + }; + + // WiFi interfaces + let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default(); + let wifi_interfaces = parse_wifi_interfaces(&wifi_raw); + + let wan_status = wan::get_wan_status(&router); + + Ok(serde_json::json!({ + "host": host, + "hostname": hostname, + "uptime_secs": uptime_secs, + "release": parse_release(&release), + "tollgate": tollgate, + "wifi_interfaces": wifi_interfaces, + "wan": wan_status, + })) + }) + .await + .context("openwrt status task")?? + }; // Persist the connection so other views (e.g. the Home dashboard's // Network tile) can poll `openwrt.get-status` with no params instead @@ -107,62 +179,7 @@ impl RpcHandler { .await; } - // System info - let release = router - .run_ok("cat /etc/openwrt_release") - .unwrap_or_default(); - let hostname = router - .uci_get("system.@system[0].hostname") - .unwrap_or_else(|_| "unknown".into()); - let uptime_secs: u64 = router - .run_ok("cat /proc/uptime") - .unwrap_or_default() - .split_whitespace() - .next() - .and_then(|s| s.split('.').next()) - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - // TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native). - // The service binary is /usr/bin/tollgate-wrt (per its init.d script), - // not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk - // *package* name, never an on-disk filename. - let tollgate_installed = router - .run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \ - test -f /usr/bin/tollgate-wrt 2>/dev/null") - .map(|(_, code)| code == 0) - .unwrap_or(false); - - let tollgate = if tollgate_installed { - serde_json::json!({ - "installed": true, - "enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false), - "metric": router.uci_get("tollgate.main.metric").unwrap_or_default(), - "step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), - "price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), - "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::().ok()).unwrap_or(1), - "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), - "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), - }) - } else { - serde_json::json!({ "installed": false }) - }; - - // WiFi interfaces - let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default(); - let wifi_interfaces = parse_wifi_interfaces(&wifi_raw); - - let wan_status = wan::get_wan_status(&router); - - Ok(serde_json::json!({ - "host": host, - "hostname": hostname, - "uptime_secs": uptime_secs, - "release": parse_release(&release), - "tollgate": tollgate, - "wifi_interfaces": wifi_interfaces, - "wan": wan_status, - })) + Ok(status) } /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. diff --git a/core/openwrt/src/router.rs b/core/openwrt/src/router.rs index d0234178..2b51b0f6 100644 --- a/core/openwrt/src/router.rs +++ b/core/openwrt/src/router.rs @@ -13,10 +13,30 @@ pub struct Router { } impl Router { + /// Bounded TCP connect. The OS default connect timeout against an + /// unreachable RFC1918 address is ~2 minutes; a router that stayed + /// behind when its node moved networks turned every status poll into a + /// worker-thread hostage for that long, stalling unrelated RPCs + /// (framework-pt, 2026-08-15 — even TOTP codes expired in flight). + /// Read/write timeouts bound the session the same way once connected. + fn bounded_tcp(host: &str, port: u16) -> Result { + use std::net::ToSocketAddrs; + let addr = format!("{}:{}", host, port); + let resolved = addr + .to_socket_addrs() + .with_context(|| format!("resolve {}", addr))? + .next() + .with_context(|| format!("no address for {}", addr))?; + let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5)) + .with_context(|| format!("TCP connect to {}", addr))?; + tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); + tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok(); + Ok(tcp) + } + /// Connect to an OpenWrt router via SSH using a private key. pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result { - let addr = format!("{}:{}", host, port); - let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?; + let tcp = Self::bounded_tcp(host, port)?; let mut session = Session::new().context("create SSH session")?; session.set_tcp_stream(tcp); @@ -34,8 +54,7 @@ impl Router { /// Connect using a password (fallback for routers not yet provisioned with a key). pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result { - let addr = format!("{}:{}", host, port); - let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?; + let tcp = Self::bounded_tcp(host, port)?; let mut session = Session::new().context("create SSH session")?; session.set_tcp_stream(tcp);