//! Cross-distribution identity extraction for Linux systems. //! //! Provides machine-id, FQDN, IP address, and OS-detail collection //! compatible with Debian/Ubuntu, RHEL/CentOS/Fedora, Alpine, and Arch Linux. use anyhow::{anyhow, Context, Result}; use std::fs; use std::net::{IpAddr, Ipv4Addr}; use std::process::Command; /// Read the D-Bus machine identifier from `/etc/machine-id`. /// Falls back to `/var/lib/dbus/machine-id` on older systems. pub fn get_machine_id() -> Result { let primary = "/etc/machine-id"; let fallback = "/var/lib/dbus/machine-id"; if let Ok(id) = fs::read_to_string(primary) { let trimmed = id.trim().to_string(); if !trimmed.is_empty() { return Ok(trimmed); } } let id = fs::read_to_string(fallback) .with_context(|| format!("Failed to read machine-id from {} or {}", primary, fallback))?; let trimmed = id.trim().to_string(); if trimmed.is_empty() { return Err(anyhow!("machine-id file is empty")); } Ok(trimmed) } /// Resolve the fully-qualified domain name. /// /// Strategy (in priority order): /// 1. `hostname -f` → if result contains `.`, it's a real FQDN /// 2. `hostname` + `hostname -d` → combine short hostname + domain /// 3. `/etc/hostname` → short hostname fallback /// 4. `hostname` command → last resort /// 5. `"localhost"` → final fallback pub fn get_fqdn() -> Result { // 1. Try `hostname -f` — returns FQDN on properly configured systems if let Ok(output) = Command::new("hostname").arg("-f").output() { if output.status.success() { let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !name.is_empty() && name.contains('.') && name != "(none)" { tracing::debug!(fqdn = %name, "Resolved FQDN via hostname -f"); return Ok(name); } } } // 2. Try combining short hostname + domain from `hostname -d` if let Ok(short_output) = Command::new("hostname").output() { if short_output.status.success() { let short = String::from_utf8_lossy(&short_output.stdout) .trim() .to_string(); if !short.is_empty() && short != "(none)" { if let Ok(domain_output) = Command::new("hostname").arg("-d").output() { if domain_output.status.success() { let domain = String::from_utf8_lossy(&domain_output.stdout) .trim() .to_string(); if !domain.is_empty() { let fqdn = format!("{}.{}", short, domain); tracing::debug!(fqdn = %fqdn, "Resolved FQDN via hostname + hostname -d"); return Ok(fqdn); } } } // Domain not available — fall through to try other methods } } } // 3. Try reading from /etc/hostname (common on systemd systems, usually short hostname) if let Ok(name) = fs::read_to_string("/etc/hostname") { let trimmed = name.trim().to_string(); if !trimmed.is_empty() && trimmed != "(none)" { tracing::debug!(hostname = %trimmed, "Resolved hostname via /etc/hostname (may be short)"); return Ok(trimmed); } } // 4. Fallback to plain hostname command if let Ok(output) = Command::new("hostname").output() { if output.status.success() { let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !name.is_empty() { tracing::debug!(hostname = %name, "Resolved hostname via hostname command"); return Ok(name); } } } // 5. Final fallback tracing::warn!("Could not determine hostname — falling back to localhost"); Ok("localhost".into()) } /// Resolve the short hostname (without domain). /// /// Strategy: `/etc/hostname` → `hostname` command → split FQDN on `.` → `"localhost"`. pub fn get_hostname() -> Result { // Try reading from /etc/hostname (usually contains the short hostname) if let Ok(name) = fs::read_to_string("/etc/hostname") { let trimmed = name.trim().to_string(); if !trimmed.is_empty() && trimmed != "(none)" { // If it contains a dot, take just the first component let short = trimmed.split('.').next().unwrap_or(&trimmed).to_string(); tracing::debug!(hostname = %short, "Resolved short hostname via /etc/hostname"); return Ok(short); } } // Try hostname command if let Ok(output) = Command::new("hostname").output() { if output.status.success() { let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !name.is_empty() { // If it contains a dot, take just the first component let short = name.split('.').next().unwrap_or(&name).to_string(); tracing::debug!(hostname = %short, "Resolved short hostname via hostname command"); return Ok(short); } } } // Try splitting FQDN from get_fqdn() if let Ok(fqdn) = get_fqdn() { if fqdn != "localhost" && fqdn.contains('.') { let short = fqdn.split('.').next().unwrap_or(&fqdn).to_string(); tracing::debug!(hostname = %short, "Resolved short hostname by splitting FQDN"); return Ok(short); } } // Final fallback tracing::warn!("Could not determine short hostname — falling back to localhost"); Ok("localhost".into()) } /// Collect all non-loopback IPv4 addresses from network interfaces. /// /// Filters out container bridge subnets (Docker 172.16.0.0/12) and /// link-local addresses (169.254.0.0/16) that are not routable from the manager. pub fn get_ip_addresses() -> Result> { let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?; let mut addrs: Vec = ifaces .iter() .filter_map(|iface| { if iface.is_loopback() { return None; } match &iface.ip() { IpAddr::V4(addr) => { // Filter container bridge and link-local subnets if is_container_bridge(addr) || is_link_local(addr) { tracing::debug!( ip = %addr, "Excluding container bridge or link-local IP from enrollment report" ); return None; } Some(addr.to_string()) } IpAddr::V6(_) => None, } }) .collect(); addrs.sort(); addrs.dedup(); Ok(addrs) } /// Check if an IPv4 address is in a container bridge subnet. /// /// Filters the `172.16.0.0/12` range (172.16.0.0 – 172.31.255.255), which is /// Docker's default bridge network allocation. /// /// Note: `10.0.0.0/8` is NOT filtered because it is widely used for legitimate /// LAN addressing. If a deployment uses a custom Docker bridge subnet outside /// `172.16.0.0/12`, use `report_interface` or `report_ip` config to override. pub fn is_container_bridge(addr: &Ipv4Addr) -> bool { // 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255 // Binary: 10101100.0001xxxx.xxxxxxxx.xxxxxxxx let octets = addr.octets(); octets[0] == 172 && (octets[1] & 0xF0) == 0x10 } /// Check if an IPv4 address is link-local (`169.254.0.0/16`). /// /// Link-local addresses are auto-assigned when no DHCP is available and /// are never routable across networks. pub fn is_link_local(addr: &Ipv4Addr) -> bool { let octets = addr.octets(); octets[0] == 169 && octets[1] == 254 } /// Determine the local source IP that would be used to reach a target IP. /// Uses the kernel routing table via `ip route get `. /// /// This is the most accurate way to select the correct local IP because it /// queries the kernel routing table directly, which accounts for all routing /// rules, interface priorities, and source address selection. pub fn get_route_source_ip(target_ip: &str) -> Result { let output = Command::new("ip") .args(["route", "get", target_ip]) .output() .context("Failed to execute 'ip route get' — is iproute2 installed?")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow!( "'ip route get {}' failed: {}", target_ip, stderr.trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); // Parse output like: "192.168.3.36 via 192.168.1.1 dev eth0 src 192.168.3.36 uid ..." // We want the 'src' field value let mut found_src = false; for part in stdout.split_whitespace() { if found_src { // Validate it's a valid IPv4 address if part.parse::().is_ok() { let addr = part.parse::().unwrap(); if !addr.is_loopback() && !is_container_bridge(&addr) && !is_link_local(&addr) { tracing::info!( target_ip = target_ip, source_ip = part, "Route-based IP selection: local source IP for reaching target" ); return Ok(part.to_string()); } } break; } if part == "src" { found_src = true; } } Err(anyhow!( "Could not determine source IP for route to '{}' — 'ip route get' output: {}", target_ip, stdout.trim() )) } /// Get the IPv4 address of a specific network interface by name. /// /// Returns the first non-loopback IPv4 address on the named interface. /// Useful when the admin knows which interface faces the manager network. pub fn get_ip_for_interface(interface_name: &str) -> Result { let ifaces = if_addrs::get_if_addrs() .with_context(|| "Failed to enumerate network interfaces for interface lookup")?; for iface in &ifaces { if iface.name != interface_name { continue; } if let IpAddr::V4(addr) = iface.ip() { if !iface.is_loopback() { tracing::info!( interface = interface_name, ip = %addr, "Resolved IP from configured interface" ); return Ok(addr.to_string()); } } } Err(anyhow!( "No non-loopback IPv4 address found on interface '{}'", interface_name )) } /// Determine the primary IP address to report to the manager. /// /// Resolution priority: /// 1. `report_ip` — explicit IP from config (highest priority) /// 2. `report_interface` — IP from a named interface /// 3. `route_target` — route-based selection using kernel routing table /// 4. Auto-detect — first IP from `get_ip_addresses()` (bridge subnets already filtered) pub fn get_primary_ip( report_interface: Option<&str>, report_ip: Option<&str>, route_target: Option<&str>, ) -> Result { // Priority 1: Explicit IP override if let Some(ip) = report_ip { // Validate it parses as IPv4 if let Ok(addr) = ip.parse::() { if !addr.is_loopback() { tracing::info!(ip = ip, "Using explicitly configured report_ip"); return Ok(ip.to_string()); } tracing::warn!( ip = ip, "Configured report_ip is a loopback address — ignoring" ); } else { tracing::warn!( ip = ip, "Configured report_ip is not a valid IPv4 address — falling back to auto-detect" ); } } // Priority 2: Interface name override if let Some(iface) = report_interface { match get_ip_for_interface(iface) { Ok(ip) => return Ok(ip), Err(e) => { tracing::warn!( interface = iface, error = %e, "Configured report_interface lookup failed — falling back to route-based or auto-detect" ); } } } // Priority 3: Route-based selection using kernel routing table if let Some(target) = route_target { match get_route_source_ip(target) { Ok(ip) => { tracing::info!( target = target, ip = %ip, "Using route-based IP selection for target" ); return Ok(ip); } Err(e) => { tracing::warn!( target = target, error = %e, "Route-based IP selection failed — falling back to auto-detect" ); } } } // Priority 4: Auto-detect (bridge subnets already filtered by get_ip_addresses) let addrs = get_ip_addresses()?; addrs .first() .cloned() .ok_or_else(|| anyhow!("No suitable IPv4 address found on any interface")) } /// Extract OS distribution details from `/etc/os-release` and kernel version. /// Returns a JSON object with: distro, version, id_like, kernel. pub fn get_os_details() -> Result { let mut details = serde_json::Map::new(); // Parse /etc/os-release (exists on all target distros) if let Ok(content) = fs::read_to_string("/etc/os-release") { for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once('=') { // Strip surrounding quotes from value let unquoted = value.trim().trim_matches('"').trim_matches('\''); match key { "NAME" => { details.insert( "distro".into(), serde_json::Value::String(unquoted.to_string()), ); } "VERSION_ID" => { details.insert( "version".into(), serde_json::Value::String(unquoted.to_string()), ); } "ID_LIKE" => { details.insert( "id_like".into(), serde_json::Value::String(unquoted.to_string()), ); } "VERSION_CODENAME" => { details.insert( "codename".into(), serde_json::Value::String(unquoted.to_string()), ); } _ => {} } } } } else { // Fallback for systems without os-release (very rare) details.insert("distro".into(), serde_json::Value::String("unknown".into())); details.insert( "version".into(), serde_json::Value::String("unknown".into()), ); } // Kernel version via uname -r if let Ok(output) = Command::new("uname").arg("-r").output() { if output.status.success() { let kernel = String::from_utf8_lossy(&output.stdout).trim().to_string(); details.insert("kernel".into(), serde_json::Value::String(kernel)); } } else { details.insert("kernel".into(), serde_json::Value::String("unknown".into())); } Ok(serde_json::Value::Object(details)) } #[cfg(test)] mod tests { use super::*; #[test] fn machine_id_is_not_empty() { let id = get_machine_id().expect("Failed to get machine-id"); assert!(!id.is_empty(), "machine-id should not be empty"); assert_eq!(id.len(), 32, "machine-id should be 32 hex chars"); } #[test] fn fqdn_is_not_empty() { let fqdn = get_fqdn().expect("Failed to get FQDN"); assert!(!fqdn.is_empty(), "FQDN should not be empty"); } #[test] fn fqdn_prefers_full_domain() { // If hostname -f returns a value with a dot, get_fqdn should return it // (not the short hostname from /etc/hostname) let fqdn = get_fqdn().expect("Failed to get FQDN"); // On properly configured systems, FQDN should contain at least one dot // If it doesn't, it's likely a short hostname from /etc/hostname if fqdn.contains('.') { // FQDN contains domain — good assert!( fqdn.split('.').count() >= 2, "FQDN should have at least host.domain format, got: {}", fqdn ); } // If no dot, it's a short hostname — acceptable fallback but not ideal } #[test] fn hostname_is_not_empty() { let hostname = get_hostname().expect("Failed to get hostname"); assert!(!hostname.is_empty(), "Hostname should not be empty"); } #[test] fn hostname_is_short_form() { let hostname = get_hostname().expect("Failed to get hostname"); // Short hostname should NOT contain dots assert!( !hostname.contains('.'), "Short hostname should not contain dots, got: {}", hostname ); } #[test] fn hostname_is_prefix_of_fqdn() { let hostname = get_hostname().expect("Failed to get hostname"); let fqdn = get_fqdn().expect("Failed to get FQDN"); // If FQDN contains a dot, hostname should be the first component if fqdn.contains('.') { let fqdn_prefix = fqdn.split('.').next().unwrap_or(&fqdn); assert_eq!( hostname, fqdn_prefix, "Short hostname '{}' should match FQDN prefix '{}'", hostname, fqdn_prefix ); } } #[test] fn os_details_contains_kernel() { let details = get_os_details().expect("Failed to get OS details"); assert!( details.get("kernel").is_some(), "OS details must contain kernel version" ); } // ============================================================================= // Container Bridge & Link-Local Filtering Tests // ============================================================================= #[test] fn test_is_container_bridge_docker_default() { // Docker default bridge network: 172.17.0.0/16 assert!(is_container_bridge(&"172.17.0.1".parse().unwrap())); assert!(is_container_bridge(&"172.17.255.255".parse().unwrap())); } #[test] fn test_is_container_bridge_full_range() { // 172.16.0.0/12 = 172.16.0.0 – 172.31.255.255 assert!(is_container_bridge(&"172.16.0.1".parse().unwrap())); assert!(is_container_bridge(&"172.31.255.255".parse().unwrap())); assert!(is_container_bridge(&"172.20.0.1".parse().unwrap())); } #[test] fn test_is_not_container_bridge() { // Outside 172.16.0.0/12 assert!(!is_container_bridge(&"192.168.3.36".parse().unwrap())); assert!(!is_container_bridge(&"10.0.0.1".parse().unwrap())); assert!(!is_container_bridge(&"172.32.0.1".parse().unwrap())); assert!(!is_container_bridge(&"172.15.0.1".parse().unwrap())); assert!(!is_container_bridge(&"8.8.8.8".parse().unwrap())); } #[test] fn test_is_link_local() { assert!(is_link_local(&"169.254.0.1".parse().unwrap())); assert!(is_link_local(&"169.254.255.255".parse().unwrap())); } #[test] fn test_is_not_link_local() { assert!(!is_link_local(&"192.168.1.1".parse().unwrap())); assert!(!is_link_local(&"169.253.0.1".parse().unwrap())); assert!(!is_link_local(&"169.255.0.1".parse().unwrap())); } #[test] fn test_get_ip_addresses_excludes_docker_bridge() { // On a system with Docker, the returned IPs should not include 172.16.0.0/12 let addrs = get_ip_addresses().expect("Failed to get IP addresses"); for addr in &addrs { let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4"); assert!( !is_container_bridge(&parsed), "IP '{}' is in Docker bridge range 172.16.0.0/12 — should be excluded", addr ); } } #[test] fn test_get_ip_addresses_excludes_link_local() { let addrs = get_ip_addresses().expect("Failed to get IP addresses"); for addr in &addrs { let parsed: Ipv4Addr = addr.parse().expect("Should parse as IPv4"); assert!( !is_link_local(&parsed), "IP '{}' is link-local 169.254.0.0/16 — should be excluded", addr ); } } #[test] fn test_get_primary_ip_auto_detect() { // Without overrides, should return a valid non-bridge IP // In Docker containers, auto-detect may find no routable IPs — that's valid match get_primary_ip(None, None, None) { Ok(ip) => { assert!(!ip.is_empty(), "Primary IP should not be empty"); let parsed: Ipv4Addr = ip.parse().expect("Primary IP should be valid IPv4"); assert!( !is_container_bridge(&parsed), "Auto-detected IP should not be Docker bridge" ); } Err(_) => { eprintln!("NOTE: No routable IPs found — likely running inside a Docker container"); } } } #[test] fn test_get_primary_ip_explicit_override() { // Explicit IP should be returned as-is let ip = get_primary_ip(None, Some("10.99.99.1"), None).expect("Failed with explicit IP"); assert_eq!(ip, "10.99.99.1"); } #[test] fn test_get_primary_ip_rejects_loopback_override() { // Loopback in report_ip should fall back to auto-detect // In Docker containers, auto-detect may also fail — that's valid match get_primary_ip(None, Some("127.0.0.1"), None) { Ok(ip) => assert_ne!(ip, "127.0.0.1"), Err(_) => { eprintln!( "NOTE: Loopback rejected but no routable IPs for fallback — Docker container" ); } } } #[test] fn test_get_primary_ip_invalid_override_falls_back() { // Invalid IP in report_ip should fall back to auto-detect // In Docker containers, auto-detect may also fail — that's valid match get_primary_ip(None, Some("not-an-ip"), None) { Ok(ip) => assert!(!ip.is_empty()), Err(_) => { eprintln!( "NOTE: Invalid IP rejected but no routable IPs for fallback — Docker container" ); } } } #[test] fn test_get_primary_ip_route_target_priority() { // Route-based selection should be tried before auto-detect // We test with a well-known IP; if iproute2 is available this may succeed, // otherwise it falls back gracefully match get_primary_ip(None, None, Some("8.8.8.8")) { Ok(ip) => { assert!(!ip.is_empty(), "Route-based IP should not be empty"); let parsed: Ipv4Addr = ip.parse().expect("Route-based IP should be valid IPv4"); assert!( !is_container_bridge(&parsed), "Route-based IP should not be Docker bridge" ); assert!( !parsed.is_loopback(), "Route-based IP should not be loopback" ); } Err(_) => { eprintln!( "NOTE: Route-based selection failed — iproute2 may not be available in this environment" ); } } } #[test] fn test_get_primary_ip_explicit_overrides_route_target() { // Explicit report_ip should take priority over route_target let ip = get_primary_ip(None, Some("10.99.99.1"), Some("8.8.8.8")) .expect("Explicit IP should override route_target"); assert_eq!(ip, "10.99.99.1"); } #[test] fn test_get_route_source_ip_known_target() { // Test route-based IP detection with a well-known target // This test requires iproute2 to be installed match get_route_source_ip("8.8.8.8") { Ok(ip) => { let parsed: Ipv4Addr = ip.parse().expect("Route source IP should be valid IPv4"); assert!( !parsed.is_loopback(), "Route source IP should not be loopback" ); assert!( !is_container_bridge(&parsed), "Route source IP should not be Docker bridge" ); assert!( !is_link_local(&parsed), "Route source IP should not be link-local" ); } Err(e) => { // Acceptable in containers without iproute2 or routing eprintln!( "NOTE: Route-based IP detection failed: {} — may be unavailable in this environment", e ); } } } }