fix(tollgate): install and configure NoDogSplash for real client gating

tollgate-wrt has no firewall/netfilter code of its own (confirmed via
strings on the binary and the upstream Go source) — it delegates all
MAC authorization and gate open/close to NoDogSplash via ndsctl.
Upstream's package declares +nodogsplash as a hard dependency, but
neither of our install paths pull 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. Result: tollgate-wrt
ran, accepted payments, and tracked balances, but never actually
blocked unpaid clients — the static firewall zone just forwarded
everyone to WAN unconditionally.

Also fixes the config.json mismatch: tollgate-wrt reads
/etc/tollgate/config.json exclusively, never the tollgate.main.* UCI
keys we were writing — so mint/price changes through this project's
UI silently had no effect on what the daemon actually advertised.

- tollgate/nodogsplash.rs: install nodogsplash, configure it to gate
  the dedicated br-tollgate bridge, open the pre-auth walled-garden
  ports (2121 payment, 2050 portal).
- tollgate/wifi.rs: bind network.tollgate to a stable br-tollgate
  bridge device (NoDogSplash needs a known interface name — the
  driver-assigned name of a bare wifi vif isn't guaranteed); disable
  IPv6 RA/DHCPv6 on it (NoDogSplash only manages IPv4 iptables, so
  IPv6 would let clients bypass the portal entirely).
- tollgate/config.rs: apply_daemon_config() merges pricing/mint into
  the real config.json instead of (only) UCI.
- opkg.rs: generic install_package() for standard feed packages under
  either PkgManager mode.
- router.rs: upload_file() (SCP) for non-UCI config files.
This commit is contained in:
ssmithx 2026-07-02 17:23:12 +00:00
parent 51647b21cd
commit a252eb12fd
6 changed files with 184 additions and 9 deletions

View File

@ -87,4 +87,31 @@ impl Router {
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
Ok(()) 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(())
}
} }

View File

@ -1,6 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use ssh2::Session; use ssh2::Session;
use std::io::Read; use std::io::{Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
use std::path::Path; use std::path::Path;
use tracing::debug; use tracing::debug;
@ -84,4 +84,22 @@ impl Router {
.context("read /etc/openwrt_release — is this an OpenWrt device?")?; .context("read /etc/openwrt_release — is this an OpenWrt device?")?;
Ok(release) 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(())
}
} }

View File

@ -1,4 +1,4 @@
use anyhow::Result; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Router; use crate::Router;
@ -40,7 +40,11 @@ impl Default for TollGateConfig {
/// Write TollGate UCI configuration and commit. /// 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<()> { pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply( router.uci_apply(
"tollgate", "tollgate",
@ -57,3 +61,39 @@ pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
)?; )?;
Ok(()) 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(())
}

View File

@ -1,21 +1,25 @@
pub mod config; pub mod config;
pub mod install; pub mod install;
pub mod nodogsplash;
pub mod wifi; pub mod wifi;
pub use config::TollGateConfig; pub use config::TollGateConfig;
pub use install::install_tollgate; pub use install::install_tollgate;
pub use wifi::provision_ssid; pub use wifi::provision_ssid;
use anyhow::Result; use anyhow::{Context, Result};
use tracing::info; use tracing::info;
use crate::{opkg::PkgManager, Router}; use crate::{opkg::PkgManager, Router};
/// Full TollGate provisioning sequence: /// Full TollGate provisioning sequence:
/// 1. Install tollgate-module-basic-go /// 1. Install tollgate-module-basic-go
/// 2. Write TollGate UCI config (pricing, mint URL) /// 2. Install and configure NoDogSplash (client gating — tollgate-wrt has no
/// 3. Create the pay-as-you-go WiFi SSID /// firewall/enforcement code of its own; see `nodogsplash::provision`)
/// 4. Restart affected services /// 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. Restart affected services
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
info!("[{}] Starting TollGate provisioning", router.host); info!("[{}] Starting TollGate provisioning", router.host);
@ -29,9 +33,25 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
install::install_tollgate_apk_native(router)?; 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.
nodogsplash::provision(router, pkg_mgr, config)
.context("provision nodogsplash — tollgate-wrt cannot gate clients without it")?;
config::apply(router, config)?; config::apply(router, config)?;
wifi::provision_ssid(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")?;
restart_services(router, config.enabled)?; restart_services(router, config.enabled)?;
nodogsplash::restart(router)?;
info!("[{}] TollGate provisioning complete", router.host); info!("[{}] TollGate provisioning complete", router.host);
Ok(()) Ok(())

View File

@ -0,0 +1,50 @@
use anyhow::{Context, Result};
use crate::opkg::PkgManager;
use crate::tollgate::TollGateConfig;
use crate::Router;
/// Install and configure NoDogSplash — the captive-portal engine that
/// `tollgate-wrt` delegates all MAC authorization and gate open/close to via
/// `ndsctl`. `tollgate-wrt` contains no firewall/netfilter code of its own
/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all); the
/// upstream package therefore hard-depends on `+nodogsplash` and its postinst
/// restarts it. Without NoDogSplash actually running, nothing blocks
/// unauthenticated clients from reaching the internet, regardless of what
/// TollGate itself is configured to charge.
///
/// Gates 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`.
pub fn provision(router: &Router, pkg_mgr: PkgManager, cfg: &TollGateConfig) -> Result<()> {
router
.install_package(pkg_mgr, "nodogsplash")
.context("install nodogsplash — required by tollgate-wrt for client gating")?;
router.run_ok("touch /etc/config/nodogsplash")?;
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": unauthenticated clients must still be able to
// reach the TollGate payment endpoint (2121) and the splash portal itself
// (2050) — everything else is blocked by NoDogSplash until ndsctl
// authorizes their MAC. Rebuild the list each run rather than trying to
// dedupe, so repeated provisioning stays idempotent.
let _ = router.uci_delete("nodogsplash.main.users_to_router");
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")?;
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(())
}

View File

@ -38,14 +38,29 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
} }
/// Add a `tollgate` network interface (isolated LAN for TollGate clients). /// 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<()> { fn provision_network(router: &Router) -> Result<()> {
router.uci_apply( router.uci_apply(
"network", "network",
&[ &[
("network.tollgate_bridge", "device"),
("network.tollgate_bridge.type", "bridge"),
("network.tollgate_bridge.name", "br-tollgate"),
("network.tollgate", "interface"), ("network.tollgate", "interface"),
("network.tollgate.device", "br-tollgate"),
("network.tollgate.proto", "static"), ("network.tollgate.proto", "static"),
("network.tollgate.ipaddr", "192.168.99.1"), ("network.tollgate.ipaddr", "192.168.99.1"),
("network.tollgate.netmask", "255.255.255.0"), ("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.start", "100"),
("dhcp.tollgate.limit", "150"), ("dhcp.tollgate.limit", "150"),
("dhcp.tollgate.leasetime", "5m"), ("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. /// Add firewall zone for the tollgate interface.
/// ///
/// TollGate itself gates forwarding via iptables; the firewall zone isolates /// This zone only isolates tollgate clients from other LAN segments and
/// tollgate clients from other LAN segments. /// 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<()> { fn provision_firewall(router: &Router) -> Result<()> {
// Zone // Zone
router.uci_apply( router.uci_apply(