From d6c1feca9726c7f963aa3e56a3e18750939c9aac Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 11:59:43 +0000 Subject: [PATCH] fix(openwrt): fix TollGate provisioning pipeline, add reconfigure UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several compounding bugs were blocking end-to-end TollGate provisioning on OpenWrt 25.x (apk-native) routers: - install_ipk's non-ar fallback assumed a flat tarball, but some .ipks are a gzip tar of the three classic ipk members one level deep; it was dumping debian-binary/data.tar.gz/control.tar.gz straight into / instead of unpacking the real payload. - Manually-extracted packages never ran their pending /etc/uci-defaults/* scripts (that only happens through opkg/apk's own postinst bookkeeping), so nothing ever created /etc/config/tollgate. - uci_apply() never ensured the target config file existed first — `uci set` fails outright on a config namespace nothing has created yet, which is true for a package-defined one like "tollgate" (unlike wireless/ network/dhcp, which ship by default). - The installed-check and restart_services looked for a binary/init script named after the opkg package ("tollgate-module-basic-go"/"tollgate"), but the real on-disk names are tollgate-wrt — so status always reported "not installed" and service restarts silently no-op'd. - provision_ssid used `uci add`, creating a new wifi-iface section (and therefore a new duplicate broadcast SSID) on every provision call instead of updating one in place. Also adds a TollGateConfig.enabled field so the enable/disable state is actually applied to the running service and the SSID's own broadcast (stop + disable at boot, or start + enable), not just written to UCI. On the frontend, the OpenWrt Gateway page's TollGate panel was read-only once installed — add an edit form (price, step size, min steps, mint URL, enabled toggle) that reuses the same idempotent provision-tollgate call. --- core/archipelago/src/api/rpc/openwrt.rs | 9 +- core/openwrt/src/tollgate/config.rs | 5 +- core/openwrt/src/tollgate/install.rs | 110 +++++++++------ core/openwrt/src/tollgate/mod.rs | 23 +++- core/openwrt/src/tollgate/wifi.rs | 27 ++-- core/openwrt/src/uci.rs | 6 + neode-ui/src/views/server/OpenWrtGateway.vue | 133 ++++++++++++++++++- 7 files changed, 249 insertions(+), 64 deletions(-) diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index e064b984..d96f1747 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -92,10 +92,13 @@ impl RpcHandler { .and_then(|s| s.parse().ok()) .unwrap_or(0); - // TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native) + // TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native). + // The service binary is /usr/bin/tollgate-wrt (per its init.d script), + // not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk + // *package* name, never an on-disk filename. let tollgate_installed = router .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") + test -f /usr/bin/tollgate-wrt 2>/dev/null") .map(|(_, code)| code == 0) .unwrap_or(false); @@ -106,6 +109,7 @@ impl RpcHandler { "metric": router.uci_get("tollgate.main.metric").unwrap_or_default(), "step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), "price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::().ok()).unwrap_or(0), + "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::().ok()).unwrap_or(1), "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), }) @@ -183,6 +187,7 @@ impl RpcHandler { .get("min_steps") .and_then(|v| v.as_u64()) .unwrap_or(1) as u32, + enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), }; let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index dc422194..005e2cae 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -21,6 +21,8 @@ pub struct TollGateConfig { pub step_size_ms: u64, /// Minimum steps a customer must purchase at once. pub min_steps: u32, + /// Whether the TollGate service should be running and enabled at boot. + pub enabled: bool, } impl Default for TollGateConfig { @@ -31,6 +33,7 @@ impl Default for TollGateConfig { price_sats: 10, step_size_ms: 60_000, min_steps: 1, + enabled: true, } } } @@ -43,7 +46,7 @@ pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { "tollgate", &[ ("tollgate.main", "tollgate"), - ("tollgate.main.enabled", "1"), + ("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }), ("tollgate.main.metric", "milliseconds"), ("tollgate.main.step_size", &cfg.step_size_ms.to_string()), ("tollgate.main.min_steps", &cfg.min_steps.to_string()), diff --git a/core/openwrt/src/tollgate/install.rs b/core/openwrt/src/tollgate/install.rs index 00f178f9..e2634c6e 100644 --- a/core/openwrt/src/tollgate/install.rs +++ b/core/openwrt/src/tollgate/install.rs @@ -58,11 +58,14 @@ pub fn install_tollgate(router: &Router) -> Result<()> { pub fn install_tollgate_apk_native(router: &Router) -> Result<()> { info!("[{}] Installing {} (apk-native mode)", router.host, TOLLGATE_PACKAGE); - // Already installed? + // Already installed? The service binary is /usr/bin/tollgate-wrt (per its + // init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name, + // never an on-disk filename, so it can't be used for the file-existence + // fallback below. let (_, code) = router.run(&format!( "apk list --installed 2>/dev/null | grep -q '^{}' || \ - test -f /usr/bin/{} 2>/dev/null", - TOLLGATE_PACKAGE, TOLLGATE_PACKAGE + test -f /usr/bin/tollgate-wrt 2>/dev/null", + TOLLGATE_PACKAGE ))?; if code == 0 { info!("[{}] {} already installed", router.host, TOLLGATE_PACKAGE); @@ -162,23 +165,14 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> { "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. + if ar_code != 0 { + // Fallback: some builds produce the .ipk as a gzip tarball rather than + // a classic `ar` archive. This can still contain the same three ipk + // members (debian-binary/data.tar.gz/control.tar.gz) one level deep — + // just gzip-tarred together instead of ar'd — or, less commonly, a + // flat tarball of the real package files with no ipk structure at + // all. Extract to the scratch dir and check which shape it is before + // deciding how to install it. info!("[{}] ar failed ({}), trying tar -xzf", router.host, ar_out.trim()); // List contents first — validates format without writing anything. @@ -194,29 +188,67 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> { } 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 - ); - } + router.run_ok(&format!("tar -xzf {} -C /tmp/_tg_install 2>&1", ipk_path))?; - 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() - ); + let (_, nested) = router.run("test -f /tmp/_tg_install/data.tar.gz")?; + if nested != 0 { + // Genuinely flat tarball, no ipk structure — its contents are the + // real package files, already unpacked into the scratch dir. + 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 (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?; + if cp_code != 0 { + anyhow::bail!("TollGate installation failed: file copy failed: {}", cp_out.trim()); + } + // No package-manager postinst ran for these files either — see + // the uci-defaults note below. + router.run_ok( + "for f in /etc/uci-defaults/*; do \ + [ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \ + done; uci commit 2>/dev/null; true" + )?; + router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?; + return Ok(()); } + // Nested ipk-member layout — fall through to the shared unpack below. } + // Unpack data.tar.gz (the real payload) from either the `ar`-extracted or + // gzip-tar-extracted scratch dir, then run control.tar.gz's postinst. + 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" + )?; + // `default_postinst` (what most packages' postinst calls, including + // this one) only runs pending /etc/uci-defaults/* scripts for packages + // it finds in opkg/apk's own file-list records. Since these files were + // extracted manually rather than through a real package-manager install, + // no such record exists, so run any pending scripts directly — this is + // exactly what opkg's install path (or the next reboot) would otherwise + // do for them, just without waiting for either. + router.run_ok( + "for f in /etc/uci-defaults/*; do \ + [ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \ + done; uci commit 2>/dev/null; true" + )?; + router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?; Ok(()) } diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index da57904b..f694e97d 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -31,14 +31,31 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { } config::apply(router, config)?; wifi::provision_ssid(router, config)?; - restart_services(router)?; + restart_services(router, config.enabled)?; info!("[{}] TollGate provisioning complete", router.host); Ok(()) } -fn restart_services(router: &Router) -> Result<()> { - router.run_ok("/etc/init.d/tollgate restart || true")?; +/// Applies `enabled` to the actual running service, not just the UCI value — +/// the tollgate-wrt init script doesn't consult `tollgate.main.enabled` +/// itself, so toggling it requires an explicit enable/start or disable/stop. +/// +/// The service's init script is `/etc/init.d/tollgate-wrt` (its actual +/// on-disk name — "tollgate" alone does not exist). +fn restart_services(router: &Router, enabled: bool) -> Result<()> { + if enabled { + router.run_ok("/etc/init.d/tollgate-wrt enable")?; + router.run_ok( + "/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start" + )?; + } else { + router.run_ok("/etc/init.d/tollgate-wrt stop || true")?; + router.run_ok("/etc/init.d/tollgate-wrt disable || true")?; + } router.run_ok("/etc/init.d/network restart")?; + // Reload wireless so wireless.tollgate.disabled takes effect on the radio — + // `network restart` alone doesn't reliably reconfigure wifi interfaces. + router.run_ok("wifi down 2>&1; wifi up 2>&1")?; Ok(()) } diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs index 74b7e9a4..03baea1d 100644 --- a/core/openwrt/src/tollgate/wifi.rs +++ b/core/openwrt/src/tollgate/wifi.rs @@ -4,27 +4,30 @@ use tracing::info; use crate::tollgate::TollGateConfig; use crate::Router; -/// Create a dedicated pay-as-you-go WiFi interface for TollGate. +/// Create (or update) the dedicated pay-as-you-go WiFi interface for TollGate. /// -/// Adds a new `wifi-iface` section on the first detected radio, sets the SSID, -/// marks it as an open network, and ties it to a TollGate firewall zone. +/// Uses a fixed named section (`wireless.tollgate`) rather than `uci add`, so +/// re-provisioning (e.g. editing price/mint URL after install) updates the +/// same interface in place instead of piling up a new `wifi-iface` section — +/// and therefore a new duplicate broadcast SSID — on every call. pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> { let radio = detect_radio(router).context("detect WiFi radio")?; info!("[{}] Using radio {} for TollGate SSID", router.host, radio); - // Add a new wifi-iface section; uci add returns the section name (e.g. "cfg123456"). - let section = router.uci_add("wireless", "wifi-iface")?; - router.uci_apply( "wireless", &[ - (&format!("wireless.{}.device", section), &radio), - (&format!("wireless.{}.mode", section), "ap"), - (&format!("wireless.{}.ssid", section), &cfg.ssid), - (&format!("wireless.{}.encryption", section), "none"), - (&format!("wireless.{}.network", section), "tollgate"), + ("wireless.tollgate", "wifi-iface"), + ("wireless.tollgate.device", &radio), + ("wireless.tollgate.mode", "ap"), + ("wireless.tollgate.ssid", &cfg.ssid), + ("wireless.tollgate.encryption", "none"), + ("wireless.tollgate.network", "tollgate"), // Disable 802.11r/k/v — unnecessary for transient pay-as-you-go clients. - (&format!("wireless.{}.ieee80211r", section), "0"), + ("wireless.tollgate.ieee80211r", "0"), + // Stop broadcasting entirely when disabled, rather than leaving an + // open SSID up that leads nowhere once the backend is stopped. + ("wireless.tollgate.disabled", if cfg.enabled { "0" } else { "1" }), ], )?; diff --git a/core/openwrt/src/uci.rs b/core/openwrt/src/uci.rs index 3df49cd5..e74f3e27 100644 --- a/core/openwrt/src/uci.rs +++ b/core/openwrt/src/uci.rs @@ -45,6 +45,12 @@ impl Router { /// Batch: apply a list of `(key, value)` pairs then commit the config. pub fn uci_apply(&self, config: &str, pairs: &[(&str, &str)]) -> Result<()> { + // `uci set config.section=type` fails with "Entry not found" if + // /etc/config/ doesn't exist yet — true for any config file + // shipped by the base system (wireless, network, dhcp, ...) but not + // for a package-defined namespace like "tollgate" that nothing has + // created a default for. `touch` is a no-op if it already exists. + self.run_ok(&format!("touch /etc/config/{}", config))?; for (key, value) in pairs { self.uci_set(key, value)?; } diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index f1686f73..3c6c5067 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -26,6 +26,7 @@ interface TollGateStatus { metric?: string step_size_ms?: number price_per_step?: number + min_steps?: number currency?: string mint_url?: string } @@ -80,6 +81,16 @@ const provisioning = ref(false) const provisionError = ref('') const provisionSuccess = ref(false) +// TollGate reconfigure form (shown once installed) +const editingTollgate = ref(false) +const updatingTollgate = ref(false) +const updateTollgateError = ref('') +const editPriceSats = ref(10) +const editStepSizeMin = ref(1) +const editMinSteps = ref(1) +const editMintUrl = ref('') +const editEnabled = ref(true) + // WAN setup flow type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'dhcp' | 'connecting' | 'done' const wanStep = ref('idle') @@ -147,6 +158,41 @@ async function provisionTollgate() { } } +function startEditTollgate() { + const tg = status.value?.tollgate + editPriceSats.value = tg?.price_per_step ?? 10 + editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000)) + editMinSteps.value = tg?.min_steps ?? 1 + editMintUrl.value = tg?.mint_url ?? '' + editEnabled.value = tg?.enabled ?? true + updateTollgateError.value = '' + editingTollgate.value = true +} + +async function saveTollgateConfig() { + updatingTollgate.value = true + updateTollgateError.value = '' + try { + const params: Record = { + host: connectedParams.value?.host ?? status.value?.host, + ssh_user: connectedParams.value?.ssh_user ?? sshUser.value, + ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value, + price_sats: editPriceSats.value, + step_size_ms: editStepSizeMin.value * 60_000, + min_steps: editMinSteps.value, + mint_url: editMintUrl.value, + enabled: editEnabled.value, + } + await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) + editingTollgate.value = false + await load(connectedParams.value ?? undefined) + } catch (e) { + updateTollgateError.value = e instanceof Error ? e.message : String(e) + } finally { + updatingTollgate.value = false + } +} + function startWanSetup() { wanStep.value = 'scan' wanError.value = '' @@ -623,13 +669,16 @@ onMounted(() => load())

TollGate provisioned successfully.

-