fix: FQDN resolution and display_name blank bug; fix: Arch/Alpine/RPM packages
Bug fixes: - get_fqdn() now prioritizes 'hostname -f' (returns full FQDN) over /etc/hostname (returns short hostname) - Added get_hostname() for short hostname extraction - Added hostname field to EnrollmentRequest for manager display_name population - Updated SPEC.md and API_DOCUMENTATION.md Package fixes: - Arch: Added linux-patch-api.install with post_install/upgrade/remove hooks, user creation, directory creation, config handling - Alpine: Added linux-patch-api.apk-install with pre/post install/deinstall hooks, user creation, directory creation, config handling, missing config.yaml.example - RPM: Dynamic version from Cargo.toml, %ghost %config(noreplace) for live configs, tarball exclusions, /var/log in %files
This commit is contained in:
@ -18,6 +18,10 @@ pub struct EnrollmentRequest {
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
/// Short hostname (from /etc/hostname or hostname command).
|
||||
/// Used by the manager to populate `display_name` on approval.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from `POST /api/v1/enroll` (HTTP 202).
|
||||
@ -220,12 +224,18 @@ impl EnrollmentClient {
|
||||
let os_details = identity::get_os_details()
|
||||
.context("Failed to collect OS details — /etc/os-release may be missing")?;
|
||||
|
||||
// 2. Build EnrollmentRequest struct
|
||||
// 2. Collect short hostname for display_name on manager
|
||||
let hostname = identity::get_hostname()
|
||||
.map_err(|e| tracing::warn!(error = %e, "Failed to determine hostname — display_name will use FQDN fallback"))
|
||||
.ok();
|
||||
|
||||
// 3. Build EnrollmentRequest struct
|
||||
let request = EnrollmentRequest {
|
||||
machine_id,
|
||||
fqdn,
|
||||
ip_address,
|
||||
os_details,
|
||||
hostname,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@ -502,6 +512,7 @@ mod tests {
|
||||
fqdn: "node.example.com".into(),
|
||||
ip_address: "192.168.1.10".into(),
|
||||
os_details: serde_json::json!({"distro": "Debian", "version": "12"}),
|
||||
hostname: Some("node".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&request).expect("Failed to serialize EnrollmentRequest");
|
||||
assert!(json.contains("machine_id"));
|
||||
|
||||
@ -31,36 +31,109 @@ pub fn get_machine_id() -> Result<String> {
|
||||
}
|
||||
|
||||
/// Resolve the fully-qualified domain name.
|
||||
/// Strategy: `gethostname` via std → fallback to `hostname` CLI → "localhost".
|
||||
///
|
||||
/// 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> {
|
||||
// Try reading from hostname file first (common on systemd systems)
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hostname command
|
||||
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() {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to plain hostname
|
||||
// 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())
|
||||
}
|
||||
|
||||
@ -366,6 +439,56 @@ mod tests {
|
||||
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");
|
||||
|
||||
@ -16,8 +16,8 @@ pub use client::{
|
||||
};
|
||||
/// Re-export identity extraction functions.
|
||||
pub use identity::{
|
||||
get_fqdn, get_ip_addresses, get_ip_for_interface, get_machine_id, get_os_details,
|
||||
get_primary_ip, get_route_source_ip, is_container_bridge, is_link_local,
|
||||
get_fqdn, get_hostname, get_ip_addresses, get_ip_for_interface, get_machine_id,
|
||||
get_os_details, get_primary_ip, get_route_source_ip, is_container_bridge, is_link_local,
|
||||
};
|
||||
|
||||
/// Run the full enrollment flow against the manager at the given URL.
|
||||
|
||||
Reference in New Issue
Block a user