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 router",
|
||||||
"No OpenWrt",
|
"No OpenWrt",
|
||||||
"No space left",
|
"No space left",
|
||||||
|
"Not enough flash",
|
||||||
|
"Not enough space",
|
||||||
"TollGate installation failed",
|
"TollGate installation failed",
|
||||||
"No pre-built TollGate",
|
"No pre-built TollGate",
|
||||||
"opkg not found",
|
"opkg not found",
|
||||||
|
|||||||
@ -92,9 +92,10 @@ impl RpcHandler {
|
|||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
// TollGate
|
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native)
|
||||||
let tollgate_installed = router
|
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)
|
.map(|(_, code)| code == 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
@ -254,11 +255,14 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
|
.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 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 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)?;
|
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||||
router.verify_openwrt()?;
|
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)?;
|
wan::configure_wisp(&router, &config)?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||||
|
|||||||
@ -3,19 +3,36 @@ use tracing::info;
|
|||||||
|
|
||||||
use crate::Router;
|
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 {
|
impl Router {
|
||||||
/// Verify opkg is available. On OpenWrt 25.x+ which ships apk instead of
|
/// Detect which package manager is available.
|
||||||
/// opkg, bootstraps opkg via `apk add opkg` before returning.
|
///
|
||||||
pub fn opkg_check(&self) -> Result<()> {
|
/// - 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")?;
|
let (_, code) = self.run("test -x /usr/bin/opkg")?;
|
||||||
if code == 0 {
|
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")?;
|
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
|
||||||
if apk_code == 0 {
|
if apk_code == 0 {
|
||||||
info!("[{}] opkg not found, bootstrapping via apk", self.host);
|
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
|
||||||
// Capture stderr so apk errors are visible in server logs.
|
// 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")?;
|
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
|
||||||
if update_code != 0 {
|
if update_code != 0 {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@ -25,9 +42,18 @@ impl Router {
|
|||||||
update_out.trim()
|
update_out.trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.run_ok("/usr/bin/apk add opkg 2>&1")?;
|
// Try to install opkg (only available on some 25.x builds).
|
||||||
return Ok(());
|
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!(
|
anyhow::bail!(
|
||||||
"opkg not found at /usr/bin/opkg — this router's firmware may not \
|
"opkg not found at /usr/bin/opkg — this router's firmware may not \
|
||||||
support package management (TollGate requires a standard OpenWrt build)"
|
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
|
/// 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.
|
/// 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);
|
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.
|
/// Install tollgate-module-basic-go on OpenWrt 25.x where opkg is not available.
|
||||||
let (out, _code) = router.run("/usr/bin/opkg install --force-depends /tmp/tollgate.ipk 2>&1")?;
|
///
|
||||||
router.run_ok("rm -f /tmp/tollgate.ipk")?;
|
/// 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") {
|
// Already installed?
|
||||||
if out.contains("Only have") && out.contains("available on filesystem") {
|
let (_, code) = router.run(&format!(
|
||||||
// Extract the sizes from the error for a user-readable message.
|
"apk list --installed 2>/dev/null | grep -q '^{}' || \
|
||||||
// Example: "Only have 8884kb available on filesystem /overlay, pkg tollgate-wrt needs 18750"
|
test -f /usr/bin/{} 2>/dev/null",
|
||||||
anyhow::bail!(
|
TOLLGATE_PACKAGE, TOLLGATE_PACKAGE
|
||||||
"No space left on router for TollGate ({}). \
|
))?;
|
||||||
This router has insufficient flash storage. \
|
if code == 0 {
|
||||||
Consider a router with ≥32 MB flash or add an extroot USB drive.",
|
info!("[{}] {} already installed", router.host, TOLLGATE_PACKAGE);
|
||||||
out.lines()
|
return Ok(());
|
||||||
.find(|l| l.contains("Only have"))
|
|
||||||
.unwrap_or("check router disk space")
|
|
||||||
.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
anyhow::bail!("TollGate installation failed: {}", out.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,19 +9,26 @@ pub use wifi::provision_ssid;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::Router;
|
use crate::{opkg::PkgManager, Router};
|
||||||
|
|
||||||
/// Full TollGate provisioning sequence:
|
/// 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)
|
/// 2. Write TollGate UCI config (pricing, mint URL)
|
||||||
/// 3. Create the pay-as-you-go WiFi SSID
|
/// 3. Create the pay-as-you-go WiFi SSID
|
||||||
/// 4. Restart affected services
|
/// 4. Restart affected services
|
||||||
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
||||||
info!("[{}] Starting TollGate provisioning", router.host);
|
info!("[{}] Starting TollGate provisioning", router.host);
|
||||||
|
|
||||||
router.opkg_check()?;
|
let pkg_mgr = router.opkg_check()?;
|
||||||
router.opkg_update()?;
|
match pkg_mgr {
|
||||||
install_tollgate(router)?;
|
PkgManager::Opkg => {
|
||||||
|
router.opkg_update()?;
|
||||||
|
install_tollgate(router)?;
|
||||||
|
}
|
||||||
|
PkgManager::ApkNative => {
|
||||||
|
install::install_tollgate_apk_native(router)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
config::apply(router, config)?;
|
config::apply(router, config)?;
|
||||||
wifi::provision_ssid(router, config)?;
|
wifi::provision_ssid(router, config)?;
|
||||||
restart_services(router)?;
|
restart_services(router)?;
|
||||||
|
|||||||
@ -5,7 +5,10 @@ use crate::Router;
|
|||||||
pub struct WispConfig {
|
pub struct WispConfig {
|
||||||
pub ssid: String,
|
pub ssid: String,
|
||||||
pub password: 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<()> {
|
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.mode", "sta")?;
|
||||||
router.uci_set("wireless.wwan.ssid", &config.ssid)?;
|
router.uci_set("wireless.wwan.ssid", &config.ssid)?;
|
||||||
router.uci_set("wireless.wwan.network", "wwan")?;
|
router.uci_set("wireless.wwan.network", "wwan")?;
|
||||||
|
router.uci_set("wireless.wwan.disabled", "0")?;
|
||||||
router.uci_set("wireless.wwan.encryption", &config.encryption)?;
|
router.uci_set("wireless.wwan.encryption", &config.encryption)?;
|
||||||
if config.encryption != "none" && !config.password.is_empty() {
|
if config.encryption != "none" && !config.password.is_empty() {
|
||||||
router.uci_set("wireless.wwan.key", &config.password)?;
|
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)
|
// Add wwan to the WAN firewall zone (walk zones by name)
|
||||||
ensure_wwan_in_wan_zone(router)?;
|
ensure_wwan_in_wan_zone(router)?;
|
||||||
|
|
||||||
// Apply wireless changes; fall back to full network restart if wifi reload fails
|
// Configure LAN DHCP pool
|
||||||
let (out, code) = router.run("wifi reload 2>&1")?;
|
router.uci_set("dhcp.lan.start", &config.dhcp_start.to_string())?;
|
||||||
if code != 0 {
|
router.uci_set("dhcp.lan.limit", &config.dhcp_limit.to_string())?;
|
||||||
info!("[{}] wifi reload failed ({}): {} — falling back to network restart", router.host, code, out.trim());
|
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")?;
|
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()
|
.trim()
|
||||||
.to_string();
|
.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!({
|
serde_json::json!({
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
"ssid": ssid,
|
"ssid": ssid,
|
||||||
@ -99,6 +140,11 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
|||||||
"sta_iface": sta_iface,
|
"sta_iface": sta_iface,
|
||||||
"sta_state": sta_state,
|
"sta_state": sta_state,
|
||||||
"wifi_log": wifi_log,
|
"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")
|
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<()> {
|
fn ensure_wwan_in_wan_zone(router: &Router) -> Result<()> {
|
||||||
// Walk zones 0-9, find the one named "wan", add wwan to its network list
|
// Walk zones 0-9, find the one named "wan", add wwan to its network list
|
||||||
let script = "for i in $(seq 0 9); do \
|
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") {
|
} else if lower.contains("wpa2") || lower.contains("psk2") {
|
||||||
"psk2".to_string()
|
"psk2".to_string()
|
||||||
} else if lower.contains("wpa") {
|
} 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() {
|
} else if lower.contains("none") || lower.contains("open") || lower.is_empty() {
|
||||||
"none".to_string()
|
"none".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -41,6 +41,11 @@ interface WanStatus {
|
|||||||
sta_iface: string
|
sta_iface: string
|
||||||
sta_state: string
|
sta_state: string
|
||||||
wifi_log: string
|
wifi_log: string
|
||||||
|
lan_ip: string
|
||||||
|
lan_netmask: string
|
||||||
|
dhcp_start: string
|
||||||
|
dhcp_limit: string
|
||||||
|
masq: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouterStatus {
|
interface RouterStatus {
|
||||||
@ -76,13 +81,18 @@ const provisionError = ref('')
|
|||||||
const provisionSuccess = ref(false)
|
const provisionSuccess = ref(false)
|
||||||
|
|
||||||
// WAN setup flow
|
// 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 wanStep = ref<WanStep>('idle')
|
||||||
const scannedNetworks = ref<ScannedNetwork[]>([])
|
const scannedNetworks = ref<ScannedNetwork[]>([])
|
||||||
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
||||||
const wanPassword = ref('')
|
const wanPassword = ref('')
|
||||||
const wanError = 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>) {
|
async function load(params?: Record<string, string>) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@ -143,6 +153,9 @@ function startWanSetup() {
|
|||||||
scannedNetworks.value = []
|
scannedNetworks.value = []
|
||||||
selectedNetwork.value = null
|
selectedNetwork.value = null
|
||||||
wanPassword.value = ''
|
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() {
|
async function scanWifi() {
|
||||||
@ -186,6 +199,9 @@ async function configureWan() {
|
|||||||
ssid: selectedNetwork.value.ssid,
|
ssid: selectedNetwork.value.ssid,
|
||||||
password: wanPassword.value,
|
password: wanPassword.value,
|
||||||
encryption: selectedNetwork.value.encryption,
|
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 })
|
await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 })
|
||||||
wanStep.value = 'done'
|
wanStep.value = 'done'
|
||||||
@ -196,7 +212,7 @@ async function configureWan() {
|
|||||||
}, 8000)
|
}, 8000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
wanError.value = e instanceof Error ? e.message : String(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.
|
Not configured — router has no internet access.
|
||||||
</div>
|
</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) -->
|
<!-- Diagnostics (shown when not connected) -->
|
||||||
<dl v-if="status.wan?.configured && !status.wan.internet"
|
<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">
|
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"
|
type="password"
|
||||||
placeholder="WiFi 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"
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||||
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
||||||
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||||
@click="configureWan"
|
@click="wanStep = 'dhcp'"
|
||||||
>
|
>
|
||||||
Connect
|
Next →
|
||||||
</button>
|
</button>
|
||||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||||
</template>
|
</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 -->
|
<!-- Step: applying -->
|
||||||
<template v-else-if="wanStep === 'connecting'">
|
<template v-else-if="wanStep === 'connecting'">
|
||||||
<div class="flex items-center gap-3 py-2">
|
<div class="flex items-center gap-3 py-2">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user