fix(openwrt): fix TollGate provisioning pipeline, add reconfigure UI

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.
This commit is contained in:
ssmithx 2026-07-01 11:59:43 +00:00
parent 1866c40edf
commit d6c1feca97
7 changed files with 249 additions and 64 deletions

View File

@ -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::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().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)?;

View File

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

View File

@ -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(())
}

View File

@ -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(())
}

View File

@ -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" }),
],
)?;

View File

@ -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/<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)?;
}

View File

@ -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<WanStep>('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<string, unknown> = {
host: connectedParams.value?.host ?? status.value?.host,
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
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())
<p v-if="provisionSuccess && !provisioning" class="mt-3 text-xs text-green-400">TollGate provisioned successfully.</p>
</div>
<template v-else>
<div class="flex items-center gap-2 mb-4">
<span class="w-2 h-2 rounded-full inline-block"
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
</span>
<template v-else-if="!editingTollgate">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full inline-block"
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
</span>
</div>
<button class="text-xs text-white/40 hover:text-white" @click="startEditTollgate">Edit</button>
</div>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
@ -649,6 +698,76 @@ onMounted(() => load())
</div>
</dl>
</template>
<!-- Reconfigure form -->
<template v-else>
<div class="flex items-center justify-between mb-4 py-3 border-b border-white/10">
<div>
<div class="text-sm text-white">Enable TollGate</div>
<div class="text-xs text-white/40">Stops the service and the SSID broadcast when off</div>
</div>
<button
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
:class="editEnabled ? 'bg-green-500/60' : 'bg-white/15'"
@click="editEnabled = !editEnabled"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
:class="editEnabled ? 'translate-x-5' : 'translate-x-0.5'"
></span>
</button>
</div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Price per step (sats)</label>
<input
v-model.number="editPriceSats"
type="number"
min="1"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Step size (minutes)</label>
<input
v-model.number="editStepSizeMin"
type="number"
min="1"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Minimum steps per purchase</label>
<input
v-model.number="editMinSteps"
type="number"
min="1"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Mint URL</label>
<input
v-model="editMintUrl"
type="text"
placeholder="http://..."
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs"
/>
</div>
<div class="flex items-center gap-2">
<button
:disabled="updatingTollgate"
class="glass-button glass-button-success flex-1 text-sm font-medium"
:class="updatingTollgate ? 'opacity-40 cursor-not-allowed' : ''"
@click="saveTollgateConfig"
>
{{ updatingTollgate ? 'Saving…' : 'Save' }}
</button>
<button class="text-xs text-white/40 hover:text-white px-3 py-2" :disabled="updatingTollgate" @click="editingTollgate = false">
Cancel
</button>
</div>
<p v-if="updateTollgateError" class="mt-3 text-xs text-red-400">{{ updateTollgateError }}</p>
</template>
</div>
<!-- WiFi interfaces -->