Private
Public Access
1
0
Files
linux_patch_api/src/enroll/identity.rs

692 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String> {
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<String> {
// 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<String> {
// 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<Vec<String>> {
let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
let mut addrs: Vec<String> = 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 <target>`.
///
/// 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<String> {
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::<Ipv4Addr>().is_ok() {
let addr = part.parse::<Ipv4Addr>().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<String> {
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<String> {
// Priority 1: Explicit IP override
if let Some(ip) = report_ip {
// Validate it parses as IPv4
if let Ok(addr) = ip.parse::<Ipv4Addr>() {
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<serde_json::Value> {
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
);
}
}
}
}