Merge fix/tollgate-nodogsplash-enforcement: NoDogSplash client gating + router wallet sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
78b8a25099
@ -72,6 +72,7 @@ mod state;
|
||||
mod storage_crypto;
|
||||
mod streaming;
|
||||
mod swarm;
|
||||
mod tollgate_sweep;
|
||||
mod totp;
|
||||
mod transport;
|
||||
mod trust;
|
||||
|
||||
@ -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?;
|
||||
|
||||
81
core/archipelago/src/tollgate_sweep.rs
Normal file
81
core/archipelago/src/tollgate_sweep.rs
Normal file
@ -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<u64> {
|
||||
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::<u64>().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)
|
||||
}
|
||||
@ -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 <package>`, 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
139
core/openwrt/src/tollgate/nodogsplash.rs
Normal file
139
core/openwrt/src/tollgate/nodogsplash.rs
Normal file
@ -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(())
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -519,6 +519,35 @@ const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user