All checks were successful
Build and Release / build-and-release (push) Successful in 1m43s
Agent changes: - Parse /proc/net/vlan/config to extract VLAN IDs for interfaces - Detect primary physical interface via default route - Auto-assign primary interface as parent for virtual interfaces without explicit parent - Added vlan_id field to NetworkInterfaceData Dashboard changes: - Display VLAN ID in format "interface (vlan X): IP" - Show VLAN IDs for both nested and standalone virtual interfaces This ensures virtual interfaces (docker0, tailscale0, etc.) are properly nested under the primary physical NIC, and VLAN interfaces show their IDs. Updated to version 0.1.170
225 lines
9.3 KiB
Rust
225 lines
9.3 KiB
Rust
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<String> {
|
|
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<String, u16> {
|
|
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::<u16>() {
|
|
vlan_map.insert(interface_name.to_string(), vlan_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
vlan_map
|
|
}
|
|
|
|
/// Collect network interfaces using ip command
|
|
async fn collect_interfaces(&self) -> Vec<NetworkInterfaceData> {
|
|
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::<serde_json::Value>(&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(())
|
|
}
|
|
}
|