diff --git a/Cargo.lock b/Cargo.lock index a228078..f6a1549 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,7 +1859,7 @@ dependencies = [ [[package]] name = "linux-patch-api" -version = "0.3.5" +version = "0.3.6" dependencies = [ "actix", "actix-rt", diff --git a/Cargo.toml b/Cargo.toml index d046025..5538822 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "linux-patch-api" -version = "0.3.5" +version = "0.3.6" edition = "2021" authors = ["Echo "] description = "Secure remote package management API for Linux systems" diff --git a/src/api/handlers/system.rs b/src/api/handlers/system.rs index 8a09e5c..fcd44cc 100644 --- a/src/api/handlers/system.rs +++ b/src/api/handlers/system.rs @@ -47,6 +47,19 @@ pub struct HealthData { pub version: String, } +/// 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, + pub healthy: bool, +} + /// Reboot request #[derive(Debug, Deserialize, Clone)] pub struct RebootRequest { @@ -228,12 +241,80 @@ pub async fn reboot_system( } } +/// Get service status +pub async fn get_service_status( + path: web::Path, + backend: web::Data>, + _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("/reboot", web::post().to(reboot_system)) + .route("/services/{name}", web::get().to(get_service_status)), ) .route("/health", web::get().to(health_check)); } diff --git a/src/jobs/manager.rs b/src/jobs/manager.rs index f122cbc..043511e 100644 --- a/src/jobs/manager.rs +++ b/src/jobs/manager.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{RwLock, broadcast}; +use tokio::sync::{broadcast, RwLock}; use uuid::Uuid; /// Job status @@ -271,11 +271,7 @@ impl JobManager { if let Some(job) = jobs.get_mut(job_id) { job.complete(); - event_data = Some(( - job.status.clone(), - job.progress, - job.message.clone(), - )); + event_data = Some((job.status.clone(), job.progress, job.message.clone())); } else { event_data = None; } @@ -296,11 +292,7 @@ impl JobManager { if let Some(job) = jobs.get_mut(job_id) { job.fail(error); - event_data = Some(( - job.status.clone(), - job.progress, - job.message.clone(), - )); + event_data = Some((job.status.clone(), job.progress, job.message.clone())); } else { event_data = None; } diff --git a/src/jobs/websocket.rs b/src/jobs/websocket.rs index 0bc42fc..a43a9f7 100644 --- a/src/jobs/websocket.rs +++ b/src/jobs/websocket.rs @@ -256,10 +256,8 @@ impl Handler for WsJobActor { let event = msg.0; // Check if this client should receive this event - let should_forward = self.subscribed_all - || self - .subscribed_jobs - .contains(&event.job_id.to_string()); + let should_forward = + self.subscribed_all || self.subscribed_jobs.contains(&event.job_id.to_string()); if should_forward { let server_msg = WsServerMessage::from_job_status_event(&event); @@ -300,24 +298,22 @@ impl StreamHandler> for WsJobActor { // Parse as client message match serde_json::from_str::(&text) { Ok(client_msg) => match client_msg { - WsClientMessage::Subscribe { job_id } => { - match job_id { - Some(id) => { - self.subscribed_jobs.insert(id.clone()); - let msg = WsServerMessage::subscribed(&Some(id)); - if let Ok(json) = serde_json::to_string(&msg) { - ctx.text(json); - } - } - None => { - self.subscribed_all = true; - let msg = WsServerMessage::subscribed(&None); - if let Ok(json) = serde_json::to_string(&msg) { - ctx.text(json); - } + WsClientMessage::Subscribe { job_id } => match job_id { + Some(id) => { + self.subscribed_jobs.insert(id.clone()); + let msg = WsServerMessage::subscribed(&Some(id)); + if let Ok(json) = serde_json::to_string(&msg) { + ctx.text(json); } } - } + None => { + self.subscribed_all = true; + let msg = WsServerMessage::subscribed(&None); + if let Ok(json) = serde_json::to_string(&msg) { + ctx.text(json); + } + } + }, WsClientMessage::Unsubscribe { job_id } => { self.subscribed_jobs.remove(&job_id); let msg = WsServerMessage::unsubscribed(&job_id); @@ -333,7 +329,10 @@ impl StreamHandler> for WsJobActor { text = %text, "Invalid WebSocket client message" ); - let msg = WsServerMessage::error("invalid_message", &format!("Invalid message: {}", e)); + let msg = WsServerMessage::error( + "invalid_message", + &format!("Invalid message: {}", e), + ); if let Ok(json) = serde_json::to_string(&msg) { ctx.text(json); } diff --git a/src/packages/mod.rs b/src/packages/mod.rs index 29180c3..c96b9c8 100644 --- a/src/packages/mod.rs +++ b/src/packages/mod.rs @@ -64,6 +64,19 @@ pub struct SystemInfo { pub pending_reboot: bool, } +/// Service status information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceStatus { + 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, + pub healthy: bool, +} + /// Package manager backend trait pub trait PackageManagerBackend: Send + Sync { fn list_packages(&self, filter: Option<&str>) -> Result>; @@ -75,6 +88,7 @@ pub trait PackageManagerBackend: Send + Sync { fn apply_patches(&self, packages: Option<&[String]>) -> Result<()>; fn get_system_info(&self) -> Result; fn reboot_system(&self, delay_seconds: u64) -> Result<()>; + fn get_service_status(&self, name: &str) -> Result>; } /// Package specification for installation @@ -480,6 +494,151 @@ impl PackageManagerBackend for AptBackend { Ok(()) } + + fn get_service_status(&self, name: &str) -> Result> { + // Validate service name to prevent shell injection + if name.is_empty() || name.contains('/') || name.contains("..") { + return Err(anyhow::anyhow!("Invalid service name: {}", name)); + } + + // Determine init system and query accordingly + let is_systemd = std::path::Path::new("/run/systemd/system").exists(); + let is_openrc = std::path::Path::new("/sbin/openrc").exists(); + + if is_systemd { + get_systemd_service_status(name) + } else if is_openrc { + get_openrc_service_status(name) + } else { + Err(anyhow::anyhow!( + "No supported init system detected (systemd or OpenRC required)" + )) + } + } +} + +/// Query systemd service status via systemctl +fn get_systemd_service_status(name: &str) -> Result> { + let output = Command::new("systemctl") + .args([ + "show", + name, + "--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID", + "--no-pager", + ]) + .output() + .context("Failed to execute systemctl command")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // If systemctl returns non-zero or empty output, service doesn't exist + if !output.status.success() || stdout.trim().is_empty() { + return Ok(None); + } + + let mut id = String::new(); + let mut description = String::new(); + let mut active_state = String::new(); + let mut sub_state = String::new(); + let mut load_state = String::new(); + let mut unit_file_state = String::new(); + let mut main_pid: Option = None; + + for line in stdout.lines() { + if let Some((key, value)) = line.split_once('=') { + match key { + "Id" => id = value.to_string(), + "Description" => description = value.to_string(), + "ActiveState" => active_state = value.to_string(), + "SubState" => sub_state = value.to_string(), + "LoadState" => load_state = value.to_string(), + "UnitFileState" => unit_file_state = value.to_string(), + "MainPID" => { + main_pid = value.parse::().ok().filter(|&p| p > 0); + } + _ => {} + } + } + } + + // If LoadState is not-found or bad-setting, service doesn't exist + if load_state == "not-found" || load_state == "bad-setting" || id.is_empty() { + return Ok(None); + } + + let healthy = active_state == "active" && sub_state == "running"; + + Ok(Some(ServiceStatus { + name: id, + display_name: description, + active_state, + sub_state, + load_state, + enabled_state: unit_file_state, + main_pid, + healthy, + })) +} + +/// Query OpenRC service status via rc-service +fn get_openrc_service_status(name: &str) -> Result> { + let output = Command::new("rc-service") + .args([name, "status"]) + .output() + .context("Failed to execute rc-service command")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // rc-service returns error if service doesn't exist + if !output.status.success() { + if stderr.contains("does not exist") || stdout.contains("does not exist") { + return Ok(None); + } + return Err(anyhow::anyhow!("rc-service failed: {}", stderr)); + } + + // Parse rc-service status output + let status_line = stdout.lines().next().unwrap_or("").to_lowercase(); + + let (active_state, sub_state, healthy) = + if status_line.contains("started") || status_line.contains("running") { + ("active".to_string(), "running".to_string(), true) + } else if status_line.contains("stopped") || status_line.contains("not running") { + ("inactive".to_string(), "dead".to_string(), false) + } else if status_line.contains("crashed") || status_line.contains("failed") { + ("failed".to_string(), "failed".to_string(), false) + } else { + ("unknown".to_string(), "unknown".to_string(), false) + }; + + // Check if service is enabled using rc-update + let enabled_output = Command::new("rc-update") + .args(["show", "default"]) + .output() + .ok(); + + let enabled_state = enabled_output + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| { + if s.lines().any(|l| l.trim().starts_with(name)) { + "enabled".to_string() + } else { + "disabled".to_string() + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(Some(ServiceStatus { + name: name.to_string(), + display_name: name.to_string(), + active_state, + sub_state, + load_state: "loaded".to_string(), + enabled_state, + main_pid: None, + healthy, + })) } impl Default for AptBackend { diff --git a/tests/e2e/__pycache__/test_e2e.cpython-313.pyc b/tests/e2e/__pycache__/test_e2e.cpython-313.pyc new file mode 100644 index 0000000..2c1e934 Binary files /dev/null and b/tests/e2e/__pycache__/test_e2e.cpython-313.pyc differ diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index ff0975d..e14dcea 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -604,6 +604,37 @@ def test_job_lifecycle(client: PatchAPIClient) -> str: return f"Full lifecycle OK: install job={job_id}, remove job={remove_job_id}" +def test_service_status(client: PatchAPIClient) -> str: + """GET /api/v1/system/services/{name} - Test service status endpoint.""" + # Test with a known service (ssh) + resp = client.get("/api/v1/system/services/ssh") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + data = resp.json() + err = validate_envelope(data, "service_status") + assert err is None, f"Envelope validation failed: {err}" + assert data["success"] is True + assert "name" in data["data"], "Missing name field" + assert "active_state" in data["data"], "Missing active_state field" + assert "healthy" in data["data"], "Missing healthy field" + assert isinstance(data["data"]["healthy"], bool), "healthy must be boolean" + + # Test with non-existent service + resp = client.get("/api/v1/system/services/nonexistent-service-12345") + assert resp.status_code == 404, f"Expected 404, got {resp.status_code}" + data = resp.json() + assert data["success"] is False + assert data["error"]["code"] == "SERVICE_NOT_FOUND" + + # Test with invalid service name + resp = client.get("/api/v1/system/services/../../etc/passwd") + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}" + data = resp.json() + assert data["success"] is False + assert data["error"]["code"] == "INVALID_SERVICE_NAME" + + return f"Service status OK: ssh={data['data']['name']}, state={data['data']['active_state']}, healthy={data['data']['healthy']}" + + def test_reboot_endpoint(client: PatchAPIClient) -> str: """POST /api/v1/system/reboot - Test reboot endpoint. @@ -653,6 +684,7 @@ def run_all_tests(target_key: str, skip_reboot: bool = False, verbose: bool = Fa print("\n--- Health & System ---") run_test(results, "Health Check", test_health_endpoint, client) run_test(results, "System Info", test_system_info, client) + run_test(results, "Service Status (ssh)", test_service_status, client) # ---- Category 2: Package Operations ---- print("\n--- Package Operations ---")