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:
co-authored by
Claude Fable 5
parent
9c675e5c7d
commit
e282c05911
@@ -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,26 +87,20 @@ impl RpcHandler {
|
|||||||
.or_else(|| saved.password.clone())
|
.or_else(|| saved.password.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 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<serde_json::Value> {
|
||||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||||
router.verify_openwrt()?;
|
router.verify_openwrt()?;
|
||||||
|
|
||||||
// Persist the connection so other views (e.g. the Home dashboard's
|
|
||||||
// Network tile) can poll `openwrt.get-status` with no params instead
|
|
||||||
// of every caller needing to carry host/credentials around. Only do
|
|
||||||
// this when the host actually came from params — otherwise every
|
|
||||||
// no-args poll would re-save the same thing it just read.
|
|
||||||
if host_from_params {
|
|
||||||
let _ = net_router::configure_router(
|
|
||||||
&self.config.data_dir,
|
|
||||||
net_router::RouterType::OpenWrt,
|
|
||||||
&host,
|
|
||||||
None,
|
|
||||||
Some(&ssh_user),
|
|
||||||
Some(&ssh_password),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// System info
|
// System info
|
||||||
let release = router
|
let release = router
|
||||||
.run_ok("cat /etc/openwrt_release")
|
.run_ok("cat /etc/openwrt_release")
|
||||||
@@ -163,6 +157,29 @@ impl RpcHandler {
|
|||||||
"wifi_interfaces": wifi_interfaces,
|
"wifi_interfaces": wifi_interfaces,
|
||||||
"wan": wan_status,
|
"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
|
||||||
|
// of every caller needing to carry host/credentials around. Only do
|
||||||
|
// this when the host actually came from params — otherwise every
|
||||||
|
// no-args poll would re-save the same thing it just read.
|
||||||
|
if host_from_params {
|
||||||
|
let _ = net_router::configure_router(
|
||||||
|
&self.config.data_dir,
|
||||||
|
net_router::RouterType::OpenWrt,
|
||||||
|
&host,
|
||||||
|
None,
|
||||||
|
Some(&ssh_user),
|
||||||
|
Some(&ssh_password),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user