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 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
397 lines
13 KiB
Rust
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"));
|
|
}
|
|
}
|