diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index 32ac1ea6..19a4e49d 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -150,6 +150,7 @@ impl RpcHandler { "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(), + "payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(), }) } else { serde_json::json!({ "installed": false }) @@ -199,10 +200,15 @@ impl RpcHandler { /// /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, - /// "mint_url": "" }` + /// "mint_url": "", + /// "payout_address": "" }` /// /// `mint_url` defaults to `http://:3338` — the local Cashu /// mint that must be running as an Archy app before calling this endpoint. + /// + /// `payout_address` sets the "owner" identity's Lightning address for + /// TollGate's own built-in payout (see `config::apply_payout_identity`). + /// Omitted or blank leaves whatever's already on the router untouched. pub(super) async fn handle_openwrt_provision_tollgate( &self, params: Option, @@ -240,12 +246,31 @@ impl RpcHandler { .unwrap_or_default(); let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT); + // Trim trailing slash(es): tollgate-wrt matches a token's embedded + // mint URL against this value with an exact string compare, and + // Cashu wallets (Minibits included) encode mint URLs without a + // trailing slash. A stray slash here means every otherwise-valid + // token gets rejected as "untrusted mint" — confirmed live against + // archy-x250-pa3 2026-09-07 with a manually-entered + // "https://mint.minibits.cash/Bitcoin/". let mint_url = p .get("mint_url") .and_then(|v| v.as_str()) .unwrap_or(&default_mint_url) + .trim_end_matches('/') .to_string(); + // `None` (not sent, or sent blank) leaves whatever's already on the + // router untouched — see apply_payout_identity's doc comment for why + // that matters (an upstream-default placeholder otherwise survives + // forever, since nothing else ever writes this field). + let payout_address = p + .get("payout_address") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let config = TollGateConfig { ssid: "archipelago".to_string(), mint_url, @@ -256,6 +281,7 @@ impl RpcHandler { .unwrap_or(60_000), min_steps: p.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), + payout_address, }; // Blocking SSH session, and provision runs `opkg install` over it — diff --git a/core/archipelago/src/tollgate_sweep.rs b/core/archipelago/src/tollgate_sweep.rs index b86a7be9..4fbf561f 100644 --- a/core/archipelago/src/tollgate_sweep.rs +++ b/core/archipelago/src/tollgate_sweep.rs @@ -22,6 +22,46 @@ use crate::wallet::ecash; /// /// Returns the total sats swept in (0 if there was nothing to do, including /// when no router is configured or it doesn't have TollGate installed). +/// +/// # KNOWN BROKEN as of 2026-09-07 — do not "fix" by adding `--json` without +/// reading the rest of this comment first. +/// +/// Confirmed live against archy-x250-pa3, two stacked bugs in the upstream +/// `tollgate` CLI, not in this function: +/// +/// 1. **This call never actually drains anything.** `tollgate wallet drain +/// cashu` (no flags — what this function runs) prints an interactive +/// `Are you sure? (y/N)` confirmation and reads stdin for the answer. +/// `Router::run` executes over SSH with no PTY and empty stdin, so it +/// always reads EOF, defaults to "N", and prints "Operation cancelled." — +/// **with exit code 0**. The `drain_code != 0` check below can never catch +/// this, so every single tick silently falls through to "no `Token:` +/// lines found" → `Ok(0)`. No error, no log line (even at `warn!`), just +/// quiet total inaction, forever. This has presumably never swept a +/// single sat on any node. +/// +/// 2. **The obvious fix is worse.** `tollgate --json wallet drain cashu` +/// *does* skip the confirmation prompt — but confirmed live: when the +/// wallet's internal per-mint registry holds more than one entry for what +/// is really the same mint (here: `https://mint.minibits.cash/Bitcoin` vs. +/// a stale `.../Bitcoin/` — leftover from before the trailing-slash +/// `mint_url` fix elsewhere in this codebase; `wallet.db` still had a +/// proof/registry entry keyed under the old slashed URL even after +/// `config.json` was corrected), the CLI appears to complete a real swap +/// against the *good* entry — spending and irreversibly consuming the +/// original proofs, per how Cashu swaps work — then hits the second, +/// empty, stale-keyed entry, reports the whole command as +/// `"success": false`, and **never prints or persists the resulting +/// token anywhere** (checked every location its own "will be saved to a +/// file" warning implies: `/etc/tollgate/ecash/`, `/root`, `/tmp`, +/// nothing). Balance went from 50 sats to 0 across that one call. The +/// funds are gone — there is no undo once a swap is submitted to the +/// mint. +/// +/// Do not wire `--json` into this function until upstream fixes partial +/// per-mint failure handling in `drain cashu` to preserve/return whatever it +/// already successfully drained. Until then, the current silent-no-op +/// behavior, while useless, is at least safe. pub async fn sweep_once(data_dir: &Path) -> Result { let cfg = net_router::load_router_config(data_dir).await?; if !cfg.configured { diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index d6761d54..050771ef 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -23,6 +23,14 @@ pub struct TollGateConfig { pub min_steps: u32, /// Whether the TollGate service should be running and enabled at boot. pub enabled: bool, + /// Operator's own Lightning address for the daemon's built-in payout + /// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share` + /// weight 0.79 in the upstream default). `None` leaves whatever is + /// already on the router untouched — which, on a router whose TollGate + /// wasn't provisioned through this project, is an unmodified upstream + /// placeholder nobody actually controls (confirmed live against + /// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`). + pub payout_address: Option, } impl Default for TollGateConfig { @@ -34,6 +42,7 @@ impl Default for TollGateConfig { step_size_ms: 60_000, min_steps: 1, enabled: true, + payout_address: None, } } } @@ -46,19 +55,27 @@ impl Default for TollGateConfig { /// tollgate.main.enabled` etc.); changing pricing or the mint here has no /// effect on what the daemon advertises or accepts. pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { - router.uci_apply( - "tollgate", - &[ - ("tollgate.main", "tollgate"), - ("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()), - ("tollgate.main.price_per_step", &cfg.price_sats.to_string()), - ("tollgate.main.currency", "sat"), - ("tollgate.main.mint_url", &cfg.mint_url), - ], - )?; + let step_size = cfg.step_size_ms.to_string(); + let min_steps = cfg.min_steps.to_string(); + let price_sats = cfg.price_sats.to_string(); + + let mut pairs = vec![ + ("tollgate.main", "tollgate"), + ("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }), + ("tollgate.main.metric", "milliseconds"), + ("tollgate.main.step_size", step_size.as_str()), + ("tollgate.main.min_steps", min_steps.as_str()), + ("tollgate.main.price_per_step", price_sats.as_str()), + ("tollgate.main.currency", "sat"), + ("tollgate.main.mint_url", &cfg.mint_url), + ]; + // Status-display only (see doc comment above) — only written when the + // caller actually supplied one, so a reconfigure that doesn't touch + // payout leaves whatever's already there alone. + if let Some(addr) = &cfg.payout_address { + pairs.push(("tollgate.main.payout_address", addr)); + } + router.uci_apply("tollgate", &pairs)?; Ok(()) } @@ -97,3 +114,61 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()> .context("upload /etc/tollgate/config.json")?; Ok(()) } + +/// Set the operator's own payout Lightning address in +/// `/etc/tollgate/identities.json` — the "owner" entry under +/// `public_identities` (`profit_share` weight 0.79 in the upstream default; +/// the other entries there are revenue-share addresses for the upstream +/// project's own maintainers and must never be touched by this function). +/// +/// No-op when `payout_address` is `None` — the UI only sends one when the +/// operator has actually filled the field in, so a reconfigure of price/mint +/// alone never overwrites this. Merges into whatever identities.json already +/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds +/// the merchant's own private key and must survive untouched); creates an +/// "owner" entry if none exists yet rather than erroring, since a router +/// whose TollGate wasn't provisioned through this project may have any +/// upstream-default shape here. +/// +/// Must run before the daemon restart in `restart_services` — like +/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup. +pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> { + let Some(address) = payout_address else { + return Ok(()); + }; + + let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?; + let mut doc: serde_json::Value = + serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({})); + + let identities = doc + .as_object_mut() + .context("identities.json root is not a JSON object")? + .entry("public_identities") + .or_insert_with(|| serde_json::json!([])); + let identities = identities + .as_array_mut() + .context("identities.json public_identities is not an array")?; + + match identities + .iter_mut() + .find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner")) + { + Some(owner) => { + owner["lightning_address"] = serde_json::json!(address); + } + None => { + identities.push(serde_json::json!({ + "name": "owner", + "pubkey": "not currently used", + "lightning_address": address, + })); + } + } + + let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?; + router + .upload_file("/etc/tollgate/identities.json", json_str.as_bytes()) + .context("upload /etc/tollgate/identities.json")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index b87073f7..ecd28f23 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { config::apply(router, config)?; wifi::provision_ssid(router, config)?; + // Must come after provision_ssid (creates the `tollgate` network this + // folds the upstream installer's own default AP onto) — see + // regate_upstream_default_aps for why this is needed at all. + wifi::regate_upstream_default_aps(router) + .context("re-gate upstream tollgate-module-basic-go default AP(s)")?; // Must come after provision_ssid (which creates br-tollgate) and before // the daemon restart below — config.json is only read at startup. config::apply_daemon_config(router, config) .context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?; + config::apply_payout_identity(router, config.payout_address.as_deref()) + .context("write /etc/tollgate/identities.json owner payout address")?; // Also must come after provision_ssid: points gatewayinterface at // br-tollgate, which provision_ssid is what creates. nodogsplash::configure(router, config) diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs index 2bdf786e..bd88e338 100644 --- a/core/openwrt/src/tollgate/wifi.rs +++ b/core/openwrt/src/tollgate/wifi.rs @@ -118,6 +118,49 @@ fn provision_firewall(router: &Router) -> Result<()> { Ok(()) } +/// Fold the upstream `tollgate-module-basic-go` installer's own default +/// AP(s) onto the gated `tollgate` network. +/// +/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot +/// scripts itself (no real package manager to trigger them on OpenWrt 25.x — +/// see its doc comment). Those upstream scripts rebrand OpenWrt's +/// factory-default wifi sections (`wireless.default_radioN`, present on +/// every fresh install) to a `TollGate-` SSID, but only ever touch +/// the SSID — they leave `network` at its original `lan` binding. Nothing +/// else in this project's own provisioning (`provision_ssid` above) ever +/// looks at those sections; it only manages the separate `wireless.tollgate` +/// SSID it creates itself. Left alone, the result is two open SSIDs +/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's +/// (wide open on `lan`, with a direct route to whatever's plugged into the +/// wired LAN port). +/// +/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining +/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and +/// zero NoDogSplash involvement — free, unmetered internet, no captive +/// portal, on the router's own admin network. +/// +/// Must run after `provision_network` (needs the `tollgate` network/bridge +/// to already exist) and before the network/wifi restart in +/// `restart_services` picks the new binding up. +pub fn regate_upstream_default_aps(router: &Router) -> Result<()> { + let sections = router.run_ok( + "uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u", + )?; + for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) { + let network_key = format!("{}.network", section); + let current = router.uci_get(&network_key).unwrap_or_default(); + if current == "lan" { + info!( + "[{}] Re-gating upstream default AP {} (was network=lan) onto the tollgate network", + router.host, section + ); + router.uci_set(&network_key, "tollgate")?; + } + } + router.uci_commit(Some("wireless"))?; + Ok(()) +} + /// Return the first available wireless radio device name (e.g. "radio0"). fn detect_radio(router: &Router) -> Result { let out = diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index 1905b02e..da6261b4 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -36,6 +36,7 @@ interface TollGateStatus { min_steps?: number currency?: string mint_url?: string + payout_address?: string } interface WanStatus { @@ -149,6 +150,7 @@ const editPriceSats = ref(10) const editStepSizeMin = ref(1) const editMinSteps = ref(1) const editMintUrl = ref('') +const editPayoutAddress = ref('') const editEnabled = ref(true) // WAN setup flow @@ -306,6 +308,7 @@ function startEditTollgate() { editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000)) editMinSteps.value = tg?.min_steps ?? 1 editMintUrl.value = tg?.mint_url ?? '' + editPayoutAddress.value = tg?.payout_address ?? '' editEnabled.value = tg?.enabled ?? true updateTollgateError.value = '' editingTollgate.value = true @@ -321,6 +324,7 @@ async function saveTollgateConfig() { step_size_ms: editStepSizeMin.value * 60_000, min_steps: editMinSteps.value, mint_url: editMintUrl.value, + payout_address: editPayoutAddress.value, enabled: editEnabled.value, } await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) @@ -935,6 +939,21 @@ onMounted(() => { 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" /> +
+ + +

+ Where TollGate's built-in auto-payout sends your share once the on-router balance + crosses its threshold. Leave blank to keep whatever's already set on the router — + on a router not originally provisioned here, that may be an upstream default you + don't control. +

+