feat(openwrt): WAN/WISP setup from the UI with WiFi network scan
New RPC methods: - openwrt.scan-wifi: triggers iwinfo scan on the router radio, returns networks sorted by signal strength - openwrt.configure-wan: creates UCI wireless.wwan (sta mode) + network.wwan (DHCP) + adds wwan to firewall WAN zone, then calls `wifi reload` get-status now includes a `wan` object with configured/ssid/ip/ internet fields so the UI can show current uplink state. Frontend WAN panel: scan → pick SSID (signal bars) → enter password → apply. Shows "Configure WAN first" hint above TollGate install button when internet is not available. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
// 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(())
|
||||
}
|
||||
@@ -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<Vec<ScannedNetwork>> {
|
||||
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<String> {
|
||||
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<Vec<ScannedNetwork>> {
|
||||
let mut networks: Vec<ScannedNetwork> = Vec::new();
|
||||
let mut current: Option<ScannedNetwork> = 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user