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

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,
}
}
}