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
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:
361
src/enroll/provision.rs
Normal file
361
src/enroll/provision.rs
Normal file
@ -0,0 +1,361 @@
|
||||
//! PKI provisioning module for self-enrollment.
|
||||
//! Handles certificate extraction, validation, and secure file writing.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use crate::auth::WhitelistManager;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
/// Default certificate directory when TLS config is not provided.
|
||||
#[allow(dead_code)]
|
||||
const DEFAULT_CERT_DIR: &str = "/etc/linux_patch_api/certs";
|
||||
/// Default CA certificate path.
|
||||
const DEFAULT_CA_CERT: &str = "/etc/linux_patch_api/certs/ca.pem";
|
||||
/// Default server certificate path.
|
||||
const DEFAULT_SERVER_CERT: &str = "/etc/linux_patch_api/certs/server.pem";
|
||||
/// Default server key path.
|
||||
const DEFAULT_SERVER_KEY: &str = "/etc/linux_patch_api/certs/server.key.pem";
|
||||
|
||||
/// Validate that a PEM string has proper format (BEGIN/END markers present).
|
||||
///
|
||||
/// Checks for `-----BEGIN {expected_type}-----` and `-----END {expected_type}-----` markers.
|
||||
/// Returns an error if either marker is missing or the data is empty.
|
||||
pub fn validate_pem(pem_data: &str, expected_type: &str) -> Result<()> {
|
||||
let trimmed = pem_data.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
bail!("PEM data is empty for type '{}'", expected_type);
|
||||
}
|
||||
|
||||
let begin_marker = format!("-----BEGIN {}-----", expected_type);
|
||||
let end_marker = format!("-----END {}-----", expected_type);
|
||||
|
||||
if !trimmed.contains(&begin_marker) {
|
||||
bail!(
|
||||
"Invalid PEM format: missing '{}' marker for type '{}'",
|
||||
begin_marker,
|
||||
expected_type
|
||||
);
|
||||
}
|
||||
|
||||
if !trimmed.contains(&end_marker) {
|
||||
bail!(
|
||||
"Invalid PEM format: missing '{}' marker for type '{}'",
|
||||
end_marker,
|
||||
expected_type
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write PEM data to disk with secure permissions using atomic write pattern.
|
||||
///
|
||||
/// 1. Create target directory if it doesn't exist (with 0o755 permissions)
|
||||
/// 2. Backup existing file if present (.bak extension)
|
||||
/// 3. Write to temp file in same directory
|
||||
/// 4. Set correct permissions (key=0o600, certs=0o644)
|
||||
/// 5. Rename atomically to target path
|
||||
pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
|
||||
let path = std::path::Path::new(path);
|
||||
|
||||
// Ensure target directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
// Set directory permissions (0o755 for readability by service, restricted write)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(parent)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(parent, perms)
|
||||
.with_context(|| format!("Failed to set permissions on: {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup existing file if present
|
||||
if path.exists() {
|
||||
let backup_path = format!("{}.bak", path.display());
|
||||
fs::rename(path, &backup_path)
|
||||
.with_context(|| format!("Failed to backup existing file: {}", path.display()))?;
|
||||
tracing::info!(
|
||||
original = %path.display(),
|
||||
backup = %backup_path,
|
||||
"Backed up existing certificate file"
|
||||
);
|
||||
}
|
||||
|
||||
// Create temp file in same directory for atomic rename
|
||||
let temp_path = path.with_extension("tmp");
|
||||
|
||||
// Write PEM data to temp file
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.truncate(true)
|
||||
.mode(if is_key { 0o600 } else { 0o644 })
|
||||
.open(&temp_path)
|
||||
.with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
|
||||
|
||||
file.write_all(pem_data.as_bytes())
|
||||
.with_context(|| format!("Failed to write PEM data to: {}", temp_path.display()))?;
|
||||
file.flush()
|
||||
.with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?;
|
||||
|
||||
// Atomic rename to target path
|
||||
fs::rename(&temp_path, path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to atomically rename {} to {}",
|
||||
temp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
is_key = is_key,
|
||||
permissions = if is_key { "0600" } else { "0644" },
|
||||
"Successfully wrote PEM file"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Provision the full PKI bundle from an approved enrollment response.
|
||||
///
|
||||
/// Writes CA cert, server cert, and server key to configured paths.
|
||||
/// Paths are read from TLS config if available, otherwise defaults are used.
|
||||
pub async fn provision_pki_bundle(
|
||||
ca_crt: &str,
|
||||
server_crt: &str,
|
||||
server_key: &str,
|
||||
tls_config: Option<&super::super::config::loader::TlsConfig>,
|
||||
) -> Result<()> {
|
||||
// Determine target paths from config or defaults
|
||||
let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config {
|
||||
(tls.ca_cert.clone(), tls.server_cert.clone(), tls.server_key.clone())
|
||||
} else {
|
||||
(
|
||||
DEFAULT_CA_CERT.to_string(),
|
||||
DEFAULT_SERVER_CERT.to_string(),
|
||||
DEFAULT_SERVER_KEY.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
// 1. Validate all three PEM strings before any writes
|
||||
validate_pem(ca_crt, "CERTIFICATE")
|
||||
.context("CA certificate validation failed")?;
|
||||
validate_pem(server_crt, "CERTIFICATE")
|
||||
.context("Server certificate validation failed")?;
|
||||
|
||||
// Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY
|
||||
let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok()
|
||||
|| validate_pem(server_key, "RSA PRIVATE KEY").is_ok()
|
||||
|| validate_pem(server_key, "EC PRIVATE KEY").is_ok();
|
||||
|
||||
if !key_valid {
|
||||
bail!(
|
||||
"Server key validation failed: PEM must be PRIVATE KEY, RSA PRIVATE KEY, or EC PRIVATE KEY"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Write to configured paths (atomic writes)
|
||||
write_pem_file(&ca_path, ca_crt, false)
|
||||
.context("Failed to write CA certificate")?;
|
||||
|
||||
write_pem_file(&cert_path, server_crt, false)
|
||||
.context("Failed to write server certificate")?;
|
||||
|
||||
write_pem_file(&key_path, server_key, true)
|
||||
.context("Failed to write server key")?;
|
||||
|
||||
// 3. Log successful provisioning with structured fields
|
||||
tracing::info!(
|
||||
ca_cert = %ca_path,
|
||||
server_cert = %cert_path,
|
||||
server_key = %key_path,
|
||||
"PKI bundle provisioned successfully - all certificates written and validated"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append the manager IP to the whitelist after successful enrollment.
|
||||
///
|
||||
/// Creates or loads a `WhitelistManager` and calls `append_entry()` with the
|
||||
/// provided IP/CIDR string. Returns an error if the file cannot be locked,
|
||||
/// written, or reloaded.
|
||||
pub async fn append_manager_to_whitelist(manager_ip: &str, whitelist_path: &str) -> Result<()> {
|
||||
// Validate input before touching any files
|
||||
let ip_or_cidr = manager_ip.trim();
|
||||
if ip_or_cidr.is_empty() {
|
||||
bail!("Manager IP address cannot be empty");
|
||||
}
|
||||
|
||||
// Create or load WhitelistManager and call append_entry
|
||||
let mut manager = WhitelistManager::new(whitelist_path)
|
||||
.with_context(|| format!("Failed to initialize whitelist manager for path: {}", whitelist_path))?;
|
||||
|
||||
manager.append_entry(ip_or_cidr)
|
||||
.with_context(|| format!("Failed to append manager IP '{}' to whitelist at: {}", ip_or_cidr, whitelist_path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn sample_certificate() -> String {
|
||||
"-----BEGIN CERTIFICATE-----\nMIIBxTCCAWugAwIBAgIRA ...\nBASE64ENCODED DATA HERE ...\n-----END CERTIFICATE-----".to_string()
|
||||
}
|
||||
|
||||
fn sample_rsa_key() -> String {
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3...\nBASE64ENCODED DATA HERE ...\n-----END RSA PRIVATE KEY-----".to_string()
|
||||
}
|
||||
|
||||
fn sample_pkcs8_key() -> String {
|
||||
"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\nBASE64ENCODED DATA HERE ...\n-----END PRIVATE KEY-----".to_string()
|
||||
}
|
||||
|
||||
fn sample_ec_key() -> String {
|
||||
"-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIBkg5Lb/...\nBASE64ENCODED DATA HERE ...\n-----END EC PRIVATE KEY-----".to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_valid_certificate() {
|
||||
let cert = sample_certificate();
|
||||
assert!(validate_pem(&cert, "CERTIFICATE").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_valid_rsa_key() {
|
||||
let key = sample_rsa_key();
|
||||
assert!(validate_pem(&key, "RSA PRIVATE KEY").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_valid_pkcs8_key() {
|
||||
let key = sample_pkcs8_key();
|
||||
assert!(validate_pem(&key, "PRIVATE KEY").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_valid_ec_key() {
|
||||
let key = sample_ec_key();
|
||||
assert!(validate_pem(&key, "EC PRIVATE KEY").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_empty_data_fails() {
|
||||
assert!(validate_pem("", "CERTIFICATE").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_missing_begin_marker_fails() {
|
||||
let malformed = "BASE64DATA\n-----END CERTIFICATE-----".to_string();
|
||||
let err = validate_pem(&malformed, "CERTIFICATE").unwrap_err();
|
||||
assert!(err.to_string().contains("BEGIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_missing_end_marker_fails() {
|
||||
let malformed = "-----BEGIN CERTIFICATE-----\nBASE64DATA".to_string();
|
||||
let err = validate_pem(&malformed, "CERTIFICATE").unwrap_err();
|
||||
assert!(err.to_string().contains("END"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_wrong_type_fails() {
|
||||
let cert = sample_certificate();
|
||||
// Certificate data checked against wrong type should fail
|
||||
let err = validate_pem(&cert, "RSA PRIVATE KEY").unwrap_err();
|
||||
assert!(err.to_string().contains("BEGIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pem_whitespace_tolerance() {
|
||||
let cert = format!("\n \n {} \n ", sample_certificate());
|
||||
assert!(validate_pem(&cert, "CERTIFICATE").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pem_file_creates_directory() {
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("subdir").join("cert.pem");
|
||||
let cert = sample_certificate();
|
||||
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed");
|
||||
assert!(target_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pem_file_atomic_rename() {
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("cert.pem");
|
||||
let cert = sample_certificate();
|
||||
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed");
|
||||
|
||||
// Verify content matches
|
||||
let written = fs::read_to_string(&target_path).expect("failed to read back");
|
||||
assert_eq!(written, cert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pem_file_key_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("key.pem");
|
||||
let key = sample_rsa_key();
|
||||
|
||||
write_pem_file(target_path.to_str().unwrap(), &key, true).expect("write failed");
|
||||
|
||||
let metadata = fs::metadata(&target_path).expect("failed to get metadata");
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "Key file should have 0600 permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pem_file_cert_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("cert.pem");
|
||||
let cert = sample_certificate();
|
||||
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert, false).expect("write failed");
|
||||
|
||||
let metadata = fs::metadata(&target_path).expect("failed to get metadata");
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o644, "Cert file should have 0644 permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pem_file_backup_existing() {
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("cert.pem");
|
||||
let cert1 = sample_certificate();
|
||||
let cert2 = "-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
|
||||
|
||||
// Write initial file
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed");
|
||||
|
||||
// Write again - should create backup
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed");
|
||||
|
||||
let backup_path = format!("{}.bak", target_path.display());
|
||||
assert!(std::path::Path::new(&backup_path).exists(), "Backup file should exist");
|
||||
|
||||
// Original content in backup
|
||||
let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup");
|
||||
assert_eq!(backup_content, cert1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user