Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 4c4417787a
1661 changed files with 358288 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "archipelago-openwrt"
version = "0.1.0"
edition = "2021"
description = "OpenWrt gateway integration for Archipelago — TollGate provisioning over SSH/UCI"
[lib]
name = "archipelago_openwrt"
path = "src/lib.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
ssh2 = "0.9"
async-trait = "0.1"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
[dev-dependencies]
tokio-test = "0.4"
+76
View File
@@ -0,0 +1,76 @@
use anyhow::Result;
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::Duration;
use tracing::{debug, info};
use crate::Router;
const SSH_PORT: u16 = 22;
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Scan a CIDR subnet and return IP addresses of OpenWrt routers.
///
/// Probes TCP/22, then verifies /etc/openwrt_release over SSH.
/// `ssh_user` and `ssh_password` are used for the verification probe only.
pub async fn scan_subnet(
subnet_base: [u8; 4],
prefix_len: u8,
ssh_user: &str,
ssh_password: &str,
) -> Vec<IpAddr> {
let host_count = host_count_for_prefix(prefix_len);
let base_u32 = u32::from_be_bytes(subnet_base);
let mask = !((1u32 << (32 - prefix_len)) - 1);
let network = base_u32 & mask;
let mut candidates = Vec::new();
for i in 1..host_count {
let ip_u32 = network + i;
let ip = IpAddr::V4(std::net::Ipv4Addr::from(ip_u32));
if tcp_reachable(ip, SSH_PORT) {
candidates.push(ip);
}
}
info!(
"{} hosts with TCP/22 open in /{}",
candidates.len(),
prefix_len
);
let mut routers = Vec::new();
for ip in candidates {
match verify_openwrt(ip, ssh_user, ssh_password) {
Ok(true) => {
info!("OpenWrt detected at {}", ip);
routers.push(ip);
}
Ok(false) => debug!("{} is not OpenWrt", ip),
Err(e) => debug!("{} probe failed: {}", ip, e),
}
}
routers
}
/// Check whether a known IP is an OpenWrt router.
pub fn probe(ip: IpAddr, ssh_user: &str, ssh_password: &str) -> Result<bool> {
verify_openwrt(ip, ssh_user, ssh_password)
}
fn tcp_reachable(ip: IpAddr, port: u16) -> bool {
TcpStream::connect_timeout(&SocketAddr::new(ip, port), PROBE_TIMEOUT).is_ok()
}
fn verify_openwrt(ip: IpAddr, user: &str, password: &str) -> Result<bool> {
let router = Router::connect_password(&ip.to_string(), SSH_PORT, user, password)?;
let (out, code) = router.run("cat /etc/openwrt_release")?;
Ok(code == 0 && out.contains("OpenWrt"))
}
fn host_count_for_prefix(prefix_len: u8) -> u32 {
if prefix_len >= 32 {
return 1;
}
1u32 << (32 - prefix_len)
}
+9
View File
@@ -0,0 +1,9 @@
pub mod detect;
pub mod opkg;
pub mod router;
pub mod tollgate;
pub mod uci;
pub mod wan;
pub mod wifi_scan;
pub use router::Router;
+127
View File
@@ -0,0 +1,127 @@
use anyhow::Result;
use tracing::info;
use crate::Router;
/// Which package manager is available on this router.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkgManager {
/// Traditional opkg (OpenWrt ≤24.x).
Opkg,
/// OpenWrt 25.x+ — apk is the native manager, opkg is not in repos.
ApkNative,
}
impl Router {
/// Detect which package manager is available.
///
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
/// first to work around missing CA bundle on fresh images), then try
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
/// 25.x) → `ApkNative`.
/// - Neither found → error.
pub fn opkg_check(&self) -> Result<PkgManager> {
let (_, code) = self.run("test -x /usr/bin/opkg")?;
if code == 0 {
return Ok(PkgManager::Opkg);
}
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
if apk_code == 0 {
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
// Fresh images ship without a CA bundle; switch repos to HTTP so
// apk's wget can reach the package index without TLS verification.
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
if update_code != 0 {
anyhow::bail!(
"apk update failed (exit {}) — router may have no internet access. \
Ensure WAN/internet is working on the router before provisioning.\n{}",
update_code,
update_out.trim()
);
}
// Try to install opkg (only available on some 25.x builds).
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
if add_code == 0 {
return Ok(PkgManager::Opkg);
}
if add_out.contains("no such package") || add_out.contains("unable to select") {
info!(
"[{}] opkg not in apk repos — staying in apk-native mode",
self.host
);
return Ok(PkgManager::ApkNative);
}
anyhow::bail!(
"apk add opkg failed (exit {}): {}",
add_code,
add_out.trim()
);
}
anyhow::bail!(
"opkg not found at /usr/bin/opkg — this router's firmware may not \
support package management (TollGate requires a standard OpenWrt build)"
);
}
/// `opkg update` — refresh package lists.
pub fn opkg_update(&self) -> Result<()> {
info!("[{}] opkg update", self.host);
self.run_ok("/usr/bin/opkg update")?;
Ok(())
}
/// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!(
"/usr/bin/opkg list-installed | grep -q '^{} '",
package
))?;
if code == 0 {
info!("[{}] {} already installed", self.host, package);
return Ok(());
}
info!("[{}] opkg install {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
Ok(())
}
/// Remove a package.
pub fn opkg_remove(&self, package: &str) -> Result<()> {
info!("[{}] opkg remove {}", self.host, package);
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(())
}
}
+126
View File
@@ -0,0 +1,126 @@
use anyhow::{Context, Result};
use ssh2::Session;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::Path;
use tracing::debug;
/// An active SSH connection to an OpenWrt router.
pub struct Router {
pub host: String,
pub port: u16,
session: Session,
}
impl Router {
/// Connect to an OpenWrt router via SSH using a private key.
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
session.handshake().context("SSH handshake")?;
session
.userauth_pubkey_file(user, None, key_path, None)
.with_context(|| format!("SSH auth as {} with key {:?}", user, key_path))?;
Ok(Self {
host: host.to_string(),
port,
session,
})
}
/// Connect using a password (fallback for routers not yet provisioned with a key).
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
session.handshake().context("SSH handshake")?;
session
.userauth_password(user, password)
.with_context(|| format!("SSH password auth as {}", user))?;
Ok(Self {
host: host.to_string(),
port,
session,
})
}
/// Run a command and return (stdout, exit_code).
pub fn run(&self, cmd: &str) -> Result<(String, i32)> {
debug!("ssh [{}] $ {}", self.host, cmd);
let mut channel = self.session.channel_session().context("open channel")?;
channel
.exec(cmd)
.with_context(|| format!("exec: {}", cmd))?;
let mut stdout = String::new();
channel.read_to_string(&mut stdout).context("read stdout")?;
channel.wait_close().context("wait close")?;
let exit = channel.exit_status().context("exit status")?;
Ok((stdout, exit))
}
/// Run a command, fail if exit code is non-zero.
pub fn run_ok(&self, cmd: &str) -> Result<String> {
let (out, code) = self.run(cmd)?;
if code != 0 {
anyhow::bail!(
"command `{}` exited with code {}: {}",
cmd,
code,
out.trim()
);
}
Ok(out)
}
/// Set `user`'s login password via BusyBox `passwd`, non-interactively.
///
/// BusyBox's passwd applet reads the new password twice from stdin even
/// without a tty (unlike shadow-utils' passwd, which requires one), so
/// piping two lines in works. Root can always set its own (or another
/// user's) password without supplying the old one first. This is what
/// lets a fresh-flash router (root has no password yet) be fully set up
/// from the app — no manual SSH/console session required.
pub fn set_password(&self, user: &str, new_password: &str) -> Result<()> {
let q = crate::uci::shell_quote(new_password);
let uq = crate::uci::shell_quote(user);
let cmd = format!("printf '%s\\n%s\\n' {q} {q} | passwd {uq} 2>&1");
self.run_ok(&cmd)?;
Ok(())
}
/// Verify the remote device is actually running OpenWrt.
pub fn verify_openwrt(&self) -> Result<String> {
let release = self
.run_ok("cat /etc/openwrt_release")
.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(())
}
}
+99
View File
@@ -0,0 +1,99 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::Router;
/// TollGate provisioning parameters.
///
/// `mint_url` must be the externally-reachable URL of the Archy Cashu mint —
/// TollGate customers connect from outside the Archy node's loopback, so
/// localhost URLs will not work. Resolve this from the running mint app before
/// calling `provision`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TollGateConfig {
/// SSID name for the pay-as-you-go network.
pub ssid: String,
/// Externally-reachable URL of the Archy Cashu mint.
pub mint_url: String,
/// Price in satoshis per `step_size` interval.
pub price_sats: u64,
/// Step size in milliseconds (default: 60000 = 1 minute).
pub step_size_ms: u64,
/// Minimum steps a customer must purchase at once.
pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool,
}
impl Default for TollGateConfig {
fn default() -> Self {
Self {
ssid: "archipelago".to_string(),
mint_url: String::new(), // must be set by caller from the running mint app
price_sats: 10,
step_size_ms: 60_000,
min_steps: 1,
enabled: true,
}
}
}
/// Write TollGate UCI configuration and commit.
///
/// `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",
&[
("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),
],
)?;
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(())
}
+273
View File
@@ -0,0 +1,273 @@
use anyhow::Result;
use tracing::info;
use crate::Router;
/// The OpenWrt package name for the TollGate reference implementation.
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
/// Direct-download fallback URLs by opkg architecture string.
/// Used when the package is not in any configured feed.
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
fn ipk_url(arch: &str) -> Option<&'static str> {
match arch {
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
_ => None,
}
}
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
///
/// Tries opkg first (works if a custom feed is configured). Falls back to
/// downloading the .ipk directly from GitHub releases if opkg can't find it.
/// Caller is responsible for running `opkg_update` first.
pub fn install_tollgate(router: &Router) -> Result<()> {
info!("[{}] Installing {}", router.host, TOLLGATE_PACKAGE);
// Fast path: standard opkg install (or already installed).
if router.opkg_install(TOLLGATE_PACKAGE).is_ok() {
return Ok(());
}
// Package not in any feed — download the .ipk directly.
let arch = router
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
let arch = arch.trim();
let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \
Add a custom opkg feed or build from source.",
arch
)
})?;
info!(
"[{}] Downloading TollGate for {} from GitHub releases",
router.host, arch
);
router.run_ok(&format!(
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
url
))?;
install_ipk(router, "/tmp/tollgate.ipk")
}
/// Install tollgate-module-basic-go on OpenWrt 25.x where opkg is not available.
///
/// Downloads the .ipk from GitHub releases and extracts it manually using
/// BusyBox `ar` and `tar` (both present on all OpenWrt images).
pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
info!(
"[{}] Installing {} (apk-native mode)",
router.host, TOLLGATE_PACKAGE
);
// Already installed? The service binary is /usr/bin/tollgate-wrt (per its
// init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name,
// never an on-disk filename, so it can't be used for the file-existence
// fallback below.
let (_, code) = router.run(&format!(
"apk list --installed 2>/dev/null | grep -q '^{}' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null",
TOLLGATE_PACKAGE
))?;
if code == 0 {
info!("[{}] {} already installed", router.host, TOLLGATE_PACKAGE);
return Ok(());
}
// Get architecture from /etc/openwrt_release.
// The variable is DISTRIB_ARCH on most builds; OPENWRT_ARCH on some.
// Fall back to apk --print-arch, then uname -m.
let arch_raw = router.run_ok(
". /etc/openwrt_release 2>/dev/null \
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \
|| uname -m",
)?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
// which is the standard for home-router MIPS builds.
let arch = match arch_raw.trim() {
"mipsel" => "mipsel_24kc",
"mips" => "mips_24kc",
other => other,
};
info!("[{}] detected arch: {:?}", router.host, arch);
if arch.is_empty() {
anyhow::bail!("Could not determine router architecture");
}
let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \
Add a custom feed or build from source.",
arch
)
})?;
info!(
"[{}] Downloading TollGate for {} from GitHub releases",
router.host, arch
);
// --no-check-certificate: fresh OpenWrt 25.x images ship without a CA bundle;
// GitHub serves releases over HTTPS so wget would otherwise reject the cert.
let (dl_out, dl_code) = router.run(&format!(
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
url
))?;
if dl_code != 0 {
anyhow::bail!("TollGate download failed: {}", dl_out.trim());
}
// Sanity-check: a real .ipk is at least 50 KB.
// If wget captured an HTML error page it will be tiny.
let (size_out, _) = router.run("wc -c < /tmp/tollgate.ipk 2>/dev/null")?;
let size: u64 = size_out.trim().parse().unwrap_or(0);
if size < 50_000 {
anyhow::bail!(
"Downloaded TollGate package is only {}B — wget likely captured an error page. \
Check router internet access and that the release URL is reachable.",
size
);
}
install_ipk(router, "/tmp/tollgate.ipk")
}
/// Extract and install an .ipk file without opkg.
///
/// An .ipk is an `ar` archive containing `data.tar.gz` (package files) and
/// `control.tar.gz` (metadata + postinst script).
fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
// Check for disk space first (rough: need at least ~1 MB free on /overlay).
// TollGate is a Go binary — typically 58 MB on flash.
let (df_out, _) = router.run("df /overlay 2>/dev/null | awk 'NR==2{print $4}'")?;
let free_kb: u64 = df_out.trim().parse().unwrap_or(u64::MAX);
if free_kb < 5120 {
anyhow::bail!(
"Not enough flash space for TollGate: only {}kB free on /overlay \
(need ≥5MB). Free up space first or use a router with more storage.",
free_kb
);
}
router.run_ok("rm -rf /tmp/_tg_install && mkdir -p /tmp/_tg_install")?;
// OpenWrt 25.x BusyBox does not include `ar` — install binutils via
// whichever package manager is available before trying to unpack the ipk.
let (_, ar_found) = router.run("command -v ar >/dev/null 2>&1")?;
if ar_found != 0 {
info!("[{}] ar not found, installing binutils", router.host);
let (pkg_out, pkg_code) =
router.run("apk add binutils 2>&1 || opkg install binutils 2>&1")?;
if pkg_code != 0 {
anyhow::bail!(
"TollGate installation failed: ar not available and binutils install failed: {}",
pkg_out.trim()
);
}
}
// Try standard opkg ar format first (ar archive → data.tar.gz inside).
let (ar_out, ar_code) =
router.run(&format!("cd /tmp/_tg_install && ar x {} 2>&1", ipk_path))?;
if ar_code != 0 {
// Fallback: some builds produce the .ipk as a gzip tarball rather than
// a classic `ar` archive. This can still contain the same three ipk
// members (debian-binary/data.tar.gz/control.tar.gz) one level deep —
// just gzip-tarred together instead of ar'd — or, less commonly, a
// flat tarball of the real package files with no ipk structure at
// all. Extract to the scratch dir and check which shape it is before
// deciding how to install it.
info!(
"[{}] ar failed ({}), trying tar -xzf",
router.host,
ar_out.trim()
);
// List contents first — validates format without writing anything.
let (list_out, list_code) =
router.run(&format!("tar -tzf {} 2>&1 | head -30", ipk_path))?;
if list_code != 0 {
anyhow::bail!(
"TollGate installation failed: file is not an ar archive or gzip tar.\n\
ar: {}\ntar -t: {}",
ar_out.trim(),
list_out.trim()
);
}
info!("[{}] ipk contents:\n{}", router.host, list_out.trim());
router.run_ok(&format!("tar -xzf {} -C /tmp/_tg_install 2>&1", ipk_path))?;
let (_, nested) = router.run("test -f /tmp/_tg_install/data.tar.gz")?;
if nested != 0 {
// Genuinely flat tarball, no ipk structure — its contents are the
// real package files, already unpacked into the scratch dir.
let (ov_df, _) = router.run("df / 2>/dev/null | awk 'NR==2{print $4}'")?;
let overlay_free_kb: u64 = ov_df.trim().parse().unwrap_or(0);
if overlay_free_kb < 5120 {
anyhow::bail!(
"Not enough space to install TollGate: only {}kB free on /. \
Need at least 5MB. Free up flash space on the router first \
(e.g. remove unused packages with `apk del …`).",
overlay_free_kb
);
}
let (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?;
if cp_code != 0 {
anyhow::bail!(
"TollGate installation failed: file copy failed: {}",
cp_out.trim()
);
}
// No package-manager postinst ran for these files either — see
// the uci-defaults note below.
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true",
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
return Ok(());
}
// Nested ipk-member layout — fall through to the shared unpack below.
}
// Unpack data.tar.gz (the real payload) from either the `ar`-extracted or
// gzip-tar-extracted scratch dir, then run control.tar.gz's postinst.
let (tar_out, tar_code) = router.run("tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1")?;
if tar_code != 0 {
anyhow::bail!(
"TollGate installation failed: data extract failed: {}",
tar_out.trim()
);
}
// Run postinst if present (optional — failures are non-fatal).
router.run_ok(
"if tar -xzf /tmp/_tg_install/control.tar.gz -C /tmp/_tg_install 2>/dev/null; then \
chmod +x /tmp/_tg_install/postinst 2>/dev/null; \
/tmp/_tg_install/postinst configure 2>/dev/null || true; \
fi",
)?;
// `default_postinst` (what most packages' postinst calls, including
// this one) only runs pending /etc/uci-defaults/* scripts for packages
// it finds in opkg/apk's own file-list records. Since these files were
// extracted manually rather than through a real package-manager install,
// no such record exists, so run any pending scripts directly — this is
// exactly what opkg's install path (or the next reboot) would otherwise
// do for them, just without waiting for either.
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true",
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
Ok(())
}
+119
View File
@@ -0,0 +1,119 @@
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::{Context, Result};
use tracing::info;
use crate::{opkg::PkgManager, Router};
/// Full TollGate provisioning sequence:
/// 1. Install tollgate-module-basic-go
/// 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);
let pkg_mgr = router.opkg_check()?;
match pkg_mgr {
PkgManager::Opkg => {
router.opkg_update()?;
install_tollgate(router)?;
}
PkgManager::ApkNative => {
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(())
}
/// Applies `enabled` to the actual running service, not just the UCI value —
/// the tollgate-wrt init script doesn't consult `tollgate.main.enabled`
/// itself, so toggling it requires an explicit enable/start or disable/stop.
///
/// The service's init script is `/etc/init.d/tollgate-wrt` (its actual
/// on-disk name — "tollgate" alone does not exist).
fn restart_services(router: &Router, enabled: bool) -> Result<()> {
if enabled {
router.run_ok("/etc/init.d/tollgate-wrt enable")?;
router.run_ok("/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start")?;
} else {
router.run_ok("/etc/init.d/tollgate-wrt stop || true")?;
router.run_ok("/etc/init.d/tollgate-wrt disable || true")?;
}
router.run_ok("/etc/init.d/network restart")?;
// 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(())
}
+142
View File
@@ -0,0 +1,142 @@
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(())
}
+128
View File
@@ -0,0 +1,128 @@
use anyhow::{Context, Result};
use tracing::info;
use crate::tollgate::TollGateConfig;
use crate::Router;
/// Create (or update) the dedicated pay-as-you-go WiFi interface for TollGate.
///
/// Uses a fixed named section (`wireless.tollgate`) rather than `uci add`, so
/// re-provisioning (e.g. editing price/mint URL after install) updates the
/// same interface in place instead of piling up a new `wifi-iface` section —
/// and therefore a new duplicate broadcast SSID — on every call.
pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
let radio = detect_radio(router).context("detect WiFi radio")?;
info!("[{}] Using radio {} for TollGate SSID", router.host, radio);
router.uci_apply(
"wireless",
&[
("wireless.tollgate", "wifi-iface"),
("wireless.tollgate.device", &radio),
("wireless.tollgate.mode", "ap"),
("wireless.tollgate.ssid", &cfg.ssid),
("wireless.tollgate.encryption", "none"),
("wireless.tollgate.network", "tollgate"),
// Disable 802.11r/k/v — unnecessary for transient pay-as-you-go clients.
("wireless.tollgate.ieee80211r", "0"),
// Stop broadcasting entirely when disabled, rather than leaving an
// open SSID up that leads nowhere once the backend is stopped.
(
"wireless.tollgate.disabled",
if cfg.enabled { "0" } else { "1" },
),
],
)?;
provision_network(router)?;
provision_firewall(router)?;
Ok(())
}
/// 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"),
],
)?;
// Enable DHCP for the tollgate interface.
router.uci_apply(
"dhcp",
&[
("dhcp.tollgate", "dhcp"),
("dhcp.tollgate.interface", "tollgate"),
("dhcp.tollgate.start", "100"),
("dhcp.tollgate.limit", "150"),
("dhcp.tollgate.leasetime", "5m"),
("dhcp.tollgate.ra", "disabled"),
("dhcp.tollgate.dhcpv6", "disabled"),
],
)?;
Ok(())
}
/// Add firewall zone for the tollgate interface.
///
/// 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(
"firewall",
&[
("firewall.tollgate_zone", "zone"),
("firewall.tollgate_zone.name", "tollgate"),
("firewall.tollgate_zone.network", "tollgate"),
("firewall.tollgate_zone.input", "ACCEPT"),
("firewall.tollgate_zone.output", "ACCEPT"),
("firewall.tollgate_zone.forward", "REJECT"),
],
)?;
// Forwarding rule: tollgate → wan (TollGate manages which clients can forward)
router.uci_apply(
"firewall",
&[
("firewall.tollgate_fwd", "forwarding"),
("firewall.tollgate_fwd.src", "tollgate"),
("firewall.tollgate_fwd.dest", "wan"),
],
)?;
Ok(())
}
/// Return the first available wireless radio device name (e.g. "radio0").
fn detect_radio(router: &Router) -> Result<String> {
let out =
router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
// Extract "radioN" from "wireless.radioN.type"
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
Ok(radio)
}
+65
View File
@@ -0,0 +1,65 @@
use anyhow::Result;
use crate::Router;
/// Thin wrappers around `uci` CLI commands over SSH.
impl Router {
/// `uci get <key>` — returns trimmed value.
pub fn uci_get(&self, key: &str) -> Result<String> {
let out = self.run_ok(&format!("uci get {}", key))?;
Ok(out.trim().to_string())
}
/// `uci set <key>=<value>`
pub fn uci_set(&self, key: &str, value: &str) -> Result<()> {
self.run_ok(&format!("uci set {}={}", key, shell_quote(value)))?;
Ok(())
}
/// `uci add <config> <type>` — returns the new section name.
pub fn uci_add(&self, config: &str, section_type: &str) -> Result<String> {
let out = self.run_ok(&format!("uci add {} {}", config, section_type))?;
Ok(out.trim().to_string())
}
/// `uci add_list <key>=<value>`
pub fn uci_add_list(&self, key: &str, value: &str) -> Result<()> {
self.run_ok(&format!("uci add_list {}={}", key, shell_quote(value)))?;
Ok(())
}
/// `uci delete <key>`
pub fn uci_delete(&self, key: &str) -> Result<()> {
self.run_ok(&format!("uci delete {}", key))?;
Ok(())
}
/// `uci commit [<config>]`
pub fn uci_commit(&self, config: Option<&str>) -> Result<()> {
match config {
Some(c) => self.run_ok(&format!("uci commit {}", c))?,
None => self.run_ok("uci commit")?,
};
Ok(())
}
/// Batch: apply a list of `(key, value)` pairs then commit the config.
pub fn uci_apply(&self, config: &str, pairs: &[(&str, &str)]) -> Result<()> {
// `uci set config.section=type` fails with "Entry not found" if
// /etc/config/<config> doesn't exist yet — true for any config file
// shipped by the base system (wireless, network, dhcp, ...) but not
// for a package-defined namespace like "tollgate" that nothing has
// created a default for. `touch` is a no-op if it already exists.
self.run_ok(&format!("touch /etc/config/{}", config))?;
for (key, value) in pairs {
self.uci_set(key, value)?;
}
self.uci_commit(Some(config))?;
Ok(())
}
}
/// Wrap a value in single quotes, escaping any embedded single quotes.
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
+245
View File
@@ -0,0 +1,245 @@
use crate::Router;
use anyhow::Result;
use tracing::info;
pub struct WispConfig {
pub ssid: String,
pub password: String,
pub encryption: String, // psk2 | psk | sae | none
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
pub dhcp_limit: u32, // pool size (default 150 → .100.249)
pub masq: bool, // enable NAT on WAN zone (almost always true)
}
pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
info!("[{}] Configuring WISP → ssid={}", router.host, config.ssid);
let radio = detect_radio(router)?;
// Ensure the radio is enabled (disabled=1 by default on fresh flash)
router.uci_set("wireless.radio0.disabled", "0")?;
// Create/update named sta wifi-iface "wwan" (idempotent: uci set creates if absent)
router.uci_set("wireless.wwan", "wifi-iface")?;
router.uci_set("wireless.wwan.device", &radio)?;
router.uci_set("wireless.wwan.mode", "sta")?;
router.uci_set("wireless.wwan.ssid", &config.ssid)?;
router.uci_set("wireless.wwan.network", "wwan")?;
router.uci_set("wireless.wwan.disabled", "0")?;
router.uci_set("wireless.wwan.encryption", &config.encryption)?;
if config.encryption != "none" && !config.password.is_empty() {
router.uci_set("wireless.wwan.key", &config.password)?;
}
router.uci_commit(Some("wireless"))?;
// Create/update wwan network interface (DHCP)
router.uci_set("network.wwan", "interface")?;
router.uci_set("network.wwan.proto", "dhcp")?;
router.uci_commit(Some("network"))?;
// Add wwan to the WAN firewall zone (walk zones by name)
ensure_wwan_in_wan_zone(router)?;
// Configure LAN DHCP pool
router.uci_set("dhcp.lan.start", &config.dhcp_start.to_string())?;
router.uci_set("dhcp.lan.limit", &config.dhcp_limit.to_string())?;
router.uci_commit(Some("dhcp"))?;
// Ensure masquerade on WAN zone so LAN clients reach the internet
if config.masq {
ensure_masq_on_wan_zone(router)?;
}
// Full wifi cycle so wpa_supplicant restarts cleanly with the new config.
// "wifi reload" is not enough on some drivers — it keeps stale state.
let (down_out, down_code) = router.run("wifi down 2>&1")?;
if down_code != 0 {
info!(
"[{}] wifi down failed ({}): {}",
router.host,
down_code,
down_out.trim()
);
}
let (up_out, up_code) = router.run("wifi up 2>&1")?;
if up_code != 0 {
info!(
"[{}] wifi up failed ({}): {} — falling back to network restart",
router.host,
up_code,
up_out.trim()
);
router.run_ok("/etc/init.d/network restart 2>&1")?;
}
Ok(())
}
pub fn get_wan_status(router: &Router) -> serde_json::Value {
let configured = router
.uci_get("network.wwan.proto")
.map(|v| v == "dhcp")
.unwrap_or(false);
let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default();
let encryption = router
.uci_get("wireless.wwan.encryption")
.unwrap_or_default();
let radio0_disabled = router
.uci_get("wireless.radio0.disabled")
.map(|v| v == "1")
.unwrap_or(false);
// Find the active sta-mode interface and its association state
let iw_out = router.run_ok("iw dev 2>/dev/null").unwrap_or_default();
let (sta_iface, assoc_ssid) = parse_sta_iface(&iw_out);
// Interface operstate (up / down / absent)
let sta_state = if !sta_iface.is_empty() {
router
.run_ok(&format!(
"cat /sys/class/net/{}/operstate 2>/dev/null",
sta_iface
))
.unwrap_or_else(|_| "unknown".into())
.trim()
.to_string()
} else {
"absent".to_string()
};
// Source IP for reaching 8.8.8.8 — empty if no default route yet
let ip = router
.run_ok("ip -4 route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i==\"src\"){print $(i+1); exit}}'")
.unwrap_or_default()
.trim()
.to_string();
// Recent wifi-related kernel/syslog lines for quick diagnosis
let wifi_log = router
.run_ok("logread 2>/dev/null | grep -iE 'wlan|wwan|wifi|assoc|deauth|auth fail|CTRL-EVENT|wpa_supplicant' | tail -8 2>/dev/null")
.unwrap_or_default()
.trim()
.to_string();
// LAN info for the DHCP setup display
let lan_ip = router
.uci_get("network.lan.ipaddr")
.unwrap_or_else(|_| "192.168.1.1".into());
let lan_netmask = router
.uci_get("network.lan.netmask")
.unwrap_or_else(|_| "255.255.255.0".into());
let dhcp_start = router
.uci_get("dhcp.lan.start")
.unwrap_or_else(|_| "100".into());
let dhcp_limit = router
.uci_get("dhcp.lan.limit")
.unwrap_or_else(|_| "150".into());
// Masquerade: check WAN zone
let masq = {
let script = "for i in $(seq 0 9); do \
n=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
if [ \"$n\" = \"wan\" ]; then \
uci get firewall.@zone[$i].masq 2>/dev/null; break; \
fi; done";
router.run_ok(script).unwrap_or_default().trim().to_string() == "1"
};
info!("[{}] WAN status: configured={} ssid={:?} assoc={:?} sta_iface={:?} sta_state={:?} ip={:?} lan={} masq={}",
router.host, configured, ssid, assoc_ssid, sta_iface, sta_state, ip, lan_ip, masq);
if !wifi_log.is_empty() {
info!(
"[{}] wifi_log: {}",
router.host,
wifi_log.replace('\n', " | ")
);
}
serde_json::json!({
"configured": configured,
"ssid": ssid,
"assoc_ssid": assoc_ssid,
"encryption": encryption,
"ip": ip,
"internet": !ip.is_empty(),
"radio0_disabled": radio0_disabled,
"sta_iface": sta_iface,
"sta_state": sta_state,
"wifi_log": wifi_log,
"lan_ip": lan_ip,
"lan_netmask": lan_netmask,
"dhcp_start": dhcp_start,
"dhcp_limit": dhcp_limit,
"masq": masq,
})
}
fn parse_sta_iface(iw_out: &str) -> (String, String) {
let mut result_iface = String::new();
let mut result_ssid = String::new();
let mut current_iface = String::new();
let mut current_type = String::new();
let mut current_ssid = String::new();
for line in iw_out.lines() {
let line = line.trim();
if let Some(name) = line.strip_prefix("Interface ") {
// Save previous interface if it was a sta
if current_type == "managed" && result_iface.is_empty() {
result_iface = current_iface.clone();
result_ssid = current_ssid.clone();
}
current_iface = name.trim().to_string();
current_type.clear();
current_ssid.clear();
} else if let Some(t) = line.strip_prefix("type ") {
current_type = t.trim().to_string();
} else if let Some(s) = line.strip_prefix("ssid ") {
current_ssid = s.trim().to_string();
}
}
// Handle last block
if current_type == "managed" && result_iface.is_empty() {
result_iface = current_iface;
result_ssid = current_ssid;
}
(result_iface, result_ssid)
}
fn detect_radio(router: &Router) -> Result<String> {
// radio0 is universal; verify it exists
let out = router.uci_get("wireless.radio0").unwrap_or_default();
if !out.is_empty() {
return Ok("radio0".to_string());
}
anyhow::bail!("No wireless radio (radio0) found in UCI config")
}
fn ensure_masq_on_wan_zone(router: &Router) -> Result<()> {
let script = "for i in $(seq 0 9); do \
name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
if [ \"$name\" = \"wan\" ]; then \
uci set firewall.@zone[$i].masq=1 2>/dev/null; \
uci commit firewall; \
break; \
fi; \
done; echo ok";
router.run_ok(script)?;
Ok(())
}
fn ensure_wwan_in_wan_zone(router: &Router) -> Result<()> {
// Walk zones 0-9, find the one named "wan", add wwan to its network list
let script = "for i in $(seq 0 9); do \
name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
if [ \"$name\" = \"wan\" ]; then \
uci add_list firewall.@zone[$i].network=wwan 2>/dev/null; \
uci commit firewall; \
break; \
fi; \
done; echo ok";
router.run_ok(script)?;
Ok(())
}
+200
View File
@@ -0,0 +1,200 @@
use crate::Router;
use anyhow::Result;
pub struct ScannedNetwork {
pub ssid: String,
pub bssid: String,
pub signal: i32,
pub channel: u8,
pub encryption: String,
}
pub fn scan_networks(router: &Router) -> Result<Vec<ScannedNetwork>> {
let (iface, temp) = find_wireless_iface(router)?;
let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?;
let result = if output.contains("Scanning not possible") {
// Vendor MediaTek `mt_wifi` driver (see find_wireless_iface) doesn't
// support scanning through iwinfo/nl80211 at all. Fall back to its own
// private ioctl site-survey, which works on the same interface.
scan_via_mtk_site_survey(router, &iface)
} else if output.contains("No scan results") || output.trim().is_empty() {
Ok(vec![])
} else {
parse_iwinfo_scan(&output)
};
if temp {
let _ = router.run(&format!("iw dev {} del 2>/dev/null", iface));
}
result
}
fn scan_via_mtk_site_survey(router: &Router, iface: &str) -> Result<Vec<ScannedNetwork>> {
let _ = router.run(&format!("iwpriv {} set SiteSurvey=1 2>/dev/null", iface));
std::thread::sleep(std::time::Duration::from_secs(4));
let output = router.run_ok(&format!("iwpriv {} get_site_survey 2>&1", iface))?;
parse_mtk_site_survey(&output)
}
/// Parses MediaTek's `iwpriv <iface> get_site_survey` fixed-width table.
/// Column offsets come from the header row layout, which is part of the
/// vendor SDK's ioctl response format shared across OEMs (GL.iNet, etc.),
/// not something set per-device.
fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
let mut networks = Vec::new();
for line in output.lines() {
if !line
.trim_start()
.as_bytes()
.first()
.is_some_and(u8::is_ascii_digit)
{
continue; // skip header/summary lines; data rows start with an index
}
let ssid = line.get(8..41).unwrap_or("").trim().to_string();
if ssid.is_empty() {
continue;
}
let bssid = line.get(41..61).unwrap_or("").trim().to_string();
let security = line.get(61..84).unwrap_or("");
let channel: u8 = line
.get(4..8)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let signal: i32 = line
.get(84..92)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(-100);
networks.push(ScannedNetwork {
ssid,
bssid,
signal,
channel,
encryption: normalize_encryption(security),
});
}
networks.sort_by(|a, b| b.signal.cmp(&a.signal));
Ok(networks)
}
/// Returns `(interface_name, is_temporary)`.
/// If no interface exists, creates a temporary managed one directly on the PHY
/// so we can scan without needing any UCI wifi-iface sections.
fn find_wireless_iface(router: &Router) -> Result<(String, bool)> {
// Fast path: an interface already exists (radio was enabled previously)
let (out, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?;
if !out.trim().is_empty() {
return Ok((out.trim().to_string(), false));
}
// Some vendor wifi drivers (e.g. MediaTek's out-of-tree `mt_wifi`/`mtk` SDK
// driver used by GL.iNet and others) never register with cfg80211/mac80211,
// so they have no `iw dev` entry and no /sys/class/ieee80211 phy even though
// the radio is real and already up. `iwinfo` abstracts over those vendor
// backends too, so fall back to its device listing before concluding there's
// no radio at all.
let (iwinfo_out, _) = router.run("iwinfo 2>/dev/null | awk '/^[A-Za-z]/{print $1; exit}'")?;
if !iwinfo_out.trim().is_empty() {
return Ok((iwinfo_out.trim().to_string(), false));
}
// Find the phy — if this is empty the device has no WiFi hardware at all
let (phy_out, _) = router.run("ls /sys/class/ieee80211/ 2>/dev/null | head -1")?;
let phy = phy_out.trim().to_string();
if phy.is_empty() {
anyhow::bail!("No wireless radio found on this router");
}
// Create a temporary managed interface directly on the PHY. This bypasses
// netifd entirely so it works even when there are no wifi-iface sections in
// UCI (common on a freshly-flashed device).
tracing::info!(
"[{}] Creating temporary scan interface on {}",
router.host,
phy
);
// Remove any stale scan0 from a previous attempt, then add fresh
let _ = router.run("iw dev scan0 del 2>/dev/null");
router.run_ok(&format!(
"iw phy {} interface add scan0 type managed 2>&1 && ip link set scan0 up 2>&1",
phy
))?;
Ok(("scan0".to_string(), true))
}
fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
let mut networks: Vec<ScannedNetwork> = Vec::new();
let mut current: Option<ScannedNetwork> = None;
for line in output.lines() {
let line = line.trim();
if line.starts_with("Cell ") {
if let Some(n) = current.take() {
if !n.ssid.is_empty() {
networks.push(n);
}
}
let bssid = line
.split("Address:")
.nth(1)
.unwrap_or("")
.trim()
.to_string();
current = Some(ScannedNetwork {
ssid: String::new(),
bssid,
signal: -100,
channel: 0,
encryption: "none".to_string(),
});
} else if let Some(ref mut n) = current {
if let Some(rest) = line.strip_prefix("ESSID:") {
n.ssid = rest.trim().trim_matches('"').to_string();
} else if line.contains("Channel:") && !line.starts_with("Encryption") {
if let Some(ch_part) = line.split("Channel:").nth(1) {
n.channel = ch_part
.trim()
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
} else if line.starts_with("Signal:") {
if let Some(dbm_str) = line.split_whitespace().nth(1) {
n.signal = dbm_str.parse().unwrap_or(-100);
}
} else if let Some(rest) = line.strip_prefix("Encryption:") {
n.encryption = normalize_encryption(rest.trim());
}
}
}
if let Some(n) = current {
if !n.ssid.is_empty() {
networks.push(n);
}
}
networks.sort_by(|a, b| b.signal.cmp(&a.signal));
Ok(networks)
}
fn normalize_encryption(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower.contains("wpa3") || lower.contains("sae") {
"sae".to_string()
} else if lower.contains("wpa2") || lower.contains("psk2") {
"psk2".to_string()
} else if lower.contains("wpa") {
// CCMP/AES is WPA2's cipher suite — even if iwinfo labels it "WPA PSK (CCMP)"
// it's a WPA2 network and we must use psk2 to associate correctly.
if lower.contains("ccmp") || lower.contains("aes") {
"psk2".to_string()
} else {
"psk".to_string()
}
} else if lower.contains("none") || lower.contains("open") || lower.is_empty() {
"none".to_string()
} else {
lower
}
}