feat(openwrt): add archipelago-openwrt crate with TollGate provisioning
New `archipelago-openwrt` workspace crate provides SSH/UCI-based management of OpenWrt routers, including automated TollGate installation and configuration of a pay-as-you-go "archipelago" SSID backed by the local Cashu mint. Exposes two RPC endpoints: - `openwrt.scan` — discover OpenWrt routers on the LAN - `openwrt.provision-tollgate` — install tollgate-module-basic-go, write UCI config (TIP-01/TIP-02), and create isolated WiFi SSID + firewall zone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a57ae388ec
commit
e0cc00be0f
79
core/Cargo.lock
generated
79
core/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -4,6 +4,7 @@ resolver = "2"
|
||||
members = [
|
||||
"archipelago",
|
||||
"container",
|
||||
"openwrt",
|
||||
"performance",
|
||||
"security",
|
||||
]
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -23,6 +23,7 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
|
||||
118
core/archipelago/src/api/rpc/openwrt.rs
Normal file
118
core/archipelago/src/api/rpc/openwrt.rs
Normal file
@ -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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<String> = 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": "<optional override>" }`
|
||||
///
|
||||
/// `mint_url` defaults to `http://<this node's IP>: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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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()?,
|
||||
])
|
||||
}
|
||||
23
core/openwrt/Cargo.toml
Normal file
23
core/openwrt/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "archipelago-openwrt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "OpenWrt gateway integration for Archipelago — TollGate provisioning over SSH/UCI"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_openwrt"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
ssh2 = "0.9"
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
72
core/openwrt/src/detect.rs
Normal file
72
core/openwrt/src/detect.rs
Normal file
@ -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<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)
|
||||
}
|
||||
7
core/openwrt/src/lib.rs
Normal file
7
core/openwrt/src/lib.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod detect;
|
||||
pub mod opkg;
|
||||
pub mod router;
|
||||
pub mod tollgate;
|
||||
pub mod uci;
|
||||
|
||||
pub use router::Router;
|
||||
34
core/openwrt/src/opkg.rs
Normal file
34
core/openwrt/src/opkg.rs
Normal file
@ -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(())
|
||||
}
|
||||
}
|
||||
87
core/openwrt/src/router.rs
Normal file
87
core/openwrt/src/router.rs
Normal file
@ -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<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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
56
core/openwrt/src/tollgate/config.rs
Normal file
56
core/openwrt/src/tollgate/config.rs
Normal file
@ -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(())
|
||||
}
|
||||
16
core/openwrt/src/tollgate/install.rs
Normal file
16
core/openwrt/src/tollgate/install.rs
Normal file
@ -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(())
|
||||
}
|
||||
36
core/openwrt/src/tollgate/mod.rs
Normal file
36
core/openwrt/src/tollgate/mod.rs
Normal file
@ -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(())
|
||||
}
|
||||
106
core/openwrt/src/tollgate/wifi.rs
Normal file
106
core/openwrt/src/tollgate/wifi.rs
Normal file
@ -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<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)
|
||||
}
|
||||
59
core/openwrt/src/uci.rs
Normal file
59
core/openwrt/src/uci.rs
Normal file
@ -0,0 +1,59 @@
|
||||
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<()> {
|
||||
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"'\''"))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user