use async_trait::async_trait; use cm_dashboard_shared::{AgentData, NetworkInterfaceData, Status}; use std::process::Command; use tracing::debug; use super::{Collector, CollectorError}; use crate::config::NetworkConfig; /// Network interface collector with physical/virtual classification and link status pub struct NetworkCollector { _config: NetworkConfig, } impl NetworkCollector { pub fn new(config: NetworkConfig) -> Self { Self { _config: config } } /// Check if interface is physical (not virtual) fn is_physical_interface(name: &str) -> bool { // Physical interface patterns matches!( &name[..], s if s.starts_with("eth") || s.starts_with("ens") || s.starts_with("enp") || s.starts_with("wlan") || s.starts_with("wlp") || s.starts_with("eno") || s.starts_with("enx") ) } /// Get link status for an interface fn get_link_status(interface: &str) -> Status { let operstate_path = format!("/sys/class/net/{}/operstate", interface); match std::fs::read_to_string(&operstate_path) { Ok(state) => { let state = state.trim(); match state { "up" => Status::Ok, "down" => Status::Inactive, "unknown" => Status::Warning, _ => Status::Unknown, } } Err(_) => Status::Unknown, } } /// Get the primary physical interface (the one with default route) fn get_primary_physical_interface() -> Option { match Command::new("ip").args(["route", "show", "default"]).output() { Ok(output) if output.status.success() => { let output_str = String::from_utf8_lossy(&output.stdout); // Parse: "default via 192.168.1.1 dev eno1 ..." for line in output_str.lines() { if line.starts_with("default") { if let Some(dev_pos) = line.find(" dev ") { let after_dev = &line[dev_pos + 5..]; if let Some(space_pos) = after_dev.find(' ') { let interface = &after_dev[..space_pos]; // Only return if it's a physical interface if Self::is_physical_interface(interface) { return Some(interface.to_string()); } } else { // No space after interface name (end of line) let interface = after_dev.trim(); if Self::is_physical_interface(interface) { return Some(interface.to_string()); } } } } } None } _ => None, } } /// Parse VLAN configuration from /proc/net/vlan/config /// Returns a map of interface name -> VLAN ID fn parse_vlan_config() -> std::collections::HashMap { let mut vlan_map = std::collections::HashMap::new(); if let Ok(contents) = std::fs::read_to_string("/proc/net/vlan/config") { for line in contents.lines().skip(2) { // Skip header lines let parts: Vec<&str> = line.split('|').collect(); if parts.len() >= 2 { let interface_name = parts[0].trim(); let vlan_id_str = parts[1].trim(); if let Ok(vlan_id) = vlan_id_str.parse::() { vlan_map.insert(interface_name.to_string(), vlan_id); } } } } vlan_map } /// Collect network interfaces using ip command async fn collect_interfaces(&self) -> Vec { let mut interfaces = Vec::new(); // Parse VLAN configuration let vlan_map = Self::parse_vlan_config(); match Command::new("ip").args(["-j", "addr"]).output() { Ok(output) if output.status.success() => { let json_str = String::from_utf8_lossy(&output.stdout); if let Ok(json_data) = serde_json::from_str::(&json_str) { if let Some(ifaces) = json_data.as_array() { for iface in ifaces { let name = iface["ifname"].as_str().unwrap_or("").to_string(); // Skip loopback, empty names, and ifb* interfaces if name.is_empty() || name == "lo" || name.starts_with("ifb") { continue; } // Parse parent interface from @parent notation (e.g., lan@enp0s31f6) let (interface_name, parent_interface) = if let Some(at_pos) = name.find('@') { let (child, parent) = name.split_at(at_pos); (child.to_string(), Some(parent[1..].to_string())) } else { (name.clone(), None) }; let mut ipv4_addresses = Vec::new(); let mut ipv6_addresses = Vec::new(); // Extract IP addresses if let Some(addr_info) = iface["addr_info"].as_array() { for addr in addr_info { if let Some(family) = addr["family"].as_str() { if let Some(local) = addr["local"].as_str() { match family { "inet" => ipv4_addresses.push(local.to_string()), "inet6" => { // Skip link-local IPv6 addresses (fe80::) if !local.starts_with("fe80:") { ipv6_addresses.push(local.to_string()); } } _ => {} } } } } } // Determine if physical and get status let is_physical = Self::is_physical_interface(&interface_name); // Only filter out virtual interfaces without IPs // Physical interfaces should always be shown even if down/no IPs if !is_physical && ipv4_addresses.is_empty() && ipv6_addresses.is_empty() { continue; } let link_status = if is_physical { Self::get_link_status(&name) } else { Status::Unknown // Virtual interfaces don't have meaningful link status }; // Look up VLAN ID from the map (use original name before @ parsing) let vlan_id = vlan_map.get(&name).copied(); interfaces.push(NetworkInterfaceData { name: interface_name, ipv4_addresses, ipv6_addresses, is_physical, link_status, parent_interface, vlan_id, }); } } } } Err(e) => { debug!("Failed to execute ip command: {}", e); } Ok(output) => { debug!("ip command failed with status: {}", output.status); } } // Assign primary physical interface as parent to virtual interfaces without explicit parent let primary_interface = Self::get_primary_physical_interface(); if let Some(primary) = primary_interface { for interface in interfaces.iter_mut() { // Only assign parent to virtual interfaces that don't already have one if !interface.is_physical && interface.parent_interface.is_none() { interface.parent_interface = Some(primary.clone()); } } } interfaces } } #[async_trait] impl Collector for NetworkCollector { async fn collect_structured(&self, agent_data: &mut AgentData) -> Result<(), CollectorError> { debug!("Collecting network interface data"); // Collect all network interfaces let interfaces = self.collect_interfaces().await; agent_data.system.network.interfaces = interfaces; Ok(()) } }