Private
Public Access
1
0
Files
linux_patch_api/src/main.rs
git-echo fe9bdce3c1 feat(crl): add CRL consumption and custom verifier for mTLS revocation enforcement
Implements agent-side CRL consumption for mTLS certificate revocation
checking, as specified in issue #20.

Changes:
- NEW: src/auth/crl.rs - CRL loading, parsing, signature verification,
  in-memory revoked serial index (HashSet), 24h background refresh task
- MODIFY: src/auth/mtls.rs - CrlAwareVerifier wrapping WebPkiClientVerifier
  with post-chain CRL serial lookup; fails closed on invalid signature,
  degrades gracefully when CRL is missing
- MODIFY: src/auth/mod.rs - Register crl module, re-export CrlState/CrlStatus
- MODIFY: src/config/loader.rs - Add crl_path field to TlsConfig
- MODIFY: src/main.rs - Load CRL on startup, spawn refresh task, wire
  SharedCrlState into server and health endpoint
- MODIFY: src/api/handlers/system.rs - Add crl_status and crl_age_seconds
  to health check response
- MODIFY: Cargo.toml - Add arc-swap, base64 deps; enable x509-parser
  verify feature for CRL signature verification

Design decisions:
- ArcSwap for lock-free atomic CRL state swaps on the hot path
- O(1) serial lookup via HashSet<String> of hex-encoded serials
- Stale CRL = continue serving + warn + health reports degraded
- Invalid CRL signature = refuse to start (fail-closed)
- Missing CRL = fall back to WebPKI-only (backward compatible)

Companion to PR #26 in linux-patch-manager (manager-side CRL generation)

Refs: #20
2026-06-05 13:42:35 -05:00

498 lines
19 KiB
Rust

//! Linux Patch API - Main Entry Point
//!
//! Secure remote package management API for Linux systems.
//!
//! # Configuration
//!
//! Configuration is loaded from `/etc/linux_patch_api/config.yaml` by default.
//! Use `--config` flag to specify a custom configuration path.
//!
//! # Security
//!
//! - mTLS authentication required on port 12443
//! - IP whitelist enforced (deny by default)
//! - Detailed audit logging
//!
//! # Exit Codes
//!
//! - 0: Clean exit (no certs + no enrollment URL, or --enroll/--renew-certs success)
//! - 1: Error (config error, enrollment network failure, cert validation error)
//! - 2: Certs invalid, auto-enrollment in progress (triggers systemd restart with backoff)
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpServer};
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tracing::{error, info, warn};
use linux_patch_api::api::{configure_api_routes, configure_health_route};
use linux_patch_api::auth::crl::{self, CrlStatus};
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
use linux_patch_api::config::loader::{validate_certs, CertStatus};
use linux_patch_api::enroll;
use linux_patch_api::packages::cache::PackageCacheState;
use linux_patch_api::packages::create_backend;
use linux_patch_api::{init_logging, AppConfig, JobManager};
/// Linux Patch API CLI arguments
#[derive(Parser, Debug)]
#[command(name = "linux-patch-api")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Secure remote package management API for Linux systems")]
struct Args {
/// Path to configuration file
#[arg(short, long, default_value = "/etc/linux_patch_api/config.yaml")]
config: String,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
/// Enroll with manager at URL (skips mTLS startup, runs enrollment flow only, then exits)
#[arg(
long,
help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only, then exits)"
)]
enroll: Option<String>,
/// Validate existing certs and re-enroll if expiring within threshold or invalid
#[arg(
long,
help = "Validate existing certs and re-enroll if expiring within threshold or invalid, then exits"
)]
renew_certs: bool,
}
/// Exit codes for the daemon
enum ExitCode {
/// Clean exit: no certs + no enrollment URL, or --enroll/--renew-certs success
Clean = 0,
/// Error: config error, enrollment network failure, cert validation error
Error = 1,
/// Certs invalid, auto-enrollment in progress (triggers systemd restart with backoff)
EnrollmentInProgress = 2,
}
#[actix_web::main]
async fn main() -> Result<()> {
// Parse command line arguments
let args = Args::parse();
// Initialize logging
let _guard = init_logging(args.verbose)?;
// Install rustls crypto provider (required for mTLS and HTTPS clients)
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider (aws-lc-rs)");
info!(
version = env!("CARGO_PKG_VERSION"),
config_path = args.config,
"Linux Patch API starting"
);
// Load configuration (skip TLS validation during enrollment mode)
let skip_tls_validation = args.enroll.is_some();
let mut config = match AppConfig::load(&args.config, skip_tls_validation) {
Ok(cfg) => {
info!(
port = cfg.server.port,
bind = &cfg.server.bind,
"Configuration loaded"
);
cfg
}
Err(e) => {
error!(error = %e, path = args.config, "Failed to load configuration");
std::process::exit(ExitCode::Error as i32);
}
};
// Handle --renew-certs flag: validate certs and re-enroll if needed
if args.renew_certs {
info!("Certificate renewal mode activated - validating existing certificates");
match validate_certs(&config) {
Ok(CertStatus::Valid) => {
info!("Certificates are valid and not expiring soon. No renewal needed.");
std::process::exit(ExitCode::Clean as i32);
}
Ok(CertStatus::ExpiringSoon { not_after }) => {
info!(
not_after = %not_after,
"Certificates expiring soon - starting re-enrollment"
);
}
Ok(status) => {
info!(
status = %status,
"Certificates are {} - starting re-enrollment",
status
);
}
Err(e) => {
error!(error = %e, "Certificate validation failed");
std::process::exit(ExitCode::Error as i32);
}
}
// Need enrollment URL to re-enroll
let manager_url = match config.enrollment_manager_url() {
Some(url) => url.to_string(),
None => {
error!(
"Cannot re-enroll: enrollment.manager_url not configured. \
Add the manager URL to config.yaml or use --enroll <url>"
);
std::process::exit(ExitCode::Error as i32);
}
};
match enroll::run_enrollment(&manager_url, &mut config, &args.config).await {
Ok(()) => {
info!(
"Certificate renewal complete. Start service: systemctl start linux-patch-api"
);
std::process::exit(ExitCode::Clean as i32);
}
Err(e) => {
error!(error = %e, "Certificate renewal failed");
std::process::exit(ExitCode::Error as i32);
}
}
}
// Handle --enroll flag: run enrollment flow then EXIT
if let Some(ref manager_url) = args.enroll {
info!(
manager_url = manager_url,
"Enrollment mode activated - running enrollment flow"
);
match enroll::run_enrollment(manager_url, &mut config, &args.config).await {
Ok(()) => {
info!("Enrollment complete. Start service: systemctl start linux-patch-api");
std::process::exit(ExitCode::Clean as i32);
}
Err(e) => {
error!(error = %e, "Enrollment failed");
std::process::exit(ExitCode::Error as i32);
}
}
}
// Auto-enrollment on startup: validate certs before starting server
if config.tls_config().is_some() {
match validate_certs(&config) {
Ok(CertStatus::Valid) => {
info!("TLS certificates validated successfully");
}
Ok(CertStatus::ExpiringSoon { not_after }) => {
warn!(
not_after = %not_after,
"Certificates expiring soon - starting normally, consider re-enrollment"
);
// TODO: Schedule background re-enrollment in future phase
}
Ok(status @ CertStatus::Missing { .. })
| Ok(status @ CertStatus::Corrupt { .. })
| Ok(status @ CertStatus::Expired { .. })
| Ok(status @ CertStatus::KeyMismatch)
| Ok(status @ CertStatus::Untrusted) => {
// Certs are invalid - check if we can auto-enroll
// Clone the manager URL before mutable borrow of config
let manager_url_opt = config.enrollment_manager_url().map(|s| s.to_string());
match manager_url_opt {
Some(manager_url) => {
info!(
status = %status,
manager_url = manager_url,
"Certs {}. Auto-enrolling with {}",
status,
manager_url
);
match enroll::run_enrollment(&manager_url, &mut config, &args.config).await
{
Ok(()) => {
info!("Auto-enrollment complete - continuing to server startup");
// Re-load config to pick up any changes from enrollment
config = AppConfig::load(&args.config, false)?;
}
Err(e) => {
error!(
error = %e,
"Auto-enrollment failed - will retry on next restart"
);
std::process::exit(ExitCode::EnrollmentInProgress as i32);
}
}
}
None => {
// No enrollment URL configured - exit cleanly to avoid crash loop
error!(
status = %status,
"Certs {}. No enrollment URL configured. \
To fix this, either:\n\
1. Add enrollment.manager_url to config.yaml and restart\n\
2. Run: linux-patch-api --enroll <manager_url>\n\
3. Place certificates manually in the configured paths",
status
);
std::process::exit(ExitCode::Clean as i32);
}
}
}
Err(e) => {
error!(error = %e, "Certificate validation error");
std::process::exit(ExitCode::Error as i32);
}
}
}
// Initialize job manager
let job_manager = JobManager::new(config.jobs.max_concurrent, config.jobs.timeout_minutes)?;
info!(
max_jobs = config.jobs.max_concurrent,
timeout_minutes = config.jobs.timeout_minutes,
"Job manager initialized"
);
// Initialize package manager backend
let package_backend = match create_backend() {
Ok(backend) => {
info!("Package manager backend initialized");
backend
}
Err(e) => {
error!(error = %e, "Failed to initialize package manager backend");
return Err(anyhow::anyhow!("Package backend error: {}", e));
}
};
// Initialize IP whitelist manager
let whitelist_path = config.whitelist_path();
info!(
path = whitelist_path,
"Initializing IP whitelist enforcement"
);
let whitelist_manager = match WhitelistManager::new(whitelist_path) {
Ok(manager) => {
info!(
entries = manager.entry_count(),
"Whitelist manager initialized"
);
Some(Arc::new(manager))
}
Err(e) => {
warn!(error = %e, "Failed to load whitelist - continuing with empty whitelist (all denied)");
None
}
};
// Store job manager and backend in Arc for sharing
let job_manager_data = web::Data::new(job_manager);
let backend_data = web::Data::new(package_backend);
// Initialize package cache state
let cache_state = web::Data::new(PackageCacheState::new());
info!("Package cache state initialized");
// Initialize shared CRL state (available even when TLS is off for health reporting)
let shared_crl_state = crl::new_shared_state();
let crl_state_data = web::Data::new(shared_crl_state.clone());
// Configure bind address
let bind_address = format!("{}:{}", config.server.bind, config.server.port);
// Create server builder
let server_builder = HttpServer::new(move || {
let mut app = App::new()
.wrap(Logger::default())
.app_data(job_manager_data.clone())
.app_data(backend_data.clone())
.app_data(cache_state.clone())
.app_data(crl_state_data.clone());
// Configure API routes
app = app.configure(|cfg| {
configure_api_routes(
cfg,
job_manager_data.clone(),
backend_data.clone(),
cache_state.clone(),
);
});
// Configure health route (outside API scope)
app = app.configure(configure_health_route);
app
})
.workers(4)
// VULN-004: Configure header size limit to 8KB to prevent DoS via oversized headers
.client_request_timeout(std::time::Duration::from_secs(5))
// FIX: Set explicit client disconnect timeout to prevent connection resets on larger responses
.client_disconnect_timeout(std::time::Duration::from_secs(5))
.keep_alive(std::time::Duration::from_secs(15))
.max_connection_rate(1000);
info!(
mtls_enabled = config.tls_config().is_some(),
whitelist_enabled = whitelist_manager.is_some(),
"Security layer status"
);
info!("Linux Patch API initialized successfully");
// Apply TLS/mTLS configuration if enabled
if let Some(tls_config) = config.tls_config() {
info!(
ca_cert = %tls_config.ca_cert,
server_cert = %tls_config.server_cert,
server_key = %tls_config.server_key,
min_tls_version = %tls_config.min_tls_version,
crl_path = %tls_config.crl_path,
"Initializing mTLS authentication with TLS binding"
);
let mtls_config = mtls::MtlsConfig {
ca_cert_path: tls_config.ca_cert.clone(),
server_cert_path: tls_config.server_cert.clone(),
server_key_path: tls_config.server_key.clone(),
min_tls_version: tls_config.min_tls_version.clone(),
};
// Load CRL from disk into the shared CRL state
let crl_path = std::path::PathBuf::from(&tls_config.crl_path);
let ca_cert_der = std::fs::read(&tls_config.ca_cert).unwrap_or_default();
// Load initial CRL from disk (missing is OK -- degraded mode)
let initial_crl = crl::load_crl(&crl_path, &ca_cert_der);
match initial_crl.status {
CrlStatus::Invalid => {
error!("CRL signature is invalid -- refusing to start (fail-closed)");
std::process::exit(ExitCode::Error as i32);
}
CrlStatus::Valid | CrlStatus::Expired => {
info!(
status = %initial_crl.status,
revoked = initial_crl.revoked_serials.len(),
"CRL loaded from disk"
);
shared_crl_state.store(std::sync::Arc::new(initial_crl));
}
CrlStatus::Missing => {
info!("No CRL on disk -- starting in WebPKI-only mode");
}
CrlStatus::Degraded => {
warn!("CRL load failed -- starting in degraded (WebPKI-only) mode");
}
}
// Spawn CRL refresh background task if manager URL is configured
if let Some(manager_url) = config.enrollment_manager_url() {
crl::spawn_crl_refresh_task(
manager_url.to_string(),
crl_path,
ca_cert_der,
shared_crl_state.clone(),
);
} else {
info!("No manager URL configured -- CRL auto-refresh disabled");
}
match MtlsMiddleware::new(mtls_config.clone()) {
Ok(middleware) => {
// Build rustls server configuration with CRL-aware verifier
let rustls_config = middleware
.build_rustls_config(Some(shared_crl_state.clone()))
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
info!("mTLS middleware and rustls config initialized successfully");
// Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
let bind_addr: std::net::SocketAddr = bind_address.parse().map_err(|e| {
anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e)
})?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| {
anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e)
})?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
let tcp_listener: std::net::TcpListener = socket.into();
// Log listening AFTER successful bind
info!("Listening on {} (mTLS enabled)", bind_address);
// Clone the ServerConfig from Arc for listen_rustls_0_23
let server_config = (*rustls_config).clone();
info!("Binding server with TLS 1.3 - non-TLS connections will be rejected");
// Bind with TLS using rustls 0.23 - non-TLS connections fail at handshake
server_builder
.listen_rustls_0_23(tcp_listener, server_config)?
.run()
.await?;
}
Err(e) => {
error!(error = %e, "Failed to initialize mTLS middleware");
return Err(anyhow::anyhow!("mTLS initialization failed: {}", e));
}
}
} else {
// Create TCP listener with SO_REUSEADDR for non-TLS mode
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
let bind_addr: std::net::SocketAddr = bind_address
.parse()
.map_err(|e| anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e))?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e))?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
let tcp_listener: std::net::TcpListener = socket.into();
// Log listening AFTER successful bind
info!("Listening on {} (no TLS)", bind_address);
warn!("TLS is disabled - running without mTLS authentication (INSECURE)");
server_builder.listen(tcp_listener)?.run().await?;
}
info!("Linux Patch API shutting down");
Ok(())
}