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:
parent
a862877189
commit
edbad30501
@ -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",
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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 5–8 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(())
|
||||
}
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user