forked from NationTech/harmony
		
	## Fully automated inventory gathering now works! Boot up harmony_inventory_agent with `cargo run -p harmony_inventory_agent` Launch the DiscoverInventoryAgentScore , currently available this way : `RUST_LOG=info cargo run -p example-cli -- -f Discover -y` And you will have automatically all hosts saved to the database. Run `cargo sqlx setup` if you have not done it yet. Co-authored-by: Ian Letourneau <ian@noma.to> Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/127 Co-authored-by: Jean-Gabriel Gill-Couture <jg@nationtech.io> Co-committed-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
		
			
				
	
	
		
			878 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			878 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
| use harmony_types::net::MacAddress;
 | |
| use log::{debug, warn};
 | |
| use serde::{Deserialize, Serialize};
 | |
| use serde_json::Value;
 | |
| use std::fs;
 | |
| use std::path::Path;
 | |
| use std::process::Command;
 | |
| use sysinfo::System;
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug)]
 | |
| pub struct PhysicalHost {
 | |
|     pub storage_drives: Vec<StorageDrive>,
 | |
|     pub storage_controller: StorageController,
 | |
|     pub memory_modules: Vec<MemoryModule>,
 | |
|     pub cpus: Vec<CPU>,
 | |
|     pub chipset: Chipset,
 | |
|     pub network_interfaces: Vec<NetworkInterface>,
 | |
|     pub management_interface: Option<ManagementInterface>,
 | |
|     pub host_uuid: String,
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug, Clone)]
 | |
| pub struct StorageDrive {
 | |
|     pub name: String,
 | |
|     pub model: String,
 | |
|     pub serial: String,
 | |
|     pub size_bytes: u64,
 | |
|     pub logical_block_size: u32,
 | |
|     pub physical_block_size: u32,
 | |
|     pub rotational: bool,
 | |
|     pub wwn: Option<String>,
 | |
|     pub interface_type: String,
 | |
|     pub smart_status: Option<String>,
 | |
| }
 | |
| 
 | |
| impl StorageDrive {
 | |
|     pub fn dummy() -> Self {
 | |
|         Self {
 | |
|             name: String::new(),
 | |
|             model: String::new(),
 | |
|             serial: String::new(),
 | |
|             size_bytes: 0,
 | |
|             logical_block_size: 0,
 | |
|             physical_block_size: 0,
 | |
|             rotational: false,
 | |
|             wwn: None,
 | |
|             interface_type: String::new(),
 | |
|             smart_status: None,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug)]
 | |
| pub struct StorageController {
 | |
|     pub name: String,
 | |
|     pub driver: String,
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug, Clone)]
 | |
| pub struct MemoryModule {
 | |
|     pub size_bytes: u64,
 | |
|     pub speed_mhz: Option<u32>,
 | |
|     pub manufacturer: Option<String>,
 | |
|     pub part_number: Option<String>,
 | |
|     pub serial_number: Option<String>,
 | |
|     pub rank: Option<u8>,
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug, Clone)]
 | |
| pub struct CPU {
 | |
|     pub model: String,
 | |
|     pub vendor: String,
 | |
|     pub cores: u32,
 | |
|     pub threads: u32,
 | |
|     pub frequency_mhz: u64,
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug)]
 | |
| pub struct Chipset {
 | |
|     pub name: String,
 | |
|     pub vendor: String,
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug, Clone)]
 | |
| pub struct NetworkInterface {
 | |
|     pub name: String,
 | |
|     pub mac_address: MacAddress,
 | |
|     pub speed_mbps: Option<u32>,
 | |
|     pub is_up: bool,
 | |
|     pub mtu: u32,
 | |
|     pub ipv4_addresses: Vec<String>,
 | |
|     pub ipv6_addresses: Vec<String>,
 | |
|     pub driver: String,
 | |
|     pub firmware_version: Option<String>,
 | |
| }
 | |
| 
 | |
| impl NetworkInterface {
 | |
|     pub fn dummy() -> Self {
 | |
|         use harmony_macros::mac_address;
 | |
| 
 | |
|         Self {
 | |
|             name: String::new(),
 | |
|             mac_address: mac_address!("00:00:00:00:00:00"),
 | |
|             speed_mbps: Some(0),
 | |
|             is_up: false,
 | |
|             mtu: 0,
 | |
|             ipv4_addresses: vec![],
 | |
|             ipv6_addresses: vec![],
 | |
|             driver: String::new(),
 | |
|             firmware_version: None,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, Deserialize, Debug)]
 | |
| pub struct ManagementInterface {
 | |
|     pub kind: String,
 | |
|     pub address: Option<String>,
 | |
|     pub firmware: Option<String>,
 | |
| }
 | |
| 
 | |
| impl PhysicalHost {
 | |
|     pub fn gather() -> Result<Self, String> {
 | |
|         let mut sys = System::new_all();
 | |
|         sys.refresh_all();
 | |
| 
 | |
|         Self::all_tools_available()?;
 | |
| 
 | |
|         Ok(Self {
 | |
|             storage_drives: Self::gather_storage_drives()?,
 | |
|             storage_controller: Self::gather_storage_controller()?,
 | |
|             memory_modules: Self::gather_memory_modules()?,
 | |
|             cpus: Self::gather_cpus(&sys)?,
 | |
|             chipset: Self::gather_chipset()?,
 | |
|             network_interfaces: Self::gather_network_interfaces()?,
 | |
|             management_interface: Self::gather_management_interface()?,
 | |
|             host_uuid: Self::get_host_uuid()?,
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     fn all_tools_available() -> Result<(), String> {
 | |
|         let required_tools = [
 | |
|             ("lsblk", Some("--version")),
 | |
|             ("lspci", Some("--version")),
 | |
|             ("lsmod", None),
 | |
|             ("dmidecode", Some("--version")),
 | |
|             ("smartctl", Some("--version")),
 | |
|             ("ip", Some("route")), // No version flag available
 | |
|         ];
 | |
| 
 | |
|         let mut missing_tools = Vec::new();
 | |
| 
 | |
|         debug!("Looking for required_tools {required_tools:?}");
 | |
|         for (tool, tool_arg) in required_tools.iter() {
 | |
|             // First check if tool exists in PATH using which(1)
 | |
|             let mut exists = if let Ok(output) = Command::new("which").arg(tool).output() {
 | |
|                 output.status.success()
 | |
|             } else {
 | |
|                 false
 | |
|             };
 | |
| 
 | |
|             if !exists {
 | |
|                 // Fallback: manual PATH search if which(1) is unavailable
 | |
|                 debug!("Looking for {tool} in path");
 | |
|                 if let Ok(path_var) = std::env::var("PATH") {
 | |
|                     debug!("PATH is {path_var}");
 | |
|                     exists = path_var.split(':').any(|dir| {
 | |
|                         let tool_path = std::path::Path::new(dir).join(tool);
 | |
|                         tool_path.exists() && Self::is_executable(&tool_path)
 | |
|                     })
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             if !exists {
 | |
|                 warn!("Unable to find tool {tool} from PATH");
 | |
|                 missing_tools.push(*tool);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Verify tool is functional by checking version/help output
 | |
|             let mut cmd = Command::new(tool);
 | |
|             if let Some(tool_arg) = tool_arg {
 | |
|                 cmd.arg(tool_arg);
 | |
|             }
 | |
|             cmd.stdout(std::process::Stdio::null());
 | |
|             cmd.stderr(std::process::Stdio::null());
 | |
| 
 | |
|             if let Ok(status) = cmd.status() {
 | |
|                 if !status.success() {
 | |
|                     warn!("Unable to test {tool} status failed");
 | |
|                     missing_tools.push(*tool);
 | |
|                 }
 | |
|             } else {
 | |
|                 warn!("Unable to test {tool}");
 | |
|                 missing_tools.push(*tool);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if !missing_tools.is_empty() {
 | |
|             let missing_str = missing_tools
 | |
|                 .iter()
 | |
|                 .map(|s| s.to_string())
 | |
|                 .collect::<Vec<String>>()
 | |
|                 .join(", ");
 | |
|             return Err(format!(
 | |
|                 "The following required tools are not available: {}. Please install these tools to use PhysicalHost::gather()",
 | |
|                 missing_str
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         Ok(())
 | |
|     }
 | |
| 
 | |
|     #[cfg(unix)]
 | |
|     fn is_executable(path: &std::path::Path) -> bool {
 | |
|         debug!("Checking if {} is executable", path.to_string_lossy());
 | |
|         use std::os::unix::fs::PermissionsExt;
 | |
| 
 | |
|         match std::fs::metadata(path) {
 | |
|             Ok(meta) => meta.permissions().mode() & 0o111 != 0,
 | |
|             Err(_) => false,
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     #[cfg(not(unix))]
 | |
|     fn is_executable(_path: &std::path::Path) -> bool {
 | |
|         // On non-Unix systems, we assume existence implies executability
 | |
|         true
 | |
|     }
 | |
| 
 | |
|     fn gather_storage_drives() -> Result<Vec<StorageDrive>, String> {
 | |
|         let mut drives = Vec::new();
 | |
| 
 | |
|         // Use lsblk with JSON output for robust parsing
 | |
|         let output = Command::new("lsblk")
 | |
|             .args([
 | |
|                 "-d",
 | |
|                 "-o",
 | |
|                 "NAME,MODEL,SERIAL,SIZE,ROTA,WWN",
 | |
|                 "-n",
 | |
|                 "-e",
 | |
|                 "7",
 | |
|                 "--json",
 | |
|             ])
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute lsblk: {}", e))?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Err(format!(
 | |
|                 "lsblk command failed: {}",
 | |
|                 String::from_utf8_lossy(&output.stderr)
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         let json: Value = serde_json::from_slice(&output.stdout)
 | |
|             .map_err(|e| format!("Failed to parse lsblk JSON output: {}", e))?;
 | |
| 
 | |
|         let blockdevices = json
 | |
|             .get("blockdevices")
 | |
|             .and_then(|v| v.as_array())
 | |
|             .ok_or("Invalid lsblk JSON: missing 'blockdevices' array")?;
 | |
| 
 | |
|         for device in blockdevices {
 | |
|             let name = device
 | |
|                 .get("name")
 | |
|                 .and_then(|v| v.as_str())
 | |
|                 .ok_or("Missing 'name' in lsblk device")?
 | |
|                 .to_string();
 | |
| 
 | |
|             if name.is_empty() {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             let model = device
 | |
|                 .get("model")
 | |
|                 .and_then(|v| v.as_str())
 | |
|                 .map(|s| s.trim().to_string())
 | |
|                 .unwrap_or_default();
 | |
| 
 | |
|             let serial = device
 | |
|                 .get("serial")
 | |
|                 .and_then(|v| v.as_str())
 | |
|                 .map(|s| s.trim().to_string())
 | |
|                 .unwrap_or_default();
 | |
| 
 | |
|             let size_str = device
 | |
|                 .get("size")
 | |
|                 .and_then(|v| v.as_str())
 | |
|                 .ok_or("Missing 'size' in lsblk device")?;
 | |
|             let size_bytes = Self::parse_size(size_str)?;
 | |
| 
 | |
|             let rotational = device
 | |
|                 .get("rota")
 | |
|                 .and_then(|v| v.as_bool())
 | |
|                 .ok_or("Missing 'rota' in lsblk device")?;
 | |
| 
 | |
|             let wwn = device
 | |
|                 .get("wwn")
 | |
|                 .and_then(|v| v.as_str())
 | |
|                 .map(|s| s.trim().to_string())
 | |
|                 .filter(|s| !s.is_empty() && s != "null");
 | |
| 
 | |
|             let device_path = Path::new("/sys/block").join(&name);
 | |
| 
 | |
|             let logical_block_size = Self::read_sysfs_u32(
 | |
|                 &device_path.join("queue/logical_block_size"),
 | |
|             )
 | |
|             .map_err(|e| format!("Failed to read logical block size for {}: {}", name, e))?;
 | |
| 
 | |
|             let physical_block_size = Self::read_sysfs_u32(
 | |
|                 &device_path.join("queue/physical_block_size"),
 | |
|             )
 | |
|             .map_err(|e| format!("Failed to read physical block size for {}: {}", name, e))?;
 | |
| 
 | |
|             let interface_type = Self::get_interface_type(&name, &device_path)?;
 | |
|             let smart_status = Self::get_smart_status(&name)?;
 | |
| 
 | |
|             let mut drive = StorageDrive {
 | |
|                 name: name.clone(),
 | |
|                 model,
 | |
|                 serial,
 | |
|                 size_bytes,
 | |
|                 logical_block_size,
 | |
|                 physical_block_size,
 | |
|                 rotational,
 | |
|                 wwn,
 | |
|                 interface_type,
 | |
|                 smart_status,
 | |
|             };
 | |
| 
 | |
|             // Enhance with additional sysfs info if available
 | |
|             if device_path.exists() {
 | |
|                 if drive.model.is_empty() {
 | |
|                     drive.model = Self::read_sysfs_string(&device_path.join("device/model"))
 | |
|                         .unwrap_or(format!("Failed to read model for {}", name));
 | |
|                 }
 | |
|                 if drive.serial.is_empty() {
 | |
|                     drive.serial = Self::read_sysfs_string(&device_path.join("device/serial"))
 | |
|                         .unwrap_or(format!("Failed to read serial for {}", name));
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             drives.push(drive);
 | |
|         }
 | |
| 
 | |
|         Ok(drives)
 | |
|     }
 | |
| 
 | |
|     fn gather_storage_controller() -> Result<StorageController, String> {
 | |
|         let mut controller = StorageController {
 | |
|             name: "Unknown".to_string(),
 | |
|             driver: "Unknown".to_string(),
 | |
|         };
 | |
| 
 | |
|         // Use lspci with JSON output if available
 | |
|         let output = Command::new("lspci")
 | |
|             .args(["-nn", "-d", "::0100", "-J"]) // Storage controllers class with JSON
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute lspci: {}", e))?;
 | |
| 
 | |
|         if output.status.success() {
 | |
|             let json: Value = serde_json::from_slice(&output.stdout)
 | |
|                 .map_err(|e| format!("Failed to parse lspci JSON output: {}", e))?;
 | |
| 
 | |
|             if let Some(devices) = json.as_array() {
 | |
|                 for device in devices {
 | |
|                     if let Some(device_info) = device.as_object()
 | |
|                         && let Some(name) = device_info
 | |
|                             .get("device")
 | |
|                             .and_then(|v| v.as_object())
 | |
|                             .and_then(|v| v.get("name"))
 | |
|                             .and_then(|v| v.as_str())
 | |
|                     {
 | |
|                         controller.name = name.to_string();
 | |
|                         break;
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Fallback to text output if JSON fails or no device found
 | |
|         if controller.name == "Unknown" {
 | |
|             let output = Command::new("lspci")
 | |
|                 .args(["-nn", "-d", "::0100"]) // Storage controllers class
 | |
|                 .output()
 | |
|                 .map_err(|e| format!("Failed to execute lspci (fallback): {}", e))?;
 | |
| 
 | |
|             if output.status.success() {
 | |
|                 let output_str = String::from_utf8_lossy(&output.stdout);
 | |
|                 if let Some(line) = output_str.lines().next() {
 | |
|                     let parts: Vec<&str> = line.split(':').collect();
 | |
|                     if parts.len() > 2 {
 | |
|                         controller.name = parts[2].trim().to_string();
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Try to get driver info from lsmod
 | |
|         let output = Command::new("lsmod")
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute lsmod: {}", e))?;
 | |
| 
 | |
|         if output.status.success() {
 | |
|             let output_str = String::from_utf8_lossy(&output.stdout);
 | |
|             for line in output_str.lines() {
 | |
|                 if line.contains("ahci")
 | |
|                     || line.contains("nvme")
 | |
|                     || line.contains("megaraid")
 | |
|                     || line.contains("mpt3sas")
 | |
|                 {
 | |
|                     let parts: Vec<&str> = line.split_whitespace().collect();
 | |
|                     if !parts.is_empty() {
 | |
|                         controller.driver = parts[0].to_string();
 | |
|                         break;
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         Ok(controller)
 | |
|     }
 | |
| 
 | |
|     fn gather_memory_modules() -> Result<Vec<MemoryModule>, String> {
 | |
|         let mut modules = Vec::new();
 | |
| 
 | |
|         let output = Command::new("dmidecode")
 | |
|             .arg("--type")
 | |
|             .arg("17")
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute dmidecode: {}", e))?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Err(format!(
 | |
|                 "dmidecode command failed: {}",
 | |
|                 String::from_utf8_lossy(&output.stderr)
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         let output_str = String::from_utf8(output.stdout)
 | |
|             .map_err(|e| format!("Failed to parse dmidecode output: {}", e))?;
 | |
| 
 | |
|         let sections: Vec<&str> = output_str.split("Memory Device").collect();
 | |
| 
 | |
|         for section in sections.into_iter().skip(1) {
 | |
|             let mut module = MemoryModule {
 | |
|                 size_bytes: 0,
 | |
|                 speed_mhz: None,
 | |
|                 manufacturer: None,
 | |
|                 part_number: None,
 | |
|                 serial_number: None,
 | |
|                 rank: None,
 | |
|             };
 | |
| 
 | |
|             for line in section.lines() {
 | |
|                 let line = line.trim();
 | |
|                 if let Some(size_str) = line.strip_prefix("Size: ") {
 | |
|                     if size_str != "No Module Installed"
 | |
|                         && let Some((num, unit)) = size_str.split_once(' ')
 | |
|                         && let Ok(num) = num.parse::<u64>()
 | |
|                     {
 | |
|                         module.size_bytes = match unit {
 | |
|                             "MB" => num * 1024 * 1024,
 | |
|                             "GB" => num * 1024 * 1024 * 1024,
 | |
|                             "KB" => num * 1024,
 | |
|                             _ => 0,
 | |
|                         };
 | |
|                     }
 | |
|                 } else if let Some(speed_str) = line.strip_prefix("Speed: ") {
 | |
|                     if let Some((num, _unit)) = speed_str.split_once(' ') {
 | |
|                         module.speed_mhz = num.parse().ok();
 | |
|                     }
 | |
|                 } else if let Some(man) = line.strip_prefix("Manufacturer: ") {
 | |
|                     module.manufacturer = Some(man.to_string());
 | |
|                 } else if let Some(part) = line.strip_prefix("Part Number: ") {
 | |
|                     module.part_number = Some(part.to_string());
 | |
|                 } else if let Some(serial) = line.strip_prefix("Serial Number: ") {
 | |
|                     module.serial_number = Some(serial.to_string());
 | |
|                 } else if let Some(rank) = line.strip_prefix("Rank: ") {
 | |
|                     module.rank = rank.parse().ok();
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             if module.size_bytes > 0 {
 | |
|                 modules.push(module);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         Ok(modules)
 | |
|     }
 | |
| 
 | |
|     fn gather_cpus(sys: &System) -> Result<Vec<CPU>, String> {
 | |
|         let mut cpus = Vec::new();
 | |
|         let global_cpu = sys.global_cpu_info();
 | |
| 
 | |
|         cpus.push(CPU {
 | |
|             model: global_cpu.brand().to_string(),
 | |
|             vendor: global_cpu.vendor_id().to_string(),
 | |
|             cores: sys.physical_core_count().unwrap_or(1) as u32,
 | |
|             threads: sys.cpus().len() as u32,
 | |
|             frequency_mhz: global_cpu.frequency(),
 | |
|         });
 | |
| 
 | |
|         Ok(cpus)
 | |
|     }
 | |
| 
 | |
|     fn gather_chipset() -> Result<Chipset, String> {
 | |
|         Ok(Chipset {
 | |
|             name: Self::read_dmi("baseboard-product-name")?,
 | |
|             vendor: Self::read_dmi("baseboard-manufacturer")?,
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     fn gather_network_interfaces() -> Result<Vec<NetworkInterface>, String> {
 | |
|         let mut interfaces = Vec::new();
 | |
|         let sys_net_path = Path::new("/sys/class/net");
 | |
| 
 | |
|         let entries = fs::read_dir(sys_net_path)
 | |
|             .map_err(|e| format!("Failed to read /sys/class/net: {}", e))?;
 | |
| 
 | |
|         for entry in entries {
 | |
|             let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
 | |
|             let iface_name = entry
 | |
|                 .file_name()
 | |
|                 .into_string()
 | |
|                 .map_err(|_| "Invalid UTF-8 in interface name")?;
 | |
|             let iface_path = entry.path();
 | |
| 
 | |
|             // Skip virtual interfaces
 | |
|             if iface_name.starts_with("lo")
 | |
|                 || iface_name.starts_with("docker")
 | |
|                 || iface_name.starts_with("virbr")
 | |
|                 || iface_name.starts_with("veth")
 | |
|                 || iface_name.starts_with("br-")
 | |
|                 || iface_name.starts_with("tun")
 | |
|                 || iface_name.starts_with("wg")
 | |
|             {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Check if it's a physical interface by looking for device directory
 | |
|             if !iface_path.join("device").exists() {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             let mac_address = Self::read_sysfs_string(&iface_path.join("address"))
 | |
|                 .map_err(|e| format!("Failed to read MAC address for {}: {}", iface_name, e))?;
 | |
|             let mac_address = MacAddress::try_from(mac_address).map_err(|e| e.to_string())?;
 | |
| 
 | |
|             let speed_mbps = if iface_path.join("speed").exists() {
 | |
|                 match Self::read_sysfs_u32(&iface_path.join("speed")) {
 | |
|                     Ok(speed) => Some(speed),
 | |
|                     Err(e) => {
 | |
|                         debug!(
 | |
|                             "Failed to read speed for {}: {} . This is expected to fail on wifi interfaces.",
 | |
|                             iface_name, e
 | |
|                         );
 | |
|                         None
 | |
|                     }
 | |
|                 }
 | |
|             } else {
 | |
|                 None
 | |
|             };
 | |
| 
 | |
|             let operstate = Self::read_sysfs_string(&iface_path.join("operstate"))
 | |
|                 .map_err(|e| format!("Failed to read operstate for {}: {}", iface_name, e))?;
 | |
| 
 | |
|             let mtu = Self::read_sysfs_u32(&iface_path.join("mtu"))
 | |
|                 .map_err(|e| format!("Failed to read MTU for {}: {}", iface_name, e))?;
 | |
| 
 | |
|             let driver =
 | |
|                 Self::read_sysfs_symlink_basename(&iface_path.join("device/driver/module"))
 | |
|                     .map_err(|e| format!("Failed to read driver for {}: {}", iface_name, e))?;
 | |
| 
 | |
|             let firmware_version = Self::read_sysfs_opt_string(
 | |
|                 &iface_path.join("device/firmware_version"),
 | |
|             )
 | |
|             .map_err(|e| format!("Failed to read firmware version for {}: {}", iface_name, e))?;
 | |
| 
 | |
|             // Get IP addresses using ip command with JSON output
 | |
|             let (ipv4_addresses, ipv6_addresses) = Self::get_interface_ips_json(&iface_name)
 | |
|                 .map_err(|e| format!("Failed to get IP addresses for {}: {}", iface_name, e))?;
 | |
| 
 | |
|             interfaces.push(NetworkInterface {
 | |
|                 name: iface_name,
 | |
|                 mac_address,
 | |
|                 speed_mbps,
 | |
|                 is_up: operstate == "up",
 | |
|                 mtu,
 | |
|                 ipv4_addresses,
 | |
|                 ipv6_addresses,
 | |
|                 driver,
 | |
|                 firmware_version,
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         Ok(interfaces)
 | |
|     }
 | |
| 
 | |
|     fn gather_management_interface() -> Result<Option<ManagementInterface>, String> {
 | |
|         if Path::new("/dev/ipmi0").exists() {
 | |
|             Ok(Some(ManagementInterface {
 | |
|                 kind: "IPMI".to_string(),
 | |
|                 address: None,
 | |
|                 firmware: Some(Self::read_dmi("bios-version")?),
 | |
|             }))
 | |
|         } else if Path::new("/sys/class/misc/mei").exists() {
 | |
|             Ok(Some(ManagementInterface {
 | |
|                 kind: "Intel ME".to_string(),
 | |
|                 address: None,
 | |
|                 firmware: None,
 | |
|             }))
 | |
|         } else {
 | |
|             Ok(None)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn get_host_uuid() -> Result<String, String> {
 | |
|         Self::read_dmi("system-uuid")
 | |
|     }
 | |
| 
 | |
|     // Helper methods
 | |
|     fn read_sysfs_string(path: &Path) -> Result<String, String> {
 | |
|         fs::read_to_string(path)
 | |
|             .map(|s| s.trim().to_string())
 | |
|             .map_err(|e| format!("Failed to read {}: {}", path.display(), e))
 | |
|     }
 | |
| 
 | |
|     fn read_sysfs_opt_string(path: &Path) -> Result<Option<String>, String> {
 | |
|         match fs::read_to_string(path) {
 | |
|             Ok(s) => {
 | |
|                 let s = s.trim().to_string();
 | |
|                 Ok(if s.is_empty() { None } else { Some(s) })
 | |
|             }
 | |
|             Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
 | |
|             Err(e) => Err(format!("Failed to read {}: {}", path.display(), e)),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn read_sysfs_u32(path: &Path) -> Result<u32, String> {
 | |
|         fs::read_to_string(path)
 | |
|             .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?
 | |
|             .trim()
 | |
|             .parse()
 | |
|             .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
 | |
|     }
 | |
| 
 | |
|     fn read_sysfs_symlink_basename(path: &Path) -> Result<String, String> {
 | |
|         match fs::read_link(path) {
 | |
|             Ok(target_path) => match target_path.file_name() {
 | |
|                 Some(name_osstr) => match name_osstr.to_str() {
 | |
|                     Some(name_str) => Ok(name_str.to_string()),
 | |
|                     None => Err(format!(
 | |
|                         "Symlink target basename is not valid UTF-8: {}",
 | |
|                         target_path.display()
 | |
|                     )),
 | |
|                 },
 | |
|                 None => Err(format!(
 | |
|                     "Symlink target has no basename: {} -> {}",
 | |
|                     path.display(),
 | |
|                     target_path.display()
 | |
|                 )),
 | |
|             },
 | |
|             Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
 | |
|                 "Could not resolve symlink for path : {}",
 | |
|                 path.display()
 | |
|             )),
 | |
|             Err(e) => Err(format!("Failed to read symlink {}: {}", path.display(), e)),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn read_dmi(field: &str) -> Result<String, String> {
 | |
|         let output = Command::new("dmidecode")
 | |
|             .arg("-s")
 | |
|             .arg(field)
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute dmidecode for field {}: {}", field, e))?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Err(format!(
 | |
|                 "dmidecode command failed for field {}: {}",
 | |
|                 field,
 | |
|                 String::from_utf8_lossy(&output.stderr)
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         String::from_utf8(output.stdout)
 | |
|             .map(|s| s.trim().to_string())
 | |
|             .map_err(|e| {
 | |
|                 format!(
 | |
|                     "Failed to parse dmidecode output for field {}: {}",
 | |
|                     field, e
 | |
|                 )
 | |
|             })
 | |
|     }
 | |
| 
 | |
|     fn get_interface_type(device_name: &str, device_path: &Path) -> Result<String, String> {
 | |
|         if device_name.starts_with("nvme") {
 | |
|             Ok("NVMe".to_string())
 | |
|         } else if device_name.starts_with("sd") {
 | |
|             Ok("SATA".to_string())
 | |
|         } else if device_name.starts_with("hd") {
 | |
|             Ok("IDE".to_string())
 | |
|         } else if device_name.starts_with("vd") {
 | |
|             Ok("VirtIO".to_string())
 | |
|         } else if device_name.starts_with("sr") {
 | |
|             Ok("CDROM".to_string())
 | |
|         } else if device_name.starts_with("zram") {
 | |
|             Ok("Ramdisk".to_string())
 | |
|         } else {
 | |
|             // Try to determine from device path
 | |
|             let subsystem = Self::read_sysfs_string(&device_path.join("device/subsystem"))?;
 | |
|             Ok(subsystem
 | |
|                 .split('/')
 | |
|                 .next_back()
 | |
|                 .unwrap_or("Unknown")
 | |
|                 .to_string())
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn get_smart_status(device_name: &str) -> Result<Option<String>, String> {
 | |
|         let output = Command::new("smartctl")
 | |
|             .arg("-H")
 | |
|             .arg(format!("/dev/{}", device_name))
 | |
|             .output()
 | |
|             .map_err(|e| format!("Failed to execute smartctl for {}: {}", device_name, e))?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Ok(None);
 | |
|         }
 | |
| 
 | |
|         let stdout = String::from_utf8(output.stdout)
 | |
|             .map_err(|e| format!("Failed to parse smartctl output for {}: {}", device_name, e))?;
 | |
| 
 | |
|         for line in stdout.lines() {
 | |
|             if line.contains("SMART overall-health self-assessment") {
 | |
|                 if let Some(status) = line.split(':').nth(1) {
 | |
|                     return Ok(Some(status.trim().to_string()));
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         Ok(None)
 | |
|     }
 | |
| 
 | |
|     fn parse_size(size_str: &str) -> Result<u64, String> {
 | |
|         debug!("Parsing size_str '{size_str}'");
 | |
|         let size;
 | |
|         if size_str.ends_with('T') {
 | |
|             size = size_str[..size_str.len() - 1]
 | |
|                 .parse::<f64>()
 | |
|                 .map(|t| t * 1024.0 * 1024.0 * 1024.0 * 1024.0)
 | |
|                 .map_err(|e| format!("Failed to parse T size '{}': {}", size_str, e))
 | |
|         } else if size_str.ends_with('G') {
 | |
|             size = size_str[..size_str.len() - 1]
 | |
|                 .parse::<f64>()
 | |
|                 .map(|g| g * 1024.0 * 1024.0 * 1024.0)
 | |
|                 .map_err(|e| format!("Failed to parse G size '{}': {}", size_str, e))
 | |
|         } else if size_str.ends_with('M') {
 | |
|             size = size_str[..size_str.len() - 1]
 | |
|                 .parse::<f64>()
 | |
|                 .map(|m| m * 1024.0 * 1024.0)
 | |
|                 .map_err(|e| format!("Failed to parse M size '{}': {}", size_str, e))
 | |
|         } else if size_str.ends_with('K') {
 | |
|             size = size_str[..size_str.len() - 1]
 | |
|                 .parse::<f64>()
 | |
|                 .map(|k| k * 1024.0)
 | |
|                 .map_err(|e| format!("Failed to parse K size '{}': {}", size_str, e))
 | |
|         } else if size_str.ends_with('B') {
 | |
|             size = size_str[..size_str.len() - 1]
 | |
|                 .parse::<f64>()
 | |
|                 .map_err(|e| format!("Failed to parse B size '{}': {}", size_str, e))
 | |
|         } else {
 | |
|             size = size_str
 | |
|                 .parse::<f64>()
 | |
|                 .map_err(|e| format!("Failed to parse size '{}': {}", size_str, e))
 | |
|         }
 | |
| 
 | |
|         size.map(|s| s as u64)
 | |
|     }
 | |
| 
 | |
|     fn get_interface_ips_json(iface_name: &str) -> Result<(Vec<String>, Vec<String>), String> {
 | |
|         let mut ipv4 = Vec::new();
 | |
|         let mut ipv6 = Vec::new();
 | |
| 
 | |
|         // Get IPv4 addresses using JSON output
 | |
|         let output = Command::new("ip")
 | |
|             .args(["-j", "-4", "addr", "show", iface_name])
 | |
|             .output()
 | |
|             .map_err(|e| {
 | |
|                 format!(
 | |
|                     "Failed to execute ip command for IPv4 on {}: {}",
 | |
|                     iface_name, e
 | |
|                 )
 | |
|             })?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Err(format!(
 | |
|                 "ip command for IPv4 on {} failed: {}",
 | |
|                 iface_name,
 | |
|                 String::from_utf8_lossy(&output.stderr)
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         let json: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
 | |
|             format!(
 | |
|                 "Failed to parse ip JSON output for IPv4 on {}: {}",
 | |
|                 iface_name, e
 | |
|             )
 | |
|         })?;
 | |
| 
 | |
|         if let Some(addrs) = json.as_array() {
 | |
|             for addr_info in addrs {
 | |
|                 if let Some(addr_info_obj) = addr_info.as_object()
 | |
|                     && let Some(addr_info) =
 | |
|                         addr_info_obj.get("addr_info").and_then(|v| v.as_array())
 | |
|                 {
 | |
|                     for addr in addr_info {
 | |
|                         if let Some(addr_obj) = addr.as_object()
 | |
|                             && let Some(ip) = addr_obj.get("local").and_then(|v| v.as_str())
 | |
|                         {
 | |
|                             ipv4.push(ip.to_string());
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Get IPv6 addresses using JSON output
 | |
|         let output = Command::new("ip")
 | |
|             .args(["-j", "-6", "addr", "show", iface_name])
 | |
|             .output()
 | |
|             .map_err(|e| {
 | |
|                 format!(
 | |
|                     "Failed to execute ip command for IPv6 on {}: {}",
 | |
|                     iface_name, e
 | |
|                 )
 | |
|             })?;
 | |
| 
 | |
|         if !output.status.success() {
 | |
|             return Err(format!(
 | |
|                 "ip command for IPv6 on {} failed: {}",
 | |
|                 iface_name,
 | |
|                 String::from_utf8_lossy(&output.stderr)
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         let json: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
 | |
|             format!(
 | |
|                 "Failed to parse ip JSON output for IPv6 on {}: {}",
 | |
|                 iface_name, e
 | |
|             )
 | |
|         })?;
 | |
| 
 | |
|         if let Some(addrs) = json.as_array() {
 | |
|             for addr_info in addrs {
 | |
|                 if let Some(addr_info_obj) = addr_info.as_object()
 | |
|                     && let Some(addr_info) =
 | |
|                         addr_info_obj.get("addr_info").and_then(|v| v.as_array())
 | |
|                 {
 | |
|                     for addr in addr_info {
 | |
|                         if let Some(addr_obj) = addr.as_object()
 | |
|                             && let Some(ip) = addr_obj.get("local").and_then(|v| v.as_str())
 | |
|                         {
 | |
|                             // Skip link-local addresses
 | |
|                             if !ip.starts_with("fe80::") {
 | |
|                                 ipv6.push(ip.to_string());
 | |
|                             }
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         Ok((ipv4, ipv6))
 | |
|     }
 | |
| }
 |