diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 0fa17971..b740f701 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -234,6 +234,8 @@ impl RpcHandler { "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, diff --git a/core/archipelago/src/api/rpc/middleware.rs b/core/archipelago/src/api/rpc/middleware.rs index b273837b..714068e3 100644 --- a/core/archipelago/src/api/rpc/middleware.rs +++ b/core/archipelago/src/api/rpc/middleware.rs @@ -71,6 +71,9 @@ pub(super) fn sanitize_error_message(msg: &str) -> String { "No pre-built TollGate", "opkg not found", "apk update failed", + "No wireless interface", + "No wireless radio", + "Missing required field", ]; for prefix in &user_facing_prefixes { if msg.starts_with(prefix) { diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index 03cbdd1a..55261676 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -4,6 +4,8 @@ use archipelago_openwrt::{ detect, router::Router, tollgate::{self, TollGateConfig}, + wan, + wifi_scan, }; use crate::network::router as net_router; @@ -114,6 +116,8 @@ impl RpcHandler { 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, @@ -121,6 +125,7 @@ impl RpcHandler { "release": parse_release(&release), "tollgate": tollgate, "wifi_interfaces": wifi_interfaces, + "wan": wan_status, })) } @@ -190,6 +195,74 @@ impl RpcHandler { "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 router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; + router.verify_openwrt()?; + + let config = wan::WispConfig { ssid: ssid.clone(), password, encryption }; + 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. diff --git a/core/openwrt/src/lib.rs b/core/openwrt/src/lib.rs index 8ae838e6..36a2c2ed 100644 --- a/core/openwrt/src/lib.rs +++ b/core/openwrt/src/lib.rs @@ -3,5 +3,7 @@ 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/wan.rs b/core/openwrt/src/wan.rs new file mode 100644 index 00000000..b75ef866 --- /dev/null +++ b/core/openwrt/src/wan.rs @@ -0,0 +1,91 @@ +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 fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> { + info!("[{}] Configuring WISP → ssid={}", router.host, config.ssid); + + let radio = detect_radio(router)?; + + // 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.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)?; + + // Apply wireless changes; fall back to full network restart if wifi reload fails + let (_, code) = router.run("wifi reload 2>&1")?; + if code != 0 { + 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(); + + // 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(); + + serde_json::json!({ + "configured": configured, + "ssid": ssid, + "encryption": encryption, + "ip": ip, + "internet": !ip.is_empty(), + }) +} + +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_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..a39626e7 --- /dev/null +++ b/core/openwrt/src/wifi_scan.rs @@ -0,0 +1,97 @@ +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 = find_wireless_iface(router)?; + let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?; + if output.contains("No scan results") || output.trim().is_empty() { + return Ok(vec![]); + } + parse_iwinfo_scan(&output) +} + +fn find_wireless_iface(router: &Router) -> Result { + let (_, code) = router.run("test -d /sys/class/net/wlan0/wireless")?; + if code == 0 { + return Ok("wlan0".to_string()); + } + let out = router.run_ok( + "ls /sys/class/net/ | while read i; do [ -d /sys/class/net/$i/wireless ] && echo $i && break; done", + )?; + let iface = out.trim().to_string(); + if iface.is_empty() { + anyhow::bail!("No wireless interface found on this router"); + } + Ok(iface) +} + +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") { + "psk".to_string() + } else if lower.contains("none") || lower.contains("open") || lower.is_empty() { + "none".to_string() + } else { + lower + } +} diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index 89d572a4..7446d2a9 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -30,6 +30,14 @@ interface TollGateStatus { mint_url?: string } +interface WanStatus { + configured: boolean + ssid: string + encryption: string + ip: string + internet: boolean +} + interface RouterStatus { host: string hostname: string @@ -37,6 +45,15 @@ interface RouterStatus { release: ReleaseInfo tollgate: TollGateStatus wifi_interfaces: WifiInterface[] + wan: WanStatus +} + +interface ScannedNetwork { + ssid: string + bssid: string + signal: number + channel: number + encryption: string } const status = ref(null) @@ -47,14 +64,20 @@ const sshUser = ref('root') const sshPassword = ref('') const showConnectForm = ref(false) const connecting = ref(false) - -// Credentials used for the last successful connection (reused for provisioning) const connectedParams = ref | null>(null) const provisioning = ref(false) const provisionError = ref('') const provisionSuccess = ref(false) +// WAN setup flow +type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'connecting' | 'done' +const wanStep = ref('idle') +const scannedNetworks = ref([]) +const selectedNetwork = ref(null) +const wanPassword = ref('') +const wanError = ref('') + async function load(params?: Record) { loading.value = true error.value = '' @@ -95,13 +118,11 @@ async function provisionTollgate() { provisionSuccess.value = false try { const params: Record = { - // Use explicitly connected creds if available, otherwise fall back to - // host from the loaded status (backend will use saved router_config). host: connectedParams.value?.host ?? status.value?.host, ssh_user: connectedParams.value?.ssh_user ?? sshUser.value, ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value, } - await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 180000 }) + await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) provisionSuccess.value = true await load(connectedParams.value ?? undefined) } catch (e) { @@ -111,6 +132,76 @@ async function provisionTollgate() { } } +function startWanSetup() { + wanStep.value = 'scan' + wanError.value = '' + scannedNetworks.value = [] + selectedNetwork.value = null + wanPassword.value = '' +} + +async function scanWifi() { + wanStep.value = 'scanning' + wanError.value = '' + try { + const params: Record = { + host: connectedParams.value?.host ?? status.value?.host, + ssh_user: connectedParams.value?.ssh_user ?? sshUser.value, + ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value, + } + const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({ + method: 'openwrt.scan-wifi', + params, + timeout: 30000, + }) + scannedNetworks.value = result.networks + wanStep.value = 'list' + } catch (e) { + wanError.value = e instanceof Error ? e.message : String(e) + wanStep.value = 'scan' + } +} + +function selectNetwork(net: ScannedNetwork) { + selectedNetwork.value = net + wanPassword.value = '' + wanError.value = '' + wanStep.value = 'password' +} + +async function configureWan() { + if (!selectedNetwork.value) return + wanStep.value = 'connecting' + wanError.value = '' + try { + const params: Record = { + host: connectedParams.value?.host ?? status.value?.host, + ssh_user: connectedParams.value?.ssh_user ?? sshUser.value, + ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value, + ssid: selectedNetwork.value.ssid, + password: wanPassword.value, + encryption: selectedNetwork.value.encryption, + } + await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 }) + wanStep.value = 'done' + // Give the router ~8s to associate before reloading status + setTimeout(() => { + wanStep.value = 'idle' + load(connectedParams.value ?? undefined) + }, 8000) + } catch (e) { + wanError.value = e instanceof Error ? e.message : String(e) + wanStep.value = 'password' + } +} + +function signalBars(dbm: number): number { + if (dbm >= -50) return 4 + if (dbm >= -65) return 3 + if (dbm >= -75) return 2 + return 1 +} + function formatUptime(secs: number): string { const d = Math.floor(secs / 86400) const h = Math.floor((secs % 86400) / 3600) @@ -150,7 +241,7 @@ onMounted(() => load())

OpenWrt Gateway

- +

Connect to Router

@@ -245,14 +336,137 @@ onMounted(() => load())
{{ formatUptime(status.uptime_secs) }}
-
+ +
+
+

WAN / Uplink

+ +
+ + + + + + + + + + + + + + + + + + +
+

TollGate

@@ -262,6 +476,9 @@ onMounted(() => load()) Not installed
+
+ Router needs internet access to install TollGate. Configure WAN above first. +