Archipelago — open-source initial import
This commit is contained in:
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 5–8 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"'\''"))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user