diff --git a/core/Cargo.lock b/core/Cargo.lock index b0940218..4596ff06 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -99,6 +99,7 @@ version = "1.7.99-alpha" dependencies = [ "anyhow", "archipelago-container", + "archipelago-openwrt", "archipelago-performance", "archipelago-security", "argon2", @@ -180,6 +181,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "archipelago-openwrt" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "reqwest 0.11.27", + "serde", + "serde_json", + "ssh2", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tracing", +] + [[package]] name = "archipelago-performance" version = "0.1.0" @@ -2839,6 +2856,32 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3580,6 +3623,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "papaya" version = "0.2.4" @@ -3758,6 +3813,12 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plain" version = "0.2.3" @@ -4988,6 +5049,18 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libssh2-sys", + "parking_lot 0.12.5", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5775,6 +5848,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.1.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 80875fd4..24823e5c 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "archipelago", "container", + "openwrt", "performance", "security", ] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9c87e2d1..157c1d00 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -42,6 +42,7 @@ futures-util = "0.3" # Our modules archipelago-container = { path = "../container" } +archipelago-openwrt = { path = "../openwrt" } archipelago-security = { path = "../security" } archipelago-performance = { path = "../performance" } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 1855cb6a..faf7b637 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -223,6 +223,7 @@ impl RpcHandler { "network.list-interfaces" => self.handle_network_list_interfaces().await, "network.scan-wifi" => self.handle_network_scan_wifi().await, "network.configure-wifi" => self.handle_network_configure_wifi(params).await, + "network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await, "network.configure-ethernet" => self.handle_network_configure_ethernet(params).await, "network.dns-status" => self.handle_network_dns_status().await, "network.configure-dns" => self.handle_network_configure_dns(params).await, @@ -230,6 +231,13 @@ impl RpcHandler { "router.info" => self.handle_router_info().await, "router.configure" => self.handle_router_configure(params).await, + // 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, + "openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await, + "openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await, + // Ecash wallet "wallet.ecash-balance" => self.handle_wallet_ecash_balance().await, "wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await, diff --git a/core/archipelago/src/api/rpc/interfaces.rs b/core/archipelago/src/api/rpc/interfaces.rs index 2e5b7fd3..bddb76a3 100644 --- a/core/archipelago/src/api/rpc/interfaces.rs +++ b/core/archipelago/src/api/rpc/interfaces.rs @@ -18,6 +18,24 @@ impl RpcHandler { Ok(serde_json::json!({ "networks": networks })) } + /// network.set-wifi-radio — turn the wifi adapter fully on or off (not just + /// disconnect from a network). Params: `{ "enabled": bool }`. + pub(super) async fn handle_network_set_wifi_radio( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let enabled = params + .get("enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?; + + tracing::info!(enabled, "Setting wifi radio state"); + set_wifi_radio(enabled).await?; + + Ok(serde_json::json!({ "ok": true, "enabled": enabled })) + } + /// network.configure-wifi — connect to a WiFi network. pub(super) async fn handle_network_configure_wifi( &self, @@ -327,6 +345,27 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec { fields } +/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not +/// just disconnecting from the current network — the adapter stops scanning/ +/// associating entirely until switched back on). +async fn set_wifi_radio(enabled: bool) -> Result<()> { + let state = if enabled { "on" } else { "off" }; + let output = tokio::process::Command::new("nmcli") + .args(["radio", "wifi", state]) + .output() + .await + .context("Failed to run nmcli radio wifi")?; + + if !output.status.success() { + anyhow::bail!( + "nmcli radio wifi {} failed: {}", + state, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + /// Connect to a WiFi network using nmcli. async fn connect_wifi(ssid: &str, password: &str) -> Result<()> { let conn_name = format!("archipelago-wifi-{ssid}"); diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index 8cef041e..ff007f27 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -64,6 +64,19 @@ pub(super) fn sanitize_error_message(msg: &str) -> String { "Container", "Image", "Bitcoin address", + "No router", + "No OpenWrt", + "No space left", + "Not enough flash", + "Not enough space", + "TollGate installation failed", + "No pre-built TollGate", + "opkg not found", + "apk update failed", + "No wireless interface", + "No wireless radio", + "WiFi radio enabled but", + "Missing required field", ]; for prefix in &user_facing_prefixes { if msg.starts_with(prefix) { diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index ac30275a..e474cbe6 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -23,6 +23,7 @@ mod names; mod network; mod node; mod nostr; +mod openwrt; mod package; mod peers; mod response; diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs new file mode 100644 index 00000000..664335de --- /dev/null +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -0,0 +1,353 @@ +use super::RpcHandler; +use anyhow::Result; +use archipelago_openwrt::{ + detect, + router::Router, + tollgate::{self, TollGateConfig}, + wan, + wifi_scan, +}; +use crate::network::router as net_router; + +/// Default port for the local Cashu mint (nutshell / cashu-mint app). +const LOCAL_MINT_PORT: u16 = 3338; + +impl RpcHandler { + /// Scan the local subnet for OpenWrt routers. + /// + /// Params: `{ "subnet": "192.168.1.0", "prefix": 24, + /// "ssh_user": "root", "ssh_password": "" }` + pub(super) async fn handle_openwrt_scan( + &self, + params: Option, + ) -> Result { + let p = params.unwrap_or_default(); + let subnet: [u8; 4] = parse_ipv4( + p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"), + )?; + let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8; + let ssh_user = p + .get("ssh_user") + .and_then(|v| v.as_str()) + .unwrap_or("root") + .to_string(); + let ssh_password = p + .get("ssh_password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await; + let ips: Vec = routers.iter().map(|ip| ip.to_string()).collect(); + + 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_from_params = p.get("host").and_then(|v| v.as_str()).is_some(); + + 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()?; + + // 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 + 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, + })) + } + + /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. + /// + /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", + /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, + /// "mint_url": "" }` + /// + /// `mint_url` defaults to `http://:3338` — the local Cashu + /// mint that must be running as an Archy app before calling this endpoint. + pub(super) async fn handle_openwrt_provision_tollgate( + &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 default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT); + let mint_url = p + .get("mint_url") + .and_then(|v| v.as_str()) + .unwrap_or(&default_mint_url) + .to_string(); + + let config = TollGateConfig { + ssid: "archipelago".to_string(), + mint_url, + price_sats: p.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(10), + step_size_ms: p + .get("step_size_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(60_000), + min_steps: p + .get("min_steps") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32, + enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), + }; + + let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; + router.verify_openwrt()?; + tollgate::provision(&router, &config).await?; + + Ok(serde_json::json!({ + "ok": true, + "host": host, + "ssid": config.ssid, + "mint_url": config.mint_url, + })) + } + + /// Scan for visible WiFi networks from the router's radio. + /// + /// Params: same host/credentials as other openwrt methods. + pub(super) async fn handle_openwrt_scan_wifi( + &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()?; + + let networks = wifi_scan::scan_networks(&router)?; + let result: Vec = networks + .iter() + .map(|n| serde_json::json!({ + "ssid": n.ssid, + "bssid": n.bssid, + "signal": n.signal, + "channel": n.channel, + "encryption": n.encryption, + })) + .collect(); + + Ok(serde_json::json!({ "networks": result })) + } + + /// Configure WAN/WISP — connect the router to an upstream WiFi network. + /// + /// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }` + pub(super) async fn handle_openwrt_configure_wan( + &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 ssid = p.get("ssid").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string(); + let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string(); + let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32; + let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32; + let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true); + + let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; + router.verify_openwrt()?; + + let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq }; + wan::configure_wisp(&router, &config)?; + + Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid })) + } +} + +/// 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 { + anyhow::bail!("Invalid IPv4: {}", s); + } + Ok([ + parts[0].parse()?, + parts[1].parse()?, + parts[2].parse()?, + parts[3].parse()?, + ]) +} diff --git a/core/openwrt/Cargo.toml b/core/openwrt/Cargo.toml new file mode 100644 index 00000000..ca6d868f --- /dev/null +++ b/core/openwrt/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "archipelago-openwrt" +version = "0.1.0" +edition = "2021" +description = "OpenWrt gateway integration for Archipelago — TollGate provisioning over SSH/UCI" + +[lib] +name = "archipelago_openwrt" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +thiserror = "1.0" +tracing = "0.1" +ssh2 = "0.9" +async-trait = "0.1" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } + +[dev-dependencies] +tokio-test = "0.4" diff --git a/core/openwrt/src/detect.rs b/core/openwrt/src/detect.rs new file mode 100644 index 00000000..cd67bcf7 --- /dev/null +++ b/core/openwrt/src/detect.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use std::net::{IpAddr, SocketAddr, TcpStream}; +use std::time::Duration; +use tracing::{debug, info}; + +use crate::Router; + +const SSH_PORT: u16 = 22; +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); + +/// Scan a CIDR subnet and return IP addresses of OpenWrt routers. +/// +/// Probes TCP/22, then verifies /etc/openwrt_release over SSH. +/// `ssh_user` and `ssh_password` are used for the verification probe only. +pub async fn scan_subnet( + subnet_base: [u8; 4], + prefix_len: u8, + ssh_user: &str, + ssh_password: &str, +) -> Vec { + let host_count = host_count_for_prefix(prefix_len); + let base_u32 = u32::from_be_bytes(subnet_base); + let mask = !((1u32 << (32 - prefix_len)) - 1); + let network = base_u32 & mask; + + let mut candidates = Vec::new(); + for i in 1..host_count { + let ip_u32 = network + i; + let ip = IpAddr::V4(std::net::Ipv4Addr::from(ip_u32)); + if tcp_reachable(ip, SSH_PORT) { + candidates.push(ip); + } + } + + info!("{} hosts with TCP/22 open in /{}", candidates.len(), prefix_len); + + let mut routers = Vec::new(); + for ip in candidates { + match verify_openwrt(ip, ssh_user, ssh_password) { + Ok(true) => { + info!("OpenWrt detected at {}", ip); + routers.push(ip); + } + Ok(false) => debug!("{} is not OpenWrt", ip), + Err(e) => debug!("{} probe failed: {}", ip, e), + } + } + + routers +} + +/// Check whether a known IP is an OpenWrt router. +pub fn probe(ip: IpAddr, ssh_user: &str, ssh_password: &str) -> Result { + verify_openwrt(ip, ssh_user, ssh_password) +} + +fn tcp_reachable(ip: IpAddr, port: u16) -> bool { + TcpStream::connect_timeout(&SocketAddr::new(ip, port), PROBE_TIMEOUT).is_ok() +} + +fn verify_openwrt(ip: IpAddr, user: &str, password: &str) -> Result { + let router = Router::connect_password(&ip.to_string(), SSH_PORT, user, password)?; + let (out, code) = router.run("cat /etc/openwrt_release")?; + Ok(code == 0 && out.contains("OpenWrt")) +} + +fn host_count_for_prefix(prefix_len: u8) -> u32 { + if prefix_len >= 32 { + return 1; + } + 1u32 << (32 - prefix_len) +} diff --git a/core/openwrt/src/lib.rs b/core/openwrt/src/lib.rs new file mode 100644 index 00000000..36a2c2ed --- /dev/null +++ b/core/openwrt/src/lib.rs @@ -0,0 +1,9 @@ +pub mod detect; +pub mod opkg; +pub mod router; +pub mod tollgate; +pub mod uci; +pub mod wan; +pub mod wifi_scan; + +pub use router::Router; diff --git a/core/openwrt/src/opkg.rs b/core/openwrt/src/opkg.rs new file mode 100644 index 00000000..84d889e0 --- /dev/null +++ b/core/openwrt/src/opkg.rs @@ -0,0 +1,90 @@ +use anyhow::Result; +use tracing::info; + +use crate::Router; + +/// Which package manager is available on this router. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PkgManager { + /// Traditional opkg (OpenWrt ≤24.x). + Opkg, + /// OpenWrt 25.x+ — apk is the native manager, opkg is not in repos. + ApkNative, +} + +impl Router { + /// Detect which package manager is available. + /// + /// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do). + /// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP + /// first to work around missing CA bundle on fresh images), then try + /// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt + /// 25.x) → `ApkNative`. + /// - Neither found → error. + pub fn opkg_check(&self) -> Result { + let (_, code) = self.run("test -x /usr/bin/opkg")?; + if code == 0 { + return Ok(PkgManager::Opkg); + } + + let (_, apk_code) = self.run("test -x /usr/bin/apk")?; + if apk_code == 0 { + info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host); + // Fresh images ship without a CA bundle; switch repos to HTTP so + // apk's wget can reach the package index without TLS verification. + self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?; + let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?; + if update_code != 0 { + anyhow::bail!( + "apk update failed (exit {}) — router may have no internet access. \ + Ensure WAN/internet is working on the router before provisioning.\n{}", + update_code, + update_out.trim() + ); + } + // Try to install opkg (only available on some 25.x builds). + let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?; + if add_code == 0 { + return Ok(PkgManager::Opkg); + } + if add_out.contains("no such package") || add_out.contains("unable to select") { + info!("[{}] opkg not in apk repos — staying in apk-native mode", self.host); + return Ok(PkgManager::ApkNative); + } + anyhow::bail!("apk add opkg failed (exit {}): {}", add_code, add_out.trim()); + } + + anyhow::bail!( + "opkg not found at /usr/bin/opkg — this router's firmware may not \ + support package management (TollGate requires a standard OpenWrt build)" + ); + } + + /// `opkg update` — refresh package lists. + pub fn opkg_update(&self) -> Result<()> { + info!("[{}] opkg update", self.host); + self.run_ok("/usr/bin/opkg update")?; + Ok(()) + } + + /// Install a package, skipping if already installed. + pub fn opkg_install(&self, package: &str) -> Result<()> { + // Check if already installed to avoid unnecessary network traffic. + let (_, code) = self.run(&format!("/usr/bin/opkg list-installed | grep -q '^{} '", package))?; + if code == 0 { + info!("[{}] {} already installed", self.host, package); + return Ok(()); + } + + info!("[{}] opkg install {}", self.host, package); + self.run_ok(&format!("/usr/bin/opkg install {}", package))?; + Ok(()) + } + + /// Remove a package. + pub fn opkg_remove(&self, package: &str) -> Result<()> { + info!("[{}] opkg remove {}", self.host, package); + self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; + Ok(()) + } +} diff --git a/core/openwrt/src/router.rs b/core/openwrt/src/router.rs new file mode 100644 index 00000000..088ec9c5 --- /dev/null +++ b/core/openwrt/src/router.rs @@ -0,0 +1,87 @@ +use anyhow::{Context, Result}; +use ssh2::Session; +use std::io::Read; +use std::net::TcpStream; +use std::path::Path; +use tracing::debug; + +/// An active SSH connection to an OpenWrt router. +pub struct Router { + pub host: String, + pub port: u16, + session: Session, +} + +impl Router { + /// 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 mut session = Session::new().context("create SSH session")?; + session.set_tcp_stream(tcp); + session.handshake().context("SSH handshake")?; + session + .userauth_pubkey_file(user, None, key_path, None) + .with_context(|| format!("SSH auth as {} with key {:?}", user, key_path))?; + + Ok(Self { + host: host.to_string(), + port, + session, + }) + } + + /// 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 mut session = Session::new().context("create SSH session")?; + session.set_tcp_stream(tcp); + session.handshake().context("SSH handshake")?; + session + .userauth_password(user, password) + .with_context(|| format!("SSH password auth as {}", user))?; + + Ok(Self { + host: host.to_string(), + port, + session, + }) + } + + /// Run a command and return (stdout, exit_code). + pub fn run(&self, cmd: &str) -> Result<(String, i32)> { + debug!("ssh [{}] $ {}", self.host, cmd); + + let mut channel = self.session.channel_session().context("open channel")?; + channel.exec(cmd).with_context(|| format!("exec: {}", cmd))?; + + let mut stdout = String::new(); + channel.read_to_string(&mut stdout).context("read stdout")?; + channel.wait_close().context("wait close")?; + let exit = channel.exit_status().context("exit status")?; + + Ok((stdout, exit)) + } + + /// Run a command, fail if exit code is non-zero. + pub fn run_ok(&self, cmd: &str) -> Result { + let (out, code) = self.run(cmd)?; + if code != 0 { + anyhow::bail!("command `{}` exited with code {}: {}", cmd, code, out.trim()); + } + Ok(out) + } + + /// Verify the remote device is actually running OpenWrt. + pub fn verify_openwrt(&self) -> Result { + let release = self + .run_ok("cat /etc/openwrt_release") + .context("read /etc/openwrt_release — is this an OpenWrt device?")?; + Ok(release) + } +} diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs new file mode 100644 index 00000000..005e2cae --- /dev/null +++ b/core/openwrt/src/tollgate/config.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::Router; + +/// TollGate provisioning parameters. +/// +/// `mint_url` must be the externally-reachable URL of the Archy Cashu mint — +/// TollGate customers connect from outside the Archy node's loopback, so +/// localhost URLs will not work. Resolve this from the running mint app before +/// calling `provision`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TollGateConfig { + /// SSID name for the pay-as-you-go network. + pub ssid: String, + /// Externally-reachable URL of the Archy Cashu mint. + pub mint_url: String, + /// Price in satoshis per `step_size` interval. + pub price_sats: u64, + /// Step size in milliseconds (default: 60000 = 1 minute). + pub step_size_ms: u64, + /// Minimum steps a customer must purchase at once. + pub min_steps: u32, + /// Whether the TollGate service should be running and enabled at boot. + pub enabled: bool, +} + +impl Default for TollGateConfig { + fn default() -> Self { + Self { + ssid: "archipelago".to_string(), + mint_url: String::new(), // must be set by caller from the running mint app + price_sats: 10, + step_size_ms: 60_000, + min_steps: 1, + enabled: true, + } + } +} + +/// Write TollGate UCI configuration and commit. +/// +/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go. +pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { + router.uci_apply( + "tollgate", + &[ + ("tollgate.main", "tollgate"), + ("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }), + ("tollgate.main.metric", "milliseconds"), + ("tollgate.main.step_size", &cfg.step_size_ms.to_string()), + ("tollgate.main.min_steps", &cfg.min_steps.to_string()), + ("tollgate.main.price_per_step", &cfg.price_sats.to_string()), + ("tollgate.main.currency", "sat"), + ("tollgate.main.mint_url", &cfg.mint_url), + ], + )?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/install.rs b/core/openwrt/src/tollgate/install.rs new file mode 100644 index 00000000..e2634c6e --- /dev/null +++ b/core/openwrt/src/tollgate/install.rs @@ -0,0 +1,254 @@ +use anyhow::Result; +use tracing::info; + +use crate::Router; + +/// The OpenWrt package name for the TollGate reference implementation. +const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go"; + +/// Direct-download fallback URLs by opkg architecture string. +/// Used when the package is not in any configured feed. +/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0 +fn ipk_url(arch: &str) -> Option<&'static str> { + match arch { + "mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"), + "mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"), + "aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"), + "aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"), + "arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"), + _ => None, + } +} + +/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x). +/// +/// Tries opkg first (works if a custom feed is configured). Falls back to +/// downloading the .ipk directly from GitHub releases if opkg can't find it. +/// Caller is responsible for running `opkg_update` first. +pub fn install_tollgate(router: &Router) -> Result<()> { + info!("[{}] Installing {}", router.host, TOLLGATE_PACKAGE); + + // Fast path: standard opkg install (or already installed). + if router.opkg_install(TOLLGATE_PACKAGE).is_ok() { + return Ok(()); + } + + // Package not in any feed — download the .ipk directly. + let arch = router + .run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?; + let arch = arch.trim(); + + let url = ipk_url(arch).ok_or_else(|| { + anyhow::anyhow!( + "No pre-built TollGate package for architecture '{}'. \ + Add a custom opkg feed or build from source.", + arch + ) + })?; + + info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch); + router.run_ok(&format!("wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url))?; + install_ipk(router, "/tmp/tollgate.ipk") +} + +/// Install tollgate-module-basic-go on OpenWrt 25.x where opkg is not available. +/// +/// Downloads the .ipk from GitHub releases and extracts it manually using +/// BusyBox `ar` and `tar` (both present on all OpenWrt images). +pub fn install_tollgate_apk_native(router: &Router) -> Result<()> { + info!("[{}] Installing {} (apk-native mode)", router.host, TOLLGATE_PACKAGE); + + // Already installed? The service binary is /usr/bin/tollgate-wrt (per its + // init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name, + // never an on-disk filename, so it can't be used for the file-existence + // fallback below. + let (_, code) = router.run(&format!( + "apk list --installed 2>/dev/null | grep -q '^{}' || \ + test -f /usr/bin/tollgate-wrt 2>/dev/null", + TOLLGATE_PACKAGE + ))?; + if code == 0 { + info!("[{}] {} already installed", router.host, TOLLGATE_PACKAGE); + return Ok(()); + } + + // Get architecture from /etc/openwrt_release. + // The variable is DISTRIB_ARCH on most builds; OPENWRT_ARCH on some. + // Fall back to apk --print-arch, then uname -m. + let arch_raw = router.run_ok( + ". /etc/openwrt_release 2>/dev/null \ + && a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \ + && [ -n \"$a\" ] && echo \"$a\" \ + || /usr/bin/apk --print-arch 2>/dev/null \ + || uname -m" + )?; + // Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant + // which is the standard for home-router MIPS builds. + let arch = match arch_raw.trim() { + "mipsel" => "mipsel_24kc", + "mips" => "mips_24kc", + other => other, + }; + info!("[{}] detected arch: {:?}", router.host, arch); + if arch.is_empty() { + anyhow::bail!("Could not determine router architecture"); + } + + let url = ipk_url(arch).ok_or_else(|| { + anyhow::anyhow!( + "No pre-built TollGate package for architecture '{}'. \ + Add a custom feed or build from source.", + arch + ) + })?; + + info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch); + // --no-check-certificate: fresh OpenWrt 25.x images ship without a CA bundle; + // GitHub serves releases over HTTPS so wget would otherwise reject the cert. + let (dl_out, dl_code) = router.run(&format!( + "wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url + ))?; + if dl_code != 0 { + anyhow::bail!("TollGate download failed: {}", dl_out.trim()); + } + // Sanity-check: a real .ipk is at least 50 KB. + // If wget captured an HTML error page it will be tiny. + let (size_out, _) = router.run("wc -c < /tmp/tollgate.ipk 2>/dev/null")?; + let size: u64 = size_out.trim().parse().unwrap_or(0); + if size < 50_000 { + anyhow::bail!( + "Downloaded TollGate package is only {}B — wget likely captured an error page. \ + Check router internet access and that the release URL is reachable.", + size + ); + } + install_ipk(router, "/tmp/tollgate.ipk") +} + +/// Extract and install an .ipk file without opkg. +/// +/// An .ipk is an `ar` archive containing `data.tar.gz` (package files) and +/// `control.tar.gz` (metadata + postinst script). +fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> { + // Check for disk space first (rough: need at least ~1 MB free on /overlay). + // TollGate is a Go binary — typically 5–8 MB on flash. + let (df_out, _) = router.run("df /overlay 2>/dev/null | awk 'NR==2{print $4}'")?; + let free_kb: u64 = df_out.trim().parse().unwrap_or(u64::MAX); + if free_kb < 5120 { + anyhow::bail!( + "Not enough flash space for TollGate: only {}kB free on /overlay \ + (need ≥5MB). Free up space first or use a router with more storage.", + free_kb + ); + } + + router.run_ok("rm -rf /tmp/_tg_install && mkdir -p /tmp/_tg_install")?; + + // OpenWrt 25.x BusyBox does not include `ar` — install binutils via + // whichever package manager is available before trying to unpack the ipk. + let (_, ar_found) = router.run("command -v ar >/dev/null 2>&1")?; + if ar_found != 0 { + info!("[{}] ar not found, installing binutils", router.host); + let (pkg_out, pkg_code) = router.run( + "apk add binutils 2>&1 || opkg install binutils 2>&1" + )?; + if pkg_code != 0 { + anyhow::bail!( + "TollGate installation failed: ar not available and binutils install failed: {}", + pkg_out.trim() + ); + } + } + + // Try standard opkg ar format first (ar archive → data.tar.gz inside). + let (ar_out, ar_code) = router.run(&format!( + "cd /tmp/_tg_install && ar x {} 2>&1", ipk_path + ))?; + + if ar_code != 0 { + // Fallback: some builds produce the .ipk as a gzip tarball rather than + // a classic `ar` archive. This can still contain the same three ipk + // members (debian-binary/data.tar.gz/control.tar.gz) one level deep — + // just gzip-tarred together instead of ar'd — or, less commonly, a + // flat tarball of the real package files with no ipk structure at + // all. Extract to the scratch dir and check which shape it is before + // deciding how to install it. + info!("[{}] ar failed ({}), trying tar -xzf", router.host, ar_out.trim()); + + // List contents first — validates format without writing anything. + let (list_out, list_code) = router.run(&format!( + "tar -tzf {} 2>&1 | head -30", ipk_path + ))?; + if list_code != 0 { + anyhow::bail!( + "TollGate installation failed: file is not an ar archive or gzip tar.\n\ + ar: {}\ntar -t: {}", + ar_out.trim(), list_out.trim() + ); + } + info!("[{}] ipk contents:\n{}", router.host, list_out.trim()); + + router.run_ok(&format!("tar -xzf {} -C /tmp/_tg_install 2>&1", ipk_path))?; + + let (_, nested) = router.run("test -f /tmp/_tg_install/data.tar.gz")?; + if nested != 0 { + // Genuinely flat tarball, no ipk structure — its contents are the + // real package files, already unpacked into the scratch dir. + let (ov_df, _) = router.run("df / 2>/dev/null | awk 'NR==2{print $4}'")?; + let overlay_free_kb: u64 = ov_df.trim().parse().unwrap_or(0); + if overlay_free_kb < 5120 { + anyhow::bail!( + "Not enough space to install TollGate: only {}kB free on /. \ + Need at least 5MB. Free up flash space on the router first \ + (e.g. remove unused packages with `apk del …`).", + overlay_free_kb + ); + } + let (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?; + if cp_code != 0 { + anyhow::bail!("TollGate installation failed: file copy failed: {}", cp_out.trim()); + } + // No package-manager postinst ran for these files either — see + // the uci-defaults note below. + router.run_ok( + "for f in /etc/uci-defaults/*; do \ + [ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \ + done; uci commit 2>/dev/null; true" + )?; + router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?; + return Ok(()); + } + // Nested ipk-member layout — fall through to the shared unpack below. + } + + // Unpack data.tar.gz (the real payload) from either the `ar`-extracted or + // gzip-tar-extracted scratch dir, then run control.tar.gz's postinst. + let (tar_out, tar_code) = router.run( + "tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1" + )?; + if tar_code != 0 { + anyhow::bail!("TollGate installation failed: data extract failed: {}", tar_out.trim()); + } + // Run postinst if present (optional — failures are non-fatal). + router.run_ok( + "if tar -xzf /tmp/_tg_install/control.tar.gz -C /tmp/_tg_install 2>/dev/null; then \ + chmod +x /tmp/_tg_install/postinst 2>/dev/null; \ + /tmp/_tg_install/postinst configure 2>/dev/null || true; \ + fi" + )?; + // `default_postinst` (what most packages' postinst calls, including + // this one) only runs pending /etc/uci-defaults/* scripts for packages + // it finds in opkg/apk's own file-list records. Since these files were + // extracted manually rather than through a real package-manager install, + // no such record exists, so run any pending scripts directly — this is + // exactly what opkg's install path (or the next reboot) would otherwise + // do for them, just without waiting for either. + router.run_ok( + "for f in /etc/uci-defaults/*; do \ + [ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \ + done; uci commit 2>/dev/null; true" + )?; + + router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs new file mode 100644 index 00000000..f694e97d --- /dev/null +++ b/core/openwrt/src/tollgate/mod.rs @@ -0,0 +1,61 @@ +pub mod config; +pub mod install; +pub mod wifi; + +pub use config::TollGateConfig; +pub use install::install_tollgate; +pub use wifi::provision_ssid; + +use anyhow::Result; +use tracing::info; + +use crate::{opkg::PkgManager, Router}; + +/// Full TollGate provisioning sequence: +/// 1. Install tollgate-module-basic-go +/// 2. Write TollGate UCI config (pricing, mint URL) +/// 3. Create the pay-as-you-go WiFi SSID +/// 4. Restart affected services +pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { + info!("[{}] Starting TollGate provisioning", router.host); + + let pkg_mgr = router.opkg_check()?; + match pkg_mgr { + PkgManager::Opkg => { + router.opkg_update()?; + install_tollgate(router)?; + } + PkgManager::ApkNative => { + install::install_tollgate_apk_native(router)?; + } + } + config::apply(router, config)?; + wifi::provision_ssid(router, config)?; + restart_services(router, config.enabled)?; + + info!("[{}] TollGate provisioning complete", router.host); + Ok(()) +} + +/// Applies `enabled` to the actual running service, not just the UCI value — +/// the tollgate-wrt init script doesn't consult `tollgate.main.enabled` +/// itself, so toggling it requires an explicit enable/start or disable/stop. +/// +/// The service's init script is `/etc/init.d/tollgate-wrt` (its actual +/// on-disk name — "tollgate" alone does not exist). +fn restart_services(router: &Router, enabled: bool) -> Result<()> { + if enabled { + router.run_ok("/etc/init.d/tollgate-wrt enable")?; + router.run_ok( + "/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start" + )?; + } else { + router.run_ok("/etc/init.d/tollgate-wrt stop || true")?; + router.run_ok("/etc/init.d/tollgate-wrt disable || true")?; + } + router.run_ok("/etc/init.d/network restart")?; + // Reload wireless so wireless.tollgate.disabled takes effect on the radio — + // `network restart` alone doesn't reliably reconfigure wifi interfaces. + router.run_ok("wifi down 2>&1; wifi up 2>&1")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs new file mode 100644 index 00000000..03baea1d --- /dev/null +++ b/core/openwrt/src/tollgate/wifi.rs @@ -0,0 +1,109 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::tollgate::TollGateConfig; +use crate::Router; + +/// Create (or update) the dedicated pay-as-you-go WiFi interface for TollGate. +/// +/// Uses a fixed named section (`wireless.tollgate`) rather than `uci add`, so +/// re-provisioning (e.g. editing price/mint URL after install) updates the +/// same interface in place instead of piling up a new `wifi-iface` section — +/// and therefore a new duplicate broadcast SSID — on every call. +pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> { + let radio = detect_radio(router).context("detect WiFi radio")?; + info!("[{}] Using radio {} for TollGate SSID", router.host, radio); + + router.uci_apply( + "wireless", + &[ + ("wireless.tollgate", "wifi-iface"), + ("wireless.tollgate.device", &radio), + ("wireless.tollgate.mode", "ap"), + ("wireless.tollgate.ssid", &cfg.ssid), + ("wireless.tollgate.encryption", "none"), + ("wireless.tollgate.network", "tollgate"), + // Disable 802.11r/k/v — unnecessary for transient pay-as-you-go clients. + ("wireless.tollgate.ieee80211r", "0"), + // Stop broadcasting entirely when disabled, rather than leaving an + // open SSID up that leads nowhere once the backend is stopped. + ("wireless.tollgate.disabled", if cfg.enabled { "0" } else { "1" }), + ], + )?; + + provision_network(router)?; + provision_firewall(router)?; + + Ok(()) +} + +/// Add a `tollgate` network interface (isolated LAN for TollGate clients). +fn provision_network(router: &Router) -> Result<()> { + router.uci_apply( + "network", + &[ + ("network.tollgate", "interface"), + ("network.tollgate.proto", "static"), + ("network.tollgate.ipaddr", "192.168.99.1"), + ("network.tollgate.netmask", "255.255.255.0"), + ], + )?; + + // Enable DHCP for the tollgate interface. + router.uci_apply( + "dhcp", + &[ + ("dhcp.tollgate", "dhcp"), + ("dhcp.tollgate.interface", "tollgate"), + ("dhcp.tollgate.start", "100"), + ("dhcp.tollgate.limit", "150"), + ("dhcp.tollgate.leasetime", "5m"), + ], + )?; + + Ok(()) +} + +/// Add firewall zone for the tollgate interface. +/// +/// TollGate itself gates forwarding via iptables; the firewall zone isolates +/// tollgate clients from other LAN segments. +fn provision_firewall(router: &Router) -> Result<()> { + // Zone + router.uci_apply( + "firewall", + &[ + ("firewall.tollgate_zone", "zone"), + ("firewall.tollgate_zone.name", "tollgate"), + ("firewall.tollgate_zone.network", "tollgate"), + ("firewall.tollgate_zone.input", "ACCEPT"), + ("firewall.tollgate_zone.output", "ACCEPT"), + ("firewall.tollgate_zone.forward", "REJECT"), + ], + )?; + + // Forwarding rule: tollgate → wan (TollGate manages which clients can forward) + router.uci_apply( + "firewall", + &[ + ("firewall.tollgate_fwd", "forwarding"), + ("firewall.tollgate_fwd.src", "tollgate"), + ("firewall.tollgate_fwd.dest", "wan"), + ], + )?; + + Ok(()) +} + +/// Return the first available wireless radio device name (e.g. "radio0"). +fn detect_radio(router: &Router) -> Result { + let out = router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?; + // Extract "radioN" from "wireless.radioN.type" + let radio = out + .trim() + .split('.') + .nth(1) + .unwrap_or("radio0") + .to_string(); + Ok(radio) +} diff --git a/core/openwrt/src/uci.rs b/core/openwrt/src/uci.rs new file mode 100644 index 00000000..e74f3e27 --- /dev/null +++ b/core/openwrt/src/uci.rs @@ -0,0 +1,65 @@ +use anyhow::Result; + +use crate::Router; + +/// Thin wrappers around `uci` CLI commands over SSH. +impl Router { + /// `uci get ` — returns trimmed value. + pub fn uci_get(&self, key: &str) -> Result { + let out = self.run_ok(&format!("uci get {}", key))?; + Ok(out.trim().to_string()) + } + + /// `uci set =` + pub fn uci_set(&self, key: &str, value: &str) -> Result<()> { + self.run_ok(&format!("uci set {}={}", key, shell_quote(value)))?; + Ok(()) + } + + /// `uci add ` — returns the new section name. + pub fn uci_add(&self, config: &str, section_type: &str) -> Result { + let out = self.run_ok(&format!("uci add {} {}", config, section_type))?; + Ok(out.trim().to_string()) + } + + /// `uci add_list =` + pub fn uci_add_list(&self, key: &str, value: &str) -> Result<()> { + self.run_ok(&format!("uci add_list {}={}", key, shell_quote(value)))?; + Ok(()) + } + + /// `uci delete ` + pub fn uci_delete(&self, key: &str) -> Result<()> { + self.run_ok(&format!("uci delete {}", key))?; + Ok(()) + } + + /// `uci commit []` + pub fn uci_commit(&self, config: Option<&str>) -> Result<()> { + match config { + Some(c) => self.run_ok(&format!("uci commit {}", c))?, + None => self.run_ok("uci commit")?, + }; + Ok(()) + } + + /// Batch: apply a list of `(key, value)` pairs then commit the config. + pub fn uci_apply(&self, config: &str, pairs: &[(&str, &str)]) -> Result<()> { + // `uci set config.section=type` fails with "Entry not found" if + // /etc/config/ doesn't exist yet — true for any config file + // shipped by the base system (wireless, network, dhcp, ...) but not + // for a package-defined namespace like "tollgate" that nothing has + // created a default for. `touch` is a no-op if it already exists. + self.run_ok(&format!("touch /etc/config/{}", config))?; + for (key, value) in pairs { + self.uci_set(key, value)?; + } + self.uci_commit(Some(config))?; + Ok(()) + } +} + +/// Wrap a value in single quotes, escaping any embedded single quotes. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} diff --git a/core/openwrt/src/wan.rs b/core/openwrt/src/wan.rs new file mode 100644 index 00000000..b7dde420 --- /dev/null +++ b/core/openwrt/src/wan.rs @@ -0,0 +1,218 @@ +use anyhow::Result; +use tracing::info; +use crate::Router; + +pub struct WispConfig { + pub ssid: String, + pub password: String, + pub encryption: String, // psk2 | psk | sae | none + pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100) + pub dhcp_limit: u32, // pool size (default 150 → .100–.249) + pub masq: bool, // enable NAT on WAN zone (almost always true) +} + +pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> { + info!("[{}] Configuring WISP → ssid={}", router.host, config.ssid); + + let radio = detect_radio(router)?; + + // Ensure the radio is enabled (disabled=1 by default on fresh flash) + router.uci_set("wireless.radio0.disabled", "0")?; + + // Create/update named sta wifi-iface "wwan" (idempotent: uci set creates if absent) + router.uci_set("wireless.wwan", "wifi-iface")?; + router.uci_set("wireless.wwan.device", &radio)?; + router.uci_set("wireless.wwan.mode", "sta")?; + router.uci_set("wireless.wwan.ssid", &config.ssid)?; + router.uci_set("wireless.wwan.network", "wwan")?; + router.uci_set("wireless.wwan.disabled", "0")?; + router.uci_set("wireless.wwan.encryption", &config.encryption)?; + if config.encryption != "none" && !config.password.is_empty() { + router.uci_set("wireless.wwan.key", &config.password)?; + } + router.uci_commit(Some("wireless"))?; + + // Create/update wwan network interface (DHCP) + router.uci_set("network.wwan", "interface")?; + router.uci_set("network.wwan.proto", "dhcp")?; + router.uci_commit(Some("network"))?; + + // Add wwan to the WAN firewall zone (walk zones by name) + ensure_wwan_in_wan_zone(router)?; + + // Configure LAN DHCP pool + router.uci_set("dhcp.lan.start", &config.dhcp_start.to_string())?; + router.uci_set("dhcp.lan.limit", &config.dhcp_limit.to_string())?; + router.uci_commit(Some("dhcp"))?; + + // Ensure masquerade on WAN zone so LAN clients reach the internet + if config.masq { + ensure_masq_on_wan_zone(router)?; + } + + // Full wifi cycle so wpa_supplicant restarts cleanly with the new config. + // "wifi reload" is not enough on some drivers — it keeps stale state. + let (down_out, down_code) = router.run("wifi down 2>&1")?; + if down_code != 0 { + info!("[{}] wifi down failed ({}): {}", router.host, down_code, down_out.trim()); + } + let (up_out, up_code) = router.run("wifi up 2>&1")?; + if up_code != 0 { + info!("[{}] wifi up failed ({}): {} — falling back to network restart", router.host, up_code, up_out.trim()); + router.run_ok("/etc/init.d/network restart 2>&1")?; + } + + Ok(()) +} + +pub fn get_wan_status(router: &Router) -> serde_json::Value { + let configured = router + .uci_get("network.wwan.proto") + .map(|v| v == "dhcp") + .unwrap_or(false); + + let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default(); + let encryption = router.uci_get("wireless.wwan.encryption").unwrap_or_default(); + let radio0_disabled = router + .uci_get("wireless.radio0.disabled") + .map(|v| v == "1") + .unwrap_or(false); + + // Find the active sta-mode interface and its association state + let iw_out = router.run_ok("iw dev 2>/dev/null").unwrap_or_default(); + let (sta_iface, assoc_ssid) = parse_sta_iface(&iw_out); + + // Interface operstate (up / down / absent) + let sta_state = if !sta_iface.is_empty() { + router + .run_ok(&format!("cat /sys/class/net/{}/operstate 2>/dev/null", sta_iface)) + .unwrap_or_else(|_| "unknown".into()) + .trim() + .to_string() + } else { + "absent".to_string() + }; + + // Source IP for reaching 8.8.8.8 — empty if no default route yet + let ip = router + .run_ok("ip -4 route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i==\"src\"){print $(i+1); exit}}'") + .unwrap_or_default() + .trim() + .to_string(); + + // Recent wifi-related kernel/syslog lines for quick diagnosis + let wifi_log = router + .run_ok("logread 2>/dev/null | grep -iE 'wlan|wwan|wifi|assoc|deauth|auth fail|CTRL-EVENT|wpa_supplicant' | tail -8 2>/dev/null") + .unwrap_or_default() + .trim() + .to_string(); + + // LAN info for the DHCP setup display + let lan_ip = router.uci_get("network.lan.ipaddr").unwrap_or_else(|_| "192.168.1.1".into()); + let lan_netmask = router.uci_get("network.lan.netmask").unwrap_or_else(|_| "255.255.255.0".into()); + let dhcp_start = router.uci_get("dhcp.lan.start").unwrap_or_else(|_| "100".into()); + let dhcp_limit = router.uci_get("dhcp.lan.limit").unwrap_or_else(|_| "150".into()); + + // Masquerade: check WAN zone + let masq = { + let script = "for i in $(seq 0 9); do \ + n=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \ + if [ \"$n\" = \"wan\" ]; then \ + uci get firewall.@zone[$i].masq 2>/dev/null; break; \ + fi; done"; + router.run_ok(script).unwrap_or_default().trim().to_string() == "1" + }; + + info!("[{}] WAN status: configured={} ssid={:?} assoc={:?} sta_iface={:?} sta_state={:?} ip={:?} lan={} masq={}", + router.host, configured, ssid, assoc_ssid, sta_iface, sta_state, ip, lan_ip, masq); + if !wifi_log.is_empty() { + info!("[{}] wifi_log: {}", router.host, wifi_log.replace('\n', " | ")); + } + + serde_json::json!({ + "configured": configured, + "ssid": ssid, + "assoc_ssid": assoc_ssid, + "encryption": encryption, + "ip": ip, + "internet": !ip.is_empty(), + "radio0_disabled": radio0_disabled, + "sta_iface": sta_iface, + "sta_state": sta_state, + "wifi_log": wifi_log, + "lan_ip": lan_ip, + "lan_netmask": lan_netmask, + "dhcp_start": dhcp_start, + "dhcp_limit": dhcp_limit, + "masq": masq, + }) +} + +fn parse_sta_iface(iw_out: &str) -> (String, String) { + let mut result_iface = String::new(); + let mut result_ssid = String::new(); + let mut current_iface = String::new(); + let mut current_type = String::new(); + let mut current_ssid = String::new(); + + for line in iw_out.lines() { + let line = line.trim(); + if let Some(name) = line.strip_prefix("Interface ") { + // Save previous interface if it was a sta + if current_type == "managed" && result_iface.is_empty() { + result_iface = current_iface.clone(); + result_ssid = current_ssid.clone(); + } + current_iface = name.trim().to_string(); + current_type.clear(); + current_ssid.clear(); + } else if let Some(t) = line.strip_prefix("type ") { + current_type = t.trim().to_string(); + } else if let Some(s) = line.strip_prefix("ssid ") { + current_ssid = s.trim().to_string(); + } + } + // Handle last block + if current_type == "managed" && result_iface.is_empty() { + result_iface = current_iface; + result_ssid = current_ssid; + } + + (result_iface, result_ssid) +} + +fn detect_radio(router: &Router) -> Result { + // radio0 is universal; verify it exists + let out = router.uci_get("wireless.radio0").unwrap_or_default(); + if !out.is_empty() { + return Ok("radio0".to_string()); + } + anyhow::bail!("No wireless radio (radio0) found in UCI config") +} + +fn ensure_masq_on_wan_zone(router: &Router) -> Result<()> { + let script = "for i in $(seq 0 9); do \ + name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \ + if [ \"$name\" = \"wan\" ]; then \ + uci set firewall.@zone[$i].masq=1 2>/dev/null; \ + uci commit firewall; \ + break; \ + fi; \ + done; echo ok"; + router.run_ok(script)?; + Ok(()) +} + +fn ensure_wwan_in_wan_zone(router: &Router) -> Result<()> { + // Walk zones 0-9, find the one named "wan", add wwan to its network list + let script = "for i in $(seq 0 9); do \ + name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \ + if [ \"$name\" = \"wan\" ]; then \ + uci add_list firewall.@zone[$i].network=wwan 2>/dev/null; \ + uci commit firewall; \ + break; \ + fi; \ + done; echo ok"; + router.run_ok(script)?; + Ok(()) +} diff --git a/core/openwrt/src/wifi_scan.rs b/core/openwrt/src/wifi_scan.rs new file mode 100644 index 00000000..ac21918e --- /dev/null +++ b/core/openwrt/src/wifi_scan.rs @@ -0,0 +1,177 @@ +use anyhow::Result; +use crate::Router; + +pub struct ScannedNetwork { + pub ssid: String, + pub bssid: String, + pub signal: i32, + pub channel: u8, + pub encryption: String, +} + +pub fn scan_networks(router: &Router) -> Result> { + let (iface, temp) = find_wireless_iface(router)?; + let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?; + let result = if output.contains("Scanning not possible") { + // Vendor MediaTek `mt_wifi` driver (see find_wireless_iface) doesn't + // support scanning through iwinfo/nl80211 at all. Fall back to its own + // private ioctl site-survey, which works on the same interface. + scan_via_mtk_site_survey(router, &iface) + } else if output.contains("No scan results") || output.trim().is_empty() { + Ok(vec![]) + } else { + parse_iwinfo_scan(&output) + }; + if temp { + let _ = router.run(&format!("iw dev {} del 2>/dev/null", iface)); + } + result +} + +fn scan_via_mtk_site_survey(router: &Router, iface: &str) -> Result> { + let _ = router.run(&format!("iwpriv {} set SiteSurvey=1 2>/dev/null", iface)); + std::thread::sleep(std::time::Duration::from_secs(4)); + let output = router.run_ok(&format!("iwpriv {} get_site_survey 2>&1", iface))?; + parse_mtk_site_survey(&output) +} + +/// Parses MediaTek's `iwpriv get_site_survey` fixed-width table. +/// Column offsets come from the header row layout, which is part of the +/// vendor SDK's ioctl response format shared across OEMs (GL.iNet, etc.), +/// not something set per-device. +fn parse_mtk_site_survey(output: &str) -> Result> { + let mut networks = Vec::new(); + for line in output.lines() { + if !line.trim_start().as_bytes().first().is_some_and(u8::is_ascii_digit) { + continue; // skip header/summary lines; data rows start with an index + } + let ssid = line.get(8..41).unwrap_or("").trim().to_string(); + if ssid.is_empty() { + continue; + } + let bssid = line.get(41..61).unwrap_or("").trim().to_string(); + let security = line.get(61..84).unwrap_or(""); + let channel: u8 = line.get(4..8).and_then(|s| s.trim().parse().ok()).unwrap_or(0); + let signal: i32 = line.get(84..92).and_then(|s| s.trim().parse().ok()).unwrap_or(-100); + networks.push(ScannedNetwork { + ssid, + bssid, + signal, + channel, + encryption: normalize_encryption(security), + }); + } + networks.sort_by(|a, b| b.signal.cmp(&a.signal)); + Ok(networks) +} + +/// Returns `(interface_name, is_temporary)`. +/// If no interface exists, creates a temporary managed one directly on the PHY +/// so we can scan without needing any UCI wifi-iface sections. +fn find_wireless_iface(router: &Router) -> Result<(String, bool)> { + // Fast path: an interface already exists (radio was enabled previously) + let (out, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?; + if !out.trim().is_empty() { + return Ok((out.trim().to_string(), false)); + } + + // Some vendor wifi drivers (e.g. MediaTek's out-of-tree `mt_wifi`/`mtk` SDK + // driver used by GL.iNet and others) never register with cfg80211/mac80211, + // so they have no `iw dev` entry and no /sys/class/ieee80211 phy even though + // the radio is real and already up. `iwinfo` abstracts over those vendor + // backends too, so fall back to its device listing before concluding there's + // no radio at all. + let (iwinfo_out, _) = router.run("iwinfo 2>/dev/null | awk '/^[A-Za-z]/{print $1; exit}'")?; + if !iwinfo_out.trim().is_empty() { + return Ok((iwinfo_out.trim().to_string(), false)); + } + + // Find the phy — if this is empty the device has no WiFi hardware at all + let (phy_out, _) = router.run("ls /sys/class/ieee80211/ 2>/dev/null | head -1")?; + let phy = phy_out.trim().to_string(); + if phy.is_empty() { + anyhow::bail!("No wireless radio found on this router"); + } + + // Create a temporary managed interface directly on the PHY. This bypasses + // netifd entirely so it works even when there are no wifi-iface sections in + // UCI (common on a freshly-flashed device). + tracing::info!("[{}] Creating temporary scan interface on {}", router.host, phy); + // Remove any stale scan0 from a previous attempt, then add fresh + let _ = router.run("iw dev scan0 del 2>/dev/null"); + router.run_ok(&format!( + "iw phy {} interface add scan0 type managed 2>&1 && ip link set scan0 up 2>&1", + phy + ))?; + + Ok(("scan0".to_string(), true)) +} + +fn parse_iwinfo_scan(output: &str) -> Result> { + let mut networks: Vec = Vec::new(); + let mut current: Option = None; + + for line in output.lines() { + let line = line.trim(); + if line.starts_with("Cell ") { + if let Some(n) = current.take() { + if !n.ssid.is_empty() { + networks.push(n); + } + } + let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string(); + current = Some(ScannedNetwork { + ssid: String::new(), + bssid, + signal: -100, + channel: 0, + encryption: "none".to_string(), + }); + } else if let Some(ref mut n) = current { + if let Some(rest) = line.strip_prefix("ESSID:") { + n.ssid = rest.trim().trim_matches('"').to_string(); + } else if line.contains("Channel:") && !line.starts_with("Encryption") { + if let Some(ch_part) = line.split("Channel:").nth(1) { + n.channel = ch_part.trim().split_whitespace().next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } + } else if line.starts_with("Signal:") { + if let Some(dbm_str) = line.split_whitespace().nth(1) { + n.signal = dbm_str.parse().unwrap_or(-100); + } + } else if let Some(rest) = line.strip_prefix("Encryption:") { + n.encryption = normalize_encryption(rest.trim()); + } + } + } + if let Some(n) = current { + if !n.ssid.is_empty() { + networks.push(n); + } + } + + networks.sort_by(|a, b| b.signal.cmp(&a.signal)); + Ok(networks) +} + +fn normalize_encryption(raw: &str) -> String { + let lower = raw.to_lowercase(); + if lower.contains("wpa3") || lower.contains("sae") { + "sae".to_string() + } else if lower.contains("wpa2") || lower.contains("psk2") { + "psk2".to_string() + } else if lower.contains("wpa") { + // CCMP/AES is WPA2's cipher suite — even if iwinfo labels it "WPA PSK (CCMP)" + // it's a WPA2 network and we must use psk2 to associate correctly. + if lower.contains("ccmp") || lower.contains("aes") { + "psk2".to_string() + } else { + "psk".to_string() + } + } else if lower.contains("none") || lower.contains("open") || lower.is_empty() { + "none".to_string() + } else { + lower + } +} diff --git a/image-recipe/configs/archipelago-kiosk-launcher.sh b/image-recipe/configs/archipelago-kiosk-launcher.sh index 90d77e43..2f34a33e 100644 --- a/image-recipe/configs/archipelago-kiosk-launcher.sh +++ b/image-recipe/configs/archipelago-kiosk-launcher.sh @@ -33,14 +33,18 @@ configure_display() { [ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt) [ -n "$output" ] || return 0 + # Pick the EDID-preferred ("+") mode, falling back to the first-listed + # mode (EDID lists native first). Deliberately ignore "*" (currently + # active) — trusting "active" lets a bad clone/mirror state from a + # previous boot perpetuate itself forever instead of self-healing. mode=$(awk -v out="$output" ' $1 == out { active = 1; next } active && /^[[:space:]]+[0-9]+x[0-9]+/ { - if ($0 ~ /\*/) { print $1; exit } + if ($0 ~ /\+/ && !preferred) { preferred = $1 } if (!first) first = $1 } active && /^[^[:space:]]/ { active = 0 } - END { if (first) print first } + END { if (preferred) print preferred; else if (first) print first } ' /tmp/archipelago-kiosk-xrandr.txt) [ -n "$mode" ] || mode=1920x1080 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/stores/homeStatus.ts b/neode-ui/src/stores/homeStatus.ts index 435d78e2..4c39cd8a 100644 --- a/neode-ui/src/stores/homeStatus.ts +++ b/neode-ui/src/stores/homeStatus.ts @@ -49,10 +49,12 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { const bitcoinStale = ref(false) const vpnLoadState = ref('idle') const fipsLoadState = ref('idle') + const tollgateLoadState = ref('idle') const lastSystemRefreshAt = ref(null) const lastBitcoinRefreshAt = ref(null) const lastVpnRefreshAt = ref(null) const lastFipsRefreshAt = ref(null) + const lastTollgateRefreshAt = ref(null) const vpnStatus = ref<{ connected: boolean | null @@ -67,6 +69,9 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { authenticated_peer_count?: number } | null>(null) + // null = no OpenWrt router configured at all (tile row shows "Not configured"). + const tollgateStatus = ref<{ installed: boolean; enabled: boolean } | null>(null) + const systemStatsLoaded = computed(() => systemLoadState.value === 'ready') const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null) const vpnKnown = computed(() => vpnStatus.value.connected !== null) @@ -185,12 +190,37 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { } } + async function refreshTollgate() { + tollgateLoadState.value = tollgateLoadState.value === 'ready' ? 'ready' : 'loading' + try { + const res = await rpcClient.call<{ tollgate: { installed: boolean; enabled?: boolean } }>({ + method: 'openwrt.get-status', + timeout: 15000, + }) + tollgateStatus.value = { installed: res.tollgate.installed, enabled: res.tollgate.enabled ?? false } + tollgateLoadState.value = 'ready' + lastTollgateRefreshAt.value = Date.now() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + if (msg.includes('No router configured')) { + // Not an error — most nodes simply don't have an OpenWrt gateway set up. + tollgateStatus.value = null + tollgateLoadState.value = 'ready' + lastTollgateRefreshAt.value = Date.now() + } else { + // Transient failure (SSH hiccup, router rebooting) — keep last-known state. + tollgateLoadState.value = tollgateStatus.value ? 'ready' : 'error' + } + } + } + async function refresh(packages: Record) { await Promise.all([ refreshSystemStats(), refreshBitcoin(packages), refreshVpn(packages), refreshFips(), + refreshTollgate(), ]) } @@ -201,19 +231,23 @@ export const useHomeStatusStore = defineStore('homeStatus', () => { bitcoinStale, vpnLoadState, fipsLoadState, + tollgateLoadState, systemStatsLoaded, bitcoinKnown, vpnKnown, vpnStatus, fipsStatus, + tollgateStatus, lastSystemRefreshAt, lastBitcoinRefreshAt, lastVpnRefreshAt, lastFipsRefreshAt, + lastTollgateRefreshAt, refresh, refreshSystemStats, refreshBitcoin, refreshVpn, refreshFips, + refreshTollgate, } }) diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 81468a21..d733404e 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -173,6 +173,10 @@
FIPS
{{ fipsStatusLabel }} +
+
TollGate
+ {{ tollgateStatusLabel }} +
{{ t('home.manageNetwork') }} @@ -445,6 +449,22 @@ const fipsStatusLabel = computed(() => { const peers = s.authenticated_peer_count ?? 0 return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers` }) +const tollgateDotClass = computed(() => { + const s = homeStatus.tollgateStatus + if (!s || !s.installed) return 'bg-white/40' + return s.enabled ? 'bg-green-400' : 'bg-yellow-400' +}) +const tollgateTextClass = computed(() => { + const s = homeStatus.tollgateStatus + if (!s || !s.installed) return 'text-white/40' + return s.enabled ? 'text-green-400' : 'text-yellow-400' +}) +const tollgateStatusLabel = computed(() => { + const s = homeStatus.tollgateStatus + if (!s) return homeStatus.tollgateLoadState === 'loading' ? 'Checking…' : 'Not configured' + if (!s.installed) return 'Not installed' + return s.enabled ? 'Enabled' : 'Disabled' +}) const bitcoinSyncDisplay = computed(() => { if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…' if (!homeStatus.stats.bitcoinAvailable) return 'Not running' diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 736b8435..2355d760 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 +
+ +
@@ -244,12 +254,28 @@

{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}

-
-

{{ iface.ipv4[0] }}

-

No IP

+
+
+

{{ iface.ipv4[0] }}

+

No IP

+
+

No physical interfaces detected

+

{{ wifiRadioError }}

@@ -558,6 +584,9 @@ const allInterfaces = ref([]) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi')) +const togglingWifiRadio = ref(false) +const wifiRadioError = ref('') + const showWifiModal = ref(false) const wifiScanning = ref(false) const wifiNetworks = ref([]) @@ -613,6 +642,20 @@ async function loadInterfaces() { try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false } } +async function toggleWifiRadio(iface: NetworkInterface) { + togglingWifiRadio.value = true + wifiRadioError.value = '' + const enabled = iface.state !== 'up' + try { + await rpcClient.call({ method: 'network.set-wifi-radio', params: { enabled } }) + await loadInterfaces() + } catch (e) { + wifiRadioError.value = e instanceof Error ? e.message : 'Failed to change wifi radio state.' + } finally { + togglingWifiRadio.value = false + } +} + function wifiRequiresPassword(network: WifiNetwork | undefined): boolean { const security = (network?.security || '').trim().toLowerCase() return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open' diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue new file mode 100644 index 00000000..fd8c0aee --- /dev/null +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -0,0 +1,893 @@ + + + diff --git a/neode-ui/test-openwrt.mjs b/neode-ui/test-openwrt.mjs new file mode 100644 index 00000000..3124c39a --- /dev/null +++ b/neode-ui/test-openwrt.mjs @@ -0,0 +1,49 @@ +import { chromium } from './node_modules/playwright/index.mjs'; + +const BASE = 'https://100.66.157.121'; +const PASS = 'ThisIsWeb54321@'; +const DIR = '/tmp/claude-1000/-home-debian/97c10035-69a8-40a0-9b55-219eb8ad683a/scratchpad'; + +// Find the OpenWrt router IP from the Tailscale/LAN +const { execSync } = await import('child_process'); +let routerIp = '192.168.1.1'; +try { + const route = execSync("ssh archipelago@100.66.157.121 'ip route | grep default'", { encoding: 'utf8' }).trim(); + const match = route.match(/default via ([\d.]+)/); + if (match) routerIp = match[1]; +} catch {} +console.log('Detected router IP:', routerIp); + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); +const page = await ctx.newPage(); + +// Login +await page.goto(`${BASE}/login`, { waitUntil: 'networkidle', timeout: 20000 }); +await page.fill('input[type="password"]', PASS); +await page.keyboard.press('Enter'); +await page.waitForURL(/dashboard/, { timeout: 20000 }); + +// Server page +await page.goto(`${BASE}/dashboard/server`, { waitUntil: 'networkidle', timeout: 20000 }); +await page.screenshot({ path: `${DIR}/01-server.png` }); + +// Click OpenWrt Gateway +await page.getByText('OpenWrt Gateway').click(); +await page.waitForURL(/openwrt/, { timeout: 10000 }); +await page.waitForTimeout(2000); +await page.screenshot({ path: `${DIR}/02-connect-form.png` }); +console.log('Connect form visible'); + +// Fill in the connect form +await page.fill('input[placeholder="192.168.1.1"]', routerIp); +await page.screenshot({ path: `${DIR}/03-form-filled.png` }); +await page.getByRole('button', { name: 'Connect' }).click(); + +// Wait for SSH connection + UCI read (can take up to 30s) +console.log('Connecting to router, waiting for data...'); +await page.waitForTimeout(15000); +await page.screenshot({ path: `${DIR}/04-result.png` }); +console.log('Result page text:\n', (await page.innerText('body')).replace(/\s+/g, ' ').substring(0, 800)); + +await browser.close();