Merge remote-tracking branch 'gitea-ai/archy-openwrt'
This commit is contained in:
commit
27093e682f
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" }
|
||||
|
||||
|
||||
@ -223,6 +223,7 @@ impl RpcHandler {
|
||||
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||
"network.scan-wifi" => self.handle_network_scan_wifi().await,
|
||||
"network.configure-wifi" => self.handle_network_configure_wifi(params).await,
|
||||
"network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await,
|
||||
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
|
||||
"network.dns-status" => self.handle_network_dns_status().await,
|
||||
"network.configure-dns" => self.handle_network_configure_dns(params).await,
|
||||
@ -230,6 +231,13 @@ 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.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
|
||||
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||
|
||||
@ -18,6 +18,24 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "networks": networks }))
|
||||
}
|
||||
|
||||
/// network.set-wifi-radio — turn the wifi adapter fully on or off (not just
|
||||
/// disconnect from a network). Params: `{ "enabled": bool }`.
|
||||
pub(super) async fn handle_network_set_wifi_radio(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let enabled = params
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?;
|
||||
|
||||
tracing::info!(enabled, "Setting wifi radio state");
|
||||
set_wifi_radio(enabled).await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// network.configure-wifi — connect to a WiFi network.
|
||||
pub(super) async fn handle_network_configure_wifi(
|
||||
&self,
|
||||
@ -327,6 +345,27 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||
fields
|
||||
}
|
||||
|
||||
/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not
|
||||
/// just disconnecting from the current network — the adapter stops scanning/
|
||||
/// associating entirely until switched back on).
|
||||
async fn set_wifi_radio(enabled: bool) -> Result<()> {
|
||||
let state = if enabled { "on" } else { "off" };
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["radio", "wifi", state])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli radio wifi")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"nmcli radio wifi {} failed: {}",
|
||||
state,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to a WiFi network using nmcli.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
|
||||
@ -64,6 +64,19 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Container",
|
||||
"Image",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
"No space left",
|
||||
"Not enough flash",
|
||||
"Not enough space",
|
||||
"TollGate installation failed",
|
||||
"No pre-built TollGate",
|
||||
"opkg not found",
|
||||
"apk update failed",
|
||||
"No wireless interface",
|
||||
"No wireless radio",
|
||||
"WiFi radio enabled but",
|
||||
"Missing required field",
|
||||
];
|
||||
for prefix in &user_facing_prefixes {
|
||||
if msg.starts_with(prefix) {
|
||||
|
||||
@ -23,6 +23,7 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
|
||||
353
core/archipelago/src/api/rpc/openwrt.rs
Normal file
353
core/archipelago/src/api/rpc/openwrt.rs
Normal file
@ -0,0 +1,353 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
wan,
|
||||
wifi_scan,
|
||||
};
|
||||
use crate::network::router as net_router;
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
/// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI.
|
||||
///
|
||||
/// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }`
|
||||
/// If params are omitted the saved `router_config.json` credentials are used.
|
||||
pub(super) async fn handle_openwrt_get_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
let host_from_params = p.get("host").and_then(|v| v.as_str()).is_some();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
// Persist the connection so other views (e.g. the Home dashboard's
|
||||
// Network tile) can poll `openwrt.get-status` with no params instead
|
||||
// of every caller needing to carry host/credentials around. Only do
|
||||
// this when the host actually came from params — otherwise every
|
||||
// no-args poll would re-save the same thing it just read.
|
||||
if host_from_params {
|
||||
let _ = net_router::configure_router(
|
||||
&self.config.data_dir,
|
||||
net_router::RouterType::OpenWrt,
|
||||
&host,
|
||||
None,
|
||||
Some(&ssh_user),
|
||||
Some(&ssh_password),
|
||||
).await;
|
||||
}
|
||||
|
||||
// System info
|
||||
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
|
||||
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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 saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
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,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
};
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Scan for visible WiFi networks from the router's radio.
|
||||
///
|
||||
/// Params: same host/credentials as other openwrt methods.
|
||||
pub(super) async fn handle_openwrt_scan_wifi(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "networks": result }))
|
||||
}
|
||||
|
||||
/// Configure WAN/WISP — connect the router to an upstream WiFi network.
|
||||
///
|
||||
/// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }`
|
||||
pub(super) async fn handle_openwrt_configure_wan(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
|
||||
let ssid = p.get("ssid").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
|
||||
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
|
||||
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
|
||||
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq };
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||
fn parse_release(raw: &str) -> serde_json::Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for line in raw.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
m.insert(
|
||||
k.to_lowercase(),
|
||||
serde_json::Value::String(v.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
}
|
||||
|
||||
/// Extract AP wifi-iface sections from `uci show wireless` output.
|
||||
fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if let Some((lhs, rhs)) = line.trim().split_once('=') {
|
||||
let parts: Vec<&str> = lhs.splitn(3, '.').collect();
|
||||
if parts.len() == 3 && parts[0] == "wireless" {
|
||||
sections
|
||||
.entry(parts[1].to_string())
|
||||
.or_default()
|
||||
.insert(parts[2].to_string(), rhs.trim_matches('\'').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ifaces: Vec<serde_json::Value> = sections
|
||||
.into_iter()
|
||||
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
|
||||
.map(|(name, f)| serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
|
||||
ifaces
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
9
core/openwrt/src/lib.rs
Normal file
9
core/openwrt/src/lib.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub mod detect;
|
||||
pub mod opkg;
|
||||
pub mod router;
|
||||
pub mod tollgate;
|
||||
pub mod uci;
|
||||
pub mod wan;
|
||||
pub mod wifi_scan;
|
||||
|
||||
pub use router::Router;
|
||||
90
core/openwrt/src/opkg.rs
Normal file
90
core/openwrt/src/opkg.rs
Normal file
@ -0,0 +1,90 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
59
core/openwrt/src/tollgate/config.rs
Normal file
59
core/openwrt/src/tollgate/config.rs
Normal file
@ -0,0 +1,59 @@
|
||||
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,
|
||||
/// 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.
|
||||
///
|
||||
/// 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", 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(())
|
||||
}
|
||||
254
core/openwrt/src/tollgate/install.rs
Normal file
254
core/openwrt/src/tollgate/install.rs
Normal file
@ -0,0 +1,254 @@
|
||||
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(())
|
||||
}
|
||||
61
core/openwrt/src/tollgate/mod.rs
Normal file
61
core/openwrt/src/tollgate/mod.rs
Normal file
@ -0,0 +1,61 @@
|
||||
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::{opkg::PkgManager, Router};
|
||||
|
||||
/// Full TollGate provisioning sequence:
|
||||
/// 1. Install tollgate-module-basic-go
|
||||
/// 2. Write TollGate UCI config (pricing, mint URL)
|
||||
/// 3. Create the pay-as-you-go WiFi SSID
|
||||
/// 4. Restart affected services
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
config::apply(router, config)?;
|
||||
wifi::provision_ssid(router, config)?;
|
||||
restart_services(router, config.enabled)?;
|
||||
|
||||
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")?;
|
||||
Ok(())
|
||||
}
|
||||
109
core/openwrt/src/tollgate/wifi.rs
Normal file
109
core/openwrt/src/tollgate/wifi.rs
Normal file
@ -0,0 +1,109 @@
|
||||
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).
|
||||
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)
|
||||
}
|
||||
65
core/openwrt/src/uci.rs
Normal file
65
core/openwrt/src/uci.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::Router;
|
||||
|
||||
/// Thin wrappers around `uci` CLI commands over SSH.
|
||||
impl Router {
|
||||
/// `uci get <key>` — returns trimmed value.
|
||||
pub fn uci_get(&self, key: &str) -> Result<String> {
|
||||
let out = self.run_ok(&format!("uci get {}", key))?;
|
||||
Ok(out.trim().to_string())
|
||||
}
|
||||
|
||||
/// `uci set <key>=<value>`
|
||||
pub fn uci_set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci set {}={}", key, shell_quote(value)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci add <config> <type>` — returns the new section name.
|
||||
pub fn uci_add(&self, config: &str, section_type: &str) -> Result<String> {
|
||||
let out = self.run_ok(&format!("uci add {} {}", config, section_type))?;
|
||||
Ok(out.trim().to_string())
|
||||
}
|
||||
|
||||
/// `uci add_list <key>=<value>`
|
||||
pub fn uci_add_list(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci add_list {}={}", key, shell_quote(value)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci delete <key>`
|
||||
pub fn uci_delete(&self, key: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci delete {}", key))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci commit [<config>]`
|
||||
pub fn uci_commit(&self, config: Option<&str>) -> Result<()> {
|
||||
match config {
|
||||
Some(c) => self.run_ok(&format!("uci commit {}", c))?,
|
||||
None => self.run_ok("uci commit")?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Batch: apply a list of `(key, value)` pairs then commit the config.
|
||||
pub fn uci_apply(&self, config: &str, pairs: &[(&str, &str)]) -> Result<()> {
|
||||
// `uci set config.section=type` fails with "Entry not found" if
|
||||
// /etc/config/<config> doesn't exist yet — true for any config file
|
||||
// shipped by the base system (wireless, network, dhcp, ...) but not
|
||||
// for a package-defined namespace like "tollgate" that nothing has
|
||||
// created a default for. `touch` is a no-op if it already exists.
|
||||
self.run_ok(&format!("touch /etc/config/{}", config))?;
|
||||
for (key, value) in pairs {
|
||||
self.uci_set(key, value)?;
|
||||
}
|
||||
self.uci_commit(Some(config))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a value in single quotes, escaping any embedded single quotes.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
218
core/openwrt/src/wan.rs
Normal file
218
core/openwrt/src/wan.rs
Normal file
@ -0,0 +1,218 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use crate::Router;
|
||||
|
||||
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(())
|
||||
}
|
||||
177
core/openwrt/src/wifi_scan.rs
Normal file
177
core/openwrt/src/wifi_scan.rs
Normal file
@ -0,0 +1,177 @@
|
||||
use anyhow::Result;
|
||||
use crate::Router;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -33,14 +33,18 @@ configure_display() {
|
||||
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$output" ] || return 0
|
||||
|
||||
# Pick the EDID-preferred ("+") mode, falling back to the first-listed
|
||||
# mode (EDID lists native first). Deliberately ignore "*" (currently
|
||||
# active) — trusting "active" lets a bad clone/mirror state from a
|
||||
# previous boot perpetuate itself forever instead of self-healing.
|
||||
mode=$(awk -v out="$output" '
|
||||
$1 == out { active = 1; next }
|
||||
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
|
||||
if ($0 ~ /\*/) { print $1; exit }
|
||||
if ($0 ~ /\+/ && !preferred) { preferred = $1 }
|
||||
if (!first) first = $1
|
||||
}
|
||||
active && /^[^[:space:]]/ { active = 0 }
|
||||
END { if (first) print first }
|
||||
END { if (preferred) print preferred; else if (first) print first }
|
||||
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$mode" ] || mode=1920x1080
|
||||
|
||||
|
||||
@ -176,6 +176,11 @@ const router = createRouter({
|
||||
name: 'server',
|
||||
component: () => import('../views/Server.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/openwrt',
|
||||
name: 'openwrt-gateway',
|
||||
component: () => import('../views/server/OpenWrtGateway.vue'),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
name: 'monitoring',
|
||||
|
||||
@ -49,10 +49,12 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const bitcoinStale = ref(false)
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const tollgateLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
const lastBitcoinRefreshAt = ref<number | null>(null)
|
||||
const lastVpnRefreshAt = ref<number | null>(null)
|
||||
const lastFipsRefreshAt = ref<number | null>(null)
|
||||
const lastTollgateRefreshAt = ref<number | null>(null)
|
||||
|
||||
const vpnStatus = ref<{
|
||||
connected: boolean | null
|
||||
@ -67,6 +69,9 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
authenticated_peer_count?: number
|
||||
} | null>(null)
|
||||
|
||||
// null = no OpenWrt router configured at all (tile row shows "Not configured").
|
||||
const tollgateStatus = ref<{ installed: boolean; enabled: boolean } | null>(null)
|
||||
|
||||
const systemStatsLoaded = computed(() => systemLoadState.value === 'ready')
|
||||
const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null)
|
||||
const vpnKnown = computed(() => vpnStatus.value.connected !== null)
|
||||
@ -185,12 +190,37 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTollgate() {
|
||||
tollgateLoadState.value = tollgateLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const res = await rpcClient.call<{ tollgate: { installed: boolean; enabled?: boolean } }>({
|
||||
method: 'openwrt.get-status',
|
||||
timeout: 15000,
|
||||
})
|
||||
tollgateStatus.value = { installed: res.tollgate.installed, enabled: res.tollgate.enabled ?? false }
|
||||
tollgateLoadState.value = 'ready'
|
||||
lastTollgateRefreshAt.value = Date.now()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (msg.includes('No router configured')) {
|
||||
// Not an error — most nodes simply don't have an OpenWrt gateway set up.
|
||||
tollgateStatus.value = null
|
||||
tollgateLoadState.value = 'ready'
|
||||
lastTollgateRefreshAt.value = Date.now()
|
||||
} else {
|
||||
// Transient failure (SSH hiccup, router rebooting) — keep last-known state.
|
||||
tollgateLoadState.value = tollgateStatus.value ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(packages: Record<string, PackageDataEntry>) {
|
||||
await Promise.all([
|
||||
refreshSystemStats(),
|
||||
refreshBitcoin(packages),
|
||||
refreshVpn(packages),
|
||||
refreshFips(),
|
||||
refreshTollgate(),
|
||||
])
|
||||
}
|
||||
|
||||
@ -201,19 +231,23 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
bitcoinStale,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
tollgateLoadState,
|
||||
systemStatsLoaded,
|
||||
bitcoinKnown,
|
||||
vpnKnown,
|
||||
vpnStatus,
|
||||
fipsStatus,
|
||||
tollgateStatus,
|
||||
lastSystemRefreshAt,
|
||||
lastBitcoinRefreshAt,
|
||||
lastVpnRefreshAt,
|
||||
lastFipsRefreshAt,
|
||||
lastTollgateRefreshAt,
|
||||
refresh,
|
||||
refreshSystemStats,
|
||||
refreshBitcoin,
|
||||
refreshVpn,
|
||||
refreshFips,
|
||||
refreshTollgate,
|
||||
}
|
||||
})
|
||||
|
||||
@ -173,6 +173,10 @@
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
|
||||
<span class="text-sm font-medium" :class="fipsTextClass">{{ fipsStatusLabel }}</span>
|
||||
</div>
|
||||
<div v-if="homeStatus.tollgateStatus" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="tollgateDotClass"></div><span class="text-sm text-white/80">TollGate</span></div>
|
||||
<span class="text-sm font-medium" :class="tollgateTextClass">{{ tollgateStatusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageNetwork') }}</RouterLink>
|
||||
@ -445,6 +449,22 @@ const fipsStatusLabel = computed(() => {
|
||||
const peers = s.authenticated_peer_count ?? 0
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const tollgateDotClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
return s.enabled ? 'bg-green-400' : 'bg-yellow-400'
|
||||
})
|
||||
const tollgateTextClass = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
return s.enabled ? 'text-green-400' : 'text-yellow-400'
|
||||
})
|
||||
const tollgateStatusLabel = computed(() => {
|
||||
const s = homeStatus.tollgateStatus
|
||||
if (!s) return homeStatus.tollgateLoadState === 'loading' ? 'Checking…' : 'Not configured'
|
||||
if (!s.installed) return 'Not installed'
|
||||
return s.enabled ? 'Enabled' : 'Disabled'
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…'
|
||||
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
|
||||
|
||||
@ -108,6 +108,16 @@
|
||||
</div>
|
||||
<span class="text-sm" :class="torStatusLabel === 'running' ? 'text-green-400' : 'text-white/60'">{{ torStatusLabel === 'running' ? 'Connected' : torStatusLabel === 'checking' ? 'Checking...' : 'Stopped' }}</span>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard/server/openwrt"
|
||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
|
||||
<span class="text-white/80 text-sm">OpenWrt Gateway</span>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</router-link>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
@ -244,12 +254,28 @@
|
||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
||||
<p v-else class="text-sm text-white/40">No IP</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right">
|
||||
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
|
||||
<p v-else class="text-sm text-white/40">No IP</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="iface.type === 'wifi'"
|
||||
:disabled="togglingWifiRadio"
|
||||
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
|
||||
:class="[iface.state === 'up' ? 'bg-green-500/60' : 'bg-white/15', togglingWifiRadio ? 'opacity-40 cursor-not-allowed' : '']"
|
||||
:aria-label="iface.state === 'up' ? 'Turn off wifi adapter' : 'Turn on wifi adapter'"
|
||||
@click="toggleWifiRadio(iface)"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
:class="iface.state === 'up' ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
<p v-if="wifiRadioError" class="text-xs text-red-400">{{ wifiRadioError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -558,6 +584,9 @@ const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||
|
||||
const togglingWifiRadio = ref(false)
|
||||
const wifiRadioError = ref('')
|
||||
|
||||
const showWifiModal = ref(false)
|
||||
const wifiScanning = ref(false)
|
||||
const wifiNetworks = ref<WifiNetwork[]>([])
|
||||
@ -613,6 +642,20 @@ async function loadInterfaces() {
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||
}
|
||||
|
||||
async function toggleWifiRadio(iface: NetworkInterface) {
|
||||
togglingWifiRadio.value = true
|
||||
wifiRadioError.value = ''
|
||||
const enabled = iface.state !== 'up'
|
||||
try {
|
||||
await rpcClient.call({ method: 'network.set-wifi-radio', params: { enabled } })
|
||||
await loadInterfaces()
|
||||
} catch (e) {
|
||||
wifiRadioError.value = e instanceof Error ? e.message : 'Failed to change wifi radio state.'
|
||||
} finally {
|
||||
togglingWifiRadio.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
|
||||
const security = (network?.security || '').trim().toLowerCase()
|
||||
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
|
||||
|
||||
893
neode-ui/src/views/server/OpenWrtGateway.vue
Normal file
893
neode-ui/src/views/server/OpenWrtGateway.vue
Normal file
@ -0,0 +1,893 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
interface ReleaseInfo {
|
||||
openwrt_release?: string
|
||||
openwrt_version?: string
|
||||
openwrt_board_name?: string
|
||||
openwrt_arch?: string
|
||||
openwrt_target?: string
|
||||
}
|
||||
|
||||
interface WifiInterface {
|
||||
section: string
|
||||
ssid: string
|
||||
device: string
|
||||
encryption: string
|
||||
network: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface TollGateStatus {
|
||||
installed: boolean
|
||||
enabled?: boolean
|
||||
metric?: string
|
||||
step_size_ms?: number
|
||||
price_per_step?: number
|
||||
min_steps?: number
|
||||
currency?: string
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
interface WanStatus {
|
||||
configured: boolean
|
||||
ssid: string
|
||||
assoc_ssid: string
|
||||
encryption: string
|
||||
ip: string
|
||||
internet: boolean
|
||||
radio0_disabled: boolean
|
||||
sta_iface: string
|
||||
sta_state: string
|
||||
wifi_log: string
|
||||
lan_ip: string
|
||||
lan_netmask: string
|
||||
dhcp_start: string
|
||||
dhcp_limit: string
|
||||
masq: boolean
|
||||
}
|
||||
|
||||
interface RouterStatus {
|
||||
host: string
|
||||
hostname: string
|
||||
uptime_secs: number
|
||||
release: ReleaseInfo
|
||||
tollgate: TollGateStatus
|
||||
wifi_interfaces: WifiInterface[]
|
||||
wan: WanStatus
|
||||
}
|
||||
|
||||
interface ScannedNetwork {
|
||||
ssid: string
|
||||
bssid: string
|
||||
signal: number
|
||||
channel: number
|
||||
encryption: string
|
||||
}
|
||||
|
||||
const status = ref<RouterStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const host = ref('')
|
||||
const sshUser = ref('root')
|
||||
const sshPassword = ref('')
|
||||
const showConnectForm = ref(false)
|
||||
const connecting = ref(false)
|
||||
const connectedParams = ref<Record<string, string> | null>(null)
|
||||
|
||||
const detecting = ref(false)
|
||||
const detectError = ref('')
|
||||
const detectedCandidates = ref<string[]>([])
|
||||
|
||||
const provisioning = ref(false)
|
||||
const provisionError = ref('')
|
||||
const provisionSuccess = ref(false)
|
||||
|
||||
// TollGate reconfigure form (shown once installed)
|
||||
const editingTollgate = ref(false)
|
||||
const updatingTollgate = ref(false)
|
||||
const updateTollgateError = ref('')
|
||||
const editPriceSats = ref(10)
|
||||
const editStepSizeMin = ref(1)
|
||||
const editMinSteps = ref(1)
|
||||
const editMintUrl = ref('')
|
||||
const editEnabled = ref(true)
|
||||
|
||||
// WAN setup flow
|
||||
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'dhcp' | 'connecting' | 'done'
|
||||
const wanStep = ref<WanStep>('idle')
|
||||
const scannedNetworks = ref<ScannedNetwork[]>([])
|
||||
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
||||
const wanPassword = ref('')
|
||||
const wanError = ref('')
|
||||
|
||||
// DHCP/masq settings (step 3 of wizard)
|
||||
const dhcpStart = ref(100)
|
||||
const dhcpLimit = ref(150)
|
||||
const masqEnabled = ref(true)
|
||||
|
||||
async function load(params?: Record<string, string>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
status.value = await rpcClient.call<RouterStatus>({
|
||||
method: 'openwrt.get-status',
|
||||
params: params ?? {},
|
||||
timeout: 30000,
|
||||
})
|
||||
showConnectForm.value = false
|
||||
if (params) connectedParams.value = params
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (msg.includes('No router configured')) {
|
||||
showConnectForm.value = true
|
||||
} else {
|
||||
error.value = msg
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!host.value.trim()) return
|
||||
connecting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await load({ host: host.value.trim(), ssh_user: sshUser.value, ssh_password: sshPassword.value })
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
interface WiredInterface { name: string; type: string; state: string; ipv4: string[] }
|
||||
|
||||
async function detectRouter() {
|
||||
detecting.value = true
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
try {
|
||||
const { interfaces } = await rpcClient.call<{ interfaces: WiredInterface[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
timeout: 10000,
|
||||
})
|
||||
const wired = interfaces.find(i => i.type === 'ethernet' && i.state === 'up' && i.ipv4.length > 0)
|
||||
if (!wired) {
|
||||
detectError.value = 'No active wired ethernet connection found on this node.'
|
||||
return
|
||||
}
|
||||
const [ip, prefixStr] = wired.ipv4[0]!.split('/')
|
||||
const prefix = Number(prefixStr) || 24
|
||||
|
||||
const { routers } = await rpcClient.call<{ routers: string[] }>({
|
||||
method: 'openwrt.scan',
|
||||
params: { subnet: ip, prefix, ssh_user: sshUser.value, ssh_password: sshPassword.value },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (routers.length === 0) {
|
||||
detectError.value = `No OpenWrt router found on ${wired.name}'s network (/${prefix}).`
|
||||
} else if (routers.length === 1) {
|
||||
host.value = routers[0]!
|
||||
} else {
|
||||
detectedCandidates.value = routers
|
||||
}
|
||||
} catch (e) {
|
||||
detectError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
detecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickDetectedRouter(ip: string) {
|
||||
host.value = ip
|
||||
detectedCandidates.value = []
|
||||
}
|
||||
|
||||
// The router config now persists server-side (see handle_openwrt_get_status),
|
||||
// so onMounted's no-args load() always reconnects automatically — without this,
|
||||
// there'd be no way back to the connect form (and its Detect button) at all.
|
||||
function disconnectRouter() {
|
||||
host.value = status.value?.host ?? host.value
|
||||
connectedParams.value = null
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
showConnectForm.value = true
|
||||
}
|
||||
|
||||
async function provisionTollgate() {
|
||||
provisioning.value = true
|
||||
provisionError.value = ''
|
||||
provisionSuccess.value = false
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
provisionSuccess.value = true
|
||||
await load(connectedParams.value ?? undefined)
|
||||
} catch (e) {
|
||||
provisionError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
provisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditTollgate() {
|
||||
const tg = status.value?.tollgate
|
||||
editPriceSats.value = tg?.price_per_step ?? 10
|
||||
editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000))
|
||||
editMinSteps.value = tg?.min_steps ?? 1
|
||||
editMintUrl.value = tg?.mint_url ?? ''
|
||||
editEnabled.value = tg?.enabled ?? true
|
||||
updateTollgateError.value = ''
|
||||
editingTollgate.value = true
|
||||
}
|
||||
|
||||
async function saveTollgateConfig() {
|
||||
updatingTollgate.value = true
|
||||
updateTollgateError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
price_sats: editPriceSats.value,
|
||||
step_size_ms: editStepSizeMin.value * 60_000,
|
||||
min_steps: editMinSteps.value,
|
||||
mint_url: editMintUrl.value,
|
||||
enabled: editEnabled.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
editingTollgate.value = false
|
||||
await load(connectedParams.value ?? undefined)
|
||||
} catch (e) {
|
||||
updateTollgateError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
updatingTollgate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startWanSetup() {
|
||||
wanStep.value = 'scan'
|
||||
wanError.value = ''
|
||||
scannedNetworks.value = []
|
||||
selectedNetwork.value = null
|
||||
wanPassword.value = ''
|
||||
dhcpStart.value = Number(status.value?.wan?.dhcp_start) || 100
|
||||
dhcpLimit.value = Number(status.value?.wan?.dhcp_limit) || 150
|
||||
masqEnabled.value = true
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
wanStep.value = 'scanning'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
|
||||
method: 'openwrt.scan-wifi',
|
||||
params,
|
||||
timeout: 30000,
|
||||
})
|
||||
scannedNetworks.value = result.networks
|
||||
wanStep.value = 'list'
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'scan'
|
||||
}
|
||||
}
|
||||
|
||||
function selectNetwork(net: ScannedNetwork) {
|
||||
selectedNetwork.value = net
|
||||
wanPassword.value = ''
|
||||
wanError.value = ''
|
||||
wanStep.value = 'password'
|
||||
}
|
||||
|
||||
async function configureWan() {
|
||||
if (!selectedNetwork.value) return
|
||||
wanStep.value = 'connecting'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
ssid: selectedNetwork.value.ssid,
|
||||
password: wanPassword.value,
|
||||
encryption: selectedNetwork.value.encryption,
|
||||
dhcp_start: dhcpStart.value,
|
||||
dhcp_limit: dhcpLimit.value,
|
||||
masq: masqEnabled.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 })
|
||||
wanStep.value = 'done'
|
||||
// Give the router ~8s to associate before reloading status
|
||||
setTimeout(() => {
|
||||
wanStep.value = 'idle'
|
||||
load(connectedParams.value ?? undefined)
|
||||
}, 8000)
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'dhcp'
|
||||
}
|
||||
}
|
||||
|
||||
function signalBars(dbm: number): number {
|
||||
if (dbm >= -50) return 4
|
||||
if (dbm >= -65) return 3
|
||||
if (dbm >= -75) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function formatUptime(secs: number): string {
|
||||
const d = Math.floor(secs / 86400)
|
||||
const h = Math.floor((secs % 86400) / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const parts = []
|
||||
if (d) parts.push(`${d}d`)
|
||||
if (h) parts.push(`${h}h`)
|
||||
parts.push(`${m}m`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatStepSize(ms: number): string {
|
||||
if (ms >= 3_600_000) return `${ms / 3_600_000}h`
|
||||
if (ms >= 60_000) return `${ms / 60_000}m`
|
||||
if (ms >= 1_000) return `${ms / 1_000}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
const openwrtVersion = computed(() => {
|
||||
if (!status.value) return ''
|
||||
const r = status.value.release
|
||||
return r.openwrt_release || r.openwrt_version || ''
|
||||
})
|
||||
|
||||
const boardName = computed(() => {
|
||||
if (!status.value) return ''
|
||||
return status.value.release.openwrt_board_name || ''
|
||||
})
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton />
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
<!-- Connect form -->
|
||||
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Router IP</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="host"
|
||||
type="text"
|
||||
placeholder="192.168.1.1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
:disabled="detecting"
|
||||
class="glass-button px-4 py-3 text-sm font-medium whitespace-nowrap flex-shrink-0"
|
||||
:class="detecting ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="detectRouter"
|
||||
>
|
||||
{{ detecting ? 'Detecting…' : 'Detect' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="detectError" class="mt-2 text-xs text-red-400">{{ detectError }}</p>
|
||||
<div v-if="detectedCandidates.length > 0" class="mt-2 space-y-1">
|
||||
<p class="text-xs text-white/40">Multiple routers found — pick one:</p>
|
||||
<button
|
||||
v-for="ip in detectedCandidates"
|
||||
:key="ip"
|
||||
class="w-full text-left px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white font-mono transition-colors"
|
||||
@click="pickDetectedRouter(ip)"
|
||||
>
|
||||
{{ ip }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">SSH User</label>
|
||||
<input
|
||||
v-model="sshUser"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Password</label>
|
||||
<input
|
||||
v-model="sshPassword"
|
||||
type="password"
|
||||
placeholder="(blank for none)"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="connecting || !host.trim()"
|
||||
class="glass-button w-full text-sm font-medium"
|
||||
:class="connecting || !host.trim() ? 'opacity-40 cursor-not-allowed' : 'glass-button-warning'"
|
||||
@click="connect"
|
||||
>
|
||||
{{ connecting ? 'Connecting…' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<template v-else-if="loading">
|
||||
<div v-for="i in 3" :key="i" class="glass-card p-6 mb-4 animate-pulse">
|
||||
<div class="h-4 bg-white/10 rounded w-1/3 mb-4"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-white/10 rounded w-2/3"></div>
|
||||
<div class="h-3 bg-white/10 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="glass-card p-6 mb-4">
|
||||
<p class="text-sm text-red-300">{{ error }}</p>
|
||||
<button class="mt-3 text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Status panels -->
|
||||
<template v-else-if="status">
|
||||
|
||||
<!-- System info -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80">System</h2>
|
||||
<span class="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 inline-block"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Hostname</dt>
|
||||
<dd class="text-white font-medium">{{ status.hostname }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Address</dt>
|
||||
<dd class="text-white font-medium font-mono">{{ status.host }}</dd>
|
||||
</div>
|
||||
<div v-if="openwrtVersion">
|
||||
<dt class="text-xs text-white/40 mb-0.5">OpenWrt</dt>
|
||||
<dd class="text-white/70">{{ openwrtVersion }}</dd>
|
||||
</div>
|
||||
<div v-if="boardName">
|
||||
<dt class="text-xs text-white/40 mb-0.5">Board</dt>
|
||||
<dd class="text-white/70">{{ boardName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Uptime</dt>
|
||||
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="load()">
|
||||
↻ Refresh
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter">
|
||||
Switch router →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WAN / Uplink -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80">WAN / Uplink</h2>
|
||||
<button
|
||||
v-if="wanStep === 'idle' || wanStep === 'done'"
|
||||
class="text-xs text-white/40 hover:text-white transition-colors"
|
||||
@click="startWanSetup"
|
||||
>
|
||||
{{ status.wan?.configured ? 'Reconfigure →' : 'Set up →' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current status (idle) -->
|
||||
<template v-if="wanStep === 'idle' || wanStep === 'done'">
|
||||
<div v-if="status.wan?.configured" class="flex items-center gap-3 mb-3">
|
||||
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0"
|
||||
:class="status.wan.internet ? 'bg-green-400' : 'bg-yellow-400 animate-pulse'"></span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ status.wan.ssid }}</div>
|
||||
<div class="text-xs text-white/40">
|
||||
{{ status.wan.internet ? status.wan.ip : 'Connecting…' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-white/50 mb-3">
|
||||
Not configured — router has no internet access.
|
||||
</div>
|
||||
|
||||
<!-- Quick summary of DHCP + masq when connected -->
|
||||
<dl v-if="status.wan?.configured && status.wan.internet"
|
||||
class="grid grid-cols-3 gap-x-4 gap-y-1 text-xs text-white/40 mb-3">
|
||||
<div>
|
||||
<dt class="text-white/25">LAN</dt>
|
||||
<dd class="text-white/60 font-mono">{{ status.wan.lan_ip }}/24</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/25">DHCP</dt>
|
||||
<dd class="text-white/60 font-mono">.{{ status.wan.dhcp_start }}+{{ status.wan.dhcp_limit }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/25">NAT</dt>
|
||||
<dd :class="status.wan.masq ? 'text-green-400' : 'text-red-400'">
|
||||
{{ status.wan.masq ? 'on' : 'off' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Diagnostics (shown when not connected) -->
|
||||
<dl v-if="status.wan?.configured && !status.wan.internet"
|
||||
class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs mt-1 mb-3 border-t border-white/10 pt-3">
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Radio</dt>
|
||||
<dd :class="status.wan.radio0_disabled ? 'text-red-400' : 'text-green-400'">
|
||||
{{ status.wan.radio0_disabled ? 'disabled' : 'enabled' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Interface</dt>
|
||||
<dd class="text-white/70 font-mono">
|
||||
{{ status.wan.sta_iface || 'none' }}
|
||||
<span v-if="status.wan.sta_iface" class="ml-1"
|
||||
:class="status.wan.sta_state === 'up' ? 'text-green-400' : 'text-yellow-400'">
|
||||
({{ status.wan.sta_state }})
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Associated to</dt>
|
||||
<dd class="text-white/70">{{ status.wan.assoc_ssid || 'none' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Configured SSID</dt>
|
||||
<dd class="text-white/70">{{ status.wan.ssid || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="status.wan.wifi_log" class="col-span-2">
|
||||
<dt class="text-white/30 mb-1">Recent wifi log</dt>
|
||||
<dd class="font-mono text-white/50 text-[10px] leading-relaxed whitespace-pre-wrap break-all bg-black/20 rounded p-2">{{ status.wan.wifi_log }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p v-if="wanStep === 'done'" class="mt-2 text-xs text-green-400">
|
||||
WAN configured. Router is connecting…
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: scan prompt -->
|
||||
<template v-else-if="wanStep === 'scan'">
|
||||
<button
|
||||
class="glass-button glass-button-warning w-full text-sm font-medium"
|
||||
@click="scanWifi"
|
||||
>
|
||||
Scan for Networks
|
||||
</button>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'idle'">Cancel</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: scanning -->
|
||||
<template v-else-if="wanStep === 'scanning'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Scanning for networks…</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step: network list -->
|
||||
<template v-else-if="wanStep === 'list'">
|
||||
<p class="text-xs text-white/40 mb-3">{{ scannedNetworks.length }} network{{ scannedNetworks.length !== 1 ? 's' : '' }} found. Select one:</p>
|
||||
<div v-if="scannedNetworks.length === 0" class="text-sm text-white/50">No networks found. Try scanning again.</div>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
v-for="net in scannedNetworks"
|
||||
:key="net.bssid"
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg border border-white/10 hover:border-white/30 hover:bg-white/5 transition-colors text-left"
|
||||
@click="selectNetwork(net)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Signal bars -->
|
||||
<div class="flex items-end gap-0.5 h-4">
|
||||
<div v-for="b in 4" :key="b"
|
||||
class="w-1 rounded-sm"
|
||||
:class="[
|
||||
b * 1 <= signalBars(net.signal) ? 'bg-white/70' : 'bg-white/15',
|
||||
b === 1 ? 'h-1' : b === 2 ? 'h-2' : b === 3 ? 'h-3' : 'h-4'
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-white text-sm">{{ net.ssid || '(hidden)' }}</span>
|
||||
<span v-if="net.encryption !== 'none'" class="text-white/30 text-xs">🔒</span>
|
||||
</div>
|
||||
<span class="text-xs text-white/30 font-mono flex-shrink-0">ch{{ net.channel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'scan'">← Scan again</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: password entry -->
|
||||
<template v-else-if="wanStep === 'password'">
|
||||
<p class="text-sm text-white mb-3">
|
||||
Connect to <span class="font-semibold">{{ selectedNetwork?.ssid }}</span>
|
||||
</p>
|
||||
<input
|
||||
v-if="selectedNetwork?.encryption !== 'none'"
|
||||
v-model="wanPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-4 py-3 mb-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
@keyup.enter="wanStep = 'dhcp'"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
||||
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="wanStep = 'dhcp'"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
||||
</div>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: DHCP + masquerade config -->
|
||||
<template v-else-if="wanStep === 'dhcp'">
|
||||
<p class="text-xs text-white/50 mb-4">
|
||||
Configure how this router hands out addresses to WiFi clients.
|
||||
</p>
|
||||
|
||||
<!-- LAN info -->
|
||||
<div class="mb-4 text-xs font-mono bg-black/20 rounded-lg px-3 py-2 text-white/50">
|
||||
<span class="text-white/30">LAN: </span>{{ status?.wan?.lan_ip || '192.168.1.1' }}/24
|
||||
</div>
|
||||
|
||||
<!-- DHCP range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">DHCP range for clients</label>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-white/40 font-mono text-xs">{{ status?.wan?.lan_ip?.split('.').slice(0,3).join('.') || '192.168.1' }}.</span>
|
||||
<input
|
||||
v-model.number="dhcpStart"
|
||||
type="number"
|
||||
min="2" max="250"
|
||||
class="w-20 px-2 py-1.5 bg-transparent border border-white/20 rounded text-white text-sm text-center focus:outline-none focus:border-white/40"
|
||||
/>
|
||||
<span class="text-white/30">–</span>
|
||||
<span class="text-white/60 font-mono text-xs">{{ Math.min(254, dhcpStart + dhcpLimit - 1) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-1">{{ dhcpLimit }} addresses</p>
|
||||
</div>
|
||||
|
||||
<!-- Masquerade -->
|
||||
<div class="flex items-center justify-between mb-4 py-3 border-t border-white/10">
|
||||
<div>
|
||||
<div class="text-sm text-white">Enable NAT masquerade</div>
|
||||
<div class="text-xs text-white/40">Routes LAN traffic through the WiFi uplink</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
|
||||
:class="masqEnabled ? 'bg-green-500/60' : 'bg-white/15'"
|
||||
@click="masqEnabled = !masqEnabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
:class="masqEnabled ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="wanError" class="mb-3 text-xs text-red-400">{{ wanError }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
@click="configureWan"
|
||||
>
|
||||
Apply Settings
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'password'">←</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step: applying -->
|
||||
<template v-else-if="wanStep === 'connecting'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Applying WAN config…</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- TollGate -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
||||
|
||||
<div v-if="!status.tollgate.installed">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="w-2 h-2 rounded-full bg-white/20 inline-block"></span>
|
||||
<span class="text-sm text-white/50">Not installed</span>
|
||||
</div>
|
||||
<div v-if="!status.wan?.internet" class="mb-3 text-xs text-yellow-400/80 bg-yellow-400/10 rounded-lg px-3 py-2">
|
||||
Router needs internet access to install TollGate. Configure WAN above first.
|
||||
</div>
|
||||
<button
|
||||
:disabled="provisioning"
|
||||
class="glass-button glass-button-success w-full text-sm font-medium"
|
||||
:class="provisioning ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="provisionTollgate"
|
||||
>
|
||||
{{ provisioning ? 'Installing… this may take a few minutes' : 'Install TollGate' }}
|
||||
</button>
|
||||
<p v-if="provisionError" class="mt-3 text-xs text-red-400">{{ provisionError }}</p>
|
||||
<p v-if="provisionSuccess && !provisioning" class="mt-3 text-xs text-green-400">TollGate provisioned successfully.</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="!editingTollgate">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full inline-block"
|
||||
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
|
||||
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-xs text-white/40 hover:text-white" @click="startEditTollgate">Edit</button>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Price</dt>
|
||||
<dd class="text-white font-medium">
|
||||
{{ status.tollgate.price_per_step }} {{ status.tollgate.currency }}
|
||||
/ {{ formatStepSize(status.tollgate.step_size_ms ?? 60000) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Metric</dt>
|
||||
<dd class="text-white/70 capitalize">{{ status.tollgate.metric }}</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-xs text-white/40 mb-0.5">Mint URL</dt>
|
||||
<dd class="text-white/70 font-mono text-xs break-all">{{ status.tollgate.mint_url }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<!-- Reconfigure form -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-4 py-3 border-b border-white/10">
|
||||
<div>
|
||||
<div class="text-sm text-white">Enable TollGate</div>
|
||||
<div class="text-xs text-white/40">Stops the service and the SSID broadcast when off</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
|
||||
:class="editEnabled ? 'bg-green-500/60' : 'bg-white/15'"
|
||||
@click="editEnabled = !editEnabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
:class="editEnabled ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Price per step (sats)</label>
|
||||
<input
|
||||
v-model.number="editPriceSats"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Step size (minutes)</label>
|
||||
<input
|
||||
v-model.number="editStepSizeMin"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Minimum steps per purchase</label>
|
||||
<input
|
||||
v-model.number="editMinSteps"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Mint URL</label>
|
||||
<input
|
||||
v-model="editMintUrl"
|
||||
type="text"
|
||||
placeholder="http://..."
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="updatingTollgate"
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
:class="updatingTollgate ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="saveTollgateConfig"
|
||||
>
|
||||
{{ updatingTollgate ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" :disabled="updatingTollgate" @click="editingTollgate = false">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updateTollgateError" class="mt-3 text-xs text-red-400">{{ updateTollgateError }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- WiFi interfaces -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">
|
||||
WiFi Interfaces
|
||||
<span class="ml-2 text-xs font-normal text-white/40">(AP mode)</span>
|
||||
</h2>
|
||||
|
||||
<div v-if="status.wifi_interfaces.length === 0" class="text-sm text-white/50">
|
||||
No AP interfaces configured.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="iface in status.wifi_interfaces"
|
||||
:key="iface.section"
|
||||
class="flex items-start justify-between py-3 border-b border-white/10 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block"
|
||||
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"></span>
|
||||
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
|
||||
<span v-if="iface.network === 'tollgate'"
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">TollGate</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/40 ml-3.5">
|
||||
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-white/30 font-mono mt-0.5">{{ iface.section }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
49
neode-ui/test-openwrt.mjs
Normal file
49
neode-ui/test-openwrt.mjs
Normal file
@ -0,0 +1,49 @@
|
||||
import { chromium } from './node_modules/playwright/index.mjs';
|
||||
|
||||
const BASE = 'https://100.66.157.121';
|
||||
const PASS = 'ThisIsWeb54321@';
|
||||
const DIR = '/tmp/claude-1000/-home-debian/97c10035-69a8-40a0-9b55-219eb8ad683a/scratchpad';
|
||||
|
||||
// Find the OpenWrt router IP from the Tailscale/LAN
|
||||
const { execSync } = await import('child_process');
|
||||
let routerIp = '192.168.1.1';
|
||||
try {
|
||||
const route = execSync("ssh archipelago@100.66.157.121 'ip route | grep default'", { encoding: 'utf8' }).trim();
|
||||
const match = route.match(/default via ([\d.]+)/);
|
||||
if (match) routerIp = match[1];
|
||||
} catch {}
|
||||
console.log('Detected router IP:', routerIp);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Login
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.fill('input[type="password"]', PASS);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(/dashboard/, { timeout: 20000 });
|
||||
|
||||
// Server page
|
||||
await page.goto(`${BASE}/dashboard/server`, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.screenshot({ path: `${DIR}/01-server.png` });
|
||||
|
||||
// Click OpenWrt Gateway
|
||||
await page.getByText('OpenWrt Gateway').click();
|
||||
await page.waitForURL(/openwrt/, { timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `${DIR}/02-connect-form.png` });
|
||||
console.log('Connect form visible');
|
||||
|
||||
// Fill in the connect form
|
||||
await page.fill('input[placeholder="192.168.1.1"]', routerIp);
|
||||
await page.screenshot({ path: `${DIR}/03-form-filled.png` });
|
||||
await page.getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
// Wait for SSH connection + UCI read (can take up to 30s)
|
||||
console.log('Connecting to router, waiting for data...');
|
||||
await page.waitForTimeout(15000);
|
||||
await page.screenshot({ path: `${DIR}/04-result.png` });
|
||||
console.log('Result page text:\n', (await page.innerText('body')).replace(/\s+/g, ' ').substring(0, 800));
|
||||
|
||||
await browser.close();
|
||||
Loading…
x
Reference in New Issue
Block a user