fix(openwrt): unbounded blocking SSH connect no longer stalls the whole API

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-15 09:52:03 -04:00
co-authored by Claude Fable 5
parent 9c675e5c7d
commit e282c05911
2 changed files with 99 additions and 63 deletions
+76 -59
View File
@@ -1,6 +1,6 @@
use super::RpcHandler; use super::RpcHandler;
use crate::network::router as net_router; use crate::network::router as net_router;
use anyhow::Result; use anyhow::{Context, Result};
use archipelago_openwrt::{ use archipelago_openwrt::{
detect, detect,
router::Router, router::Router,
@@ -87,8 +87,80 @@ impl RpcHandler {
.or_else(|| saved.password.clone()) .or_else(|| saved.password.clone())
.unwrap_or_default(); .unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; // The SSH session is blocking (ssh2 over std TcpStream). Run it on the
router.verify_openwrt()?; // 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<serde_json::Value> {
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::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().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 // Persist the connection so other views (e.g. the Home dashboard's
// Network tile) can poll `openwrt.get-status` with no params instead // Network tile) can poll `openwrt.get-status` with no params instead
@@ -107,62 +179,7 @@ impl RpcHandler {
.await; .await;
} }
// System info Ok(status)
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::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().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,
}))
} }
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
+23 -4
View File
@@ -13,10 +13,30 @@ pub struct Router {
} }
impl 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<TcpStream> {
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. /// Connect to an OpenWrt router via SSH using a private key.
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> { pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
let addr = format!("{}:{}", host, port); let tcp = Self::bounded_tcp(host, port)?;
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?; let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp); session.set_tcp_stream(tcp);
@@ -34,8 +54,7 @@ impl Router {
/// Connect using a password (fallback for routers not yet provisioned with a key). /// 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<Self> { pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port); let tcp = Self::bounded_tcp(host, port)?;
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?; let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp); session.set_tcp_stream(tcp);