Apply cargo fmt formatting to fix CI/CD fmt job
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 11s
CI/CD Pipeline / Clippy Lints (push) Failing after 5m21s
CI/CD Pipeline / Unit Tests (push) Failing after 5m28s
CI/CD Pipeline / Security Audit (push) Successful in 1m47s
CI/CD Pipeline / Build Debian Package (push) Failing after 1s
CI/CD Pipeline / Build RPM Package (push) Failing after 1s
CI/CD Pipeline / Build Alpine Package (push) Failing after 2s
CI/CD Pipeline / Build Arch Package (push) Failing after 2s
CI/CD Pipeline / Create Release (push) Has been skipped
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 11s
CI/CD Pipeline / Clippy Lints (push) Failing after 5m21s
CI/CD Pipeline / Unit Tests (push) Failing after 5m28s
CI/CD Pipeline / Security Audit (push) Successful in 1m47s
CI/CD Pipeline / Build Debian Package (push) Failing after 1s
CI/CD Pipeline / Build RPM Package (push) Failing after 1s
CI/CD Pipeline / Build Alpine Package (push) Failing after 2s
CI/CD Pipeline / Build Arch Package (push) Failing after 2s
CI/CD Pipeline / Create Release (push) Has been skipped
This commit is contained in:
@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::manager::{JobManager, JobOperation, JobStatus, Job};
|
||||
use crate::jobs::manager::{Job, JobManager, JobOperation, JobStatus};
|
||||
|
||||
use super::packages::{ApiResponse, JobResponseData};
|
||||
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
//! - jobs: Job management endpoints
|
||||
//! - websocket: Real-time job status streaming
|
||||
|
||||
pub mod jobs;
|
||||
pub mod packages;
|
||||
pub mod patches;
|
||||
pub mod system;
|
||||
pub mod jobs;
|
||||
pub mod websocket;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use packages::{ApiResponse, ApiError};
|
||||
pub use packages::{ApiError, ApiResponse};
|
||||
pub use websocket::{WsClientMessage, WsServerMessage};
|
||||
|
||||
@ -14,7 +14,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
||||
use crate::packages::{Package, PackageManagerBackend, PackageSpec, InstallOptions};
|
||||
use crate::packages::{InstallOptions, Package, PackageManagerBackend, PackageSpec};
|
||||
|
||||
/// Maximum allowed length for package names
|
||||
const MAX_PACKAGE_NAME_LENGTH: usize = 256;
|
||||
@ -25,7 +25,10 @@ fn validate_package_name(name: &str) -> Result<(), String> {
|
||||
return Err("Package name cannot be empty".to_string());
|
||||
}
|
||||
if name.len() > MAX_PACKAGE_NAME_LENGTH {
|
||||
return Err(format!("Package name exceeds maximum length of {} characters", MAX_PACKAGE_NAME_LENGTH));
|
||||
return Err(format!(
|
||||
"Package name exceeds maximum length of {} characters",
|
||||
MAX_PACKAGE_NAME_LENGTH
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -59,7 +62,12 @@ impl<T: Serialize> ApiResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str, details: Option<serde_json::Value>, retryable: bool) -> Self {
|
||||
pub fn error(
|
||||
code: &str,
|
||||
message: &str,
|
||||
details: Option<serde_json::Value>,
|
||||
retryable: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
@ -134,13 +142,11 @@ pub async fn list_packages(
|
||||
Ok(mut packages) => {
|
||||
// Apply filters
|
||||
if let Some(status) = &query.status {
|
||||
packages.retain(|p| {
|
||||
match status.as_str() {
|
||||
"installed" => p.status == crate::packages::PackageStatus::Installed,
|
||||
"upgradable" => p.upgradable,
|
||||
"available" => p.status == crate::packages::PackageStatus::Available,
|
||||
_ => true,
|
||||
}
|
||||
packages.retain(|p| match status.as_str() {
|
||||
"installed" => p.status == crate::packages::PackageStatus::Installed,
|
||||
"upgradable" => p.upgradable,
|
||||
"available" => p.status == crate::packages::PackageStatus::Available,
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
@ -153,7 +159,7 @@ pub async fn list_packages(
|
||||
// Apply sorting
|
||||
let sort_field = query.sort.as_deref().unwrap_or("name");
|
||||
let ascending = query.order.as_deref().unwrap_or("asc") == "asc";
|
||||
|
||||
|
||||
packages.sort_by(|a, b| {
|
||||
let cmp = match sort_field {
|
||||
"name" => a.name.cmp(&b.name),
|
||||
@ -161,7 +167,11 @@ pub async fn list_packages(
|
||||
"status" => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
|
||||
_ => a.name.cmp(&b.name),
|
||||
};
|
||||
if ascending { cmp } else { cmp.reverse() }
|
||||
if ascending {
|
||||
cmp
|
||||
} else {
|
||||
cmp.reverse()
|
||||
}
|
||||
});
|
||||
|
||||
let total = packages.len();
|
||||
@ -200,12 +210,7 @@ pub async fn get_package(
|
||||
|
||||
// VULN-001, VULN-003: Validate package name (length and empty string)
|
||||
if let Err(e) = validate_package_name(&package_name) {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"VALIDATION_ERROR",
|
||||
&e,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
||||
return HttpResponse::BadRequest().json(response);
|
||||
}
|
||||
|
||||
@ -252,19 +257,17 @@ pub async fn install_packages(
|
||||
|
||||
// VULN-001, VULN-003: Validate all package names (length and empty string)
|
||||
if let Err(e) = validate_package_names(&body.packages) {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"VALIDATION_ERROR",
|
||||
&e,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
||||
return HttpResponse::BadRequest().json(response);
|
||||
}
|
||||
|
||||
info!(request_id = %request_id, packages = ?package_names, "Installing packages");
|
||||
|
||||
// Create async job
|
||||
match job_manager.create_job(JobOperation::Install, package_names.clone()).await {
|
||||
match job_manager
|
||||
.create_job(JobOperation::Install, package_names.clone())
|
||||
.await
|
||||
{
|
||||
Ok(job_id) => {
|
||||
// Spawn background task to execute the installation
|
||||
let backend_clone = backend.clone();
|
||||
@ -274,10 +277,19 @@ pub async fn install_packages(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let job_id_clone = job_id;
|
||||
|
||||
|
||||
// Update job to running
|
||||
let _ = job_manager_clone.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Starting installation...".to_string())).await;
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Job started".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.update_job(
|
||||
&job_id_clone,
|
||||
JobStatus::Running,
|
||||
Some(0),
|
||||
Some("Starting installation...".to_string()),
|
||||
)
|
||||
.await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Job started".to_string())
|
||||
.await;
|
||||
|
||||
// Execute installation
|
||||
match backend_clone.install_packages(&packages, &options) {
|
||||
@ -286,7 +298,9 @@ pub async fn install_packages(
|
||||
info!(job_id = %job_id_clone, "Package installation completed");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.fail_job(&job_id_clone, e.to_string())
|
||||
.await;
|
||||
error!(job_id = %job_id_clone, error = %e, "Package installation failed");
|
||||
}
|
||||
}
|
||||
@ -328,19 +342,17 @@ pub async fn update_package(
|
||||
|
||||
// VULN-001, VULN-003: Validate package name (length and empty string)
|
||||
if let Err(e) = validate_package_name(&package_name) {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"VALIDATION_ERROR",
|
||||
&e,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
||||
return HttpResponse::BadRequest().json(response);
|
||||
}
|
||||
|
||||
info!(request_id = %request_id, package = %package_name, "Updating package");
|
||||
|
||||
// Create async job
|
||||
match job_manager.create_job(JobOperation::Update, vec![package_name.clone()]).await {
|
||||
match job_manager
|
||||
.create_job(JobOperation::Update, vec![package_name.clone()])
|
||||
.await
|
||||
{
|
||||
Ok(job_id) => {
|
||||
// Spawn background task to execute the update
|
||||
let backend_clone = backend.clone();
|
||||
@ -349,10 +361,19 @@ pub async fn update_package(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let job_id_clone = job_id;
|
||||
|
||||
|
||||
// Update job to running
|
||||
let _ = job_manager_clone.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Starting update...".to_string())).await;
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Job started".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.update_job(
|
||||
&job_id_clone,
|
||||
JobStatus::Running,
|
||||
Some(0),
|
||||
Some("Starting update...".to_string()),
|
||||
)
|
||||
.await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Job started".to_string())
|
||||
.await;
|
||||
|
||||
// Execute update
|
||||
match backend_clone.update_package(&pkg_name) {
|
||||
@ -361,7 +382,9 @@ pub async fn update_package(
|
||||
info!(job_id = %job_id_clone, package = %pkg_name, "Package update completed");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.fail_job(&job_id_clone, e.to_string())
|
||||
.await;
|
||||
error!(job_id = %job_id_clone, package = %pkg_name, error = %e, "Package update failed");
|
||||
}
|
||||
}
|
||||
@ -403,17 +426,15 @@ pub async fn remove_package(
|
||||
|
||||
// VULN-001, VULN-003: Validate package name (length and empty string)
|
||||
if let Err(e) = validate_package_name(&package_name) {
|
||||
let response = ApiResponse::<()>::error(
|
||||
"VALIDATION_ERROR",
|
||||
&e,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
|
||||
return HttpResponse::BadRequest().json(response);
|
||||
}
|
||||
|
||||
info!(request_id = %request_id, package = %package_name, "Removing package");
|
||||
match job_manager.create_job(JobOperation::Remove, vec![package_name.clone()]).await {
|
||||
match job_manager
|
||||
.create_job(JobOperation::Remove, vec![package_name.clone()])
|
||||
.await
|
||||
{
|
||||
Ok(job_id) => {
|
||||
// Spawn background task to execute the removal
|
||||
let backend_clone = backend.clone();
|
||||
@ -422,10 +443,19 @@ pub async fn remove_package(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let job_id_clone = job_id;
|
||||
|
||||
|
||||
// Update job to running
|
||||
let _ = job_manager_clone.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Starting removal...".to_string())).await;
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Job started".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.update_job(
|
||||
&job_id_clone,
|
||||
JobStatus::Running,
|
||||
Some(0),
|
||||
Some("Starting removal...".to_string()),
|
||||
)
|
||||
.await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Job started".to_string())
|
||||
.await;
|
||||
|
||||
// Execute removal (purge=false for standard removal)
|
||||
match backend_clone.remove_package(&pkg_name, false) {
|
||||
@ -434,7 +464,9 @@ pub async fn remove_package(
|
||||
info!(job_id = %job_id_clone, package = %pkg_name, "Package removal completed");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.fail_job(&job_id_clone, e.to_string())
|
||||
.await;
|
||||
error!(job_id = %job_id_clone, package = %pkg_name, error = %e, "Package removal failed");
|
||||
}
|
||||
}
|
||||
@ -490,7 +522,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_api_response_error() {
|
||||
let response: ApiResponse<()> = ApiResponse::error("TEST_CODE", "Test message", None, false);
|
||||
let response: ApiResponse<()> =
|
||||
ApiResponse::error("TEST_CODE", "Test message", None, false);
|
||||
assert!(!response.success);
|
||||
assert!(response.error.is_some());
|
||||
assert_eq!(response.error.unwrap().code, "TEST_CODE");
|
||||
|
||||
@ -13,7 +13,7 @@ use uuid::Uuid;
|
||||
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
||||
use crate::packages::PackageManagerBackend;
|
||||
|
||||
use super::packages::{ApiResponse, ApiError, JobResponseData};
|
||||
use super::packages::{ApiError, ApiResponse, JobResponseData};
|
||||
|
||||
/// Patch list response data
|
||||
#[derive(Debug, Serialize)]
|
||||
@ -48,11 +48,11 @@ pub async fn list_patches(
|
||||
match backend.list_patches() {
|
||||
Ok(patches) => {
|
||||
let total = patches.len();
|
||||
let security_updates = patches.iter()
|
||||
let security_updates = patches
|
||||
.iter()
|
||||
.filter(|p| p.severity == "critical" || p.severity == "high")
|
||||
.count();
|
||||
let requires_reboot = patches.iter()
|
||||
.any(|p| p.name.contains("kernel"));
|
||||
let requires_reboot = patches.iter().any(|p| p.name.contains("kernel"));
|
||||
|
||||
let response = ApiResponse::success(PatchListData {
|
||||
patches,
|
||||
@ -96,7 +96,10 @@ pub async fn apply_patches(
|
||||
|
||||
// Create async job
|
||||
let package_list = body.packages.clone().unwrap_or_default();
|
||||
match job_manager.create_job(JobOperation::PatchApply, package_list).await {
|
||||
match job_manager
|
||||
.create_job(JobOperation::PatchApply, package_list)
|
||||
.await
|
||||
{
|
||||
Ok(job_id) => {
|
||||
// Spawn background task to execute the patching
|
||||
let backend_clone = backend.clone();
|
||||
@ -105,10 +108,19 @@ pub async fn apply_patches(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let job_id_clone = job_id;
|
||||
|
||||
|
||||
// Update job to running
|
||||
let _ = job_manager_clone.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Starting patch application...".to_string())).await;
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Job started".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.update_job(
|
||||
&job_id_clone,
|
||||
JobStatus::Running,
|
||||
Some(0),
|
||||
Some("Starting patch application...".to_string()),
|
||||
)
|
||||
.await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Job started".to_string())
|
||||
.await;
|
||||
|
||||
// Execute patching
|
||||
match backend_clone.apply_patches(request.packages.as_deref()) {
|
||||
@ -118,12 +130,22 @@ pub async fn apply_patches(
|
||||
|
||||
// Handle reboot if requested
|
||||
if request.reboot {
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, format!("Reboot scheduled in {} seconds", request.reboot_delay_seconds)).await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(
|
||||
&job_id_clone,
|
||||
format!(
|
||||
"Reboot scheduled in {} seconds",
|
||||
request.reboot_delay_seconds
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// In production, would trigger actual reboot via system handler
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.fail_job(&job_id_clone, e.to_string())
|
||||
.await;
|
||||
error!(job_id = %job_id_clone, error = %e, "Patch application failed");
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::packages::{ApiResponse, JobResponseData};
|
||||
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
|
||||
use crate::packages::PackageManagerBackend;
|
||||
use super::packages::{ApiResponse, JobResponseData};
|
||||
|
||||
/// Normalize and validate file paths to prevent path traversal attacks (VULN-002)
|
||||
/// Returns None if path contains traversal patterns
|
||||
@ -22,7 +22,7 @@ fn normalize_path(path: &str) -> Option<String> {
|
||||
if path.contains("..") || path.contains("//") {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Decode common URL-encoded traversal attempts
|
||||
let decoded = path
|
||||
.replace("%2e", ".")
|
||||
@ -31,12 +31,12 @@ fn normalize_path(path: &str) -> Option<String> {
|
||||
.replace("%2F", "/")
|
||||
.replace("%5c", "\\")
|
||||
.replace("%5C", "\\");
|
||||
|
||||
|
||||
// Check decoded path for traversal
|
||||
if decoded.contains("..") || decoded.contains("//") || decoded.contains("\\") {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
// Ensure path starts with expected prefix or is relative
|
||||
Some(path.to_string())
|
||||
}
|
||||
@ -115,9 +115,7 @@ pub async fn get_system_info(
|
||||
}
|
||||
|
||||
/// Health check endpoint
|
||||
pub async fn health_check(
|
||||
_req: HttpRequest,
|
||||
) -> impl Responder {
|
||||
pub async fn health_check(_req: HttpRequest) -> impl Responder {
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let timestamp = Utc::now().to_rfc3339();
|
||||
|
||||
@ -125,7 +123,9 @@ pub async fn health_check(
|
||||
let uptime_seconds = std::fs::read_to_string("/proc/uptime")
|
||||
.ok()
|
||||
.and_then(|content| {
|
||||
content.split_whitespace().next()
|
||||
content
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|f| f as u64)
|
||||
})
|
||||
@ -186,20 +186,33 @@ pub async fn reboot_system(
|
||||
|
||||
tokio::spawn(async move {
|
||||
let job_id_clone = job_id;
|
||||
|
||||
|
||||
// Update job to running
|
||||
let _ = job_manager_clone.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Preparing system reboot...".to_string())).await;
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Job started".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.update_job(
|
||||
&job_id_clone,
|
||||
JobStatus::Running,
|
||||
Some(0),
|
||||
Some("Preparing system reboot...".to_string()),
|
||||
)
|
||||
.await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Job started".to_string())
|
||||
.await;
|
||||
|
||||
// Execute reboot
|
||||
match backend_clone.reboot_system(delay_clone) {
|
||||
Ok(_) => {
|
||||
let _ = job_manager_clone.add_job_log(&job_id_clone, "Reboot command executed".to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.add_job_log(&job_id_clone, "Reboot command executed".to_string())
|
||||
.await;
|
||||
// Note: Job won't complete normally since system reboots
|
||||
info!(job_id = %job_id_clone, "System reboot initiated");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
|
||||
let _ = job_manager_clone
|
||||
.fail_job(&job_id_clone, e.to_string())
|
||||
.await;
|
||||
error!(job_id = %job_id_clone, error = %e, "System reboot failed");
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,11 @@
|
||||
//! Note: Full WebSocket implementation requires actix-web-actors compatibility.
|
||||
//! This stub provides the endpoint structure for future enhancement.
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Error, http::StatusCode};
|
||||
use actix_web::{http::StatusCode, web, Error, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::jobs::manager::JobManager;
|
||||
|
||||
@ -24,9 +24,7 @@ pub enum WsClientMessage {
|
||||
job_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "unsubscribe")]
|
||||
Unsubscribe {
|
||||
job_id: String,
|
||||
},
|
||||
Unsubscribe { job_id: String },
|
||||
}
|
||||
|
||||
/// WebSocket message to client
|
||||
@ -72,7 +70,7 @@ pub async fn websocket_handler(
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let ws_id = Uuid::new_v4();
|
||||
info!(ws_id = %ws_id, "WebSocket connection request");
|
||||
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
if req
|
||||
.headers()
|
||||
@ -84,7 +82,7 @@ pub async fn websocket_handler(
|
||||
// WebSocket upgrade requested
|
||||
// In full implementation, this would use actix-web-actors::ws::start()
|
||||
// For now, return a response indicating WebSocket support
|
||||
|
||||
|
||||
let response_msg = serde_json::json!({
|
||||
"event": "connected",
|
||||
"ws_id": ws_id.to_string(),
|
||||
@ -92,7 +90,7 @@ pub async fn websocket_handler(
|
||||
"message": "WebSocket endpoint ready. Full implementation requires actix-web-actors compatibility.",
|
||||
"polling_alternative": "Use GET /api/v1/jobs/{id} for job status polling"
|
||||
});
|
||||
|
||||
|
||||
// Return HTTP 101 Switching Protocols for WebSocket upgrade
|
||||
// In production, this would be handled by actix-web-actors
|
||||
Ok(HttpResponse::build(StatusCode::SWITCHING_PROTOCOLS)
|
||||
@ -113,7 +111,7 @@ pub async fn websocket_handler(
|
||||
},
|
||||
"alternative": "Use GET /api/v1/jobs/{id} for job status polling"
|
||||
});
|
||||
|
||||
|
||||
Ok(HttpResponse::Ok().json(info_msg))
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,10 @@ pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
// Re-export handlers for convenience
|
||||
pub use handlers::jobs;
|
||||
pub use handlers::packages;
|
||||
pub use handlers::patches;
|
||||
pub use handlers::system;
|
||||
pub use handlers::jobs;
|
||||
pub use handlers::websocket;
|
||||
|
||||
// Re-export routes configuration
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
//!
|
||||
//! Aggregates all endpoint routes and configures the Actix-web application.
|
||||
|
||||
use actix_web::{web, HttpResponse, http::Method};
|
||||
use actix_web::{http::Method, web, HttpResponse};
|
||||
use tracing::info;
|
||||
|
||||
use crate::packages::create_backend;
|
||||
use crate::jobs::manager::JobManager;
|
||||
use crate::packages::create_backend;
|
||||
|
||||
use super::handlers::{packages, patches, system, jobs, websocket};
|
||||
use super::handlers::{jobs, packages, patches, system, websocket};
|
||||
|
||||
/// Default service handler for unsupported HTTP methods (VULN-005)
|
||||
/// Returns 405 Method Not Allowed instead of 404 for known endpoints
|
||||
@ -25,23 +25,21 @@ pub fn configure_api_routes(
|
||||
) {
|
||||
info!("Configuring API v1 routes");
|
||||
|
||||
cfg.app_data(job_manager)
|
||||
.app_data(backend)
|
||||
.service(
|
||||
web::scope("/api/v1")
|
||||
// VULN-005: Default handler for unsupported methods returns 405 instead of 404
|
||||
.default_service(web::route().to(method_not_allowed))
|
||||
// Package Management Endpoints
|
||||
.configure(packages::configure_routes)
|
||||
// Patch Management Endpoints
|
||||
.configure(patches::configure_routes)
|
||||
// System Management Endpoints
|
||||
.configure(system::configure_routes)
|
||||
// Job Management Endpoints
|
||||
.configure(jobs::configure_routes)
|
||||
// WebSocket Endpoint
|
||||
.configure(websocket::configure_routes),
|
||||
);
|
||||
cfg.app_data(job_manager).app_data(backend).service(
|
||||
web::scope("/api/v1")
|
||||
// VULN-005: Default handler for unsupported methods returns 405 instead of 404
|
||||
.default_service(web::route().to(method_not_allowed))
|
||||
// Package Management Endpoints
|
||||
.configure(packages::configure_routes)
|
||||
// Patch Management Endpoints
|
||||
.configure(patches::configure_routes)
|
||||
// System Management Endpoints
|
||||
.configure(system::configure_routes)
|
||||
// Job Management Endpoints
|
||||
.configure(jobs::configure_routes)
|
||||
// WebSocket Endpoint
|
||||
.configure(websocket::configure_routes),
|
||||
);
|
||||
}
|
||||
|
||||
/// Health check route (outside API scope for load balancer checks)
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
pub mod mtls;
|
||||
pub mod whitelist;
|
||||
|
||||
pub use mtls::{MtlsConfig, MtlsMiddleware, MtlsError, ClientCertInfo};
|
||||
pub use whitelist::{WhitelistManager, WhitelistMiddleware, WhitelistEntry, WhitelistConfig};
|
||||
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware};
|
||||
pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware};
|
||||
|
||||
/// Combined authentication result
|
||||
#[derive(Debug, Clone)]
|
||||
@ -44,7 +44,7 @@ mod tests {
|
||||
cert_info: None,
|
||||
client_ip: Some("192.168.1.100".parse().unwrap()),
|
||||
};
|
||||
|
||||
|
||||
assert!(result.is_authenticated());
|
||||
assert!(result.mtls_valid);
|
||||
assert!(result.ip_allowed);
|
||||
@ -58,7 +58,7 @@ mod tests {
|
||||
cert_info: None,
|
||||
client_ip: Some("192.168.1.100".parse().unwrap()),
|
||||
};
|
||||
|
||||
|
||||
assert!(!result.is_authenticated());
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ mod tests {
|
||||
cert_info: None,
|
||||
client_ip: Some("192.168.1.100".parse().unwrap()),
|
||||
};
|
||||
|
||||
|
||||
assert!(!result.is_authenticated());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,13 +3,15 @@
|
||||
//! Provides mutual TLS authentication middleware for Actix-web.
|
||||
//! Non-mTLS connections are silently dropped (no response).
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, HttpMessage,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use rustls::{
|
||||
server::{WebPkiClientVerifier, ServerConfig},
|
||||
server::{ServerConfig, WebPkiClientVerifier},
|
||||
RootCertStore,
|
||||
};
|
||||
use rustls_pemfile::{certs, private_key};
|
||||
@ -20,14 +22,12 @@ use std::{
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use actix_web::http::header;
|
||||
|
||||
/// Check for duplicate critical headers (VULN-006)
|
||||
/// Returns true if duplicate headers are detected
|
||||
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
|
||||
let critical_headers = ["content-type", "authorization", "host"];
|
||||
|
||||
|
||||
for header_name in critical_headers.iter() {
|
||||
// Count occurrences of this header
|
||||
let mut count = 0;
|
||||
@ -67,7 +67,7 @@ impl MtlsMiddleware {
|
||||
/// Create a new mTLS middleware
|
||||
pub fn new(config: MtlsConfig) -> Result<Self, MtlsError> {
|
||||
let cert_store = load_ca_certs(&config.ca_cert_path)?;
|
||||
|
||||
|
||||
Ok(Self {
|
||||
config: Arc::new(config),
|
||||
cert_store: Arc::new(cert_store),
|
||||
@ -95,21 +95,21 @@ impl MtlsMiddleware {
|
||||
/// Load CA certificates from PEM file
|
||||
fn load_ca_certs(path: &str) -> Result<RootCertStore, MtlsError> {
|
||||
let mut cert_store = RootCertStore::empty();
|
||||
|
||||
|
||||
let cert_file = File::open(path)
|
||||
.map_err(|e| MtlsError::IoError(format!("Failed to open CA cert {}: {}", path, e)))?;
|
||||
let mut reader = BufReader::new(cert_file);
|
||||
|
||||
|
||||
let certs = certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| MtlsError::ParseError(format!("Failed to parse CA certs: {}", e)))?;
|
||||
|
||||
|
||||
for cert in certs {
|
||||
cert_store.add(cert).map_err(|e| {
|
||||
MtlsError::StoreError(format!("Failed to add CA cert to store: {}", e))
|
||||
})?;
|
||||
cert_store
|
||||
.add(cert)
|
||||
.map_err(|e| MtlsError::StoreError(format!("Failed to add CA cert to store: {}", e)))?;
|
||||
}
|
||||
|
||||
|
||||
info!("Loaded CA certificates from {}", path);
|
||||
Ok(cert_store)
|
||||
}
|
||||
@ -119,11 +119,11 @@ fn load_certs(path: &str) -> Result<Vec<rustls::pki_types::CertificateDer<'stati
|
||||
let cert_file = File::open(path)
|
||||
.map_err(|e| MtlsError::IoError(format!("Failed to open cert {}: {}", path, e)))?;
|
||||
let mut reader = BufReader::new(cert_file);
|
||||
|
||||
|
||||
let certs = certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| MtlsError::ParseError(format!("Failed to parse server certs: {}", e)))?;
|
||||
|
||||
|
||||
Ok(certs)
|
||||
}
|
||||
|
||||
@ -132,11 +132,11 @@ fn load_private_key(path: &str) -> Result<rustls::pki_types::PrivateKeyDer<'stat
|
||||
let key_file = File::open(path)
|
||||
.map_err(|e| MtlsError::IoError(format!("Failed to open key {}: {}", path, e)))?;
|
||||
let mut reader = BufReader::new(key_file);
|
||||
|
||||
|
||||
let key = private_key(&mut reader)
|
||||
.map_err(|e| MtlsError::ParseError(format!("Failed to parse private key: {}", e)))?
|
||||
.ok_or_else(|| MtlsError::ParseError("No private key found in file".to_string()))?;
|
||||
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@ -199,7 +199,7 @@ where
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let cert_store = self.cert_store.clone();
|
||||
let peer_addr = req.peer_addr();
|
||||
|
||||
|
||||
// VULN-006: Check for duplicate critical headers before processing
|
||||
if has_duplicate_critical_headers(&req) {
|
||||
warn!(
|
||||
@ -207,15 +207,17 @@ where
|
||||
"Duplicate critical headers detected - rejecting request (VULN-006)"
|
||||
);
|
||||
return Box::pin(async move {
|
||||
Err(actix_web::error::ErrorBadRequest("Duplicate critical headers not allowed"))
|
||||
Err(actix_web::error::ErrorBadRequest(
|
||||
"Duplicate critical headers not allowed",
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Check for client certificate in request extensions
|
||||
// In a proper mTLS setup with Actix-web + rustls, the certificate
|
||||
// would be extracted from the TLS connection before reaching this middleware
|
||||
let has_client_cert = req.extensions().get::<ClientCertInfo>().is_some();
|
||||
|
||||
|
||||
if !has_client_cert {
|
||||
// No client certificate provided - silent drop
|
||||
warn!(
|
||||
@ -224,13 +226,15 @@ where
|
||||
);
|
||||
// Return error immediately without calling service
|
||||
return Box::pin(async move {
|
||||
Err(actix_web::error::ErrorBadRequest("Client certificate required"))
|
||||
Err(actix_web::error::ErrorBadRequest(
|
||||
"Client certificate required",
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Certificate present - validate it
|
||||
let cert_info = req.extensions().get::<ClientCertInfo>().cloned();
|
||||
|
||||
|
||||
if let Some(info) = cert_info {
|
||||
// Validate certificate against CA store
|
||||
match validate_client_certificate(&info, &cert_store) {
|
||||
@ -249,7 +253,9 @@ where
|
||||
"mTLS client certificate validation failed - dropping connection"
|
||||
);
|
||||
return Box::pin(async move {
|
||||
Err(actix_web::error::ErrorBadRequest("Certificate validation failed"))
|
||||
Err(actix_web::error::ErrorBadRequest(
|
||||
"Certificate validation failed",
|
||||
))
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -259,17 +265,17 @@ where
|
||||
"No client certificate provided - dropping connection (mTLS required)"
|
||||
);
|
||||
return Box::pin(async move {
|
||||
Err(actix_web::error::ErrorBadRequest("Client certificate required"))
|
||||
Err(actix_web::error::ErrorBadRequest(
|
||||
"Client certificate required",
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
debug!("mTLS authentication passed for request");
|
||||
|
||||
|
||||
// All checks passed - call the service
|
||||
let fut = self.service.call(req);
|
||||
Box::pin(async move {
|
||||
fut.await
|
||||
})
|
||||
Box::pin(async move { fut.await })
|
||||
}
|
||||
}
|
||||
|
||||
@ -290,22 +296,22 @@ fn validate_client_certificate(
|
||||
) -> Result<(), MtlsError> {
|
||||
// Check certificate validity period
|
||||
let now = Utc::now();
|
||||
|
||||
|
||||
if now < cert_info.not_before {
|
||||
return Err(MtlsError::ValidationError(
|
||||
"Certificate is not yet valid".to_string()
|
||||
"Certificate is not yet valid".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
if now > cert_info.not_after {
|
||||
return Err(MtlsError::ValidationError(
|
||||
"Certificate has expired".to_string()
|
||||
"Certificate has expired".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// In production, would verify certificate chain against CA store
|
||||
// For now, we trust certificates that were extracted from the TLS connection
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -321,7 +327,7 @@ mod tests {
|
||||
server_key_path: "/etc/linux_patch_api/certs/server.key".to_string(),
|
||||
min_tls_version: "1.3".to_string(),
|
||||
};
|
||||
|
||||
|
||||
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
|
||||
assert_eq!(config.min_tls_version, "1.3");
|
||||
}
|
||||
@ -335,15 +341,15 @@ mod tests {
|
||||
not_before: Utc::now() - Duration::days(1),
|
||||
not_after: Utc::now() + Duration::days(365),
|
||||
};
|
||||
|
||||
|
||||
assert!(info.subject.contains("CN="));
|
||||
assert!(info.issuer.contains("CN="));
|
||||
|
||||
|
||||
// Test validation with valid cert
|
||||
let cert_store = RootCertStore::empty();
|
||||
assert!(validate_client_certificate(&info, &cert_store).is_ok());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_client_cert_expired() {
|
||||
let info = ClientCertInfo {
|
||||
@ -353,7 +359,7 @@ mod tests {
|
||||
not_before: Utc::now() - Duration::days(365),
|
||||
not_after: Utc::now() - Duration::days(1),
|
||||
};
|
||||
|
||||
|
||||
let cert_store = RootCertStore::empty();
|
||||
let result = validate_client_certificate(&info, &cert_store);
|
||||
assert!(result.is_err());
|
||||
|
||||
@ -42,19 +42,19 @@ impl WhitelistManager {
|
||||
/// Create a new whitelist manager
|
||||
pub fn new(config_path: &str) -> Result<Self> {
|
||||
let entries = Arc::new(RwLock::new(HashSet::new()));
|
||||
|
||||
|
||||
let mut manager = Self {
|
||||
entries: entries.clone(),
|
||||
config_path: config_path.to_string(),
|
||||
watcher: None,
|
||||
};
|
||||
|
||||
|
||||
// Load initial whitelist
|
||||
manager.reload()?;
|
||||
|
||||
|
||||
// Set up file watcher for auto-reload
|
||||
manager.setup_watcher()?;
|
||||
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
@ -62,26 +62,27 @@ impl WhitelistManager {
|
||||
pub fn reload(&self) -> Result<()> {
|
||||
let config = self.load_config()?;
|
||||
let entries = self.parse_entries(&config.entries)?;
|
||||
|
||||
let mut current_entries = self.entries.write().map_err(|e| {
|
||||
anyhow::anyhow!("Failed to acquire whitelist lock: {}", e)
|
||||
})?;
|
||||
|
||||
|
||||
let mut current_entries = self
|
||||
.entries
|
||||
.write()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist lock: {}", e))?;
|
||||
|
||||
*current_entries = entries;
|
||||
|
||||
|
||||
info!(
|
||||
path = %self.config_path,
|
||||
count = current_entries.len(),
|
||||
"Whitelist reloaded successfully"
|
||||
);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an IP address is allowed
|
||||
pub fn is_allowed(&self, ip: &Ipv4Addr) -> bool {
|
||||
let entries = self.entries.read().unwrap();
|
||||
|
||||
|
||||
for entry in entries.iter() {
|
||||
match entry {
|
||||
WhitelistEntry::Ip(allowed_ip) => {
|
||||
@ -101,7 +102,7 @@ impl WhitelistManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@ -126,38 +127,38 @@ impl WhitelistManager {
|
||||
fn load_config(&self) -> Result<WhitelistConfig> {
|
||||
let content = std::fs::read_to_string(&self.config_path)
|
||||
.with_context(|| format!("Failed to read whitelist config: {}", self.config_path))?;
|
||||
|
||||
|
||||
let config: WhitelistConfig = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse whitelist config: {}", self.config_path))?;
|
||||
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Parse whitelist entries from strings
|
||||
fn parse_entries(&self, entries: &[String]) -> Result<HashSet<WhitelistEntry>> {
|
||||
let mut parsed = HashSet::new();
|
||||
|
||||
|
||||
for entry_str in entries {
|
||||
let entry_str = entry_str.trim();
|
||||
|
||||
|
||||
// Skip comments and empty lines
|
||||
if entry_str.is_empty() || entry_str.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Check for CIDR notation
|
||||
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)
|
||||
})?;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
parsed.insert(WhitelistEntry::Cidr {
|
||||
network: ip,
|
||||
prefix,
|
||||
@ -185,7 +186,7 @@ impl WhitelistManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
@ -193,7 +194,7 @@ impl WhitelistManager {
|
||||
fn setup_watcher(&mut self) -> Result<()> {
|
||||
let config_path = self.config_path.clone();
|
||||
let entries = self.entries.clone();
|
||||
|
||||
|
||||
let watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
@ -208,19 +209,19 @@ impl WhitelistManager {
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_secs(5)),
|
||||
)?;
|
||||
|
||||
|
||||
let mut watcher = watcher;
|
||||
let path = Path::new(&config_path);
|
||||
|
||||
|
||||
if path.exists() {
|
||||
watcher.watch(path, RecursiveMode::NonRecursive)?;
|
||||
info!("Watching whitelist file for changes: {}", config_path);
|
||||
} else {
|
||||
warn!("Whitelist file does not exist yet: {}", config_path);
|
||||
}
|
||||
|
||||
|
||||
self.watcher = Some(watcher);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -234,24 +235,24 @@ fn ip_in_subnet(ip: &Ipv4Addr, network: Ipv4Addr, prefix: u8) -> bool {
|
||||
} else {
|
||||
!0u32 << (32 - prefix)
|
||||
};
|
||||
|
||||
|
||||
(ip_bits & mask) == (network_bits & mask)
|
||||
}
|
||||
|
||||
/// Resolve a hostname to an IPv4 address
|
||||
fn resolve_hostname(hostname: &str) -> Result<Ipv4Addr> {
|
||||
use std::net::ToSocketAddrs;
|
||||
|
||||
|
||||
let addrs = (hostname, 0)
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("Failed to resolve hostname: {}", hostname))?;
|
||||
|
||||
|
||||
for addr in addrs {
|
||||
if let IpAddr::V4(ip) = addr.ip() {
|
||||
return Ok(ip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
anyhow::bail!("No IPv4 address found for hostname: {}", hostname)
|
||||
}
|
||||
|
||||
@ -337,11 +338,11 @@ mod tests {
|
||||
std::fs::write(temp_path, "entries:\n - \"192.168.1.0/24\"\n").unwrap();
|
||||
WhitelistManager::new(temp_path).unwrap()
|
||||
});
|
||||
|
||||
|
||||
// Test IP entry
|
||||
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
|
||||
assert!(manager.is_allowed(&ip));
|
||||
|
||||
|
||||
// Test IP outside subnet
|
||||
let ip_outside: Ipv4Addr = "192.168.2.100".parse().unwrap();
|
||||
assert!(!manager.is_allowed(&ip_outside));
|
||||
|
||||
@ -122,10 +122,10 @@ impl AppConfig {
|
||||
pub fn load(path: &str) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path))?;
|
||||
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse config file: {}", path))?;
|
||||
|
||||
|
||||
// Validate TLS configuration if enabled
|
||||
if let Some(ref tls) = config.tls {
|
||||
if tls.enabled {
|
||||
@ -140,7 +140,7 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@ -165,8 +165,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_config_load_valid_yaml() {
|
||||
let result = AppConfig::load("tests/fixtures/valid_config.yaml");
|
||||
assert!(result.is_ok(), "Failed to load valid config: {:?}", result.err());
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to load valid config: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let config = result.unwrap();
|
||||
assert_eq!(config.server.port, 12443);
|
||||
assert_eq!(config.server.bind, "127.0.0.1");
|
||||
@ -187,10 +191,10 @@ mod tests {
|
||||
fn test_config_load_invalid_yaml() {
|
||||
let invalid_path = "/tmp/invalid_config_test.yaml";
|
||||
std::fs::write(invalid_path, "invalid: yaml: content: [").unwrap();
|
||||
|
||||
|
||||
let result = AppConfig::load(invalid_path);
|
||||
assert!(result.is_err(), "Should fail for invalid yaml");
|
||||
|
||||
|
||||
std::fs::remove_file(invalid_path).unwrap();
|
||||
}
|
||||
|
||||
@ -263,6 +267,9 @@ mod tests {
|
||||
|
||||
assert!(config.tls_config().is_some());
|
||||
assert_eq!(config.tls_config().unwrap().min_tls_version, "1.3");
|
||||
assert_eq!(config.whitelist_path(), "/etc/linux_patch_api/whitelist.yaml");
|
||||
assert_eq!(
|
||||
config.whitelist_path(),
|
||||
"/etc/linux_patch_api/whitelist.yaml"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
//! Manages async job execution with concurrency limits and timeout enforcement.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Job status
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
@ -141,10 +141,10 @@ impl JobManager {
|
||||
pub async fn create_job(&self, operation: JobOperation, packages: Vec<String>) -> Result<Uuid> {
|
||||
let job = Job::new(operation, packages);
|
||||
let job_id = job.id;
|
||||
|
||||
|
||||
let mut jobs = self.jobs.write().await;
|
||||
jobs.insert(job_id, job);
|
||||
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
@ -155,9 +155,15 @@ impl JobManager {
|
||||
}
|
||||
|
||||
/// Update a job's status
|
||||
pub async fn update_job(&self, job_id: &Uuid, status: JobStatus, progress: Option<u8>, message: Option<String>) -> Result<()> {
|
||||
pub async fn update_job(
|
||||
&self,
|
||||
job_id: &Uuid,
|
||||
status: JobStatus,
|
||||
progress: Option<u8>,
|
||||
message: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
|
||||
if let Some(job) = jobs.get_mut(job_id) {
|
||||
job.status = status;
|
||||
if let Some(p) = progress {
|
||||
@ -168,40 +174,40 @@ impl JobManager {
|
||||
}
|
||||
job.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a log entry to a job
|
||||
pub async fn add_job_log(&self, job_id: &Uuid, message: String) -> Result<()> {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
|
||||
if let Some(job) = jobs.get_mut(job_id) {
|
||||
job.add_log(message);
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a job as completed
|
||||
pub async fn complete_job(&self, job_id: &Uuid) -> Result<()> {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
|
||||
if let Some(job) = jobs.get_mut(job_id) {
|
||||
job.complete();
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a job as failed
|
||||
pub async fn fail_job(&self, job_id: &Uuid, error: String) -> Result<()> {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
|
||||
if let Some(job) = jobs.get_mut(job_id) {
|
||||
job.fail(error);
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -209,25 +215,27 @@ impl JobManager {
|
||||
pub async fn list_jobs(&self, status_filter: Option<JobStatus>, limit: usize) -> Vec<Job> {
|
||||
let jobs = self.jobs.read().await;
|
||||
let mut result: Vec<Job> = jobs.values().cloned().collect();
|
||||
|
||||
|
||||
// Filter by status if provided
|
||||
if let Some(status) = status_filter {
|
||||
result.retain(|j| j.status == status);
|
||||
}
|
||||
|
||||
|
||||
// Sort by created_at descending (newest first)
|
||||
result.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
|
||||
// Apply limit
|
||||
result.truncate(limit);
|
||||
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get count of running jobs
|
||||
pub async fn running_count(&self) -> usize {
|
||||
let jobs = self.jobs.read().await;
|
||||
jobs.values().filter(|j| j.status == JobStatus::Running).count()
|
||||
jobs.values()
|
||||
.filter(|j| j.status == JobStatus::Running)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if can accept new job (respecting max_concurrent)
|
||||
@ -238,15 +246,21 @@ impl JobManager {
|
||||
/// Delete a completed/failed job from history
|
||||
pub async fn delete_job(&self, job_id: &Uuid) -> Result<bool> {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
|
||||
if let Some(job) = jobs.get(job_id) {
|
||||
// Only allow deletion of completed/failed/cancelled jobs
|
||||
if matches!(job.status, JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled | JobStatus::TimedOut) {
|
||||
if matches!(
|
||||
job.status,
|
||||
JobStatus::Completed
|
||||
| JobStatus::Failed
|
||||
| JobStatus::Cancelled
|
||||
| JobStatus::TimedOut
|
||||
) {
|
||||
jobs.remove(job_id);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@ -256,15 +270,17 @@ impl JobManager {
|
||||
let jobs = self.jobs.read().await;
|
||||
jobs.get(original_job_id).cloned()
|
||||
};
|
||||
|
||||
|
||||
if let Some(original_job) = original_job {
|
||||
// Only allow rollback of failed/completed jobs
|
||||
if matches!(original_job.status, JobStatus::Failed | JobStatus::Completed) {
|
||||
let rollback_job_id = self.create_job(
|
||||
JobOperation::Rollback,
|
||||
original_job.packages.clone()
|
||||
).await?;
|
||||
|
||||
if matches!(
|
||||
original_job.status,
|
||||
JobStatus::Failed | JobStatus::Completed
|
||||
) {
|
||||
let rollback_job_id = self
|
||||
.create_job(JobOperation::Rollback, original_job.packages.clone())
|
||||
.await?;
|
||||
|
||||
// Mark as exclusive mode
|
||||
{
|
||||
let mut jobs = self.jobs.write().await;
|
||||
@ -273,11 +289,11 @@ impl JobManager {
|
||||
rollback_job.rollback_job_id = Some(*original_job_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Ok(Some(rollback_job_id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,10 @@
|
||||
//!
|
||||
//! Sets up tracing with systemd journal and file appender support.
|
||||
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
|
||||
/// Initialize logging with tracing
|
||||
///
|
||||
/// Sets up:
|
||||
@ -17,28 +15,25 @@ use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, Env
|
||||
/// - File appender fallback to /var/log/linux_patch_api/
|
||||
pub fn init_logging(verbose: bool) -> Result<WorkerGuard> {
|
||||
let log_level = if verbose { "debug" } else { "info" };
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(log_level));
|
||||
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level));
|
||||
|
||||
let file_appender = tracing_appender::rolling::daily("/var/log/linux_patch_api", "audit.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true);
|
||||
|
||||
let stdout_layer = fmt::layer()
|
||||
.with_writer(std::io::stdout)
|
||||
.with_ansi(true);
|
||||
|
||||
|
||||
let stdout_layer = fmt::layer().with_writer(std::io::stdout).with_ansi(true);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(file_layer)
|
||||
.with(stdout_layer)
|
||||
.try_init()
|
||||
.ok(); // Ignore if already initialized
|
||||
|
||||
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
@ -7,5 +7,5 @@
|
||||
//! - 30-day retention with daily rotation
|
||||
|
||||
pub mod appender;
|
||||
pub mod journal;
|
||||
pub mod init;
|
||||
pub mod journal;
|
||||
|
||||
55
src/main.rs
55
src/main.rs
@ -13,18 +13,18 @@
|
||||
//! - IP whitelist enforced (deny by default)
|
||||
//! - Detailed audit logging
|
||||
|
||||
use anyhow::Result;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing::{error, info, warn};
|
||||
use std::sync::Arc;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use linux_patch_api::{AppConfig, init_logging, JobManager};
|
||||
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
||||
use linux_patch_api::api::{configure_api_routes, configure_health_route};
|
||||
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
||||
use linux_patch_api::packages::create_backend;
|
||||
use linux_patch_api::{init_logging, AppConfig, JobManager};
|
||||
|
||||
/// Linux Patch API CLI arguments
|
||||
#[derive(Parser, Debug)]
|
||||
@ -58,7 +58,11 @@ async fn main() -> Result<()> {
|
||||
// Load configuration
|
||||
let config = match AppConfig::load(&args.config) {
|
||||
Ok(cfg) => {
|
||||
info!(port = cfg.server.port, bind = &cfg.server.bind, "Configuration loaded");
|
||||
info!(
|
||||
port = cfg.server.port,
|
||||
bind = &cfg.server.bind,
|
||||
"Configuration loaded"
|
||||
);
|
||||
cfg
|
||||
}
|
||||
Err(e) => {
|
||||
@ -69,7 +73,11 @@ async fn main() -> Result<()> {
|
||||
|
||||
// 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");
|
||||
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() {
|
||||
@ -85,11 +93,17 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Initialize IP whitelist manager
|
||||
let whitelist_path = config.whitelist_path();
|
||||
info!(path = whitelist_path, "Initializing IP whitelist enforcement");
|
||||
|
||||
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");
|
||||
info!(
|
||||
entries = manager.entry_count(),
|
||||
"Whitelist manager initialized"
|
||||
);
|
||||
Some(Arc::new(manager))
|
||||
}
|
||||
Err(e) => {
|
||||
@ -147,33 +161,34 @@ async fn main() -> Result<()> {
|
||||
min_tls_version = %tls_config.min_tls_version,
|
||||
"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(),
|
||||
};
|
||||
|
||||
|
||||
match MtlsMiddleware::new(mtls_config.clone()) {
|
||||
Ok(middleware) => {
|
||||
// Build rustls server configuration
|
||||
let rustls_config = middleware.build_rustls_config()
|
||||
let rustls_config = middleware
|
||||
.build_rustls_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
|
||||
|
||||
|
||||
info!("mTLS middleware and rustls config initialized successfully");
|
||||
|
||||
|
||||
// Create TCP listener (std::net for listen_rustls_0_23)
|
||||
let tcp_listener = TcpListener::bind(&bind_address)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", bind_address, e))?;
|
||||
|
||||
|
||||
info!("TCP listener bound to {}", 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)?
|
||||
|
||||
@ -138,14 +138,14 @@ impl AptBackend {
|
||||
/// Parse package list from apt output
|
||||
fn parse_package_list(&self, output: &str) -> Vec<Package> {
|
||||
let mut packages = Vec::new();
|
||||
|
||||
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
let name = parts[0].to_string();
|
||||
let status_str = parts[1];
|
||||
let version = parts[2].to_string();
|
||||
|
||||
|
||||
let status = if status_str.starts_with("ii") {
|
||||
PackageStatus::Installed
|
||||
} else if status_str.starts_with("iU") {
|
||||
@ -171,7 +171,7 @@ impl AptBackend {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
packages
|
||||
}
|
||||
}
|
||||
@ -182,7 +182,7 @@ impl PackageManagerBackend for AptBackend {
|
||||
Some(f) => vec!["list", f],
|
||||
None => vec!["list", "--installed"],
|
||||
};
|
||||
|
||||
|
||||
let output = self.run_apt(&args)?;
|
||||
Ok(self.parse_package_list(&output))
|
||||
}
|
||||
@ -190,17 +190,18 @@ impl PackageManagerBackend for AptBackend {
|
||||
fn get_package(&self, name: &str) -> Result<Option<Package>> {
|
||||
// Check if installed
|
||||
let dpkg_output = self.run_dpkg(&["-s", name]);
|
||||
|
||||
|
||||
if let Err(_) = dpkg_output {
|
||||
// Package not installed, check if available
|
||||
let list_output = self.run_apt(&["list", name])?;
|
||||
if list_output.contains(name) {
|
||||
let parts: Vec<&str> = list_output.lines()
|
||||
let parts: Vec<&str> = list_output
|
||||
.lines()
|
||||
.find(|l| l.contains(name))
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
|
||||
|
||||
if parts.len() >= 3 {
|
||||
return Ok(Some(Package {
|
||||
name: name.to_string(),
|
||||
@ -220,7 +221,7 @@ impl PackageManagerBackend for AptBackend {
|
||||
}
|
||||
|
||||
let dpkg_info = dpkg_output?;
|
||||
|
||||
|
||||
// Parse dpkg status output
|
||||
let mut version = String::new();
|
||||
let mut status = PackageStatus::Installed;
|
||||
@ -239,30 +240,33 @@ impl PackageManagerBackend for AptBackend {
|
||||
} else if line.starts_with("Description:") {
|
||||
description = line.trim_start_matches("Description:").trim().to_string();
|
||||
} else if line.starts_with("Depends:") {
|
||||
dependencies = line.trim_start_matches("Depends:")
|
||||
dependencies = line
|
||||
.trim_start_matches("Depends:")
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(|s| s.trim().split_whitespace().next().unwrap_or("").to_string())
|
||||
.collect();
|
||||
} else if line.starts_with("Installed-Size:") {
|
||||
size_installed = Some(format!("{} KB", line.trim_start_matches("Installed-Size:").trim()));
|
||||
size_installed = Some(format!(
|
||||
"{} KB",
|
||||
line.trim_start_matches("Installed-Size:").trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if upgradable
|
||||
let upgradable = self.run_apt(&["list", "--upgradable", name])
|
||||
let upgradable = self
|
||||
.run_apt(&["list", "--upgradable", name])
|
||||
.map(|o| o.contains(name))
|
||||
.unwrap_or(false);
|
||||
|
||||
let latest_version = if upgradable {
|
||||
self.run_apt(&["policy", name])
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
o.lines()
|
||||
.find(|l| l.contains("Candidate"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
self.run_apt(&["policy", name]).ok().and_then(|o| {
|
||||
o.lines()
|
||||
.find(|l| l.contains("Candidate"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
} else {
|
||||
Some(version.clone())
|
||||
};
|
||||
@ -283,11 +287,11 @@ impl PackageManagerBackend for AptBackend {
|
||||
|
||||
fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()> {
|
||||
let mut args: Vec<String> = vec!["install".to_string(), "-y".to_string()];
|
||||
|
||||
|
||||
if options.no_recommends {
|
||||
args.push("--no-install-recommends".to_string());
|
||||
}
|
||||
|
||||
|
||||
if options.force {
|
||||
args.push("--force-yes".to_string());
|
||||
}
|
||||
@ -303,7 +307,10 @@ impl PackageManagerBackend for AptBackend {
|
||||
|
||||
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
self.run_apt(&args_ref)?;
|
||||
info!("Installed packages: {:?}", packages.iter().map(|p| &p.name).collect::<Vec<_>>());
|
||||
info!(
|
||||
"Installed packages: {:?}",
|
||||
packages.iter().map(|p| &p.name).collect::<Vec<_>>()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -319,7 +326,7 @@ impl PackageManagerBackend for AptBackend {
|
||||
} else {
|
||||
vec!["remove", "-y", name]
|
||||
};
|
||||
|
||||
|
||||
self.run_apt(&args)?;
|
||||
info!("Removed package: {} (purge={})", name, purge);
|
||||
Ok(())
|
||||
@ -337,13 +344,15 @@ impl PackageManagerBackend for AptBackend {
|
||||
let available_version = parts[2].to_string();
|
||||
|
||||
// Determine severity based on package name heuristics
|
||||
let severity = if name.contains("kernel") || name.contains("ssl") || name.contains("security") {
|
||||
"critical".to_string()
|
||||
} else if name.contains("lib") {
|
||||
"high".to_string()
|
||||
} else {
|
||||
"medium".to_string()
|
||||
};
|
||||
let severity =
|
||||
if name.contains("kernel") || name.contains("ssl") || name.contains("security")
|
||||
{
|
||||
"critical".to_string()
|
||||
} else if name.contains("lib") {
|
||||
"high".to_string()
|
||||
} else {
|
||||
"medium".to_string()
|
||||
};
|
||||
|
||||
patches.push(Patch {
|
||||
name,
|
||||
@ -392,17 +401,29 @@ impl PackageManagerBackend for AptBackend {
|
||||
.map(|content| {
|
||||
let mut os = "Linux".to_string();
|
||||
let mut version = "unknown".to_string();
|
||||
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("PRETTY_NAME=") {
|
||||
os = line.trim_start_matches("PRETTY_NAME=").trim().trim_matches('"').to_string();
|
||||
os = line
|
||||
.trim_start_matches("PRETTY_NAME=")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
} else if line.starts_with("NAME=") {
|
||||
os = line.trim_start_matches("NAME=").trim().trim_matches('"').to_string();
|
||||
os = line
|
||||
.trim_start_matches("NAME=")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
} else if line.starts_with("VERSION=") {
|
||||
version = line.trim_start_matches("VERSION=").trim().trim_matches('"').to_string();
|
||||
version = line
|
||||
.trim_start_matches("VERSION=")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
(os, version)
|
||||
})
|
||||
.unwrap_or_else(|| ("Linux".to_string(), "unknown".to_string()));
|
||||
@ -444,12 +465,12 @@ impl PackageManagerBackend for AptBackend {
|
||||
// In production, would use systemd shutdown scheduler
|
||||
warn!("Delayed reboot not fully implemented - would use systemd in production");
|
||||
}
|
||||
|
||||
|
||||
Command::new("systemctl")
|
||||
.arg("reboot")
|
||||
.status()
|
||||
.context("Failed to execute reboot command")?;
|
||||
|
||||
|
||||
info!("System reboot initiated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user