feat: add network interface management RPC endpoints
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 <noreply@anthropic.com>
This commit is contained in:
parent
fce67baa9c
commit
d26b95e256
361
core/archipelago/src/api/rpc/interfaces.rs
Normal file
361
core/archipelago/src/api/rpc/interfaces.rs
Normal file
@ -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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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::<std::net::IpAddr>().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<Vec<serde_json::Value>> {
|
||||
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::Value> =
|
||||
serde_json::from_slice(&output.stdout).context("Failed to parse ip JSON output")?;
|
||||
|
||||
let interfaces: Vec<serde_json::Value> = 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<String> = 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<Vec<serde_json::Value>> {
|
||||
// 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<serde_json::Value> = 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(())
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user