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:
parent
dd3a3dfbac
commit
9a782fb551
@ -234,6 +234,8 @@ impl RpcHandler {
|
|||||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(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
|
// Ecash wallet
|
||||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||||
|
|||||||
@ -71,6 +71,9 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
"No pre-built TollGate",
|
"No pre-built TollGate",
|
||||||
"opkg not found",
|
"opkg not found",
|
||||||
"apk update failed",
|
"apk update failed",
|
||||||
|
"No wireless interface",
|
||||||
|
"No wireless radio",
|
||||||
|
"Missing required field",
|
||||||
];
|
];
|
||||||
for prefix in &user_facing_prefixes {
|
for prefix in &user_facing_prefixes {
|
||||||
if msg.starts_with(prefix) {
|
if msg.starts_with(prefix) {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ use archipelago_openwrt::{
|
|||||||
detect,
|
detect,
|
||||||
router::Router,
|
router::Router,
|
||||||
tollgate::{self, TollGateConfig},
|
tollgate::{self, TollGateConfig},
|
||||||
|
wan,
|
||||||
|
wifi_scan,
|
||||||
};
|
};
|
||||||
use crate::network::router as net_router;
|
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_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||||
|
|
||||||
|
let wan_status = wan::get_wan_status(&router);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"host": host,
|
"host": host,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
@ -121,6 +125,7 @@ impl RpcHandler {
|
|||||||
"release": parse_release(&release),
|
"release": parse_release(&release),
|
||||||
"tollgate": tollgate,
|
"tollgate": tollgate,
|
||||||
"wifi_interfaces": wifi_interfaces,
|
"wifi_interfaces": wifi_interfaces,
|
||||||
|
"wan": wan_status,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,6 +195,74 @@ impl RpcHandler {
|
|||||||
"mint_url": config.mint_url,
|
"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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value> = 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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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.
|
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||||
|
|||||||
@ -3,5 +3,7 @@ pub mod opkg;
|
|||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod tollgate;
|
pub mod tollgate;
|
||||||
pub mod uci;
|
pub mod uci;
|
||||||
|
pub mod wan;
|
||||||
|
pub mod wifi_scan;
|
||||||
|
|
||||||
pub use router::Router;
|
pub use router::Router;
|
||||||
|
|||||||
91
core/openwrt/src/wan.rs
Normal file
91
core/openwrt/src/wan.rs
Normal file
@ -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(())
|
||||||
|
}
|
||||||
97
core/openwrt/src/wifi_scan.rs
Normal file
97
core/openwrt/src/wifi_scan.rs
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,6 +30,14 @@ interface TollGateStatus {
|
|||||||
mint_url?: string
|
mint_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WanStatus {
|
||||||
|
configured: boolean
|
||||||
|
ssid: string
|
||||||
|
encryption: string
|
||||||
|
ip: string
|
||||||
|
internet: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface RouterStatus {
|
interface RouterStatus {
|
||||||
host: string
|
host: string
|
||||||
hostname: string
|
hostname: string
|
||||||
@ -37,6 +45,15 @@ interface RouterStatus {
|
|||||||
release: ReleaseInfo
|
release: ReleaseInfo
|
||||||
tollgate: TollGateStatus
|
tollgate: TollGateStatus
|
||||||
wifi_interfaces: WifiInterface[]
|
wifi_interfaces: WifiInterface[]
|
||||||
|
wan: WanStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScannedNetwork {
|
||||||
|
ssid: string
|
||||||
|
bssid: string
|
||||||
|
signal: number
|
||||||
|
channel: number
|
||||||
|
encryption: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = ref<RouterStatus | null>(null)
|
const status = ref<RouterStatus | null>(null)
|
||||||
@ -47,14 +64,20 @@ const sshUser = ref('root')
|
|||||||
const sshPassword = ref('')
|
const sshPassword = ref('')
|
||||||
const showConnectForm = ref(false)
|
const showConnectForm = ref(false)
|
||||||
const connecting = ref(false)
|
const connecting = ref(false)
|
||||||
|
|
||||||
// Credentials used for the last successful connection (reused for provisioning)
|
|
||||||
const connectedParams = ref<Record<string, string> | null>(null)
|
const connectedParams = ref<Record<string, string> | null>(null)
|
||||||
|
|
||||||
const provisioning = ref(false)
|
const provisioning = ref(false)
|
||||||
const provisionError = ref('')
|
const provisionError = ref('')
|
||||||
const provisionSuccess = ref(false)
|
const provisionSuccess = ref(false)
|
||||||
|
|
||||||
|
// WAN setup flow
|
||||||
|
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'connecting' | 'done'
|
||||||
|
const wanStep = ref<WanStep>('idle')
|
||||||
|
const scannedNetworks = ref<ScannedNetwork[]>([])
|
||||||
|
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
||||||
|
const wanPassword = ref('')
|
||||||
|
const wanError = ref('')
|
||||||
|
|
||||||
async function load(params?: Record<string, string>) {
|
async function load(params?: Record<string, string>) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@ -95,13 +118,11 @@ async function provisionTollgate() {
|
|||||||
provisionSuccess.value = false
|
provisionSuccess.value = false
|
||||||
try {
|
try {
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
// 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,
|
host: connectedParams.value?.host ?? status.value?.host,
|
||||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.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
|
provisionSuccess.value = true
|
||||||
await load(connectedParams.value ?? undefined)
|
await load(connectedParams.value ?? undefined)
|
||||||
} catch (e) {
|
} 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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 {
|
function formatUptime(secs: number): string {
|
||||||
const d = Math.floor(secs / 86400)
|
const d = Math.floor(secs / 86400)
|
||||||
const h = Math.floor((secs % 86400) / 3600)
|
const h = Math.floor((secs % 86400) / 3600)
|
||||||
@ -150,7 +241,7 @@ onMounted(() => load())
|
|||||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connect form (shown when no router is configured) -->
|
<!-- Connect form -->
|
||||||
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
|
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
|
||||||
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
|
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@ -245,14 +336,137 @@ onMounted(() => load())
|
|||||||
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
|
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
<button class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors" @click="load()">
|
||||||
class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors"
|
|
||||||
@click="load()"
|
|
||||||
>
|
|
||||||
↻ Refresh
|
↻ Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- WAN / Uplink -->
|
||||||
|
<div class="glass-card p-6 mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-white/80">WAN / Uplink</h2>
|
||||||
|
<button
|
||||||
|
v-if="wanStep === 'idle' || wanStep === 'done'"
|
||||||
|
class="text-xs text-white/40 hover:text-white transition-colors"
|
||||||
|
@click="startWanSetup"
|
||||||
|
>
|
||||||
|
{{ status.wan?.configured ? 'Reconfigure →' : 'Set up →' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current status (idle) -->
|
||||||
|
<template v-if="wanStep === 'idle' || wanStep === 'done'">
|
||||||
|
<div v-if="status.wan?.configured" class="flex items-center gap-3">
|
||||||
|
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0"
|
||||||
|
:class="status.wan.internet ? 'bg-green-400' : 'bg-yellow-400 animate-pulse'"></span>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-white">{{ status.wan.ssid }}</div>
|
||||||
|
<div class="text-xs text-white/40">
|
||||||
|
{{ status.wan.internet ? status.wan.ip : 'Connecting…' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-white/50">
|
||||||
|
Not configured — router has no internet access.
|
||||||
|
</div>
|
||||||
|
<p v-if="wanStep === 'done'" class="mt-3 text-xs text-green-400">
|
||||||
|
WAN configured. Router is connecting…
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: scan prompt -->
|
||||||
|
<template v-else-if="wanStep === 'scan'">
|
||||||
|
<button
|
||||||
|
class="glass-button glass-button-warning w-full text-sm font-medium"
|
||||||
|
@click="scanWifi"
|
||||||
|
>
|
||||||
|
Scan for Networks
|
||||||
|
</button>
|
||||||
|
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||||
|
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'idle'">Cancel</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: scanning -->
|
||||||
|
<template v-else-if="wanStep === 'scanning'">
|
||||||
|
<div class="flex items-center gap-3 py-2">
|
||||||
|
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-white/50">Scanning for networks…</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: network list -->
|
||||||
|
<template v-else-if="wanStep === 'list'">
|
||||||
|
<p class="text-xs text-white/40 mb-3">{{ scannedNetworks.length }} network{{ scannedNetworks.length !== 1 ? 's' : '' }} found. Select one:</p>
|
||||||
|
<div v-if="scannedNetworks.length === 0" class="text-sm text-white/50">No networks found. Try scanning again.</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<button
|
||||||
|
v-for="net in scannedNetworks"
|
||||||
|
:key="net.bssid"
|
||||||
|
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg border border-white/10 hover:border-white/30 hover:bg-white/5 transition-colors text-left"
|
||||||
|
@click="selectNetwork(net)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<!-- Signal bars -->
|
||||||
|
<div class="flex items-end gap-0.5 h-4">
|
||||||
|
<div v-for="b in 4" :key="b"
|
||||||
|
class="w-1 rounded-sm"
|
||||||
|
:class="[
|
||||||
|
b * 1 <= signalBars(net.signal) ? 'bg-white/70' : 'bg-white/15',
|
||||||
|
b === 1 ? 'h-1' : b === 2 ? 'h-2' : b === 3 ? 'h-3' : 'h-4'
|
||||||
|
]"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-white text-sm">{{ net.ssid || '(hidden)' }}</span>
|
||||||
|
<span v-if="net.encryption !== 'none'" class="text-white/30 text-xs">🔒</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-white/30 font-mono flex-shrink-0">ch{{ net.channel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'scan'">← Scan again</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: password entry -->
|
||||||
|
<template v-else-if="wanStep === 'password'">
|
||||||
|
<p class="text-sm text-white mb-3">
|
||||||
|
Connect to <span class="font-semibold">{{ selectedNetwork?.ssid }}</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
v-if="selectedNetwork?.encryption !== 'none'"
|
||||||
|
v-model="wanPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="WiFi password"
|
||||||
|
class="w-full px-4 py-3 mb-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||||
|
@keyup.enter="configureWan"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||||
|
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
||||||
|
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||||
|
@click="configureWan"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step: applying -->
|
||||||
|
<template v-else-if="wanStep === 'connecting'">
|
||||||
|
<div class="flex items-center gap-3 py-2">
|
||||||
|
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-white/50">Applying WAN config…</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TollGate -->
|
<!-- TollGate -->
|
||||||
<div class="glass-card p-6 mb-4">
|
<div class="glass-card p-6 mb-4">
|
||||||
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
||||||
@ -262,6 +476,9 @@ onMounted(() => load())
|
|||||||
<span class="w-2 h-2 rounded-full bg-white/20 inline-block"></span>
|
<span class="w-2 h-2 rounded-full bg-white/20 inline-block"></span>
|
||||||
<span class="text-sm text-white/50">Not installed</span>
|
<span class="text-sm text-white/50">Not installed</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!status.wan?.internet" class="mb-3 text-xs text-yellow-400/80 bg-yellow-400/10 rounded-lg px-3 py-2">
|
||||||
|
Router needs internet access to install TollGate. Configure WAN above first.
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="provisioning"
|
:disabled="provisioning"
|
||||||
class="glass-button glass-button-success w-full text-sm font-medium"
|
class="glass-button glass-button-success w-full text-sm font-medium"
|
||||||
@ -276,10 +493,8 @@ onMounted(() => load())
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<span
|
<span class="w-2 h-2 rounded-full inline-block"
|
||||||
class="w-2 h-2 rounded-full inline-block"
|
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||||
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"
|
|
||||||
></span>
|
|
||||||
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
|
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
|
||||||
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
|
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
|
||||||
</span>
|
</span>
|
||||||
@ -322,15 +537,11 @@ onMounted(() => load())
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-0.5">
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
<span
|
<span class="w-1.5 h-1.5 rounded-full inline-block"
|
||||||
class="w-1.5 h-1.5 rounded-full inline-block"
|
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"></span>
|
||||||
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"
|
|
||||||
></span>
|
|
||||||
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
|
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
|
||||||
<span
|
<span v-if="iface.network === 'tollgate'"
|
||||||
v-if="iface.network === 'tollgate'"
|
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">TollGate</span>
|
||||||
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300"
|
|
||||||
>TollGate</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-white/40 ml-3.5">
|
<div class="text-xs text-white/40 ml-3.5">
|
||||||
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}
|
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user