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..88d8db0a 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -1,21 +1,25 @@ 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 and configure NoDogSplash (client gating — tollgate-wrt has no +/// firewall/enforcement code of its own; see `nodogsplash::provision`) +/// 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<()> { 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)?; } } + + // 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)?; 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)?; + nodogsplash::restart(router)?; info!("[{}] TollGate provisioning complete", router.host); Ok(()) diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs new file mode 100644 index 00000000..5742cee2 --- /dev/null +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -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(()) +} 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(