From d26b95e256e2b0ef3595570d0dec59b18cbbea03 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 11 Mar 2026 00:34:17 +0000 Subject: [PATCH] feat: add network interface management RPC endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add network.list-interfaces, network.scan-wifi, network.configure-wifi, and network.configure-ethernet endpoints using ip and nmcli commands. Includes input validation to prevent command injection. Deployed and verified — list-interfaces returns real interface data. Co-Authored-By: Claude Opus 4.6 --- core/archipelago/src/api/rpc/interfaces.rs | 361 +++++++++++++++++++++ core/archipelago/src/api/rpc/mod.rs | 5 + loop/plan.md | 2 +- 3 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 core/archipelago/src/api/rpc/interfaces.rs diff --git a/core/archipelago/src/api/rpc/interfaces.rs b/core/archipelago/src/api/rpc/interfaces.rs new file mode 100644 index 00000000..c23ecd78 --- /dev/null +++ b/core/archipelago/src/api/rpc/interfaces.rs @@ -0,0 +1,361 @@ +use super::RpcHandler; +use anyhow::{Context, Result}; +use tracing::debug; + +impl RpcHandler { + /// network.list-interfaces — list all network interfaces with IP, MAC, status. + pub(super) async fn handle_network_list_interfaces(&self) -> Result { + debug!("Listing network interfaces"); + let interfaces = list_interfaces().await?; + Ok(serde_json::json!({ "interfaces": interfaces })) + } + + /// network.scan-wifi — scan for available WiFi networks. + pub(super) async fn handle_network_scan_wifi(&self) -> Result { + debug!("Scanning WiFi networks"); + let networks = scan_wifi().await?; + Ok(serde_json::json!({ "networks": networks })) + } + + /// network.configure-wifi — connect to a WiFi network. + pub(super) async fn handle_network_configure_wifi( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let ssid = params + .get("ssid") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: ssid"))?; + let password = params + .get("password") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?; + + // Validate SSID (prevent command injection) + if ssid.len() > 64 || ssid.contains('\0') { + anyhow::bail!("Invalid SSID"); + } + + tracing::info!("Connecting to WiFi network: {}", ssid); + connect_wifi(ssid, password).await?; + + Ok(serde_json::json!({ "ok": true, "ssid": ssid })) + } + + /// network.configure-ethernet — set DHCP or static IP for an ethernet interface. + pub(super) async fn handle_network_configure_ethernet( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let interface = params + .get("interface") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: interface"))?; + let mode = params + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("dhcp"); + + // Validate interface name (alphanumeric + digits only) + if !interface + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + anyhow::bail!("Invalid interface name"); + } + + match mode { + "dhcp" => { + tracing::info!("Setting {} to DHCP", interface); + configure_ethernet_dhcp(interface).await?; + } + "static" => { + let ip = params + .get("ip") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: ip for static mode"))?; + let gateway = params + .get("gateway") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let dns = params + .get("dns") + .and_then(|v| v.as_str()) + .unwrap_or("1.1.1.1"); + + // Basic IP format validation + if ip.parse::().is_err() && !ip.contains('/') { + anyhow::bail!("Invalid IP address format"); + } + + tracing::info!("Setting {} to static IP {}", interface, ip); + configure_ethernet_static(interface, ip, gateway, dns).await?; + } + _ => anyhow::bail!("Invalid mode: {}. Use 'dhcp' or 'static'", mode), + } + + Ok(serde_json::json!({ "ok": true, "interface": interface, "mode": mode })) + } +} + +/// List network interfaces using `ip -j addr show`. +async fn list_interfaces() -> Result> { + let output = tokio::process::Command::new("ip") + .args(["-j", "addr", "show"]) + .output() + .await + .context("Failed to run `ip addr show`")?; + + if !output.status.success() { + anyhow::bail!( + "ip addr show failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let raw: Vec = + serde_json::from_slice(&output.stdout).context("Failed to parse ip JSON output")?; + + let interfaces: Vec = raw + .into_iter() + .filter_map(|iface| { + let name = iface.get("ifname")?.as_str()?; + // Skip loopback + if name == "lo" { + return None; + } + let operstate = iface + .get("operstate") + .and_then(|v| v.as_str()) + .unwrap_or("UNKNOWN"); + let mac = iface + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Get IPv4 addresses + let addrs: Vec = iface + .get("addr_info") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter(|a| a.get("family").and_then(|f| f.as_str()) == Some("inet")) + .filter_map(|a| { + let local = a.get("local")?.as_str()?; + let prefix = a.get("prefixlen")?.as_u64()?; + Some(format!("{}/{}", local, prefix)) + }) + .collect() + }) + .unwrap_or_default(); + + let iface_type = if name.starts_with("wl") { + "wifi" + } else if name.starts_with("en") || name.starts_with("eth") { + "ethernet" + } else if name.starts_with("veth") || name.starts_with("br-") || name.starts_with("docker") || name.starts_with("podman") { + "virtual" + } else { + "other" + }; + + Some(serde_json::json!({ + "name": name, + "type": iface_type, + "state": operstate.to_lowercase(), + "mac": mac, + "ipv4": addrs, + })) + }) + .collect(); + + Ok(interfaces) +} + +/// Scan WiFi networks using `nmcli -t -f SSID,SIGNAL,SECURITY device wifi list`. +async fn scan_wifi() -> Result> { + // Trigger a rescan first + let _ = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "rescan"]) + .output() + .await; + + // Short delay for scan to complete + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let output = tokio::process::Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"]) + .output() + .await + .context("Failed to run nmcli wifi list")?; + + if !output.status.success() { + anyhow::bail!( + "nmcli wifi list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8(output.stdout).context("nmcli output not utf8")?; + let mut seen = std::collections::HashSet::new(); + let networks: Vec = stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(3, ':').collect(); + if parts.len() < 3 { + return None; + } + let ssid = parts[0].trim(); + if ssid.is_empty() || !seen.insert(ssid.to_string()) { + return None; + } + let signal: u32 = parts[1].parse().unwrap_or(0); + let security = parts[2].trim(); + Some(serde_json::json!({ + "ssid": ssid, + "signal": signal, + "security": security, + })) + }) + .collect(); + + Ok(networks) +} + +/// Connect to a WiFi network using nmcli. +async fn connect_wifi(ssid: &str, password: &str) -> Result<()> { + let output = tokio::process::Command::new("nmcli") + .args(["device", "wifi", "connect", ssid, "password", password]) + .output() + .await + .context("Failed to run nmcli wifi connect")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("WiFi connection failed: {}", stderr); + } + + Ok(()) +} + +/// Configure ethernet interface for DHCP using nmcli. +async fn configure_ethernet_dhcp(interface: &str) -> Result<()> { + // Find or create a connection for this interface + let conn_name = format!("archipelago-{}", interface); + + // Delete existing connection if any + let _ = tokio::process::Command::new("nmcli") + .args(["connection", "delete", &conn_name]) + .output() + .await; + + // Create new DHCP connection + let output = tokio::process::Command::new("nmcli") + .args([ + "connection", + "add", + "type", + "ethernet", + "con-name", + &conn_name, + "ifname", + interface, + "ipv4.method", + "auto", + ]) + .output() + .await + .context("Failed to create DHCP connection")?; + + if !output.status.success() { + anyhow::bail!( + "nmcli connection add failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // Activate the connection + let activate = tokio::process::Command::new("nmcli") + .args(["connection", "up", &conn_name]) + .output() + .await + .context("Failed to activate connection")?; + + if !activate.status.success() { + anyhow::bail!( + "nmcli connection up failed: {}", + String::from_utf8_lossy(&activate.stderr) + ); + } + + Ok(()) +} + +/// Configure ethernet interface with a static IP. +async fn configure_ethernet_static( + interface: &str, + ip: &str, + gateway: &str, + dns: &str, +) -> Result<()> { + let conn_name = format!("archipelago-{}", interface); + + // Delete existing connection if any + let _ = tokio::process::Command::new("nmcli") + .args(["connection", "delete", &conn_name]) + .output() + .await; + + let mut args = vec![ + "connection", + "add", + "type", + "ethernet", + "con-name", + &conn_name, + "ifname", + interface, + "ipv4.method", + "manual", + "ipv4.addresses", + ip, + ]; + + if !gateway.is_empty() { + args.push("ipv4.gateway"); + args.push(gateway); + } + + args.push("ipv4.dns"); + args.push(dns); + + let output = tokio::process::Command::new("nmcli") + .args(&args) + .output() + .await + .context("Failed to create static connection")?; + + if !output.status.success() { + anyhow::bail!( + "nmcli connection add failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let activate = tokio::process::Command::new("nmcli") + .args(["connection", "up", &conn_name]) + .output() + .await + .context("Failed to activate connection")?; + + if !activate.status.success() { + anyhow::bail!( + "nmcli connection up failed: {}", + String::from_utf8_lossy(&activate.stderr) + ); + } + + Ok(()) +} diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 3abe79d3..8dcb41d7 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -5,6 +5,7 @@ mod content; mod credentials; mod dwn; mod identity; +mod interfaces; mod names; mod lnd; mod network; @@ -295,6 +296,10 @@ impl RpcHandler { "router.add-forward" => self.handle_router_add_forward(params).await, "router.remove-forward" => self.handle_router_remove_forward(params).await, "network.diagnostics" => self.handle_network_diagnostics().await, + "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.configure-ethernet" => self.handle_network_configure_ethernet(params).await, "router.detect" => self.handle_router_detect(params).await, "router.info" => self.handle_router_info().await, "router.configure" => self.handle_router_configure(params).await, diff --git a/loop/plan.md b/loop/plan.md index b6a2e86b..3a522a30 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -52,7 +52,7 @@ - [x] **BACK-02** — Add system monitoring to frontend Dashboard. In `neode-ui/src/views/Home.vue`, add a system stats section (CPU, RAM, Disk gauges) that calls `system.stats` RPC on mount and refreshes every 30s. Use `bg-white/5 rounded-lg` sub-cards inside an existing glass container. Show percentage bars with color coding (green <70%, orange 70-90%, red >90%). **Acceptance**: Dashboard shows real CPU/RAM/Disk usage. Deploy and verify. -- [ ] **BACK-03** — Add WiFi/Ethernet configuration RPC endpoints. Create `core/archipelago/src/network/interfaces.rs` with: `network.list-interfaces` (lists eth0, wlan0, etc. with IP, MAC, status), `network.configure-wifi` (SSID, password, connects via `nmcli`), `network.configure-ethernet` (static IP or DHCP via `nmcli`), `network.scan-wifi` (available networks). Register in RPC router. **Acceptance**: `network.list-interfaces` returns real interface data on dev server. +- [x] **BACK-03** — Add WiFi/Ethernet configuration RPC endpoints. Create `core/archipelago/src/network/interfaces.rs` with: `network.list-interfaces` (lists eth0, wlan0, etc. with IP, MAC, status), `network.configure-wifi` (SSID, password, connects via `nmcli`), `network.configure-ethernet` (static IP or DHCP via `nmcli`), `network.scan-wifi` (available networks). Register in RPC router. **Acceptance**: `network.list-interfaces` returns real interface data on dev server. - [ ] **BACK-04** — Add WiFi/Ethernet UI to Server.vue. Add a "Network Interfaces" section to Server.vue showing detected interfaces with their IPs and statuses. For WiFi, add "Scan & Connect" button that opens a modal listing available networks. For Ethernet, show DHCP/Static toggle. Use `glass-card` container with `bg-white/5` sub-rows. **Acceptance**: Real network interfaces visible on Server page; WiFi scan works on dev server. Deploy and verify.