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
498 lines
19 KiB
Rust
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(())
|
|
}
|