Private
Public Access
1
0

feat: add self-enrollment workflow for automated PKI provisioning
Some checks failed
CI/CD Pipeline / Code Format (push) Failing after 1s
CI/CD Pipeline / Clippy Lints (push) Failing after 43s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m14s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 5s

- Phase 1: CLI args (--enroll flag), enroll module skeleton, config support
- Phase 2: Registration request, polling loop (24h timeout), main.rs integration
- Phase 3: PKI extraction, atomic cert writing, whitelist auto-append, mTLS transition
- Phase 4: E2E test suite, README/DEPLOYMENT docs, CI pipeline
- Phase 5: SPEC.md, API_DOCUMENTATION.md, CHANGELOG.md, ROADMAP.md sync

Security review: APPROVED (0 critical, 0 high findings)
Cross-distro compatible: Debian/Ubuntu, RHEL/CentOS/Fedora, Alpine, Arch Linux
This commit is contained in:
2026-05-17 05:30:42 +00:00
parent d297c8d3b1
commit 9a129170f8
25 changed files with 4610 additions and 70 deletions

164
src/enroll/identity.rs Normal file
View File

@ -0,0 +1,164 @@
//! 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;
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: `gethostname` via std → fallback to `hostname` CLI → "localhost".
pub fn get_fqdn() -> Result<String> {
// Try reading from hostname file first (common on systemd systems)
if let Ok(name) = fs::read_to_string("/etc/hostname") {
let trimmed = name.trim().to_string();
if !trimmed.is_empty() && trimmed != "(none)" {
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
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() {
return Ok(name);
}
}
}
Ok("localhost".into())
}
/// Collect all non-loopback IPv4 addresses from network interfaces.
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) => Some(addr.to_string()),
IpAddr::V6(_) => None,
}
})
.collect();
addrs.sort();
addrs.dedup();
Ok(addrs)
}
/// 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 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");
}
}