Private
Public Access
1
0
Files
linux_patch_api/src/api/handlers/system.rs
git-echo 6f63eeed57
All checks were successful
CI/CD Pipeline / Code Format (pull_request) Successful in 4s
CI/CD Pipeline / Clippy Lints (pull_request) Successful in 47s
CI/CD Pipeline / All Unit Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / Security Audit (pull_request) Successful in 5s
CI/CD Pipeline / Enrollment Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / Verify Enrollment CLI Flag (pull_request) Successful in 1m27s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (pull_request) Successful in 2m49s
CI/CD Pipeline / Build RPM Package (pull_request) Successful in 2m40s
CI/CD Pipeline / Build Arch Package (pull_request) Successful in 3m2s
CI/CD Pipeline / Build Debian Package (pull_request) Successful in 2m34s
CI/CD Pipeline / Build Alpine Package (pull_request) Successful in 4m3s
fix: resolve CI failures (fmt, clippy, tests)
- Fix rustfmt formatting in cache.rs, patches.rs, system.rs, routes.rs, main.rs
- Add Default impl for PackageCacheState (clippy new_without_default)
- Change apply_with_cache_retry generic bound from Fn to FnMut
- Add mut to refresh_fn parameter for FnMut compatibility
- Replace bool comparison with ! operator (clippy bool_comparison)
- Update todo.md with completed status
2026-05-27 15:04:25 -05:00

397 lines
13 KiB
Rust

//! System Management API Handlers
//!
//! Implements REST endpoints for system management operations:
//! - GET /api/v1/system/info - OS version, kernel, last update time
//! - GET /api/v1/health - Health check endpoint
//! - POST /api/v1/system/reboot - System reboot - async
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use uuid::Uuid;
use super::packages::ApiResponse;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
use crate::packages::PackageManagerBackend;
/// Normalize and validate file paths to prevent path traversal attacks (VULN-002)
/// Returns None if path contains traversal patterns
#[allow(dead_code)]
fn validate_path_no_traversal(path: &str) -> bool {
// Validate path - check for traversal patterns
if path.contains("..") || path.contains("//") {
return false;
}
true
}
/// System info response data
#[derive(Debug, Serialize)]
pub struct SystemInfoData {
pub hostname: String,
pub os: String,
pub os_version: String,
pub kernel: String,
pub architecture: String,
pub last_update_check: Option<String>,
pub last_update_apply: Option<String>,
pub pending_reboot: bool,
}
/// Health check response data
#[derive(Debug, Serialize)]
pub struct HealthData {
pub status: String, // "healthy" or "degraded"
pub uptime_seconds: u64,
pub version: String,
pub last_cache_update: Option<String>, // RFC3339 timestamp
pub cache_status: String, // "fresh", "stale", "unknown", "failed"
}
/// Service status response data
#[derive(Debug, Serialize)]
pub struct ServiceStatusData {
pub name: String,
pub display_name: String,
pub active_state: String,
pub sub_state: String,
pub load_state: String,
pub enabled_state: String,
pub main_pid: Option<u32>,
pub healthy: bool,
}
/// Reboot request
#[derive(Debug, Deserialize, Clone)]
pub struct RebootRequest {
#[serde(default)]
pub delay_seconds: u64,
#[serde(default)]
pub force: bool,
}
/// Get system information
pub async fn get_system_info(
backend: web::Data<Box<dyn PackageManagerBackend>>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
info!(request_id = %request_id, "Getting system information");
match backend.get_system_info() {
Ok(sys_info) => {
let response = ApiResponse::success(SystemInfoData {
hostname: sys_info.hostname,
os: sys_info.os,
os_version: sys_info.os_version,
kernel: sys_info.kernel,
architecture: sys_info.architecture,
last_update_check: sys_info.last_update_check,
last_update_apply: sys_info.last_update_apply,
pending_reboot: sys_info.pending_reboot,
});
HttpResponse::Ok().json(response)
}
Err(e) => {
error!(request_id = %request_id, error = %e, "Failed to get system info");
let response = ApiResponse::<()>::error(
"SYSTEM_INFO_ERROR",
&format!("Failed to get system info: {}", e),
None,
true,
);
HttpResponse::InternalServerError().json(response)
}
}
}
/// Health check endpoint
pub async fn health_check(
backend: web::Data<Box<dyn PackageManagerBackend>>,
cache_state: web::Data<crate::packages::cache::PackageCacheState>,
_req: HttpRequest,
) -> impl Responder {
let _request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
// Calculate uptime from /proc/uptime
let uptime_seconds = std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|content| {
content
.split_whitespace()
.next()
.and_then(|s| s.parse::<f64>().ok())
.map(|f| f as u64)
})
.unwrap_or(0);
let version = env!("CARGO_PKG_VERSION").to_string();
// Check cache status and refresh if stale
let cache_status_val = cache_state.status();
let (status, cache_status_str, last_cache_update) = if cache_state.is_stale() {
match backend.refresh_package_cache(&cache_state) {
Ok(_) => {
let updated = cache_state.status();
(
"healthy".to_string(),
"fresh".to_string(),
updated.last_update.map(|dt| dt.to_rfc3339()),
)
}
Err(e) => {
error!("Health check cache refresh failed: {}", e);
(
"degraded".to_string(),
"failed".to_string(),
cache_status_val.last_update.map(|dt| dt.to_rfc3339()),
)
}
}
} else {
(
"healthy".to_string(),
"fresh".to_string(),
cache_status_val.last_update.map(|dt| dt.to_rfc3339()),
)
};
let response = ApiResponse::success(HealthData {
status,
uptime_seconds,
version,
last_cache_update,
cache_status: cache_status_str,
});
HttpResponse::Ok().json(response)
}
/// Reboot the system (async operation)
pub async fn reboot_system(
body: web::Json<RebootRequest>,
backend: web::Data<Box<dyn PackageManagerBackend>>,
job_manager: web::Data<JobManager>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
let delay = body.delay_seconds;
let force = body.force;
info!(
request_id = %request_id,
delay_seconds = delay,
force = force,
"Initiating system reboot"
);
// Check for running jobs unless force is true
if !force {
let running_count = job_manager.running_count().await;
if running_count > 0 {
warn!(request_id = %request_id, running_jobs = running_count, "Reboot blocked by running jobs");
let response = ApiResponse::<()>::error(
"REBOOT_BLOCKED",
"Cannot reboot while jobs are running. Use force=true to override.",
Some(serde_json::json!({"running_jobs": running_count})),
false,
);
return HttpResponse::Conflict().json(response);
}
}
// Create async job for reboot
match job_manager.create_job(JobOperation::Reboot, vec![]).await {
Ok(job_id) => {
// Spawn background task to execute the reboot
let backend_clone = backend.clone();
let job_manager_clone = job_manager.clone();
let delay_clone = delay;
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;
// 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;
// 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;
error!(job_id = %job_id_clone, error = %e, "System reboot failed");
}
}
});
let scheduled_at = if delay > 0 {
Utc::now() + chrono::Duration::seconds(delay as i64)
} else {
Utc::now()
};
let response = ApiResponse::success(serde_json::json!({
"job_id": job_id.to_string(),
"status": "pending",
"operation": "reboot",
"scheduled_at": scheduled_at.to_rfc3339(),
"delay_seconds": delay,
"force": force,
}));
HttpResponse::Accepted().json(response)
}
Err(e) => {
error!(request_id = %request_id, error = %e, "Failed to create reboot job");
let response = ApiResponse::<()>::error(
"JOB_CREATE_ERROR",
&format!("Failed to create job: {}", e),
None,
true,
);
HttpResponse::InternalServerError().json(response)
}
}
}
/// Get service status
pub async fn get_service_status(
path: web::Path<String>,
backend: web::Data<Box<dyn PackageManagerBackend>>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let service_name = path.into_inner();
info!(
request_id = %request_id,
service = %service_name,
"Getting service status"
);
// Validate service name
if service_name.is_empty() || service_name.contains('/') || service_name.contains("..") {
let response = ApiResponse::<()>::error(
"INVALID_SERVICE_NAME",
&format!("Invalid service name: {}", service_name),
None,
false,
);
return HttpResponse::BadRequest().json(response);
}
match backend.get_service_status(&service_name) {
Ok(Some(status)) => {
let response = ApiResponse::success(ServiceStatusData {
name: status.name,
display_name: status.display_name,
active_state: status.active_state,
sub_state: status.sub_state,
load_state: status.load_state,
enabled_state: status.enabled_state,
main_pid: status.main_pid,
healthy: status.healthy,
});
HttpResponse::Ok().json(response)
}
Ok(None) => {
let response = ApiResponse::<()>::error(
"SERVICE_NOT_FOUND",
&format!("Service '{}' not found", service_name),
None,
false,
);
HttpResponse::NotFound().json(response)
}
Err(e) => {
error!(
request_id = %request_id,
service = %service_name,
error = %e,
"Failed to get service status"
);
let response = ApiResponse::<()>::error(
"SERVICE_STATUS_ERROR",
&format!("Failed to get service status: {}", e),
None,
true,
);
HttpResponse::InternalServerError().json(response)
}
}
}
/// Configure routes for system endpoints
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/system")
.route("/info", web::get().to(get_system_info))
.route("/reboot", web::post().to(reboot_system))
.route("/services/{name}", web::get().to(get_service_status)),
)
.route("/health", web::get().to(health_check));
// Note: health_check receives backend and cache_state via app_data injection
// They are registered in routes.rs and main.rs as web::Data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reboot_request_default() {
let json = r#"{}"#;
let request: RebootRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.delay_seconds, 0);
assert!(!request.force);
}
#[test]
fn test_reboot_request_full() {
let json = r#"{"delay_seconds": 60, "force": true}"#;
let request: RebootRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.delay_seconds, 60);
assert!(request.force);
}
#[test]
fn test_health_data_serialization() {
let health = HealthData {
status: "healthy".to_string(),
uptime_seconds: 12345,
version: "0.1.0".to_string(),
last_cache_update: Some("2026-05-27T14:00:00+00:00".to_string()),
cache_status: "fresh".to_string(),
};
let json = serde_json::to_string(&health).unwrap();
assert!(json.contains("healthy"));
assert!(json.contains("12345"));
assert!(json.contains("fresh"));
assert!(json.contains("last_cache_update"));
}
}