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:
ssmithx 2026-06-29 15:11:48 +00:00
parent dd3a3dfbac
commit 9a782fb551
7 changed files with 501 additions and 22 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -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<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.

View File

@ -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;

91
core/openwrt/src/wan.rs Normal file
View 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(())
}

View 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
}
}

View File

@ -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<RouterStatus | null>(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<Record<string, string> | 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<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>) {
loading.value = true
error.value = ''
@ -95,13 +118,11 @@ async function provisionTollgate() {
provisionSuccess.value = false
try {
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,
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<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 {
const d = Math.floor(secs / 86400)
const h = Math.floor((secs % 86400) / 3600)
@ -150,7 +241,7 @@ onMounted(() => load())
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
</div>
<!-- Connect form (shown when no router is configured) -->
<!-- Connect form -->
<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>
<div class="space-y-3">
@ -245,14 +336,137 @@ onMounted(() => load())
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
</div>
</dl>
<button
class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors"
@click="load()"
>
<button class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors" @click="load()">
Refresh
</button>
</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 -->
<div class="glass-card p-6 mb-4">
<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="text-sm text-white/50">Not installed</span>
</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
:disabled="provisioning"
class="glass-button glass-button-success w-full text-sm font-medium"
@ -276,10 +493,8 @@ onMounted(() => load())
<template v-else>
<div class="flex items-center gap-2 mb-4">
<span
class="w-2 h-2 rounded-full inline-block"
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"
></span>
<span class="w-2 h-2 rounded-full inline-block"
: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'">
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
</span>
@ -322,15 +537,11 @@ onMounted(() => load())
>
<div>
<div class="flex items-center gap-2 mb-0.5">
<span
class="w-1.5 h-1.5 rounded-full inline-block"
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"
></span>
<span class="w-1.5 h-1.5 rounded-full inline-block"
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"></span>
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
<span
v-if="iface.network === 'tollgate'"
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300"
>TollGate</span>
<span v-if="iface.network === 'tollgate'"
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">TollGate</span>
</div>
<div class="text-xs text-white/40 ml-3.5">
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}