From d71f36370ddaccb2efa9f721176e20f43db29fcf Mon Sep 17 00:00:00 2001 From: ssmithx Date: Sun, 28 Jun 2026 19:28:25 +0000 Subject: [PATCH] feat(openwrt): add OpenWrt gateway status view and get-status RPC Backend: new `openwrt.get-status` RPC endpoint SSHes into the saved (or provided) OpenWrt router and returns system info, TollGate config, and WiFi AP interfaces via UCI. Frontend: new OpenWrtGateway.vue view at /dashboard/server/openwrt shows system hostname, OpenWrt version, uptime, TollGate install/enable state with pricing and mint URL, and all AP-mode WiFi interfaces. Linked from the Local Network section of the Server view. Co-Authored-By: Claude Sonnet 4.6 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/openwrt.rs | 132 ++++++++ neode-ui/src/router/index.ts | 5 + neode-ui/src/views/Server.vue | 10 + neode-ui/src/views/server/OpenWrtGateway.vue | 305 +++++++++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 neode-ui/src/views/server/OpenWrtGateway.vue diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 249b65a3..0fa17971 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -232,6 +232,7 @@ impl RpcHandler { // OpenWrt / TollGate "openwrt.scan" => self.handle_openwrt_scan(params).await, + "openwrt.get-status" => self.handle_openwrt_get_status(params).await, "openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await, // Ecash wallet diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index e3cc1ed0..2f74bd2d 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -5,6 +5,7 @@ use archipelago_openwrt::{ router::Router, tollgate::{self, TollGateConfig}, }; +use crate::network::router as net_router; /// Default port for the local Cashu mint (nutshell / cashu-mint app). const LOCAL_MINT_PORT: u16 = 3338; @@ -40,6 +41,89 @@ impl RpcHandler { Ok(serde_json::json!({ "routers": ips })) } + /// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI. + /// + /// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }` + /// If params are omitted the saved `router_config.json` credentials are used. + pub(super) async fn handle_openwrt_get_status( + &self, + params: Option, + ) -> Result { + let saved = net_router::load_router_config(&self.config.data_dir).await?; + let p = params.unwrap_or_default(); + + let host = p + .get("host") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| if saved.configured { Some(saved.address.clone()) } else { None }) + .ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?; + + let ssh_user = p + .get("ssh_user") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| saved.username.clone()) + .unwrap_or_else(|| "root".to_string()); + + let ssh_password = p + .get("ssh_password") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| saved.password.clone()) + .unwrap_or_default(); + + 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 + let tollgate_installed = router + .run("opkg list-installed | grep -q '^tollgate-module-basic-go '") + .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), + "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); + + Ok(serde_json::json!({ + "host": host, + "hostname": hostname, + "uptime_secs": uptime_secs, + "release": parse_release(&release), + "tollgate": tollgate, + "wifi_interfaces": wifi_interfaces, + })) + } + /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. /// /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", @@ -104,6 +188,54 @@ impl RpcHandler { } } +/// Parse /etc/openwrt_release key=value pairs into a JSON object. +fn parse_release(raw: &str) -> serde_json::Value { + let mut m = serde_json::Map::new(); + for line in raw.lines() { + if let Some((k, v)) = line.split_once('=') { + m.insert( + k.to_lowercase(), + serde_json::Value::String(v.trim_matches('"').to_string()), + ); + } + } + serde_json::Value::Object(m) +} + +/// Extract AP wifi-iface sections from `uci show wireless` output. +fn parse_wifi_interfaces(raw: &str) -> Vec { + use std::collections::HashMap; + let mut sections: HashMap> = HashMap::new(); + + for line in raw.lines() { + if let Some((lhs, rhs)) = line.trim().split_once('=') { + let parts: Vec<&str> = lhs.splitn(3, '.').collect(); + if parts.len() == 3 && parts[0] == "wireless" { + sections + .entry(parts[1].to_string()) + .or_default() + .insert(parts[2].to_string(), rhs.trim_matches('\'').to_string()); + } + } + } + + let mut ifaces: Vec = sections + .into_iter() + .filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false)) + .map(|(name, f)| serde_json::json!({ + "section": name, + "ssid": f.get("ssid").cloned().unwrap_or_default(), + "device": f.get("device").cloned().unwrap_or_default(), + "encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()), + "network": f.get("network").cloned().unwrap_or_default(), + "disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false), + })) + .collect(); + + ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string()); + ifaces +} + fn parse_ipv4(s: &str) -> Result<[u8; 4]> { let parts: Vec<&str> = s.split('.').collect(); if parts.len() != 4 { diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 14594d25..1af23925 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -176,6 +176,11 @@ const router = createRouter({ name: 'server', component: () => import('../views/Server.vue'), }, + { + path: 'server/openwrt', + name: 'openwrt-gateway', + component: () => import('../views/server/OpenWrtGateway.vue'), + }, { path: 'monitoring', name: 'monitoring', diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 736b8435..b42b529f 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -108,6 +108,16 @@ {{ torStatusLabel === 'running' ? 'Connected' : torStatusLabel === 'checking' ? 'Checking...' : 'Stopped' }} + +
+ + OpenWrt Gateway +
+ +
diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue new file mode 100644 index 00000000..6e0481d5 --- /dev/null +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -0,0 +1,305 @@ + + +