diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 5a80230e..c33d36c1 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -72,6 +72,7 @@ mod state; mod storage_crypto; mod streaming; mod swarm; +mod tollgate_sweep; mod totp; mod transport; mod trust; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 48eb94f0..58fc5eda 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -537,6 +537,28 @@ impl Server { }); } + // Periodic TollGate ecash sweep. tollgate-wrt keeps its own separate + // Cashu wallet on the router — customer payments never land in this + // node's wallet on their own, and its Lightning auto-payout config is + // independent and easy to leave misconfigured. This drains whatever + // TollGate has collected straight into the local wallet on a timer, + // sidestepping Lightning payout configuration entirely. + { + let data_dir = config.data_dir.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + let mut interval = tokio::time::interval(Duration::from_secs(300)); + loop { + interval.tick().await; + match crate::tollgate_sweep::sweep_once(&data_dir).await { + Ok(0) => {} + Ok(swept) => info!(sats = swept, "tollgate wallet sweep complete"), + Err(e) => debug!(error = %e, "tollgate wallet sweep (non-fatal)"), + } + } + }); + } + // Initialize container scanner — discovers installed apps from Podman/Docker { let scanner = create_docker_scanner(&config).await?; diff --git a/core/archipelago/src/tollgate_sweep.rs b/core/archipelago/src/tollgate_sweep.rs new file mode 100644 index 00000000..ed4aa878 --- /dev/null +++ b/core/archipelago/src/tollgate_sweep.rs @@ -0,0 +1,81 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use archipelago_openwrt::router::Router; +use tracing::{info, warn}; + +use crate::network::router as net_router; +use crate::wallet::ecash; + +/// Pull whatever TollGate has collected in its on-router Cashu wallet into +/// this node's own wallet. +/// +/// `tollgate-wrt` keeps a completely separate Cashu wallet on the router +/// (`/etc/tollgate/wallet.db`) — customer payments land there, never in this +/// node's wallet directly. Its own Lightning auto-payout is configured +/// independently in `/etc/tollgate/identities.json`, which is easy to leave +/// pointed at the wrong (or a placeholder) address and easy to lose track of. +/// This sweep sidesteps that entirely: periodically drain the router's +/// TollGate wallet to Cashu tokens (`tollgate wallet drain cashu`, over SSH) +/// and receive them straight into the local wallet via the same path the +/// "Receive ecash" UI uses. +/// +/// 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). +pub async fn sweep_once(data_dir: &Path) -> Result { + let cfg = net_router::load_router_config(data_dir).await?; + if !cfg.configured { + return Ok(0); + } + let ssh_user = cfg.username.clone().unwrap_or_else(|| "root".to_string()); + let ssh_password = cfg.password.clone().unwrap_or_default(); + + let router = Router::connect_password(&cfg.address, 22, &ssh_user, &ssh_password) + .context("connect to router for tollgate wallet sweep")?; + + // `tollgate` CLI missing (no TollGate installed here) is a normal, + // expected case, not an error — just nothing to sweep. + let (balance_out, code) = router.run("tollgate wallet balance 2>/dev/null")?; + if code != 0 { + return Ok(0); + } + let balance_sats = balance_out + .lines() + .find_map(|l| l.trim().strip_prefix("balance_sats:")) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + if balance_sats == 0 { + return Ok(0); + } + + let (drain_out, drain_code) = router.run("tollgate wallet drain cashu 2>&1")?; + if drain_code != 0 { + anyhow::bail!("tollgate wallet drain cashu failed: {}", drain_out.trim()); + } + + // The CLI has no machine-readable output mode; pull tokens out of its + // " Token: cashuB..." lines. + let mut received_total = 0u64; + for line in drain_out.lines() { + let Some(token) = line.trim().strip_prefix("Token:") else { + continue; + }; + let token = token.trim(); + if token.is_empty() { + continue; + } + match ecash::receive_token(data_dir, token).await { + Ok(amount) => { + received_total += amount; + info!(amount_sats = amount, "swept TollGate ecash into local wallet"); + } + Err(e) => { + // The token is still in this log line if this happens — not + // silently lost, just needs manual `wallet.ecash-receive`. + warn!(error = %e, token, "failed to receive swept TollGate token"); + } + } + } + + Ok(received_total) +} diff --git a/core/openwrt/src/opkg.rs b/core/openwrt/src/opkg.rs index 84d889e0..296715ba 100644 --- a/core/openwrt/src/opkg.rs +++ b/core/openwrt/src/opkg.rs @@ -87,4 +87,31 @@ impl Router { self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; Ok(()) } + + /// Install a standard OpenWrt package via whichever manager is active. + /// + /// Unlike `tollgate-module-basic-go` itself (which falls back to manual + /// `.ipk` extraction and therefore skips dependency resolution — see + /// `tollgate::install`), packages like `nodogsplash` are in every + /// upstream OpenWrt feed, so a plain `apk add` / `opkg install` works + /// even in `ApkNative` mode. + pub fn install_package(&self, pkg_mgr: PkgManager, package: &str) -> Result<()> { + match pkg_mgr { + PkgManager::Opkg => self.opkg_install(package), + PkgManager::ApkNative => self.apk_install(package), + } + } + + /// `apk add `, skipping if already installed. + pub fn apk_install(&self, package: &str) -> Result<()> { + let (_, code) = self.run(&format!("apk info -e {} >/dev/null 2>&1", package))?; + if code == 0 { + info!("[{}] {} already installed", self.host, package); + return Ok(()); + } + + info!("[{}] apk add {}", self.host, package); + self.run_ok(&format!("/usr/bin/apk add {}", package))?; + Ok(()) + } } diff --git a/core/openwrt/src/router.rs b/core/openwrt/src/router.rs index 088ec9c5..97b1a84c 100644 --- a/core/openwrt/src/router.rs +++ b/core/openwrt/src/router.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use ssh2::Session; -use std::io::Read; +use std::io::{Read, Write}; use std::net::TcpStream; use std::path::Path; use tracing::debug; @@ -84,4 +84,22 @@ impl Router { .context("read /etc/openwrt_release — is this an OpenWrt device?")?; Ok(release) } + + /// Upload file contents to the router over SCP, overwriting any existing + /// file at `remote_path`. Used for config files that aren't UCI-backed + /// (e.g. `/etc/tollgate/config.json`), where `uci_*` helpers don't apply. + pub fn upload_file(&self, remote_path: &str, contents: &[u8]) -> Result<()> { + let mut channel = self + .session + .scp_send(Path::new(remote_path), 0o644, contents.len() as u64, None) + .with_context(|| format!("scp_send to {}", remote_path))?; + channel + .write_all(contents) + .with_context(|| format!("write contents to {}", remote_path))?; + channel.send_eof().context("scp send_eof")?; + channel.wait_eof().context("scp wait_eof")?; + channel.close().context("scp close")?; + channel.wait_close().context("scp wait_close")?; + Ok(()) + } } diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index 005e2cae..d6761d54 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::Router; @@ -40,7 +40,11 @@ impl Default for TollGateConfig { /// Write TollGate UCI configuration and commit. /// -/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go. +/// `tollgate-wrt` never reads UCI — see `apply_daemon_config` below for the +/// config it actually consumes. These `tollgate.main.*` keys exist only for +/// this project's own status display / detection probes (`uci get +/// 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", @@ -57,3 +61,39 @@ pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { )?; Ok(()) } + +/// Write the config `tollgate-wrt` actually reads: `/etc/tollgate/config.json` +/// (schema `v0.0.6`/`v0.0.7`, see `config_manager` in the upstream Go source). +/// +/// Merges into whatever config.json already exists (the daemon writes a +/// default on first boot) rather than overwriting it wholesale — fields this +/// project doesn't manage (`profit_share`, `upstream_detector`, +/// `upstream_session_manager`/`chandler`, `relays`, ...) must survive +/// re-provisioning. +/// +/// Must run before the daemon is (re)started — it only reads this file at +/// startup, it does not hot-reload. +pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()> { + let existing = router.run_ok("cat /etc/tollgate/config.json 2>/dev/null || echo '{}'")?; + let mut doc: serde_json::Value = + serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({})); + + doc["metric"] = serde_json::json!("milliseconds"); + doc["step_size"] = serde_json::json!(cfg.step_size_ms); + doc["accepted_mints"] = serde_json::json!([{ + "url": cfg.mint_url, + "min_balance": 64, + "balance_tolerance_percent": 10, + "payout_interval_seconds": 60, + "min_payout_amount": 128, + "price_per_step": cfg.price_sats, + "price_unit": "sats", + "purchase_min_steps": cfg.min_steps, + }]); + + let json_str = serde_json::to_string_pretty(&doc).context("serialize config.json")?; + router + .upload_file("/etc/tollgate/config.json", json_str.as_bytes()) + .context("upload /etc/tollgate/config.json")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index f694e97d..d92b1c55 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -1,21 +1,27 @@ pub mod config; pub mod install; +pub mod nodogsplash; pub mod wifi; pub use config::TollGateConfig; pub use install::install_tollgate; pub use wifi::provision_ssid; -use anyhow::Result; +use anyhow::{Context, Result}; use tracing::info; use crate::{opkg::PkgManager, Router}; /// Full TollGate provisioning sequence: /// 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 +/// 2. Install NoDogSplash and immediately stop it (its postinst auto-starts +/// it against `br-lan` by default — see `nodogsplash::install_and_stop`) +/// 3. Write TollGate config: UCI (status/detection only) + the JSON file the +/// daemon actually reads +/// 4. Create the pay-as-you-go WiFi SSID and its dedicated bridge/network +/// 5. Configure NoDogSplash to gate that bridge (now that it exists) — +/// client gating; tollgate-wrt has no enforcement code of its own +/// 6. Restart affected services pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { info!("[{}] Starting TollGate provisioning", router.host); @@ -29,9 +35,41 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { install::install_tollgate_apk_native(router)?; } } + + // NoDogSplash is a hard runtime dependency of tollgate-wrt (upstream's + // package declares `+nodogsplash`), but neither install path above pulls + // it in: the opkg fast path only resolves deps against a real feed, and + // the raw .ipk-extraction fallback (used whenever the package isn't in a + // feed, and always on ApkNative) skips dependency resolution entirely. + // Without it, tollgate-wrt runs and accepts payments but never actually + // blocks unpaid clients. Install + stop happens before anything else so + // its auto-started default config (gating br-lan) is live for as little + // time as possible. + nodogsplash::install_and_stop(router, pkg_mgr) + .context("install nodogsplash — tollgate-wrt cannot gate clients without it")?; + + // Wire NoDogSplash's webroot to TollGate's actual payment portal instead + // of the generic stock splash page it ships with. Confirmed live: without + // this, "click continue" on the stock page authorizes the client via + // NoDogSplash's own built-in handler with zero payment involved. + nodogsplash::install_captive_portal_symlink(router).context( + "wire up TollGate's captive portal — without it NoDogSplash serves its own \ + generic splash page, which authorizes clients on click with no payment", + )?; + config::apply(router, config)?; wifi::provision_ssid(router, config)?; + // 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")?; + // Also must come after provision_ssid: points gatewayinterface at + // br-tollgate, which provision_ssid is what creates. + nodogsplash::configure(router, config) + .context("configure nodogsplash — tollgate-wrt cannot gate clients without it")?; + restart_services(router, config.enabled)?; + nodogsplash::restart(router)?; info!("[{}] TollGate provisioning complete", router.host); Ok(()) @@ -57,5 +95,27 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> { // 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")?; + // Observed live, twice, in two different ways: netifd can lose the race + // to claim br-tollgate as the wifi vif attaches to it during the restart + // above. The first time it showed up as netifd reporting + // "up: false, DEVICE_CLAIM_FAILED"; the second time netifd reported the + // interface up with its address assigned while the kernel-level device + // genuinely had none (`ip -4 addr show br-tollgate` empty) — dnsmasq + // logged "DHCP packet received on br-tollgate which has no address" and + // silently dropped every DISCOVER. A single blind ifdown/ifup isn't + // trustworthy here — verify the address actually landed at the kernel + // level (not just what netifd claims) and retry the cycle if not, since + // NoDogSplash refuses to start against an interface that isn't really up + // and dnsmasq will silently refuse to answer DHCP without erroring loudly. + router.run_ok( + "sleep 2; \ + for i in 1 2 3 4 5; do \ + ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2; \ + ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' && break; \ + echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \ + done; \ + ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \ + { echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }" + )?; Ok(()) } diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs new file mode 100644 index 00000000..82bafed4 --- /dev/null +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -0,0 +1,139 @@ +use anyhow::{Context, Result}; + +use crate::opkg::PkgManager; +use crate::tollgate::TollGateConfig; +use crate::Router; + +/// Install NoDogSplash and immediately stop it, before configuring anything. +/// +/// The OpenWrt package's postinst auto-enables and starts nodogsplash on +/// install using its stock default config — critically, `gatewayinterface` +/// defaults to `br-lan`. On a fresh install that window is real: NoDogSplash +/// only manages IPv4 iptables, so anything plugged into `br-lan` (e.g. an +/// admin's own management box) silently loses IPv4 connectivity (DHCP still +/// listens, but the gate blocks the client until ndsctl authorizes its MAC) +/// until we get a chance to repoint it — a full network re-scan can take +/// long enough for that to matter. Stopping it right after install, before +/// `configure()` ever runs, closes that window as early as possible. +/// +/// `tollgate-wrt` delegates all MAC authorization and gate open/close to +/// NoDogSplash via `ndsctl` — it has no firewall/netfilter code of its own +/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all). +/// Upstream's package therefore hard-depends on `+nodogsplash`, but neither +/// of our install paths (see `tollgate::install`) pull it in automatically. +pub fn install_and_stop(router: &Router, pkg_mgr: PkgManager) -> Result<()> { + router + .install_package(pkg_mgr, "nodogsplash") + .context("install nodogsplash — required by tollgate-wrt for client gating")?; + router.run_ok("/etc/init.d/nodogsplash stop || true")?; + Ok(()) +} + +/// Point NoDogSplash's webroot at TollGate's own splash page instead of the +/// generic stock one NoDogSplash ships with. +/// +/// Confirmed live: without this, NoDogSplash serves its own bundled +/// click-to-continue splash page — clicking "Continue" calls NDS's built-in +/// auth handler directly and authorizes the client with zero payment +/// involved. TollGate's actual payment UI (a QR/Cashu-token entry SPA) lives +/// at `/etc/tollgate/tollgate-captive-portal-site` — the .ipk's data payload +/// stages it there (see `packaging/files/tollgate-captive-portal-site/` in +/// the upstream repo), it's just never wired up as NoDogSplash's webroot. +/// +/// Mirrors upstream's own `90-tollgate-captive-portal-symlink` uci-defaults +/// script exactly (symlink swap, not a `webroot` UCI override) — confirmed +/// live that setting `option webroot` directly instead causes NoDogSplash to +/// 500 on every request, for reasons not fully understood (worth filing +/// upstream, but the symlink approach is what's actually shipped/tested). +pub fn install_captive_portal_symlink(router: &Router) -> Result<()> { + let (_, exists) = router.run("test -d /etc/tollgate/tollgate-captive-portal-site")?; + if exists != 0 { + anyhow::bail!( + "/etc/tollgate/tollgate-captive-portal-site missing — expected to be staged \ + by the tollgate-wrt package install" + ); + } + + router.run_ok( + "if [ -L /etc/nodogsplash/htdocs ]; then \ + true; \ + else \ + if [ -d /etc/nodogsplash/htdocs ]; then \ + mv /etc/nodogsplash/htdocs /etc/nodogsplash/htdocs.backup; \ + fi; \ + rm -rf /etc/nodogsplash/htdocs; \ + ln -sf /etc/tollgate/tollgate-captive-portal-site /etc/nodogsplash/htdocs; \ + fi" + )?; + Ok(()) +} + +/// Configure NoDogSplash to gate the dedicated `br-tollgate` bridge (see +/// `wifi::provision_network`), not `br-lan` — the paid SSID here lives on its +/// own isolated network/subnet rather than the canonical upstream layout +/// where it's bridged into `lan`. +/// +/// Must run after `wifi::provision_ssid` has created `br-tollgate` — pointing +/// `gatewayinterface` at a bridge that doesn't exist yet is at best a no-op +/// and at worst leaves NoDogSplash in a confused state. +pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> { + router.run_ok("touch /etc/config/nodogsplash")?; + + // The nodogsplash package's own uci-defaults populate an anonymous + // `@nodogsplash[0]` section on first install, pointed at `br-lan` (its + // stock default — see `install_and_stop`). NoDogSplash supports multiple + // simultaneous gateway instances, one per config section, so leaving this + // in place alongside our own `nodogsplash.main` doesn't get overridden by + // it — it starts a *second* instance gating br-lan for real. Delete it; + // `main` is the only instance this project manages. + let _ = router.uci_delete("nodogsplash.@nodogsplash[0]"); + + router.uci_set("nodogsplash.main", "nodogsplash")?; + router.uci_set("nodogsplash.main.enabled", "1")?; + router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?; + router.uci_set("nodogsplash.main.gatewayname", &format!("{} Portal", cfg.ssid))?; + router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?; + router.uci_set("nodogsplash.main.gatewayport", "2050")?; + + // Pre-auth "walled garden": traffic an unauthenticated client must still + // reach before ndsctl authorizes their MAC. `uci_delete` + rebuild (rather + // than only adding our own entries) is deliberate — the stock package + // config ships a `users_to_router` default of its own (DNS, DHCP, plus + // SSH/Telnet to the router), and `uci_set`/`add_list` on an existing + // *named* section does not clear an inherited default list, so without + // an explicit delete first, re-provisioning would silently keep + // whatever was there before. + // + // DNS (53) and DHCP (67, udp) are carried over from that stock default — + // without them a client can't even get an IP or resolve the portal + // domain before authenticating (confirmed live: omitting udp/67 here + // broke DHCP entirely for new clients on the archipelago SSID). 2121 + // (TollGate payment) and 2050 (NDS's own splash portal) are ours. + // SSH/Telnet (22/23) are deliberately *not* carried over — the stock + // default exposes router shell access to every unauthenticated device + // on a public pay-as-you-go network, which is a bad default here. + let _ = router.uci_delete("nodogsplash.main.users_to_router"); + router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 53")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 53")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 67")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2121")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2050")?; + + // Post-auth (paid) clients get full access — matches the stock package + // default (`list authenticated_users 'allow all'`), which our from-scratch + // named section never carried over. Under this router's default-ACCEPT + // FORWARD policy an empty list happens to behave the same, but that's an + // accident of this specific setup, not something to depend on. + let _ = router.uci_delete("nodogsplash.main.authenticated_users"); + router.uci_add_list("nodogsplash.main.authenticated_users", "allow all")?; + + router.uci_commit(Some("nodogsplash"))?; + Ok(()) +} + +/// (Re)start the nodogsplash service so config changes and gate state take effect. +pub fn restart(router: &Router) -> Result<()> { + router.run_ok("/etc/init.d/nodogsplash enable")?; + router.run_ok("/etc/init.d/nodogsplash restart || /etc/init.d/nodogsplash start")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs index 03baea1d..5d3428bd 100644 --- a/core/openwrt/src/tollgate/wifi.rs +++ b/core/openwrt/src/tollgate/wifi.rs @@ -38,14 +38,29 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> { } /// Add a `tollgate` network interface (isolated LAN for TollGate clients). +/// +/// Binds to a named bridge device (`br-tollgate`) rather than leaving the +/// wifi-iface as the network's raw device — NoDogSplash's `gatewayinterface` +/// needs a stable, known interface name to gate (see `nodogsplash::provision`), +/// and the driver-assigned name of a bare wifi vif (e.g. `phy0-ap0`) isn't +/// guaranteed across hardware. fn provision_network(router: &Router) -> Result<()> { router.uci_apply( "network", &[ + ("network.tollgate_bridge", "device"), + ("network.tollgate_bridge.type", "bridge"), + ("network.tollgate_bridge.name", "br-tollgate"), ("network.tollgate", "interface"), + ("network.tollgate.device", "br-tollgate"), ("network.tollgate.proto", "static"), ("network.tollgate.ipaddr", "192.168.99.1"), ("network.tollgate.netmask", "255.255.255.0"), + // NoDogSplash only manages IPv4 iptables rules. If IPv6 RA/DHCPv6 + // stays enabled, clients get routable IPv6 addresses and their OS + // validates connectivity (and browses freely) over IPv6, bypassing + // the portal entirely. See OpenTollGate/tollgate-module-basic-go#148. + ("network.tollgate.ip6assign", "0"), ], )?; @@ -58,6 +73,8 @@ fn provision_network(router: &Router) -> Result<()> { ("dhcp.tollgate.start", "100"), ("dhcp.tollgate.limit", "150"), ("dhcp.tollgate.leasetime", "5m"), + ("dhcp.tollgate.ra", "disabled"), + ("dhcp.tollgate.dhcpv6", "disabled"), ], )?; @@ -66,8 +83,11 @@ fn provision_network(router: &Router) -> Result<()> { /// Add firewall zone for the tollgate interface. /// -/// TollGate itself gates forwarding via iptables; the firewall zone isolates -/// tollgate clients from other LAN segments. +/// This zone only isolates tollgate clients from other LAN segments and +/// opens the payment port to the router. Per-client forwarding to WAN is +/// actually gated by NoDogSplash's own iptables rules (via `ndsctl`), not by +/// anything in this static firewall config — `tollgate-wrt` has no netfilter +/// code of its own. See `nodogsplash::provision`. fn provision_firewall(router: &Router) -> Result<()> { // Zone router.uci_apply( diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index d733404e..245988bb 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -519,6 +519,35 @@ const walletTransactions = ref([]) function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) } +// wallet.ecash-history's shape (see handle_wallet_ecash_history in +// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used +// elsewhere in this file, so it's mapped into that shape below rather than +// widening WalletTransaction itself with a pile of ecash-only fields. +interface EcashTransaction { + id: string + tx_type: 'send' | 'receive' + amount_sats: number + timestamp: string + description: string + mint_url: string + peer: string + kind: 'cashu' | 'fedimint' +} + +function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction { + return { + tx_hash: tx.id, + amount_sats: tx.amount_sats, + direction: tx.tx_type === 'receive' ? 'incoming' : 'outgoing', + num_confirmations: 1, + time_stamp: Math.floor(new Date(tx.timestamp).getTime() / 1000), + total_fees: 0, + dest_addresses: [], + label: tx.description, + block_height: 0, + } +} + async function loadWeb5Status() { // A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when // there is a balance"). On failure keep the last-known value — the refs start @@ -526,7 +555,15 @@ async function loadWeb5Status() { try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false } try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } - try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ } + // Merge LND transactions with ecash/Fedimint history (wallet.ecash-history + // already unifies both) — previously only LND transactions were fetched + // here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never + // appeared in the Transactions modal even though the balance included it. + let lndTxs: WalletTransaction[] = [] + try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = res.transactions || [] } catch { /* keep last-known transactions */ } + let ecashTxs: WalletTransaction[] = [] + try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ } + walletTransactions.value = [...lndTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp) } // System stats