feat: Implementación completa Fase 4 - Sistema de monitoreo con API REST y WebSocket

 Nuevas funcionalidades:
- API REST unificada en puerto 8080 (eliminado CORS)
- WebSocket para logs en tiempo real desde journalctl
- Integración completa con systemd para gestión de servicios
- Escaneo automático de procesos Node.js y Python
- Rate limiting (1 operación/segundo por app)
- Interface web moderna con Tailwind CSS (tema oscuro)
- Documentación API estilo Swagger completamente en español

🎨 Interface Web (todas las páginas en español):
- Dashboard con estadísticas en tiempo real
- Visor de escaneo de procesos con filtros
- Formulario de registro de aplicaciones con variables de entorno
- Visor de logs en tiempo real con WebSocket y sidebar
- Página de selección de apps detectadas
- Documentación completa de API REST

🏗️ Arquitectura:
- Módulo models: ServiceConfig, ManagedApp, AppStatus
- Módulo systemd: wrapper de systemctl, generador de .service, parser
- Módulo orchestrator: AppManager, LifecycleManager con validaciones
- Módulo api: handlers REST, WebSocket manager, DTOs
- Servidor unificado en puerto 8080 (Web + API + WS)

🔧 Mejoras técnicas:
- Eliminación de CORS mediante servidor unificado
- Separación clara frontend/backend con carga dinámica
- Thread-safe con Arc<DashMap> para estado compartido
- Reconciliación de estados: sysinfo vs systemd
- Validaciones de paths, usuarios y configuraciones
- Manejo robusto de errores con thiserror

📝 Documentación:
- README.md actualizado con arquitectura completa
- EJEMPLOS.md con casos de uso detallados
- ESTADO_PROYECTO.md con progreso de Fase 4
- API docs interactiva en /api-docs
- Script de despliegue mejorado con health checks

🚀 Producción:
- Deployment script con validaciones
- Health checks y rollback capability
- Configuración de sudoers para systemctl
- Hardening de seguridad en servicios systemd
This commit is contained in:
2026-01-13 08:24:13 -05:00
parent 3595e55a1e
commit b0489739cf
33 changed files with 6893 additions and 1261 deletions

86
src/api/dto.rs Normal file
View File

@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterAppRequest {
pub app_name: String,
pub script_path: String,
pub working_directory: String,
pub user: String,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default = "default_restart_policy")]
pub restart_policy: String,
pub app_type: String,
pub description: Option<String>,
}
fn default_restart_policy() -> String {
"always".to_string()
}
#[derive(Debug, Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
ApiResponse {
success: true,
data: Some(data),
error: None,
}
}
pub fn error(error: String) -> Self {
ApiResponse {
success: false,
data: None,
error: Some(error),
}
}
}
#[derive(Debug, Serialize)]
pub struct AppStatusResponse {
pub name: String,
pub status: String,
pub pid: Option<i32>,
pub cpu_usage: f32,
pub memory_usage: String,
pub systemd_status: String,
pub last_updated: String,
}
#[derive(Debug, Serialize)]
pub struct AppListResponse {
pub apps: Vec<String>,
pub total: usize,
}
#[derive(Debug, Serialize)]
pub struct OperationResponse {
pub app_name: String,
pub operation: String,
pub success: bool,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct DetectedProcess {
pub pid: i32,
pub name: String,
pub user: Option<String>,
pub cpu_usage: f64,
pub memory_mb: f64,
pub process_type: String,
}
#[derive(Debug, Serialize)]
pub struct ProcessScanResponse {
pub processes: Vec<DetectedProcess>,
pub total: usize,
}

195
src/api/handlers.rs Normal file
View File

@@ -0,0 +1,195 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use std::sync::Arc;
use sysinfo::System;
use crate::orchestrator::{AppManager, LifecycleManager};
use crate::models::{ServiceConfig, RestartPolicy, AppType};
use super::dto::*;
pub struct ApiState {
pub app_manager: Arc<AppManager>,
pub lifecycle_manager: Arc<LifecycleManager>,
}
pub async fn register_app_handler(
State(state): State<Arc<ApiState>>,
Json(payload): Json<RegisterAppRequest>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
// Parsear tipo de aplicación
let app_type = match payload.app_type.to_lowercase().as_str() {
"nodejs" | "node" => AppType::NodeJs,
"python" | "py" => AppType::Python,
_ => return Ok(Json(ApiResponse::error(
"Tipo de aplicación inválido. Use 'nodejs' o 'python'".to_string()
))),
};
// Parsear política de reinicio
let restart_policy = match payload.restart_policy.to_lowercase().as_str() {
"always" => RestartPolicy::Always,
"on-failure" | "onfailure" => RestartPolicy::OnFailure,
"no" | "never" => RestartPolicy::No,
_ => RestartPolicy::Always,
};
let config = ServiceConfig {
app_name: payload.app_name.clone(),
script_path: payload.script_path,
working_directory: payload.working_directory,
user: payload.user,
environment: payload.environment,
restart_policy,
app_type,
description: payload.description,
};
match state.app_manager.register_app(config) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: payload.app_name,
operation: "register".to_string(),
success: true,
message: "Aplicación registrada exitosamente".to_string(),
}))),
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
}
}
pub async fn unregister_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
match state.app_manager.unregister_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "unregister".to_string(),
success: true,
message: "Aplicación eliminada exitosamente".to_string(),
}))),
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
}
}
pub async fn start_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
match state.lifecycle_manager.start_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "start".to_string(),
success: true,
message: "Aplicación iniciada exitosamente".to_string(),
}))),
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
}
}
pub async fn stop_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
match state.lifecycle_manager.stop_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "stop".to_string(),
success: true,
message: "Aplicación detenida exitosamente".to_string(),
}))),
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
}
}
pub async fn restart_app_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<OperationResponse>>, StatusCode> {
match state.lifecycle_manager.restart_app(&app_name) {
Ok(_) => Ok(Json(ApiResponse::success(OperationResponse {
app_name: app_name.clone(),
operation: "restart".to_string(),
success: true,
message: "Aplicación reiniciada exitosamente".to_string(),
}))),
Err(e) => Ok(Json(ApiResponse::error(e.to_string()))),
}
}
pub async fn get_app_status_handler(
State(state): State<Arc<ApiState>>,
Path(app_name): Path<String>,
) -> Result<Json<ApiResponse<AppStatusResponse>>, StatusCode> {
match state.app_manager.get_app_status(&app_name) {
Some(managed_app) => {
let response = AppStatusResponse {
name: managed_app.name,
status: managed_app.status.as_str().to_string(),
pid: managed_app.pid,
cpu_usage: managed_app.cpu_usage,
memory_usage: format!("{:.2} MB", managed_app.memory_usage as f64 / 1024.0 / 1024.0),
systemd_status: managed_app.systemd_status.as_str().to_string(),
last_updated: managed_app.last_updated,
};
Ok(Json(ApiResponse::success(response)))
}
None => Ok(Json(ApiResponse::error(
format!("Aplicación '{}' no encontrada", app_name)
))),
}
}
pub async fn list_apps_handler(
State(state): State<Arc<ApiState>>,
) -> Result<Json<ApiResponse<AppListResponse>>, StatusCode> {
let apps = state.app_manager.list_apps();
let total = apps.len();
Ok(Json(ApiResponse::success(AppListResponse { apps, total })))
}
pub async fn scan_processes_handler() -> Result<Json<ApiResponse<ProcessScanResponse>>, StatusCode> {
let mut sys = System::new_all();
sys.refresh_all();
let mut detected_processes = Vec::new();
for (pid, process) in sys.processes() {
let process_name = process.name().to_lowercase();
let cmd = process.cmd().join(" ").to_lowercase();
let process_type = if process_name.contains("node") || cmd.contains("node") {
Some("nodejs")
} else if process_name.contains("python") || cmd.contains("python") {
Some("python")
} else {
None
};
if let Some(ptype) = process_type {
detected_processes.push(DetectedProcess {
pid: pid.as_u32() as i32,
name: process.name().to_string(),
user: process.user_id().map(|u| u.to_string()),
cpu_usage: process.cpu_usage() as f64,
memory_mb: process.memory() as f64 / 1024.0 / 1024.0,
process_type: ptype.to_string(),
});
}
}
let total = detected_processes.len();
Ok(Json(ApiResponse::success(ProcessScanResponse {
processes: detected_processes,
total,
})))
}

7
src/api/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod handlers;
pub mod websocket;
pub mod dto;
pub use handlers::*;
pub use websocket::*;
pub use dto::*;

188
src/api/websocket.rs Normal file
View File

@@ -0,0 +1,188 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, State,
},
response::IntoResponse,
};
use futures::{sink::SinkExt, stream::StreamExt};
use std::process::Stdio;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as TokioCommand;
use crate::logger::get_logger;
use dashmap::DashMap;
pub struct WebSocketManager {
active_connections: Arc<DashMap<String, usize>>,
max_connections_per_app: usize,
}
impl WebSocketManager {
pub fn new() -> Self {
WebSocketManager {
active_connections: Arc::new(DashMap::new()),
max_connections_per_app: 5,
}
}
fn can_connect(&self, app_name: &str) -> bool {
let count = self.active_connections
.get(app_name)
.map(|v| *v)
.unwrap_or(0);
count < self.max_connections_per_app
}
fn increment_connection(&self, app_name: &str) {
self.active_connections
.entry(app_name.to_string())
.and_modify(|c| *c += 1)
.or_insert(1);
}
fn decrement_connection(&self, app_name: &str) {
self.active_connections
.entry(app_name.to_string())
.and_modify(|c| {
if *c > 0 {
*c -= 1;
}
});
}
}
impl Default for WebSocketManager {
fn default() -> Self {
Self::new()
}
}
pub async fn logs_websocket_handler(
ws: WebSocketUpgrade,
Path(app_name): Path<String>,
State(ws_manager): State<Arc<WebSocketManager>>,
) -> impl IntoResponse {
let logger = get_logger();
// Verificar límite de conexiones
if !ws_manager.can_connect(&app_name) {
logger.warning(
"WebSocket",
"Límite de conexiones excedido",
Some(&app_name)
);
return ws.on_upgrade(move |socket| async move {
let mut socket = socket;
let _ = socket.send(Message::Text(
"Error: Límite de conexiones simultáneas excedido".to_string()
)).await;
let _ = socket.close().await;
});
}
logger.info("WebSocket", &format!("Nueva conexión para logs de: {}", app_name));
ws_manager.increment_connection(&app_name);
ws.on_upgrade(move |socket| handle_logs_socket(socket, app_name, ws_manager))
}
async fn handle_logs_socket(
socket: WebSocket,
app_name: String,
ws_manager: Arc<WebSocketManager>,
) {
let logger = get_logger();
let service_name = format!("{}.service", app_name);
// Iniciar journalctl
let mut child = match TokioCommand::new("journalctl")
.arg("-u")
.arg(&service_name)
.arg("-f")
.arg("--output=json")
.arg("-n")
.arg("50") // Últimas 50 líneas
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => {
logger.error("WebSocket", "Error iniciando journalctl", Some(&e.to_string()));
ws_manager.decrement_connection(&app_name);
return;
}
};
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let (mut sender, mut receiver) = socket.split();
// Enviar mensaje de bienvenida
let welcome = format!("📡 Conectado a logs de: {}", app_name);
let _ = sender.send(Message::Text(welcome)).await;
// Task para enviar logs
let send_task = tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
// Parsear JSON de journalctl
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
let message = json.get("MESSAGE")
.and_then(|m| m.as_str())
.unwrap_or(&line);
let timestamp = json.get("__REALTIME_TIMESTAMP")
.and_then(|t| t.as_str())
.unwrap_or("");
let formatted = if !timestamp.is_empty() {
format!("[{}] {}", timestamp, message)
} else {
message.to_string()
};
if sender.send(Message::Text(formatted)).await.is_err() {
break;
}
} else {
// Si no es JSON, enviar la línea tal cual
if sender.send(Message::Text(line)).await.is_err() {
break;
}
}
}
});
// Task para recibir mensajes del cliente (para detectar desconexión)
let receive_task = tokio::spawn(async move {
while let Some(msg) = receiver.next().await {
if let Ok(msg) = msg {
match msg {
Message::Close(_) => break,
Message::Ping(_) => {
// Los pings se manejan automáticamente
}
_ => {}
}
} else {
break;
}
}
});
// Esperar a que termine alguna de las dos tasks
tokio::select! {
_ = send_task => {},
_ = receive_task => {},
}
// Cleanup
let _ = child.kill().await;
ws_manager.decrement_connection(&app_name);
logger.info("WebSocket", &format!("Conexión cerrada para: {}", app_name));
}

View File

@@ -4,10 +4,8 @@ use axum::{
Router,
extract::Form,
};
use std::net::SocketAddr;
use sysinfo::System;
use serde::Deserialize;
use crate::logger::{get_logger, LogLevel};
use crate::logger::get_logger;
#[derive(Deserialize)]
struct ProcessForm {
@@ -15,20 +13,16 @@ struct ProcessForm {
port: String,
}
pub async fn start_web_server(port: u16) {
let app = Router::new()
pub fn create_web_router() -> Router {
Router::new()
.route("/", get(index_handler))
.route("/scan", get(scan_processes_handler))
.route("/select", get(select_processes_handler))
.route("/register", get(register_handler))
.route("/add-process", post(add_process_handler))
.route("/logs", get(logs_handler))
.route("/clear-logs", post(clear_logs_handler));
let addr = SocketAddr::from(([0, 0, 0, 0], port));
println!("🖥️ Interface Web en: http://localhost:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
.route("/clear-logs", post(clear_logs_handler))
.route("/api-docs", get(api_docs_handler))
}
async fn index_handler() -> Html<String> {
@@ -38,105 +32,13 @@ async fn index_handler() -> Html<String> {
}
async fn scan_processes_handler() -> Html<String> {
let mut sys = System::new_all();
sys.refresh_all();
let template = include_str!("../web/scan.html");
let mut content = String::new();
let mut node_count = 0;
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
node_count += 1;
let cpu = process.cpu_usage();
let mem_mb = process.memory() as f64 / 1024.0 / 1024.0;
let cwd = if let Some(path) = process.cwd() {
path.to_string_lossy().to_string()
} else {
"N/A".to_string()
};
content.push_str(&format!(
r#"
<div class="process">
<div><span class="pid">PID: {}</span> | <span class="name">{}</span></div>
<div><span class="cpu">CPU: {:.2}%</span> | <span class="mem">RAM: {:.2} MB</span></div>
<div class="path">📁 {}</div>
</div>
"#,
pid.as_u32(),
process_name,
cpu,
mem_mb,
cwd
));
}
}
if node_count == 0 {
content = r#"<div class="no-results">⚠️ No se detectaron procesos Node.js en ejecución</div>"#.to_string();
} else {
let summary = format!(r#"<p class="summary">✅ Total: {} proceso(s) Node.js detectado(s)</p>"#, node_count);
content = summary + &content;
}
let html = template.replace("{{CONTENT}}", &content);
Html(html)
Html(template.to_string())
}
async fn select_processes_handler() -> Html<String> {
let mut sys = System::new_all();
sys.refresh_all();
let template = include_str!("../web/select.html");
let mut processes_list = String::new();
let mut node_processes = Vec::new();
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
let cwd = if let Some(path) = process.cwd() {
path.to_string_lossy().to_string()
} else {
"N/A".to_string()
};
node_processes.push((pid.as_u32(), process_name.to_string(), cwd));
}
}
if node_processes.is_empty() {
processes_list = r#"<div class="no-results">⚠️ No se detectaron procesos Node.js en ejecución</div>"#.to_string();
} else {
for (pid, name, cwd) in node_processes {
let suggested_name = cwd.split(&['/', '\\'][..])
.filter(|s| !s.is_empty())
.last()
.unwrap_or("app");
processes_list.push_str(&format!(
r#"
<div class="process-item">
<div class="process-info">
<div><span class="pid">PID: {}</span> | {}</div>
<div class="path">📁 {}</div>
</div>
<button class="select-btn" onclick="fillForm('{}', {})">
✅ Seleccionar
</button>
</div>
"#,
pid, name, cwd, suggested_name, pid
));
}
}
let html = template.replace("{{PROCESSES_LIST}}", &processes_list);
Html(html)
Html(template.to_string())
}
async fn add_process_handler(Form(form): Form<ProcessForm>) -> Html<String> {
@@ -154,94 +56,8 @@ async fn add_process_handler(Form(form): Form<ProcessForm>) -> Html<String> {
}
async fn logs_handler() -> Html<String> {
let logger = get_logger();
let template = include_str!("../web/logs.html");
let logs = logger.read_logs(Some(100)); // Últimos 100 logs
// Calcular estadísticas
let mut info_count = 0;
let mut warning_count = 0;
let mut error_count = 0;
let mut critical_count = 0;
for log in &logs {
match log.level {
LogLevel::Info => info_count += 1,
LogLevel::Warning => warning_count += 1,
LogLevel::Error => error_count += 1,
LogLevel::Critical => critical_count += 1,
}
}
let stats = format!(
r#"
<div class="stat-item stat-info">
<div class="stat-number" style="color: #3b82f6;">{}</div>
<div class="stat-label">Info</div>
</div>
<div class="stat-item stat-warning">
<div class="stat-number" style="color: #f59e0b;">{}</div>
<div class="stat-label">Warnings</div>
</div>
<div class="stat-item stat-error">
<div class="stat-number" style="color: #ef4444;">{}</div>
<div class="stat-label">Errors</div>
</div>
<div class="stat-item stat-critical">
<div class="stat-number" style="color: #dc2626;">{}</div>
<div class="stat-label">Critical</div>
</div>
"#,
info_count, warning_count, error_count, critical_count
);
let mut logs_html = String::new();
if logs.is_empty() {
logs_html = r#"<div class="no-logs">📭 No hay logs registrados</div>"#.to_string();
} else {
for log in logs {
let level_class = match log.level {
LogLevel::Info => "info",
LogLevel::Warning => "warning",
LogLevel::Error => "error",
LogLevel::Critical => "critical",
};
let details_html = if let Some(details) = &log.details {
format!(r#"<div class="log-details">📝 {}</div>"#, details)
} else {
String::new()
};
logs_html.push_str(&format!(
r#"
<div class="log-entry log-{}" data-level="{}">
<div class="log-header">
<span class="log-module">[{}]</span>
<span class="log-timestamp">{}</span>
</div>
<div class="log-message">{} {}</div>
{}
</div>
"#,
level_class,
level_class,
log.module,
log.timestamp,
log.level.emoji(),
log.message,
details_html
));
}
}
let html = template
.replace("{{STATS}}", &stats)
.replace("{{LOGS}}", &logs_html);
Html(html)
Html(template.to_string())
}
async fn clear_logs_handler() -> Html<&'static str> {
@@ -257,4 +73,14 @@ async fn clear_logs_handler() -> Html<&'static str> {
Html("ERROR")
}
}
}
}
async fn register_handler() -> Html<String> {
let template = include_str!("../web/register.html");
Html(template.to_string())
}
async fn api_docs_handler() -> Html<String> {
let template = include_str!("../web/api-docs.html");
Html(template.to_string())
}

13
src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
pub mod models;
pub mod systemd;
pub mod orchestrator;
pub mod api;
pub mod logger;
pub mod config;
pub mod monitor;
pub mod interface;
// Re-exportar solo lo necesario para evitar conflictos
pub use models::{ServiceConfig, ManagedApp, AppStatus, ServiceStatus, AppType, RestartPolicy};
pub use logger::{Logger, LogEntry, LogLevel, get_logger};
pub use config::{ConfigManager, MonitoredApp, AppConfig, get_config_manager};

View File

@@ -2,9 +2,21 @@ mod monitor;
mod interface;
mod logger;
mod config;
mod models;
mod systemd;
mod orchestrator;
mod api;
use logger::get_logger;
use config::get_config_manager;
use orchestrator::{AppManager, LifecycleManager};
use api::{ApiState, WebSocketManager};
use std::sync::Arc;
use axum::{
routing::{get, post, delete},
Router,
};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
@@ -21,19 +33,63 @@ async fn main() {
let api_key = "ak_VVeNzGxK2mCq8s7YpFtHjL3b9dR4TuZ6".to_string();
let cloud_url = "https://api.siax-system.net/api/apps_servcs/apps".to_string();
// 1. Iniciamos el Monitor
// Inicializar orchestrator
let app_manager = Arc::new(AppManager::new());
let lifecycle_manager = Arc::new(LifecycleManager::new());
let ws_manager = Arc::new(WebSocketManager::new());
// Estado compartido para la API
let api_state = Arc::new(ApiState {
app_manager: app_manager.clone(),
lifecycle_manager: lifecycle_manager.clone(),
});
// 1. Iniciamos el Monitor en background
let monitor_handle = tokio::spawn(async move {
monitor::run_monitoring(server_name, api_key, cloud_url).await;
});
// 2. Iniciamos la Interface Web
let web_handle = tokio::spawn(async move {
interface::start_web_server(8080).await;
// 2. Servidor unificado en puerto 8080 (Web UI + API REST + WebSocket)
let logger_clone = get_logger();
let web_api_handle = tokio::spawn(async move {
// Router para la API REST
let api_router = Router::new()
.route("/api/apps", get(api::list_apps_handler).post(api::register_app_handler))
.route("/api/apps/:name", delete(api::unregister_app_handler))
.route("/api/apps/:name/status", get(api::get_app_status_handler))
.route("/api/apps/:name/start", post(api::start_app_handler))
.route("/api/apps/:name/stop", post(api::stop_app_handler))
.route("/api/apps/:name/restart", post(api::restart_app_handler))
.route("/api/scan", get(api::scan_processes_handler))
.with_state(api_state);
// Router para WebSocket
let ws_router = Router::new()
.route("/api/apps/:name/logs", get(api::logs_websocket_handler))
.with_state(ws_manager);
// Router para la Interface Web (UI estática)
let web_router = interface::create_web_router();
// Combinar todos los routers
let app = Router::new()
.merge(api_router)
.merge(ws_router)
.merge(web_router);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
println!("✅ Sistema SIAX operativo en: http://localhost:8080");
println!(" 📊 Interface Web: http://localhost:8080");
println!(" 🔌 API REST: http://localhost:8080/api");
println!(" 📡 WebSocket Logs: ws://localhost:8080/api/apps/:name/logs");
logger_clone.info("Sistema", "Sistema SIAX completamente operativo en puerto 8080");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
});
println!("Sistema SIAX operativo. Monitor en segundo plano e Interface en puerto 8080.");
logger.info("Sistema", "Sistema SIAX completamente operativo");
logger.info("Sistema", "Iniciando servidor unificado en puerto 8080");
// Esperamos a ambos
let _ = tokio::join!(monitor_handle, web_handle);
let _ = tokio::join!(monitor_handle, web_api_handle);
}

95
src/models/app.rs Normal file
View File

@@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceStatus {
Active,
Inactive,
Failed,
Activating,
Deactivating,
Unknown,
}
impl ServiceStatus {
pub fn as_str(&self) -> &str {
match self {
ServiceStatus::Active => "active",
ServiceStatus::Inactive => "inactive",
ServiceStatus::Failed => "failed",
ServiceStatus::Activating => "activating",
ServiceStatus::Deactivating => "deactivating",
ServiceStatus::Unknown => "unknown",
}
}
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"active" => ServiceStatus::Active,
"inactive" => ServiceStatus::Inactive,
"failed" => ServiceStatus::Failed,
"activating" => ServiceStatus::Activating,
"deactivating" => ServiceStatus::Deactivating,
_ => ServiceStatus::Unknown,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedApp {
pub name: String,
pub status: AppStatus,
pub pid: Option<i32>,
pub cpu_usage: f32,
pub memory_usage: u64,
pub systemd_status: ServiceStatus,
pub last_updated: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AppStatus {
Running,
Stopped,
Failed,
Crashed,
Zombie,
Unknown,
}
impl AppStatus {
pub fn as_str(&self) -> &str {
match self {
AppStatus::Running => "running",
AppStatus::Stopped => "stopped",
AppStatus::Failed => "failed",
AppStatus::Crashed => "crashed",
AppStatus::Zombie => "zombie",
AppStatus::Unknown => "unknown",
}
}
pub fn reconcile(process_detected: bool, systemd_status: &ServiceStatus) -> Self {
match (process_detected, systemd_status) {
(true, ServiceStatus::Active) => AppStatus::Running,
(false, ServiceStatus::Active) => AppStatus::Crashed,
(false, ServiceStatus::Failed) => AppStatus::Failed,
(true, ServiceStatus::Inactive) => AppStatus::Zombie,
(false, ServiceStatus::Inactive) => AppStatus::Stopped,
(_, ServiceStatus::Activating) => AppStatus::Unknown,
(_, ServiceStatus::Deactivating) => AppStatus::Unknown,
_ => AppStatus::Unknown,
}
}
pub fn emoji(&self) -> &str {
match self {
AppStatus::Running => "",
AppStatus::Stopped => "⏹️",
AppStatus::Failed => "",
AppStatus::Crashed => "💥",
AppStatus::Zombie => "👻",
AppStatus::Unknown => "",
}
}
}

5
src/models/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod app;
pub mod service_config;
pub use app::*;
pub use service_config::*;

View File

@@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
pub app_name: String,
pub script_path: String,
pub working_directory: String,
pub user: String,
pub environment: HashMap<String, String>,
pub restart_policy: RestartPolicy,
pub app_type: AppType,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
NodeJs,
Python,
}
impl AppType {
pub fn get_executable(&self) -> &str {
match self {
AppType::NodeJs => "/usr/bin/node",
AppType::Python => "/usr/bin/python3",
}
}
pub fn from_script_path(path: &str) -> Option<Self> {
if path.ends_with(".js") || path.ends_with(".mjs") {
Some(AppType::NodeJs)
} else if path.ends_with(".py") {
Some(AppType::Python)
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RestartPolicy {
Always,
OnFailure,
No,
}
impl RestartPolicy {
pub fn as_systemd_str(&self) -> &str {
match self {
RestartPolicy::Always => "always",
RestartPolicy::OnFailure => "on-failure",
RestartPolicy::No => "no",
}
}
}
impl Default for ServiceConfig {
fn default() -> Self {
ServiceConfig {
app_name: String::new(),
script_path: String::new(),
working_directory: String::new(),
user: "root".to_string(),
environment: HashMap::new(),
restart_policy: RestartPolicy::Always,
app_type: AppType::NodeJs,
description: None,
}
}
}
impl ServiceConfig {
pub fn validate(&self) -> Result<(), String> {
if self.app_name.is_empty() {
return Err("app_name no puede estar vacío".to_string());
}
if self.script_path.is_empty() {
return Err("script_path no puede estar vacío".to_string());
}
if self.working_directory.is_empty() {
return Err("working_directory no puede estar vacío".to_string());
}
if self.user.is_empty() {
return Err("user no puede estar vacío".to_string());
}
// Validar que el nombre solo contenga caracteres válidos para systemd
if !self.app_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return Err("app_name solo puede contener letras, números, guiones y guiones bajos".to_string());
}
Ok(())
}
pub fn service_name(&self) -> String {
format!("{}.service", self.app_name)
}
}

View File

@@ -4,6 +4,8 @@ use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use std::time::Duration;
use crate::logger::get_logger;
use crate::config::get_config_manager;
use crate::systemd::SystemCtl;
use crate::models::{AppStatus, ServiceStatus};
// User-Agent dinámico
fn generate_user_agent() -> String {
@@ -24,6 +26,9 @@ struct AppStatusUpdate {
memory_usage: String,
cpu_usage: String,
last_check: String,
systemd_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
discrepancy: Option<String>,
}
pub async fn run_monitoring(server_name: String, api_key: String, cloud_url: String) {
@@ -47,7 +52,16 @@ pub async fn run_monitoring(server_name: String, api_key: String, cloud_url: Str
}
for app in apps_to_monitor {
let data = collect_metrics(&sys, &app.name, app.port, &server_name);
let data = collect_metrics_with_systemd(&sys, &app.name, app.port, &server_name);
// Reportar discrepancias
if let Some(ref disc) = data.discrepancy {
logger.warning(
"Monitor",
&format!("Discrepancia detectada en {}", app.name),
Some(disc)
);
}
match send_to_cloud(data, &api_key, &cloud_url, &user_agent).await {
Ok(_) => {},
@@ -66,41 +80,63 @@ pub async fn run_monitoring(server_name: String, api_key: String, cloud_url: Str
}
}
fn collect_metrics(sys: &System, name: &str, port: i32, server: &str) -> AppStatusUpdate {
fn collect_metrics_with_systemd(sys: &System, name: &str, port: i32, server: &str) -> AppStatusUpdate {
let mut pid_encontrado = 0;
let mut cpu = 0.0;
let mut mem = 0.0;
let mut status = "stopped".to_string();
let mut process_detected = false;
// 1. Detección por proceso (método original)
for (pid, process) in sys.processes() {
let process_name = process.name();
if process_name.contains("node") {
// Soportar node y python
if process_name.contains("node") || process_name.contains("python") {
if let Some(cwd) = process.cwd() {
let cwd_str = cwd.to_string_lossy();
if cwd_str.contains(name) {
pid_encontrado = pid.as_u32() as i32;
cpu = process.cpu_usage();
mem = process.memory() as f64 / 1024.0 / 1024.0;
status = "running".to_string();
process_detected = true;
break;
}
}
}
}
// 2. Consultar systemd
let service_name = format!("{}.service", name);
let systemd_status = SystemCtl::status(&service_name);
// 3. Reconciliar estados
let final_status = AppStatus::reconcile(process_detected, &systemd_status);
// 4. Detectar discrepancias
let discrepancy = match (&final_status, process_detected, &systemd_status) {
(AppStatus::Crashed, false, ServiceStatus::Active) => {
Some(format!("Systemd reporta activo pero proceso no detectado"))
}
(AppStatus::Zombie, true, ServiceStatus::Inactive) => {
Some(format!("Proceso detectado pero systemd reporta inactivo"))
}
_ => None,
};
let now = chrono::Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
AppStatusUpdate {
app_name: name.to_string(),
server: server.to_string(),
status,
status: final_status.as_str().to_string(),
port,
pid: pid_encontrado,
pid: if pid_encontrado > 0 { pid_encontrado } else { 0 },
memory_usage: format!("{:.2}MB", mem),
cpu_usage: format!("{:.2}%", cpu),
last_check: timestamp,
systemd_status: systemd_status.as_str().to_string(),
discrepancy,
}
}
@@ -157,4 +193,4 @@ async fn send_to_cloud(
eprintln!("⚠️ Error HTTP {}: {}", status, error_text);
Err(format!("HTTP {}: {}", status, error_text).into())
}
}
}

View File

@@ -0,0 +1,128 @@
use super::{Result, OrchestratorError};
use crate::models::{ServiceConfig, ManagedApp, AppStatus};
use crate::systemd::{ServiceGenerator, SystemCtl};
use crate::logger::get_logger;
use dashmap::DashMap;
use std::sync::Arc;
pub struct AppManager {
apps: Arc<DashMap<String, ServiceConfig>>,
}
impl AppManager {
pub fn new() -> Self {
AppManager {
apps: Arc::new(DashMap::new()),
}
}
pub fn register_app(&self, config: ServiceConfig) -> Result<()> {
let logger = get_logger();
// Validar configuración
config.validate()
.map_err(|e| OrchestratorError::ValidationError(e))?;
// Verificar si ya existe
if self.apps.contains_key(&config.app_name) {
logger.warning("AppManager", "Aplicación ya registrada", Some(&config.app_name));
return Err(OrchestratorError::AppAlreadyExists(config.app_name.clone()));
}
// Verificar si el servicio ya existe en systemd
if SystemCtl::is_service_exists(&config.service_name()) {
logger.warning("AppManager", "Servicio systemd ya existe", Some(&config.service_name()));
return Err(OrchestratorError::AppAlreadyExists(
format!("El servicio {} ya existe en systemd", config.service_name())
));
}
logger.info("AppManager", &format!("Registrando aplicación: {}", config.app_name));
// Generar archivo de servicio
let service_content = ServiceGenerator::create_service(&config)?;
ServiceGenerator::write_service_file(&config, &service_content)?;
// Recargar daemon de systemd
SystemCtl::daemon_reload()?;
// Habilitar el servicio
SystemCtl::enable(&config.service_name())?;
// Guardar en memoria
self.apps.insert(config.app_name.clone(), config.clone());
logger.info("AppManager", &format!("Aplicación {} registrada exitosamente", config.app_name));
Ok(())
}
pub fn unregister_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("AppManager", &format!("Desregistrando aplicación: {}", app_name));
// Obtener configuración
let config = self.apps.get(app_name)
.ok_or_else(|| OrchestratorError::AppNotFound(app_name.to_string()))?;
let service_name = config.service_name();
drop(config); // Liberar el lock
// Detener el servicio si está corriendo
let _ = SystemCtl::stop(&service_name);
// Deshabilitar el servicio
let _ = SystemCtl::disable(&service_name);
// Eliminar archivo de servicio
ServiceGenerator::delete_service_file(&service_name)?;
// Recargar daemon
SystemCtl::daemon_reload()?;
// Eliminar de memoria
self.apps.remove(app_name);
logger.info("AppManager", &format!("Aplicación {} desregistrada exitosamente", app_name));
Ok(())
}
pub fn list_apps(&self) -> Vec<String> {
self.apps.iter()
.map(|entry| entry.key().clone())
.collect()
}
pub fn get_app(&self, app_name: &str) -> Option<ServiceConfig> {
self.apps.get(app_name).map(|entry| entry.clone())
}
pub fn app_exists(&self, app_name: &str) -> bool {
self.apps.contains_key(app_name)
}
pub fn get_app_status(&self, app_name: &str) -> Option<ManagedApp> {
let config = self.get_app(app_name)?;
let systemd_status = SystemCtl::status(&config.service_name());
// Por ahora retornamos información básica
// El monitor.rs se encargará de enriquecer con PID, CPU, RAM
Some(ManagedApp {
name: app_name.to_string(),
status: AppStatus::reconcile(false, &systemd_status),
pid: None,
cpu_usage: 0.0,
memory_usage: 0,
systemd_status,
last_updated: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
})
}
}
impl Default for AppManager {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,126 @@
use super::{Result, OrchestratorError};
use crate::systemd::SystemCtl;
use crate::logger::get_logger;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct LifecycleManager {
rate_limiter: Arc<DashMap<String, Instant>>,
rate_limit_duration: Duration,
}
impl LifecycleManager {
pub fn new() -> Self {
LifecycleManager {
rate_limiter: Arc::new(DashMap::new()),
rate_limit_duration: Duration::from_secs(1),
}
}
pub fn start_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger();
// Verificar rate limit
self.check_rate_limit(app_name)?;
logger.info("Lifecycle", &format!("Iniciando aplicación: {}", app_name));
let service_name = format!("{}.service", app_name);
SystemCtl::start(&service_name)?;
// Actualizar rate limiter
self.update_rate_limiter(app_name);
logger.info("Lifecycle", &format!("Aplicación {} iniciada", app_name));
Ok(())
}
pub fn stop_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger();
// Verificar rate limit
self.check_rate_limit(app_name)?;
logger.info("Lifecycle", &format!("Deteniendo aplicación: {}", app_name));
let service_name = format!("{}.service", app_name);
SystemCtl::stop(&service_name)?;
// Actualizar rate limiter
self.update_rate_limiter(app_name);
logger.info("Lifecycle", &format!("Aplicación {} detenida", app_name));
Ok(())
}
pub fn restart_app(&self, app_name: &str) -> Result<()> {
let logger = get_logger();
// Verificar rate limit
self.check_rate_limit(app_name)?;
logger.info("Lifecycle", &format!("Reiniciando aplicación: {}", app_name));
let service_name = format!("{}.service", app_name);
SystemCtl::restart(&service_name)?;
// Actualizar rate limiter
self.update_rate_limiter(app_name);
logger.info("Lifecycle", &format!("Aplicación {} reiniciada", app_name));
Ok(())
}
fn check_rate_limit(&self, app_name: &str) -> Result<()> {
if let Some(last_action) = self.rate_limiter.get(app_name) {
let elapsed = last_action.elapsed();
if elapsed < self.rate_limit_duration {
let logger = get_logger();
logger.warning(
"Lifecycle",
"Rate limit excedido",
Some(&format!("App: {}, Espera: {:?}", app_name, self.rate_limit_duration - elapsed))
);
return Err(OrchestratorError::RateLimitExceeded(app_name.to_string()));
}
}
Ok(())
}
fn update_rate_limiter(&self, app_name: &str) {
self.rate_limiter.insert(app_name.to_string(), Instant::now());
}
pub fn recover_inconsistent_state(&self, app_name: &str, expected_running: bool) -> Result<()> {
let logger = get_logger();
logger.warning(
"Lifecycle",
"Intentando recuperar estado inconsistente",
Some(app_name)
);
let service_name = format!("{}.service", app_name);
if expected_running {
// Se espera que esté corriendo pero no está
logger.info("Lifecycle", &format!("Intentando reiniciar {}", app_name));
SystemCtl::start(&service_name)?;
} else {
// Se espera que esté detenido pero está corriendo
logger.info("Lifecycle", &format!("Intentando detener {}", app_name));
SystemCtl::stop(&service_name)?;
}
Ok(())
}
}
impl Default for LifecycleManager {
fn default() -> Self {
Self::new()
}
}

27
src/orchestrator/mod.rs Normal file
View File

@@ -0,0 +1,27 @@
pub mod app_manager;
pub mod lifecycle;
pub use app_manager::*;
pub use lifecycle::*;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum OrchestratorError {
#[error("Error de systemd: {0}")]
SystemdError(#[from] crate::systemd::SystemdError),
#[error("Aplicación ya existe: {0}")]
AppAlreadyExists(String),
#[error("Aplicación no encontrada: {0}")]
AppNotFound(String),
#[error("Rate limit excedido para: {0}")]
RateLimitExceeded(String),
#[error("Error de validación: {0}")]
ValidationError(String),
}
pub type Result<T> = std::result::Result<T, OrchestratorError>;

29
src/systemd/mod.rs Normal file
View File

@@ -0,0 +1,29 @@
pub mod systemctl;
pub mod service_generator;
pub mod parser;
pub use systemctl::*;
pub use service_generator::*;
pub use parser::*;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SystemdError {
#[error("Error ejecutando comando systemctl: {0}")]
CommandError(String),
#[error("Permisos insuficientes: {0}")]
PermissionError(String),
#[error("Servicio no encontrado: {0}")]
ServiceNotFound(String),
#[error("Error de validación: {0}")]
ValidationError(String),
#[error("Error de I/O: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, SystemdError>;

34
src/systemd/parser.rs Normal file
View File

@@ -0,0 +1,34 @@
use crate::models::ServiceStatus;
pub struct SystemdParser;
impl SystemdParser {
pub fn parse_status_output(output: &str) -> ServiceStatus {
let output_lower = output.to_lowercase();
if output_lower.contains("active (running)") {
ServiceStatus::Active
} else if output_lower.contains("inactive") {
ServiceStatus::Inactive
} else if output_lower.contains("failed") {
ServiceStatus::Failed
} else if output_lower.contains("activating") {
ServiceStatus::Activating
} else if output_lower.contains("deactivating") {
ServiceStatus::Deactivating
} else {
ServiceStatus::Unknown
}
}
pub fn parse_is_active_output(output: &str) -> ServiceStatus {
match output.trim().to_lowercase().as_str() {
"active" => ServiceStatus::Active,
"inactive" => ServiceStatus::Inactive,
"failed" => ServiceStatus::Failed,
"activating" => ServiceStatus::Activating,
"deactivating" => ServiceStatus::Deactivating,
_ => ServiceStatus::Unknown,
}
}
}

View File

@@ -0,0 +1,152 @@
use super::{Result, SystemdError};
use crate::models::ServiceConfig;
use std::fs;
use std::path::Path;
use crate::logger::get_logger;
pub struct ServiceGenerator;
impl ServiceGenerator {
pub fn create_service(config: &ServiceConfig) -> Result<String> {
let logger = get_logger();
// Validar configuración
config.validate().map_err(|e| SystemdError::ValidationError(e))?;
// Validar que el script existe
if !Path::new(&config.script_path).exists() {
logger.error("ServiceGenerator", "Script no encontrado", Some(&config.script_path));
return Err(SystemdError::ValidationError(
format!("El script '{}' no existe", config.script_path)
));
}
// Validar que el directorio de trabajo existe
if !Path::new(&config.working_directory).exists() {
logger.error("ServiceGenerator", "Directorio de trabajo no encontrado", Some(&config.working_directory));
return Err(SystemdError::ValidationError(
format!("El directorio '{}' no existe", config.working_directory)
));
}
// Validar que el usuario existe
if !Self::user_exists(&config.user) {
logger.warning("ServiceGenerator", "Usuario podría no existir", Some(&config.user));
}
// Generar contenido del servicio
let service_content = Self::generate_service_content(config);
logger.info("ServiceGenerator", &format!("Servicio generado: {}", config.service_name()));
Ok(service_content)
}
fn generate_service_content(config: &ServiceConfig) -> String {
let default_desc = format!("SIAX Managed Service: {}", config.app_name);
let description = config.description.as_ref()
.map(|d| d.as_str())
.unwrap_or(&default_desc);
let executable = config.app_type.get_executable();
// Generar variables de entorno
let env_vars = config.environment
.iter()
.map(|(key, value)| format!("Environment=\"{}={}\"", key, value))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"[Unit]
Description={}
After=network.target
[Service]
Type=simple
User={}
WorkingDirectory={}
ExecStart={} {}
Restart={}
RestartSec=10
{}
[Install]
WantedBy=multi-user.target
"#,
description,
config.user,
config.working_directory,
executable,
config.script_path,
config.restart_policy.as_systemd_str(),
env_vars
)
}
pub fn write_service_file(config: &ServiceConfig, content: &str) -> Result<()> {
let logger = get_logger();
let service_path = format!("/etc/systemd/system/{}", config.service_name());
logger.info("ServiceGenerator", &format!("Escribiendo servicio en: {}", service_path));
match fs::write(&service_path, content) {
Ok(_) => {
logger.info("ServiceGenerator", &format!("Servicio {} creado exitosamente", config.service_name()));
Ok(())
}
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
logger.error("ServiceGenerator", "Permisos insuficientes", Some(&service_path));
Err(SystemdError::PermissionError(
"Se requieren permisos sudo para escribir en /etc/systemd/system".to_string()
))
} else {
logger.error("ServiceGenerator", "Error escribiendo archivo", Some(&e.to_string()));
Err(SystemdError::IoError(e))
}
}
}
}
pub fn delete_service_file(service_name: &str) -> Result<()> {
let logger = get_logger();
let service_path = format!("/etc/systemd/system/{}", service_name);
logger.info("ServiceGenerator", &format!("Eliminando servicio: {}", service_path));
match fs::remove_file(&service_path) {
Ok(_) => {
logger.info("ServiceGenerator", &format!("Servicio {} eliminado exitosamente", service_name));
Ok(())
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
logger.warning("ServiceGenerator", "Servicio no encontrado", Some(service_name));
Ok(()) // No es un error si ya no existe
} else if e.kind() == std::io::ErrorKind::PermissionDenied {
logger.error("ServiceGenerator", "Permisos insuficientes", Some(&service_path));
Err(SystemdError::PermissionError(
"Se requieren permisos sudo para eliminar servicios".to_string()
))
} else {
logger.error("ServiceGenerator", "Error eliminando archivo", Some(&e.to_string()));
Err(SystemdError::IoError(e))
}
}
}
}
fn user_exists(username: &str) -> bool {
use std::process::Command;
let output = Command::new("id")
.arg(username)
.output();
match output {
Ok(out) => out.status.success(),
Err(_) => false,
}
}
}

171
src/systemd/systemctl.rs Normal file
View File

@@ -0,0 +1,171 @@
use super::{Result, SystemdError};
use crate::models::ServiceStatus;
use std::process::Command;
use crate::logger::get_logger;
pub struct SystemCtl;
impl SystemCtl {
pub fn start(service_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", &format!("Iniciando servicio: {}", service_name));
let output = Command::new("systemctl")
.arg("start")
.arg(service_name)
.output()?;
if output.status.success() {
logger.info("SystemCtl", &format!("Servicio {} iniciado exitosamente", service_name));
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
if error.contains("permission denied") || error.contains("Authentication is required") {
logger.error("SystemCtl", "Error de permisos", Some(&error));
Err(SystemdError::PermissionError(
"Se requieren permisos sudo. Configura sudoers para systemctl".to_string()
))
} else if error.contains("not found") || error.contains("not-found") {
logger.error("SystemCtl", "Servicio no encontrado", Some(service_name));
Err(SystemdError::ServiceNotFound(service_name.to_string()))
} else {
logger.error("SystemCtl", "Error ejecutando start", Some(&error));
Err(SystemdError::CommandError(error.to_string()))
}
}
}
pub fn stop(service_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", &format!("Deteniendo servicio: {}", service_name));
let output = Command::new("systemctl")
.arg("stop")
.arg(service_name)
.output()?;
if output.status.success() {
logger.info("SystemCtl", &format!("Servicio {} detenido exitosamente", service_name));
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
if error.contains("permission denied") || error.contains("Authentication is required") {
Err(SystemdError::PermissionError(
"Se requieren permisos sudo. Configura sudoers para systemctl".to_string()
))
} else {
Err(SystemdError::CommandError(error.to_string()))
}
}
}
pub fn restart(service_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", &format!("Reiniciando servicio: {}", service_name));
let output = Command::new("systemctl")
.arg("restart")
.arg(service_name)
.output()?;
if output.status.success() {
logger.info("SystemCtl", &format!("Servicio {} reiniciado exitosamente", service_name));
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
if error.contains("permission denied") || error.contains("Authentication is required") {
Err(SystemdError::PermissionError(
"Se requieren permisos sudo. Configura sudoers para systemctl".to_string()
))
} else {
Err(SystemdError::CommandError(error.to_string()))
}
}
}
pub fn enable(service_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", &format!("Habilitando servicio: {}", service_name));
let output = Command::new("systemctl")
.arg("enable")
.arg(service_name)
.output()?;
if output.status.success() {
logger.info("SystemCtl", &format!("Servicio {} habilitado exitosamente", service_name));
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(SystemdError::CommandError(error.to_string()))
}
}
pub fn disable(service_name: &str) -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", &format!("Deshabilitando servicio: {}", service_name));
let output = Command::new("systemctl")
.arg("disable")
.arg(service_name)
.output()?;
if output.status.success() {
logger.info("SystemCtl", &format!("Servicio {} deshabilitado exitosamente", service_name));
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(SystemdError::CommandError(error.to_string()))
}
}
pub fn daemon_reload() -> Result<()> {
let logger = get_logger();
logger.info("SystemCtl", "Recargando daemon de systemd");
let output = Command::new("systemctl")
.arg("daemon-reload")
.output()?;
if output.status.success() {
logger.info("SystemCtl", "Daemon recargado exitosamente");
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(SystemdError::CommandError(error.to_string()))
}
}
pub fn status(service_name: &str) -> ServiceStatus {
let output = Command::new("systemctl")
.arg("is-active")
.arg(service_name)
.output();
match output {
Ok(out) => {
let status_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
ServiceStatus::from_str(&status_str)
}
Err(_) => ServiceStatus::Unknown,
}
}
pub fn is_service_exists(service_name: &str) -> bool {
let output = Command::new("systemctl")
.arg("list-unit-files")
.arg(service_name)
.output();
match output {
Ok(out) => {
let output_str = String::from_utf8_lossy(&out.stdout);
output_str.contains(service_name)
}
Err(_) => false,
}
}
}