diff --git a/core/Cargo.lock b/core/Cargo.lock index b0940218..4596ff06 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -99,6 +99,7 @@ version = "1.7.99-alpha" dependencies = [ "anyhow", "archipelago-container", + "archipelago-openwrt", "archipelago-performance", "archipelago-security", "argon2", @@ -180,6 +181,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "archipelago-openwrt" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "reqwest 0.11.27", + "serde", + "serde_json", + "ssh2", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tracing", +] + [[package]] name = "archipelago-performance" version = "0.1.0" @@ -2839,6 +2856,32 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3580,6 +3623,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "papaya" version = "0.2.4" @@ -3758,6 +3813,12 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plain" version = "0.2.3" @@ -4988,6 +5049,18 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libssh2-sys", + "parking_lot 0.12.5", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5775,6 +5848,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.1.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 80875fd4..24823e5c 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "archipelago", "container", + "openwrt", "performance", "security", ] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9c87e2d1..157c1d00 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -42,6 +42,7 @@ futures-util = "0.3" # Our modules archipelago-container = { path = "../container" } +archipelago-openwrt = { path = "../openwrt" } archipelago-security = { path = "../security" } archipelago-performance = { path = "../performance" } diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 3e132fb8..249b65a3 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -230,6 +230,10 @@ impl RpcHandler { "router.info" => self.handle_router_info().await, "router.configure" => self.handle_router_configure(params).await, + // OpenWrt / TollGate + "openwrt.scan" => self.handle_openwrt_scan(params).await, + "openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await, + // Ecash wallet "wallet.ecash-balance" => self.handle_wallet_ecash_balance().await, "wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await, diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index ac30275a..e474cbe6 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -23,6 +23,7 @@ mod names; mod network; mod node; mod nostr; +mod openwrt; mod package; mod peers; mod response; diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs new file mode 100644 index 00000000..e3cc1ed0 --- /dev/null +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -0,0 +1,118 @@ +use super::RpcHandler; +use anyhow::Result; +use archipelago_openwrt::{ + detect, + router::Router, + tollgate::{self, TollGateConfig}, +}; + +/// Default port for the local Cashu mint (nutshell / cashu-mint app). +const LOCAL_MINT_PORT: u16 = 3338; + +impl RpcHandler { + /// Scan the local subnet for OpenWrt routers. + /// + /// Params: `{ "subnet": "192.168.1.0", "prefix": 24, + /// "ssh_user": "root", "ssh_password": "" }` + pub(super) async fn handle_openwrt_scan( + &self, + params: Option, + ) -> Result { + let p = params.unwrap_or_default(); + let subnet: [u8; 4] = parse_ipv4( + p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"), + )?; + let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8; + let ssh_user = p + .get("ssh_user") + .and_then(|v| v.as_str()) + .unwrap_or("root") + .to_string(); + let ssh_password = p + .get("ssh_password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await; + let ips: Vec = routers.iter().map(|ip| ip.to_string()).collect(); + + Ok(serde_json::json!({ "routers": ips })) + } + + /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. + /// + /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", + /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, + /// "mint_url": "" }` + /// + /// `mint_url` defaults to `http://:3338` — the local Cashu + /// mint that must be running as an Archy app before calling this endpoint. + pub(super) async fn handle_openwrt_provision_tollgate( + &self, + params: Option, + ) -> Result { + let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + + let host = p + .get("host") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing host"))? + .to_string(); + let ssh_user = p + .get("ssh_user") + .and_then(|v| v.as_str()) + .unwrap_or("root") + .to_string(); + let ssh_password = p + .get("ssh_password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT); + let mint_url = p + .get("mint_url") + .and_then(|v| v.as_str()) + .unwrap_or(&default_mint_url) + .to_string(); + + let config = TollGateConfig { + ssid: "archipelago".to_string(), + mint_url, + price_sats: p.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(10), + step_size_ms: p + .get("step_size_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(60_000), + min_steps: p + .get("min_steps") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32, + }; + + let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; + router.verify_openwrt()?; + tollgate::provision(&router, &config).await?; + + Ok(serde_json::json!({ + "ok": true, + "host": host, + "ssid": config.ssid, + "mint_url": config.mint_url, + })) + } +} + +fn parse_ipv4(s: &str) -> Result<[u8; 4]> { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 4 { + anyhow::bail!("Invalid IPv4: {}", s); + } + Ok([ + parts[0].parse()?, + parts[1].parse()?, + parts[2].parse()?, + parts[3].parse()?, + ]) +} diff --git a/core/openwrt/Cargo.toml b/core/openwrt/Cargo.toml new file mode 100644 index 00000000..ca6d868f --- /dev/null +++ b/core/openwrt/Cargo.toml @@ -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" diff --git a/core/openwrt/src/detect.rs b/core/openwrt/src/detect.rs new file mode 100644 index 00000000..cd67bcf7 --- /dev/null +++ b/core/openwrt/src/detect.rs @@ -0,0 +1,72 @@ +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 { + 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 { + 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 { + 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) +} diff --git a/core/openwrt/src/lib.rs b/core/openwrt/src/lib.rs new file mode 100644 index 00000000..8ae838e6 --- /dev/null +++ b/core/openwrt/src/lib.rs @@ -0,0 +1,7 @@ +pub mod detect; +pub mod opkg; +pub mod router; +pub mod tollgate; +pub mod uci; + +pub use router::Router; diff --git a/core/openwrt/src/opkg.rs b/core/openwrt/src/opkg.rs new file mode 100644 index 00000000..4ee0b45f --- /dev/null +++ b/core/openwrt/src/opkg.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use tracing::info; + +use crate::Router; + +impl Router { + /// `opkg update` — refresh package lists. + pub fn opkg_update(&self) -> Result<()> { + info!("[{}] opkg update", self.host); + self.run_ok("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!("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!("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!("opkg remove {}", package))?; + Ok(()) + } +} diff --git a/core/openwrt/src/router.rs b/core/openwrt/src/router.rs new file mode 100644 index 00000000..088ec9c5 --- /dev/null +++ b/core/openwrt/src/router.rs @@ -0,0 +1,87 @@ +use anyhow::{Context, Result}; +use ssh2::Session; +use std::io::Read; +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 { + 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 { + 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 { + let (out, code) = self.run(cmd)?; + if code != 0 { + anyhow::bail!("command `{}` exited with code {}: {}", cmd, code, out.trim()); + } + Ok(out) + } + + /// Verify the remote device is actually running OpenWrt. + pub fn verify_openwrt(&self) -> Result { + let release = self + .run_ok("cat /etc/openwrt_release") + .context("read /etc/openwrt_release — is this an OpenWrt device?")?; + Ok(release) + } +} diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs new file mode 100644 index 00000000..dc422194 --- /dev/null +++ b/core/openwrt/src/tollgate/config.rs @@ -0,0 +1,56 @@ +use anyhow::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, +} + +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, + } + } +} + +/// Write TollGate UCI configuration and commit. +/// +/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go. +pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { + router.uci_apply( + "tollgate", + &[ + ("tollgate.main", "tollgate"), + ("tollgate.main.enabled", "1"), + ("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(()) +} diff --git a/core/openwrt/src/tollgate/install.rs b/core/openwrt/src/tollgate/install.rs new file mode 100644 index 00000000..d559d832 --- /dev/null +++ b/core/openwrt/src/tollgate/install.rs @@ -0,0 +1,16 @@ +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"; + +/// Install tollgate-module-basic-go via opkg. +/// +/// Caller is responsible for running `opkg_update` first. +pub fn install_tollgate(router: &Router) -> Result<()> { + info!("[{}] Installing {}", router.host, TOLLGATE_PACKAGE); + router.opkg_install(TOLLGATE_PACKAGE)?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs new file mode 100644 index 00000000..a756a675 --- /dev/null +++ b/core/openwrt/src/tollgate/mod.rs @@ -0,0 +1,36 @@ +pub mod config; +pub mod install; +pub mod wifi; + +pub use config::TollGateConfig; +pub use install::install_tollgate; +pub use wifi::provision_ssid; + +use anyhow::Result; +use tracing::info; + +use crate::Router; + +/// Full TollGate provisioning sequence: +/// 1. Install tollgate-module-basic-go via opkg +/// 2. Write TollGate UCI config (pricing, mint URL) +/// 3. Create the pay-as-you-go WiFi SSID +/// 4. Restart affected services +pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { + info!("[{}] Starting TollGate provisioning", router.host); + + router.opkg_update()?; + install_tollgate(router)?; + config::apply(router, config)?; + wifi::provision_ssid(router, config)?; + restart_services(router)?; + + info!("[{}] TollGate provisioning complete", router.host); + Ok(()) +} + +fn restart_services(router: &Router) -> Result<()> { + router.run_ok("/etc/init.d/tollgate restart || true")?; + router.run_ok("/etc/init.d/network restart")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs new file mode 100644 index 00000000..74b7e9a4 --- /dev/null +++ b/core/openwrt/src/tollgate/wifi.rs @@ -0,0 +1,106 @@ +use anyhow::{Context, Result}; +use tracing::info; + +use crate::tollgate::TollGateConfig; +use crate::Router; + +/// Create a dedicated pay-as-you-go WiFi interface for TollGate. +/// +/// Adds a new `wifi-iface` section on the first detected radio, sets the SSID, +/// marks it as an open network, and ties it to a TollGate firewall zone. +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); + + // Add a new wifi-iface section; uci add returns the section name (e.g. "cfg123456"). + let section = router.uci_add("wireless", "wifi-iface")?; + + router.uci_apply( + "wireless", + &[ + (&format!("wireless.{}.device", section), &radio), + (&format!("wireless.{}.mode", section), "ap"), + (&format!("wireless.{}.ssid", section), &cfg.ssid), + (&format!("wireless.{}.encryption", section), "none"), + (&format!("wireless.{}.network", section), "tollgate"), + // Disable 802.11r/k/v — unnecessary for transient pay-as-you-go clients. + (&format!("wireless.{}.ieee80211r", section), "0"), + ], + )?; + + provision_network(router)?; + provision_firewall(router)?; + + Ok(()) +} + +/// Add a `tollgate` network interface (isolated LAN for TollGate clients). +fn provision_network(router: &Router) -> Result<()> { + router.uci_apply( + "network", + &[ + ("network.tollgate", "interface"), + ("network.tollgate.proto", "static"), + ("network.tollgate.ipaddr", "192.168.99.1"), + ("network.tollgate.netmask", "255.255.255.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"), + ], + )?; + + Ok(()) +} + +/// Add firewall zone for the tollgate interface. +/// +/// TollGate itself gates forwarding via iptables; the firewall zone isolates +/// tollgate clients from other LAN segments. +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 { + 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) +} diff --git a/core/openwrt/src/uci.rs b/core/openwrt/src/uci.rs new file mode 100644 index 00000000..3df49cd5 --- /dev/null +++ b/core/openwrt/src/uci.rs @@ -0,0 +1,59 @@ +use anyhow::Result; + +use crate::Router; + +/// Thin wrappers around `uci` CLI commands over SSH. +impl Router { + /// `uci get ` — returns trimmed value. + pub fn uci_get(&self, key: &str) -> Result { + let out = self.run_ok(&format!("uci get {}", key))?; + Ok(out.trim().to_string()) + } + + /// `uci set =` + pub fn uci_set(&self, key: &str, value: &str) -> Result<()> { + self.run_ok(&format!("uci set {}={}", key, shell_quote(value)))?; + Ok(()) + } + + /// `uci add ` — returns the new section name. + pub fn uci_add(&self, config: &str, section_type: &str) -> Result { + let out = self.run_ok(&format!("uci add {} {}", config, section_type))?; + Ok(out.trim().to_string()) + } + + /// `uci add_list =` + 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 ` + pub fn uci_delete(&self, key: &str) -> Result<()> { + self.run_ok(&format!("uci delete {}", key))?; + Ok(()) + } + + /// `uci commit []` + 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<()> { + 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. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +}