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(()) }