Private
Public Access
1
0

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

This commit is contained in:
2026-04-12 14:13:36 +00:00
parent 9ae2b8c48d
commit 24e7d9a796
21 changed files with 563 additions and 421 deletions

View File

@ -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};

View File

@ -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};

View File

@ -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");

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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))
}
}

View File

@ -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

View File

@ -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)