fix(openwrt): TollGate apk-native install for OpenWrt 25.x

- WISP wizard: step-by-step flow for WiFi, DHCP, masquerade config
- WAN status: expose lan_ip, dhcp_start/limit, masq, sta_state, wifi_log
- wifi_scan: detect CCMP as WPA2 (psk2) so association succeeds
- opkg: PkgManager enum — detect apk-native mode when opkg not in repos
- tollgate: apk-native install path using manual ipk extraction
- arch detection: read DISTRIB_ARCH from /etc/openwrt_release; normalise
  bare mipsel/mips from uname -m to mipsel_24kc/mips_24kc
- install_ipk: install binutils via apk when ar not in BusyBox
- install_ipk: wget --no-check-certificate for routers without CA bundle
- install_ipk: ar fallback to tar -xzf for non-standard ipk formats
- install_ipk: 5MB overlay space check with clear user-facing error
- middleware: allow "Not enough flash/space" errors through sanitizer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ssmithx 2026-06-30 17:12:50 +00:00
parent a862877189
commit edbad30501
8 changed files with 393 additions and 48 deletions

View File

@ -67,6 +67,8 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"No router",
"No OpenWrt",
"No space left",
"Not enough flash",
"Not enough space",
"TollGate installation failed",
"No pre-built TollGate",
"opkg not found",

View File

@ -92,9 +92,10 @@ impl RpcHandler {
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native)
let tollgate_installed = router
.run("/usr/bin/opkg list-installed | grep -q '^tollgate-module-basic-go '")
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-module-basic-go 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
@ -254,11 +255,14 @@ impl RpcHandler {
.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 };
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 }))

View File

@ -3,19 +3,36 @@ 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 {
/// Verify opkg is available. On OpenWrt 25.x+ which ships apk instead of
/// opkg, bootstraps opkg via `apk add opkg` before returning.
pub fn opkg_check(&self) -> Result<()> {
/// 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<PkgManager> {
let (_, code) = self.run("test -x /usr/bin/opkg")?;
if code == 0 {
return Ok(());
return Ok(PkgManager::Opkg);
}
// OpenWrt 25.x switched to apk as default — install opkg through it.
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
if apk_code == 0 {
info!("[{}] opkg not found, bootstrapping via apk", self.host);
// Capture stderr so apk errors are visible in server logs.
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!(
@ -25,9 +42,18 @@ impl Router {
update_out.trim()
);
}
self.run_ok("/usr/bin/apk add opkg 2>&1")?;
return Ok(());
// 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)"

View File

@ -20,7 +20,7 @@ fn ipk_url(arch: &str) -> Option<&'static str> {
}
}
/// Install tollgate-module-basic-go.
/// 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.
@ -47,28 +47,176 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
})?;
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
router.run_ok(&format!("wget -O /tmp/tollgate.ipk '{}'", url))?;
router.run_ok(&format!("wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url))?;
install_ipk(router, "/tmp/tollgate.ipk")
}
// Capture stderr too — BusyBox opkg exits 0 even on "Cannot install" failures.
let (out, _code) = router.run("/usr/bin/opkg install --force-depends /tmp/tollgate.ipk 2>&1")?;
router.run_ok("rm -f /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);
if out.contains("Cannot install") || out.contains("errors encountered") {
if out.contains("Only have") && out.contains("available on filesystem") {
// Extract the sizes from the error for a user-readable message.
// Example: "Only have 8884kb available on filesystem /overlay, pkg tollgate-wrt needs 18750"
anyhow::bail!(
"No space left on router for TollGate ({}). \
This router has insufficient flash storage. \
Consider a router with 32 MB flash or add an extroot USB drive.",
out.lines()
.find(|l| l.contains("Only have"))
.unwrap_or("check router disk space")
.trim()
);
}
anyhow::bail!("TollGate installation failed: {}", out.trim());
// Already installed?
let (_, code) = router.run(&format!(
"apk list --installed 2>/dev/null | grep -q '^{}' || \
test -f /usr/bin/{} 2>/dev/null",
TOLLGATE_PACKAGE, 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 58 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 {
// ar succeeded: unpack data.tar.gz from the inner archive.
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"
)?;
} else {
// Fallback: some packages ship .ipk as a plain gzip tarball.
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());
// Check free space on the root overlay before writing.
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 (tar_out, tar_code) = router.run(&format!(
"tar -xzf {} -C / 2>&1", ipk_path
))?;
if tar_code != 0 {
anyhow::bail!(
"TollGate installation failed: tar extract failed: {}",
tar_out.trim()
);
}
}
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
Ok(())
}

View File

@ -9,19 +9,26 @@ pub use wifi::provision_ssid;
use anyhow::Result;
use tracing::info;
use crate::Router;
use crate::{opkg::PkgManager, Router};
/// Full TollGate provisioning sequence:
/// 1. Install tollgate-module-basic-go via opkg
/// 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);
router.opkg_check()?;
router.opkg_update()?;
install_tollgate(router)?;
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)?;

View File

@ -5,7 +5,10 @@ use crate::Router;
pub struct WispConfig {
pub ssid: String,
pub password: String,
pub encryption: String, // psk2 | psk | sae | none
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<()> {
@ -22,6 +25,7 @@ pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
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)?;
@ -36,10 +40,25 @@ pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
// 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 (out, code) = router.run("wifi reload 2>&1")?;
if code != 0 {
info!("[{}] wifi reload failed ({}): {} — falling back to network restart", router.host, code, out.trim());
// 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")?;
}
@ -88,6 +107,28 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
.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,
@ -99,6 +140,11 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
"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,
})
}
@ -144,6 +190,19 @@ fn detect_radio(router: &Router) -> Result<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 \

View File

@ -107,7 +107,13 @@ fn normalize_encryption(raw: &str) -> String {
} else if lower.contains("wpa2") || lower.contains("psk2") {
"psk2".to_string()
} else if lower.contains("wpa") {
"psk".to_string()
// 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 {

View File

@ -41,6 +41,11 @@ interface WanStatus {
sta_iface: string
sta_state: string
wifi_log: string
lan_ip: string
lan_netmask: string
dhcp_start: string
dhcp_limit: string
masq: boolean
}
interface RouterStatus {
@ -76,13 +81,18 @@ const provisionError = ref('')
const provisionSuccess = ref(false)
// WAN setup flow
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'connecting' | 'done'
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'dhcp' | 'connecting' | 'done'
const wanStep = ref<WanStep>('idle')
const scannedNetworks = ref<ScannedNetwork[]>([])
const selectedNetwork = ref<ScannedNetwork | null>(null)
const wanPassword = ref('')
const wanError = ref('')
// DHCP/masq settings (step 3 of wizard)
const dhcpStart = ref(100)
const dhcpLimit = ref(150)
const masqEnabled = ref(true)
async function load(params?: Record<string, string>) {
loading.value = true
error.value = ''
@ -143,6 +153,9 @@ function startWanSetup() {
scannedNetworks.value = []
selectedNetwork.value = null
wanPassword.value = ''
dhcpStart.value = Number(status.value?.wan?.dhcp_start) || 100
dhcpLimit.value = Number(status.value?.wan?.dhcp_limit) || 150
masqEnabled.value = true
}
async function scanWifi() {
@ -186,6 +199,9 @@ async function configureWan() {
ssid: selectedNetwork.value.ssid,
password: wanPassword.value,
encryption: selectedNetwork.value.encryption,
dhcp_start: dhcpStart.value,
dhcp_limit: dhcpLimit.value,
masq: masqEnabled.value,
}
await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 })
wanStep.value = 'done'
@ -196,7 +212,7 @@ async function configureWan() {
}, 8000)
} catch (e) {
wanError.value = e instanceof Error ? e.message : String(e)
wanStep.value = 'password'
wanStep.value = 'dhcp'
}
}
@ -375,6 +391,25 @@ onMounted(() => load())
Not configured router has no internet access.
</div>
<!-- Quick summary of DHCP + masq when connected -->
<dl v-if="status.wan?.configured && status.wan.internet"
class="grid grid-cols-3 gap-x-4 gap-y-1 text-xs text-white/40 mb-3">
<div>
<dt class="text-white/25">LAN</dt>
<dd class="text-white/60 font-mono">{{ status.wan.lan_ip }}/24</dd>
</div>
<div>
<dt class="text-white/25">DHCP</dt>
<dd class="text-white/60 font-mono">.{{ status.wan.dhcp_start }}+{{ status.wan.dhcp_limit }}</dd>
</div>
<div>
<dt class="text-white/25">NAT</dt>
<dd :class="status.wan.masq ? 'text-green-400' : 'text-red-400'">
{{ status.wan.masq ? 'on' : 'off' }}
</dd>
</div>
</dl>
<!-- Diagnostics (shown when not connected) -->
<dl v-if="status.wan?.configured && !status.wan.internet"
class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs mt-1 mb-3 border-t border-white/10 pt-3">
@ -478,22 +513,80 @@ onMounted(() => load())
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"
@keyup.enter="wanStep = 'dhcp'"
/>
<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"
@click="wanStep = 'dhcp'"
>
Connect
Next
</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: DHCP + masquerade config -->
<template v-else-if="wanStep === 'dhcp'">
<p class="text-xs text-white/50 mb-4">
Configure how this router hands out addresses to WiFi clients.
</p>
<!-- LAN info -->
<div class="mb-4 text-xs font-mono bg-black/20 rounded-lg px-3 py-2 text-white/50">
<span class="text-white/30">LAN: </span>{{ status?.wan?.lan_ip || '192.168.1.1' }}/24
</div>
<!-- DHCP range -->
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">DHCP range for clients</label>
<div class="flex items-center gap-2 text-sm">
<span class="text-white/40 font-mono text-xs">{{ status?.wan?.lan_ip?.split('.').slice(0,3).join('.') || '192.168.1' }}.</span>
<input
v-model.number="dhcpStart"
type="number"
min="2" max="250"
class="w-20 px-2 py-1.5 bg-transparent border border-white/20 rounded text-white text-sm text-center focus:outline-none focus:border-white/40"
/>
<span class="text-white/30"></span>
<span class="text-white/60 font-mono text-xs">{{ Math.min(254, dhcpStart + dhcpLimit - 1) }}</span>
</div>
<p class="text-xs text-white/30 mt-1">{{ dhcpLimit }} addresses</p>
</div>
<!-- Masquerade -->
<div class="flex items-center justify-between mb-4 py-3 border-t border-white/10">
<div>
<div class="text-sm text-white">Enable NAT masquerade</div>
<div class="text-xs text-white/40">Routes LAN traffic through the WiFi uplink</div>
</div>
<button
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
:class="masqEnabled ? 'bg-green-500/60' : 'bg-white/15'"
@click="masqEnabled = !masqEnabled"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
:class="masqEnabled ? 'translate-x-5' : 'translate-x-0.5'"
></span>
</button>
</div>
<p v-if="wanError" class="mb-3 text-xs text-red-400">{{ wanError }}</p>
<div class="flex items-center gap-2">
<button
class="glass-button glass-button-success flex-1 text-sm font-medium"
@click="configureWan"
>
Apply Settings
</button>
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'password'"></button>
</div>
</template>
<!-- Step: applying -->
<template v-else-if="wanStep === 'connecting'">
<div class="flex items-center gap-3 py-2">