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

View File

@ -4,10 +4,13 @@
//! Loads configuration from YAML file with auto-reload support.
//! All connections not in whitelist are silently dropped.
use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use fs2::FileExt;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::Path;
use std::sync::{Arc, RwLock};
@ -26,7 +29,7 @@ pub enum WhitelistEntry {
}
/// Whitelist configuration loaded from YAML
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WhitelistConfig {
pub entries: Vec<String>,
}
@ -79,6 +82,141 @@ impl WhitelistManager {
Ok(())
}
/// Append an IP address or CIDR entry to the whitelist file.
/// Creates the file if it doesn't exist. Uses file locking for concurrent safety.
/// Logs the change to audit log.
pub fn append_entry(&mut self, ip_or_cidr: &str) -> Result<()> {
// 1. Validate IP/CIDR format
let entry_str = ip_or_cidr.trim();
if entry_str.is_empty() {
bail!("Cannot append empty whitelist entry");
}
// Parse to validate - must be IPv4 or CIDR, no hostnames in auto-append
let parsed_entry = if let Some((ip_str, prefix_str)) = entry_str.split_once('/') {
let ip: Ipv4Addr = ip_str.parse()
.with_context(|| format!("Invalid IP in CIDR notation: {}", entry_str))?;
let prefix: u8 = prefix_str.parse()
.with_context(|| format!("Invalid prefix in CIDR notation: {}", entry_str))?;
if prefix > 32 {
anyhow::bail!("Invalid CIDR prefix (must be 0-32): {}", entry_str);
}
WhitelistEntry::Cidr { network: ip, prefix }
} else {
let ip: Ipv4Addr = entry_str.parse()
.with_context(|| format!("Invalid IPv4 address: {}", entry_str))?;
WhitelistEntry::Ip(ip)
};
// 2. Check for duplicate in current in-memory state
{
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() {
if *existing == parsed_entry {
info!(
action = "whitelist_append",
ip = entry_str,
source = "enrollment",
already_exists = true,
"Whitelist entry already exists, skipping duplicate"
);
return Ok(());
}
}
}
// 3. Acquire exclusive file lock using fs2
let lock_path = format!("{}.lock", self.config_path);
let lock_file = OpenOptions::new()
.create(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("Failed to create lock file: {}", lock_path))?;
lock_file.lock_exclusive().context("Failed to acquire exclusive whitelist lock")?;
// Double-check for duplicates after acquiring lock (concurrent append scenario)
{
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
for existing in entries.iter() {
if *existing == parsed_entry {
info!(
action = "whitelist_append",
ip = entry_str,
source = "enrollment",
already_exists = true,
"Whitelist entry already exists (post-lock check), skipping duplicate"
);
return Ok(());
}
}
}
// 4. Read current whitelist YAML or create empty config
let mut config = if Path::new(&self.config_path).exists() {
self.load_config().context("Failed to load existing whitelist for append")?
} else {
WhitelistConfig { entries: Vec::new() }
};
// 5. Append new entry to allowed_ips list
config.entries.push(entry_str.to_string());
// 6. Write back atomically (temp file + rename)
let config_path = Path::new(&self.config_path);
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create whitelist directory: {}", parent.display()))?;
}
}
let yaml_content = serde_yaml::to_string(&config)
.with_context(|| "Failed to serialize whitelist config")?;
let temp_path = config_path.with_extension("tmp");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(&temp_path)
.with_context(|| format!("Failed to create temp whitelist file: {}", temp_path.display()))?;
file.write_all(yaml_content.as_bytes())
.with_context(|| format!("Failed to write whitelist data to: {}", temp_path.display()))?;
file.flush()
.with_context(|| format!("Failed to flush whitelist data to: {}", temp_path.display()))?;
// Atomic rename
fs::rename(&temp_path, config_path)
.with_context(|| {
format!(
"Failed to atomically rename whitelist temp file {} to {}",
temp_path.display(),
config_path.display()
)
})?;
// Release lock explicitly before reload (drop happens at end of scope)
drop(lock_file);
// 7. Reload in-memory state
self.reload().context("Failed to reload whitelist after append")?;
// 8. Log audit event
tracing::info!(
action = "whitelist_append",
ip = entry_str,
source = "enrollment",
total_entries = self.entry_count(),
"Whitelist entry added during enrollment"
);
Ok(())
}
/// Check if an IP address is allowed
pub fn is_allowed(&self, ip: &Ipv4Addr) -> bool {
let entries = self.entries.read().unwrap();