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, pub storage_controller: StorageController, pub memory_modules: Vec, pub cpus: Vec, pub chipset: Chipset, pub network_interfaces: Vec, pub management_interface: Option, pub host_uuid: String, } #[derive(Serialize, Deserialize, Debug)] 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, pub interface_type: String, pub smart_status: Option, } #[derive(Serialize, Deserialize, Debug)] pub struct StorageController { pub name: String, pub driver: String, } #[derive(Serialize, Deserialize, Debug)] pub struct MemoryModule { pub size_bytes: u64, pub speed_mhz: Option, pub manufacturer: Option, pub part_number: Option, pub serial_number: Option, pub rank: Option, } #[derive(Serialize, Deserialize, Debug)] 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)] pub struct NetworkInterface { pub name: String, pub mac_address: String, pub speed_mbps: Option, pub is_up: bool, pub mtu: u32, pub ipv4_addresses: Vec, pub ipv6_addresses: Vec, pub driver: String, pub firmware_version: Option, } #[derive(Serialize, Deserialize, Debug)] pub struct ManagementInterface { pub kind: String, pub address: Option, pub firmware: Option, } impl PhysicalHost { pub fn gather() -> Result { 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::>() .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, 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 { 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, 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::() { 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, 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 { Ok(Chipset { name: Self::read_dmi("baseboard-product-name")?, vendor: Self::read_dmi("baseboard-manufacturer")?, }) } fn gather_network_interfaces() -> Result, 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 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, 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 { Self::read_dmi("system-uuid") } // Helper methods fn read_sysfs_string(path: &Path) -> Result { 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, 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 { 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 { 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 { 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 { 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, 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 { debug!("Parsing size_str '{size_str}'"); let size; if size_str.ends_with('T') { size = size_str[..size_str.len() - 1] .parse::() .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::() .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::() .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::() .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::() .map_err(|e| format!("Failed to parse B size '{}': {}", size_str, e)) } else { size = size_str .parse::() .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, Vec), 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)) } }