Compare commits

...
10 Commits
32 changed files with 4260 additions and 70 deletions
+68
View File
@@ -41,3 +41,71 @@ Anteriormente, si un cliente perdía su archivo de configuración `config.json`
- **TypeError en el Tray Icon**: Se resolvió un error de casteo en PyQt6 (`TypeError: unable to convert a C++ 'QSystemTrayIcon::ActivationReason'`) al reabrir el panel del agente desde el área de notificaciones de Windows.
- **Colisiones en Cliente ID**: Se corrigió el algoritmo de generación del código de cliente (`CLIENT-XXXX`). En lugar de usar un recuento dinámico de la tabla (que colisionaba si se borraban clientes intermedios), ahora se calcula usando el ID máximo de la base de datos más uno (`max(id) + 1`).
- **Historial en Caliente**: Ahora el backend actualiza de forma automática el estado (`SUCCESS` / `FAILED`) y la fecha (`last_run_at`) de los trabajos en la base de datos al finalizar exitosamente la subida de todos sus bloques.
---
## 📅 5. Planificación Calendarizada Avanzada (Cron y Calendario Proxmox) — Fase 3
Se ha ampliado el evaluador de tiempos de ejecución (`is_job_due` en el Agente de Windows) para soportar planificaciones complejas:
- **Expresiones Cron**: Admite la sintaxis estándar de 5 campos (minuto, hora, día de mes, mes, día de semana) para disparos programados de forma precisa (ej. `0 2 * * *`).
- **Cadenas de Calendario de Proxmox (PBS)**: Permite programar rangos específicos de días y horas (ej. `mon..fri 22:00` o `sat,sun 18:00`).
- **Simulación Histórica**: El agente evalúa si ocurrió algún disparo agendado en el intervalo comprendido entre la última ejecución registrada y la hora actual del sistema, eliminando riesgos de omisión.
---
## 📊 6. Historial de Ejecuciones, Métricas y Telemetría (Job Runs) — Fase 4
Se ha estructurado un motor de auditoría centralizado para las corridas de backup:
- **Base de Datos**: Creación de la tabla `job_runs` para registrar marcas temporales y métricas de cada corrida (archivos escaneados, copiados, omitidos, cantidad de errores, bytes transferidos y resumen de errores).
- **Notificaciones del Agente**: El daemon notifica el inicio de la corrida (`POST /api/jobs/{id}/runs/start`) obteniendo un identificador de corrida, y reporta su estado y métricas al culminar (`POST /api/jobs/{id}/runs/{run_id}/complete`).
- **Panel Web**: Incorporación de un botón interactivo de historial (icono de reloj) que despliega un modal detallando las corridas previas, duración y su rendimiento de transferencia.
---
## 📁 7. Copia Local Duplicada y Copia Robusta — Fase 5 (Fusión con Karen's Replicator)
Implementación del almacenamiento redundante local o de red:
- **Destino Adicional**: Permite configurar una ruta física local o recurso de red UNC (`\\Servidor\Recurso`) para duplicar el backup en caliente.
- **Réplica Exacta**: Si se activa la eliminación de huérfanos, el agente remueve archivos y subcarpetas vacías del destino local si estos ya no existen en el origen.
- **Copia Robusta (Estilo Karen's Replicator v3.5.0)**: Para evitar corromper archivos locales ante caídas del sistema o cortes de energía, los archivos se escriben primero con una extensión temporal `.tmp` y se renombran a su nombre final solo cuando la copia de `shutil` se completa exitosamente.
- **Interfaz PyQt**: Agregado el campo de "Copia Local Adicional" con un explorador nativo de directorios de Windows para facilitar la configuración del cliente.
---
## 🛠️ 8. Corrección de Autenticación y Dependencias
- **Corrupción de Módulo `jwt`**: Se resolvió una colisión de dependencias en Python donde instalar el paquete genérico `jwt` en lugar de `PyJWT` rompía el método `jwt.encode`. Se forzó la reinstalación limpia de `pyjwt` en el entorno virtual.
- **Reset de Admin por Defecto**: Añadida lógica de restablecimiento forzado de contraseña en el inicio del backend (`main.py`) para asegurar que el usuario administrador (`admin@oneverdrive.local`) y su contraseña (`Admin1234!`) se impongan y validen automáticamente en entornos de desarrollo/pruebas.
---
## 📂 9. Explorador Local del Servidor y Conectores de Red UNC (NAS)
Se han añadido controles y APIs de red para flexibilizar la ubicación del almacenamiento centralizado:
- **Explorador Interactivo**: En la configuración global del panel web, se añadió el botón **Explorar** al almacenamiento local. Permite navegar interactivamente por las carpetas del servidor y seleccionar el directorio ideal sin escribirlo manualmente. En Windows, expone al inicio las letras de unidad disponibles (`C:\`, `D:\`, etc.).
- **Montaje UNC en Caliente**: Implementación de `connect_network_share` en [`settings_service.py`](file:///c:/Workspace/onever_drive/backend/app/services/settings_service.py) que realiza llamadas controladas al comando `net use` de Windows con credenciales de red. El backend valida el recurso y monta la unidad de almacenamiento central antes de guardar la configuración de destino.
- **Botón "Probar Conexión"**: Permite testear en caliente permisos de lectura y escritura en la unidad compartida.
---
## ⚡ 10. Motor Programable de Scripts del Servidor (Automatización Externa)
Se ha creado un motor asíncrono y planificador en el servidor para disparar backups en plataformas web externas:
- **Base de Datos y Modelos**: Creación de las tablas `script_jobs` y `script_job_runs` para persistir tareas automatizadas e historiales completos.
- **Programador (Scheduler Daemon)**: El daemon `script_scheduler_daemon` en `main.py` corre minuto a minuto. Valida las expresiones cron de tareas activas de manera asíncrona mediante un pool de hilos (`run_in_executor`) para evitar bloqueos del loop principal de FastAPI.
- **Plantillas Pre-configuradas**: Sembrado de tareas listas para producción destinadas a:
- **FortiGate**: Copias de seguridad a través de la API REST de configuración de FortiOS.
- **UniFi Controller**: Descargas automatizadas usando cookies de autenticación de red.
- **Grandstream UCM/GWN**: Script completo en Python para automatizar el volcado del sistema.
- **Terminal Oscura e Historial**: Nueva pestaña en la web con capacidad de ejecutar scripts manualmente y abrir una modal del historial con terminal oscura (`monospace` font, terminal negra) para auditar la salida estándar (`stdout`/`stderr`) de los intérpretes Python/Bash con codificación UTF-8 robusta.
---
## 🤖 11. Paridad de Características de Karen's Replicator en el Agente de Windows
Hemos añadido paridad absoluta en el cliente nativo de Windows (PyQt6) con respecto a la interfaz original de Karen's Replicator, implementando tres características clave:
- **Tags de Carpeta Destino (Destination Folder Tags)**: El motor resuelve expresiones de fecha en la ruta local (ej. `D:\Backup\<yyyy>-<mm>-<dd>`) reemplazando etiquetas en tiempo real al ejecutar el backup. La GUI del agente PyQt6 incorpora un botón interactivo **Tags...** que despliega un menú flotante con las variables utilizables.
- **Papelera de Reciclaje (Move to Recycle Bin)**: Implementación de la API ctypes `SHFileOperationW` mapeada de la librería nativa de Windows `shell32.dll`. Al replicar eliminaciones locales, los archivos huérfanos se mueven de forma segura a la Papelera de Reciclaje permitiendo su restauración manual, con fallback transparente a la remoción física permanente si el sistema no es compatible.
- **Exclusiones Globales del Sistema (Global Exclusions)**: Inclusión automática en el escáner del agente de exclusiones de archivos del sistema bloqueados (`pagefile.sys`, `hiberfil.sys`, `Thumbs.db`, `Desktop.ini`, `*.tmp`) y carpetas temporales o de papelera (`Temp`, `Recycler`, `$Recycle.Bin`). El agente PyQt incluye una casilla en la sección de configuraciones para activar/desactivar el filtrado global de forma persistente.
+50
View File
@@ -33,6 +33,56 @@ Plataforma empresarial centralizada de backup y sincronización para entornos Wi
---
## 🔄 Flujo de Trabajo (Workflow) y Resolución del Problema
OnEver Drive está diseñado específicamente para resolver la necesidad de **programar y monitorear backups en clientes fuera de la red local (a través de Internet)** mediante una consola de gestión web centralizada ejecutada en una VM Linux.
### 🌐 Conectividad a través de Internet (Sin VPN)
El agente de Windows no requiere de red local ni VPN para comunicarse con el servidor central. Toda la comunicación (Handshake, Sincronización de Tareas, Subida de Chunks e Historial de Ejecuciones) se realiza a través de **HTTPS/TLS** (puerto 443) con seguridad criptográfica robusta:
- Cada agente posee un par único de `device_id` y `device_token` permanente que se valida en el backend mediante un middleware de seguridad.
### 📦 Distribución del Agente "MeshCentral-Style" (Compilación y Enrolamiento)
Para facilitar el despliegue masivo y sencillo en clientes remotos:
1. **Compilación Centralizada**: El administrador puede empaquetar y generar el binario del agente directamente con el script de compilación `windows-agent/build_exe.py` (generando un archivo ejecutable portable de un solo archivo `OnEverDriveAgent-Standalone.exe`).
2. **Enrolamiento por Código**: Al igual que en MeshCentral, el administrador genera un **Código de Registro Único** (ej: `OED-5752-EFED`) con duración temporal (24 horas) en la interfaz web del servidor.
3. **Handshake Seguro**: El instalador del Agente de Windows se ejecuta en la máquina cliente, solicita este código y realiza una solicitud inicial segura. El servidor asocia la máquina al registro y devuelve un token exclusivo, vinculando el cliente de por vida.
### 📊 Diagrama de Secuencia del Flujo de Trabajo
```mermaid
sequenceDiagram
autonumber
actor Admin as Administrador Web
participant Server as VM Linux (FastAPI + React)
participant Agent as Agente Windows (en Internet)
Note over Admin,Server: 1. Descarga y Vinculación (Estilo MeshCentral)
Admin->>Server: Generar código de vinculación temporal (web)
Server-->>Admin: Código temporal (Ej: OED-5752-EFED)
Agent->>Server: Registro inicial con Código Temporal (HTTPS POST)
Server-->>Agent: device_id y device_token seguro (Guardado en config.json)
Note over Admin,Agent: 2. Sincronización de Configuración
Admin->>Server: Crear/Editar Trabajo (Filtros, Cron, Copia Local)
Agent->>Server: Consultar trabajos asignados (HTTPS GET)
Server-->>Agent: Retorna configuración del Trabajo
Agent->>Agent: Guarda/Actualiza config.json local
Note over Agent,Server: 3. Ejecución de Tarea y Sincronización
Agent->>Agent: Evalúa is_job_due (Planificador Cron / Proxmox)
Agent->>Server: Iniciar sesión de corrida (POST /runs/start)
Server-->>Agent: Retorna run_id
Agent->>Agent: Copia Local Duplicada Robusta (.tmp -> original)
Agent->>Server: Sube archivos por Chunks de 4MB (HTTPS)
Agent->>Server: Reporta métricas finales y estado (POST /runs/{id}/complete)
Note over Admin,Server: 4. Auditoría
Admin->>Server: Abre modal "Historial de Ejecuciones" (React)
Server-->>Admin: Muestra estadísticas de la corrida (eficiencia, errores, MBs)
```
---
## 📦 Estructura del Repositorio
- **`backend/`**: API REST FastAPI con autenticación JWT, registro de dispositivos por código temporal, motor de chunks con reanudación, políticas de retención y WebSockets.
+48 -12
View File
@@ -13,27 +13,62 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
import logging
logger = logging.getLogger("uvicorn.error")
from app.core.radius import authenticate_radius
from app.services.settings_service import get_setting
@router.post("/login", response_model=TokenResponse)
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
email_clean = credentials.email.strip().lower()
result = await db.execute(select(User).where(User.email == email_clean))
user = result.scalar_one_or_none()
auth_mode = await get_setting(db, "auth_mode")
radius_host = await get_setting(db, "radius_host")
radius_port = await get_setting(db, "radius_port")
radius_secret = await get_setting(db, "radius_secret")
if not user:
logger.warning(f"Login failed: User not found with email '{email_clean}'")
is_authenticated = False
user = None
# 1. RADIUS Authentication Path (except for default admin fallback)
if auth_mode == "radius" and email_clean != "admin@oneverdrive.local":
username_radius = email_clean.split("@")[0] if "@" in email_clean else email_clean
try:
port_num = int(radius_port) if radius_port else 1812
except ValueError:
port_num = 1812
radius_ok = authenticate_radius(username_radius, credentials.password, radius_host, radius_secret, port=port_num)
if radius_ok:
result = await db.execute(select(User).where(User.email == email_clean))
user = result.scalar_one_or_none()
if not user:
# Auto-provision (JIT) RADIUS User
user = User(
email=email_clean,
hashed_password=get_password_hash(credentials.password),
full_name=username_radius.capitalize(),
role="OPERATOR",
auth_source="radius",
is_active=True
)
db.add(user)
await db.commit()
await db.refresh(user)
is_authenticated = True
else:
# 2. Local Authentication Path (or Admin local login fallback)
result = await db.execute(select(User).where(User.email == email_clean))
user = result.scalar_one_or_none()
if user:
if verify_password(credentials.password, user.hashed_password):
is_authenticated = True
if not is_authenticated:
logger.warning(f"Login failed: Invalid credentials for '{email_clean}'")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
if not verify_password(credentials.password, user.hashed_password):
logger.warning(f"Login failed: Password mismatch for email '{email_clean}' (sent password length: {len(credentials.password)})")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -57,7 +92,8 @@ async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"role": user.role
"role": user.role,
"auth_source": user.auth_source
}
}
+87 -5
View File
@@ -1,7 +1,11 @@
import uuid
import sys
import subprocess
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
@@ -99,10 +103,8 @@ async def register_client(
client.last_seen_at = now
client.is_active = True
else:
# Create a new Client
max_id_res = await db.execute(select(func.max(Client.id)))
max_id = max_id_res.scalar() or 0
client_code = f"CLIENT-{max_id + 1:04d}"
# Create a new Client with a unique generated client_code
client_code = f"CLI-{uuid.uuid4().hex[:8].upper()}"
# Load default client quota from settings
from app.services.settings_service import get_setting
@@ -356,3 +358,83 @@ async def delete_client(
)
return {"message": f"Client {client.client_code} deleted"}
# Global in-memory build state
BUILD_STATE = {
"status": "IDLE", # IDLE, BUILDING, SUCCESS, FAILED
"started_at": None,
"finished_at": None,
"error_message": None
}
def run_pyinstaller_build():
global BUILD_STATE
BUILD_STATE["status"] = "BUILDING"
BUILD_STATE["started_at"] = datetime.now(timezone.utc).isoformat()
BUILD_STATE["error_message"] = None
try:
# Cwd is workspace root
base_dir = Path(__file__).resolve().parent.parent.parent.parent
script_path = base_dir / "windows-agent" / "build_exe.py"
venv_python = base_dir / "backend" / "venv" / "Scripts" / "python.exe"
if not venv_python.exists():
venv_python = sys.executable
print(f"Starting PyInstaller compilation: {venv_python} {script_path}")
result = subprocess.run(
[str(venv_python), str(script_path)],
capture_output=True,
text=True,
cwd=str(base_dir / "windows-agent")
)
if result.returncode == 0:
BUILD_STATE["status"] = "SUCCESS"
else:
BUILD_STATE["status"] = "FAILED"
BUILD_STATE["error_message"] = result.stderr or result.stdout
except Exception as e:
BUILD_STATE["status"] = "FAILED"
BUILD_STATE["error_message"] = str(e)
finally:
BUILD_STATE["finished_at"] = datetime.now(timezone.utc).isoformat()
@router.post("/build-agent")
async def trigger_build_agent(
background_tasks: BackgroundTasks,
admin_user: User = Depends(require_admin)
):
global BUILD_STATE
if BUILD_STATE["status"] == "BUILDING":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A compilation is already in progress."
)
background_tasks.add_task(run_pyinstaller_build)
return {"message": "Compilation started in the background."}
@router.get("/build-agent/status")
async def get_build_agent_status(
current_user: User = Depends(get_current_user)
):
return BUILD_STATE
@router.get("/download-agent")
async def download_agent(
current_user: User = Depends(get_current_user)
):
base_dir = Path(__file__).resolve().parent.parent.parent.parent
exe_path = base_dir / "windows-agent" / "dist" / "OnEverDriveAgent-Standalone.exe"
if not exe_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Agent executable not found. Please compile it first."
)
return FileResponse(
path=str(exe_path),
filename="OnEverDriveAgent-Standalone.exe",
media_type="application/octet-stream"
)
+189
View File
@@ -0,0 +1,189 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List
import time
import subprocess
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import User, ScriptJob, ScriptJobRun
from app.schemas.schemas import ScriptJobResponse, ScriptJobCreate, ScriptJobUpdate, ScriptJobRunResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/script-jobs", tags=["Script Jobs Management"], dependencies=[Depends(require_admin)])
async def execute_script_in_background(job_id: int):
"""
Executes a script (python/bash) in a separate thread and saves status and logs to the DB.
"""
async with AsyncSessionLocal() as db:
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
return
run = ScriptJobRun(
script_job_id=job.id,
status="RUNNING",
started_at=datetime.now(timezone.utc)
)
db.add(run)
await db.commit()
await db.refresh(run)
start_time = time.time()
base_dir = Path(__file__).resolve().parent.parent.parent.parent
script_full_path = base_dir / job.script_path
# Find virtualenv python interpreter or fallback
if job.script_type == "python":
venv_python_win = base_dir / "backend" / "venv" / "Scripts" / "python.exe"
venv_python_lin = base_dir / "backend" / "venv" / "bin" / "python"
if venv_python_win.exists():
venv_python = venv_python_win
elif venv_python_lin.exists():
venv_python = venv_python_lin
else:
import sys
venv_python = sys.executable
cmd = [str(venv_python), str(script_full_path)]
else:
import platform
if platform.system().lower() == "windows":
# For Windows bash/sh script, run PowerShell as wrapper
cmd = ["powershell.exe", "-Command", str(script_full_path)]
else:
cmd = ["bash", str(script_full_path)]
try:
loop = asyncio.get_running_loop()
def run_subprocess():
return subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(base_dir),
encoding='utf-8',
errors='replace' # Handle Karen-style unicode error logs safely!
)
res = await loop.run_in_executor(None, run_subprocess)
duration = time.time() - start_time
run.completed_at = datetime.now(timezone.utc)
run.duration_seconds = round(duration, 2)
run.log_output = f"--- STDOUT ---\n{res.stdout}\n\n--- STDERR ---\n{res.stderr}"
run.status = "SUCCESS" if res.returncode == 0 else "FAILED"
if res.returncode != 0:
run.log_output += f"\n\nProcess exited with return code: {res.returncode}"
except Exception as e:
duration = time.time() - start_time
run.completed_at = datetime.now(timezone.utc)
run.duration_seconds = round(duration, 2)
run.status = "FAILED"
run.log_output = f"Execution failed due to launcher error:\n{str(e)}"
db.add(run)
await db.commit()
@router.get("", response_model=List[ScriptJobResponse])
async def list_script_jobs(db: AsyncSession = Depends(get_db)):
"""List all configured server-side script backup jobs."""
result = await db.execute(select(ScriptJob).order_by(ScriptJob.id.asc()))
return result.scalars().all()
@router.post("", response_model=ScriptJobResponse)
async def create_script_job(payload: ScriptJobCreate, db: AsyncSession = Depends(get_db)):
"""Create a new server-side script job (cron schedule)."""
# Verify path exists
base_dir = Path(__file__).resolve().parent.parent.parent.parent
target_path = base_dir / payload.script_path
if not target_path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script file '{payload.script_path}' was not found on the server."
)
job = ScriptJob(
name=payload.name,
script_type=payload.script_type,
script_path=payload.script_path,
schedule_cron=payload.schedule_cron,
is_active=payload.is_active
)
db.add(job)
await db.commit()
await db.refresh(job)
return job
@router.put("/{job_id}", response_model=ScriptJobResponse)
async def update_script_job(job_id: int, payload: ScriptJobUpdate, db: AsyncSession = Depends(get_db)):
"""Update a script job configuration."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
if payload.name is not None:
job.name = payload.name
if payload.script_type is not None:
job.script_type = payload.script_type
if payload.script_path is not None:
# Verify path
base_dir = Path(__file__).resolve().parent.parent.parent.parent
target_path = base_dir / payload.script_path
if not target_path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script file '{payload.script_path}' was not found on the server."
)
job.script_path = payload.script_path
if payload.schedule_cron is not None:
job.schedule_cron = payload.schedule_cron
if payload.is_active is not None:
job.is_active = payload.is_active
db.add(job)
await db.commit()
await db.refresh(job)
return job
@router.delete("/{job_id}")
async def delete_script_job(job_id: int, db: AsyncSession = Depends(get_db)):
"""Deletes a script job and its execution history."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
await db.delete(job)
await db.commit()
return {"message": f"Script job '{job.name}' deleted successfully."}
@router.post("/{job_id}/trigger")
async def trigger_script_job(job_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
"""Triggers immediate execution of a script job in the background."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
background_tasks.add_task(execute_script_in_background, job.id)
return {"message": f"Script execution for '{job.name}' triggered successfully in background."}
@router.get("/{job_id}/runs", response_model=List[ScriptJobRunResponse])
async def get_script_job_runs(job_id: int, db: AsyncSession = Depends(get_db)):
"""List execution history runs for a specific script job."""
result = await db.execute(
select(ScriptJobRun)
.where(ScriptJobRun.script_job_id == job_id)
.order_by(desc(ScriptJobRun.started_at))
.limit(50)
)
return result.scalars().all()
+110
View File
@@ -1,5 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
import os
import string
import uuid
from pathlib import Path
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import require_admin
from app.models.models import User
@@ -8,6 +15,11 @@ from app.services.settings_service import get_all_settings, update_settings_serv
router = APIRouter(prefix="/settings", tags=["System Settings"])
class TestShareRequest(BaseModel):
path: str
username: str
password: str
@router.get("", response_model=SystemSettingsResponse)
async def get_settings(
db: AsyncSession = Depends(get_db),
@@ -22,6 +34,13 @@ async def get_settings(
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
"auth_mode": settings_dict.get("auth_mode", "local"),
"radius_host": settings_dict.get("radius_host", ""),
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
"radius_secret": settings_dict.get("radius_secret", ""),
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
"storage_network_user": settings_dict.get("storage_network_user", ""),
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
}
@router.put("", response_model=SystemSettingsResponse)
@@ -49,4 +68,95 @@ async def update_settings(
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
"auth_mode": settings_dict.get("auth_mode", "local"),
"radius_host": settings_dict.get("radius_host", ""),
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
"radius_secret": settings_dict.get("radius_secret", ""),
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
"storage_network_user": settings_dict.get("storage_network_user", ""),
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
}
@router.get("/explore-dir")
async def explore_directory(
path: Optional[str] = None,
admin_user: User = Depends(require_admin)
):
"""
Explore directories on the host filesystem.
"""
import platform
if not path or path.strip() == "":
if platform.system().lower() == "windows":
drives = []
for letter in string.ascii_uppercase:
drive_path = f"{letter}:\\"
if os.path.exists(drive_path):
drives.append({"name": drive_path, "path": drive_path})
return {"current_path": "", "folders": drives}
else:
return {"current_path": "/", "folders": [{"name": "/", "path": "/"}]}
# Resolve path
target = Path(path).resolve()
if not target.exists() or not target.is_dir():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"The path '{path}' does not exist or is not a directory."
)
try:
folders = []
for entry in os.scandir(target):
try:
if entry.is_dir():
folders.append({
"name": entry.name,
"path": str(Path(entry.path).resolve())
})
except Exception:
continue # Skip inaccessible
folders.sort(key=lambda x: x["name"].lower())
return {
"current_path": str(target),
"parent_path": str(target.parent) if target.parent != target else None,
"folders": folders
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to read directory contents: {str(e)}"
)
@router.post("/test-network-share")
async def test_network_share(
payload: TestShareRequest,
admin_user: User = Depends(require_admin)
):
"""
Attempts to connect to a UNC network share and tests write access.
"""
from app.services.settings_service import connect_network_share
ok = connect_network_share(payload.path, payload.username, payload.password)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to connect to the network share. Check the server path, username, and password."
)
try:
target_path = Path(payload.path)
os.makedirs(target_path, exist_ok=True)
test_file = target_path / f".write_test_{uuid.uuid4().hex[:6]}"
test_file.touch()
test_file.unlink()
return {"message": "Network share connected and write access verified successfully!"}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Network share connected, but write access test failed: {str(e)}"
)
+130
View File
@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.core.database import get_db
from app.core.security import get_password_hash
from app.models.models import User
from app.schemas.schemas import UserResponse, UserCreate, UserUpdate
from app.api.deps import get_current_user, require_admin
router = APIRouter(prefix="/users", tags=["Users Management"], dependencies=[Depends(require_admin)])
@router.get("", response_model=List[UserResponse])
async def list_users(db: AsyncSession = Depends(get_db)):
"""Lists all user accounts in the database."""
result = await db.execute(select(User).order_by(User.id.asc()))
users = result.scalars().all()
return users
@router.post("", response_model=UserResponse)
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
"""Creates a new user account (Local or RADIUS)."""
# Check if email already registered
email_clean = payload.email.strip().lower()
result = await db.execute(select(User).where(User.email == email_clean))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email address is already registered."
)
user = User(
email=email_clean,
hashed_password=get_password_hash(payload.password),
full_name=payload.full_name,
role=payload.role,
auth_source=payload.auth_source,
is_active=payload.is_active
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, payload: UserUpdate, db: AsyncSession = Depends(get_db)):
"""Updates an existing user account."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found."
)
# Prevent changing email of default admin
if user.email == "admin@oneverdrive.local" and payload.email and payload.email.strip().lower() != user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email address of the default system administrator cannot be changed."
)
if payload.email is not None:
email_clean = payload.email.strip().lower()
if email_clean != user.email:
existing_res = await db.execute(select(User).where(User.email == email_clean))
if existing_res.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Target email address is already in use."
)
user.email = email_clean
if payload.password is not None and payload.password != "":
user.hashed_password = get_password_hash(payload.password)
if payload.full_name is not None:
user.full_name = payload.full_name
if payload.role is not None:
# Prevent demoting the main admin
if user.email == "admin@oneverdrive.local" and payload.role != "ADMIN":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Default system administrator role cannot be demoted."
)
user.role = payload.role
if payload.is_active is not None:
# Prevent deactivating the main admin
if user.email == "admin@oneverdrive.local" and not payload.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Default system administrator account cannot be deactivated."
)
user.is_active = payload.is_active
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Deletes a user account from the system."""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account."
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found."
)
if user.email == "admin@oneverdrive.local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="The default system administrator account cannot be deleted."
)
await db.delete(user)
await db.commit()
return {"message": f"User account '{user.email}' deleted successfully."}
+112
View File
@@ -0,0 +1,112 @@
import socket
import hashlib
import os
import logging
logger = logging.getLogger("uvicorn.error")
def authenticate_radius(username: str, password: str, server: str, secret: str, port: int = 1812, timeout: float = 3.0) -> bool:
"""
Performs RADIUS Access-Request authentication natively using raw UDP sockets.
Conforms to RFC 2865 standard for RADIUS protocol and PAP password encryption.
"""
if not server or not secret:
logger.error("RADIUS authentication failed: Server host or Shared Secret is not configured.")
return False
try:
secret_bytes = secret.encode('utf-8')
# Access-Request Header fields:
# Code: 1 (Access-Request)
# Identifier: 1 byte (random/sequential)
# Length: 2 bytes (20 + attributes length)
# Authenticator: 16 bytes (cryptographically strong random value)
identifier = os.urandom(1)[0]
authenticator = os.urandom(16)
# Attribute 1: User-Name (Type 1)
user_bytes = username.encode('utf-8')
attr_username = bytes([1, len(user_bytes) + 2]) + user_bytes
# Attribute 2: User-Password (Type 2, PAP Encryption)
# 1. Pad password with null bytes (\x00) to a multiple of 16 bytes
password_bytes = password.encode('utf-8')
pad_len = 16 - (len(password_bytes) % 16)
if pad_len == 16 and len(password_bytes) > 0:
pad_len = 0
if pad_len > 0:
password_bytes += b'\x00' * pad_len
# 2. Encrypt password chunks
# b(1) = MD5(Secret + Request Authenticator)
# c(1) = p(1) XOR b(1)
# b(2) = MD5(Secret + c(1))
# c(2) = p(2) XOR b(2)
# and so on...
encrypted_password = b''
last_chunk = authenticator
for i in range(0, len(password_bytes), 16):
chunk = password_bytes[i:i+16]
md5_hash = hashlib.md5(secret_bytes + last_chunk).digest()
encrypted_chunk = bytes(a ^ b for a, b in zip(chunk, md5_hash))
encrypted_password += encrypted_chunk
last_chunk = encrypted_chunk
attr_password = bytes([2, len(encrypted_password) + 2]) + encrypted_password
# Combine attributes
attributes = attr_username + attr_password
packet_len = 20 + len(attributes)
# Assemble complete packet
header = bytes([1, identifier, (packet_len >> 8) & 0xff, packet_len & 0xff]) + authenticator
packet = header + attributes
# UDP Send/Receive
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
logger.info(f"Sending RADIUS Access-Request to {server}:{port} for user '{username}'...")
sock.sendto(packet, (server, port))
response, _ = sock.recvfrom(4096)
if len(response) < 20:
logger.warning("RADIUS response packet is too short.")
return False
resp_code = response[0]
resp_identifier = response[1]
resp_length = (response[2] << 8) + response[3]
resp_authenticator = response[4:20]
# Validate Identifier match
if resp_identifier != identifier:
logger.warning(f"RADIUS response identifier mismatch (sent {identifier}, received {resp_identifier}).")
return False
# Validate Response Authenticator:
# Response Authenticator = MD5(Code + ID + Length + Request Authenticator + Attributes + Secret)
resp_attributes = response[20:resp_length]
calc_auth = hashlib.md5(response[0:4] + authenticator + resp_attributes + secret_bytes).digest()
if calc_auth != resp_authenticator:
logger.warning("RADIUS response authenticator signature validation failed (spoofing check).")
return False
if resp_code == 2:
logger.info(f"RADIUS Access-Accept received for user '{username}'. Authentication successful!")
return True
elif resp_code == 3:
logger.warning(f"RADIUS Access-Reject received for user '{username}'.")
return False
else:
logger.warning(f"RADIUS server returned unknown response code: {resp_code}")
return False
except socket.timeout:
logger.error(f"RADIUS authentication timed out (server {server}:{port} unreachable).")
return False
except Exception as e:
logger.error(f"RADIUS authentication system exception: {e}")
return False
+90 -1
View File
@@ -1,4 +1,6 @@
from contextlib import asynccontextmanager
from datetime import datetime
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
@@ -15,12 +17,89 @@ from app.api.backups import router as backups_router
from app.api.events import router as events_router
from app.api.stats import router as stats_router
from app.api.settings import router as settings_router
from app.api.users import router as users_router
from app.api.script_jobs import router as script_jobs_router
from app.ws.manager import ws_manager
async def script_scheduler_daemon():
"""
Background daemon that runs every minute to execute scheduled script jobs.
"""
from app.models.models import ScriptJob
from app.api.script_jobs import execute_script_in_background
from app.core.database import AsyncSessionLocal
from sqlalchemy import select
print("[*] Script Scheduler Daemon started.")
def match_cron(cron_expr: str, dt: datetime) -> bool:
try:
fields = cron_expr.strip().split()
if len(fields) < 5:
return False
minute, hour, dom, month, dow = fields
def match_field(val: int, field: str, dow_check=False) -> bool:
if field == '*':
return True
if '/' in field:
base, step = field.split('/')
step = int(step)
if base == '*':
return val % step == 0
return (val - int(base)) % step == 0
if ',' in field:
parts = field.split(',')
return any(match_field(val, p, dow_check) for p in parts)
if '-' in field:
start, end = map(int, field.split('-'))
return start <= val <= end
if dow_check:
cron_dow = (dt.weekday() + 1) % 7
if int(field) == 7 and cron_dow == 0:
return True
return cron_dow == int(field)
return val == int(field)
return (
match_field(dt.minute, minute) and
match_field(dt.hour, hour) and
match_field(dt.day, dom) and
match_field(dt.month, month) and
match_field(dt.weekday(), dow, dow_check=True)
)
except Exception:
return False
while True:
try:
now_sec = datetime.now().second
sleep_time = 60 - now_sec
await asyncio.sleep(sleep_time)
now = datetime.now()
async with AsyncSessionLocal() as db:
result = await db.execute(select(ScriptJob).where(ScriptJob.is_active == True))
jobs = result.scalars().all()
for job in jobs:
if match_cron(job.schedule_cron, now):
print(f"[*] Scheduler: Script Job '{job.name}' is due. Triggering execution...")
asyncio.create_task(execute_script_in_background(job.id))
except asyncio.CancelledError:
break
except Exception as e:
print(f"[!] Scheduler error: {e}")
await asyncio.sleep(5)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize database tables
await init_db()
# Start scheduler daemon task
scheduler_task = asyncio.create_task(script_scheduler_daemon())
# Seed default administrator and initialize settings
async with AsyncSessionLocal() as session:
@@ -58,7 +137,15 @@ async def lifespan(app: FastAPI):
await session.commit()
print(">> [OnEver Drive] Default admin verified & password reset to: Admin1234!")
yield
try:
yield
finally:
scheduler_task.cancel()
try:
await scheduler_task
except asyncio.CancelledError:
pass
print("[*] Script Scheduler Daemon stopped.")
app = FastAPI(
title=settings.PROJECT_NAME,
@@ -85,6 +172,8 @@ app.include_router(backups_router, prefix=settings.API_V1_PREFIX)
app.include_router(events_router, prefix=settings.API_V1_PREFIX)
app.include_router(stats_router, prefix=settings.API_V1_PREFIX)
app.include_router(settings_router, prefix=settings.API_V1_PREFIX)
app.include_router(users_router, prefix=settings.API_V1_PREFIX)
app.include_router(script_jobs_router, prefix=settings.API_V1_PREFIX)
@app.websocket("/ws/telemetry")
async def websocket_telemetry(websocket: WebSocket):
+28 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
import uuid
from sqlalchemy import (
Column, String, Integer, BigInteger, Boolean, DateTime,
ForeignKey, Text, Index
ForeignKey, Text, Index, Float
)
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -19,6 +19,7 @@ class User(Base):
full_name = Column(String(255), nullable=True)
role = Column(String(50), default="ADMIN", nullable=False) # ADMIN, OPERATOR, VIEWER
is_active = Column(Boolean, default=True, nullable=False)
auth_source = Column(String(50), default="local", nullable=False) # local, radius
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
class Client(Base):
@@ -212,3 +213,29 @@ class SystemSetting(Base):
key = Column(String(100), primary_key=True, index=True)
value = Column(String(1024), nullable=False)
class ScriptJob(Base):
__tablename__ = "script_jobs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
script_type = Column(String(50), nullable=False) # python, bash
script_path = Column(String(1024), nullable=False) # relative to app/scripts/ or absolute
schedule_cron = Column(String(100), default="0 2 * * *", nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
runs = relationship("ScriptJobRun", back_populates="job", cascade="all, delete-orphan")
class ScriptJobRun(Base):
__tablename__ = "script_job_runs"
id = Column(Integer, primary_key=True, index=True)
script_job_id = Column(Integer, ForeignKey("script_jobs.id", ondelete="CASCADE"), nullable=False)
started_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(50), default="RUNNING", nullable=False) # RUNNING, SUCCESS, FAILED
duration_seconds = Column(Float, default=0.0, nullable=False)
log_output = Column(Text, nullable=True)
job = relationship("ScriptJob", back_populates="runs")
+67
View File
@@ -18,10 +18,26 @@ class UserResponse(BaseModel):
full_name: Optional[str]
role: str
is_active: bool
auth_source: str
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
email: str
password: str
full_name: Optional[str] = None
role: str = "OPERATOR"
is_active: bool = True
auth_source: str = "local"
class UserUpdate(BaseModel):
email: Optional[str] = None
password: Optional[str] = None
full_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
# --- Client Schemas ---
class RegistrationCodeCreate(BaseModel):
client_name_hint: Optional[str] = None
@@ -240,6 +256,13 @@ class SystemSettingsResponse(BaseModel):
default_keep_daily: int
default_keep_weekly: int
default_keep_monthly: int
auth_mode: Optional[str] = "local"
radius_host: Optional[str] = ""
radius_port: Optional[int] = 1812
radius_secret: Optional[str] = ""
storage_network_enabled: Optional[bool] = False
storage_network_user: Optional[str] = ""
storage_network_pass: Optional[str] = ""
class SystemSettingsUpdate(BaseModel):
storage_root: Optional[str] = None
@@ -248,6 +271,13 @@ class SystemSettingsUpdate(BaseModel):
default_keep_daily: Optional[int] = None
default_keep_weekly: Optional[int] = None
default_keep_monthly: Optional[int] = None
auth_mode: Optional[str] = None
radius_host: Optional[str] = None
radius_port: Optional[int] = None
radius_secret: Optional[str] = None
storage_network_enabled: Optional[bool] = None
storage_network_user: Optional[str] = None
storage_network_pass: Optional[str] = None
class AgentJobRegister(BaseModel):
name: str
@@ -296,3 +326,40 @@ class JobRunResponse(BaseModel):
error_summary: Optional[str]
model_config = {"from_attributes": True}
# --- Script Jobs Schemas ---
class ScriptJobRunResponse(BaseModel):
id: int
script_job_id: int
started_at: datetime
completed_at: Optional[datetime]
status: str
duration_seconds: float
log_output: Optional[str]
model_config = {"from_attributes": True}
class ScriptJobResponse(BaseModel):
id: int
name: str
script_type: str
script_path: str
schedule_cron: str
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class ScriptJobCreate(BaseModel):
name: str
script_type: str
script_path: str
schedule_cron: str
is_active: bool = True
class ScriptJobUpdate(BaseModel):
name: Optional[str] = None
script_type: Optional[str] = None
script_path: Optional[str] = None
schedule_cron: Optional[str] = None
is_active: Optional[bool] = None
+223
View File
@@ -0,0 +1,223 @@
# backup_fortigate.py
import os
import re
import sys
import subprocess
from datetime import datetime
import requests
import urllib3
# Desactivar advertencias de certificados SSL autofirmados
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuración FortiGate
FGT_HOST = "192.168.99.99"
FGT_PORT = "8443"
FGT_TOKEN = "bxtQkG7mymccqqc1wgwQyH7ngb4nbb"
FGT_BASE_URL = f"https://{FGT_HOST}:{FGT_PORT}"
# Configuración NAS / Ruta de Destino
# En Windows usará el UNC \\10.0.0.6\bak-fortigate
# En Linux (Debian LXC) usará /mnt/bak-fortigate (o lo que defina la variable de entorno NAS_PATH)
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-fortigate"
else:
DEFAULT_NAS_PATH = "/mnt/bak-fortigate"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7 # Días a conservar en el NAS (7 días x 2 ejecuciones/día = 14 archivos)
def sanitize_filename(text: str) -> str:
"""Elimina caracteres inválidos para nombres de archivos."""
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
"""Asegura la disponibilidad del recurso NAS en Windows o Linux (Debian)."""
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
# En Windows, intentar autenticación implícita con net use
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
# En Linux / Debian
print(f"[!] La ruta '{path}' no existe o no está montada.")
print(f"[*] Intentando crear el directorio local '{path}'...")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
"""
Elimina archivos de backup (.conf) en el directorio que superen los días de retención.
Con 2 ejecuciones diarias (06:00 y 18:00), se mantendrán hasta 14 archivos de los últimos 7 días.
"""
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
print(f"[!] La ruta {directory_path} no está disponible para limpieza.")
return
now = datetime.now()
cutoff_time = now.timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [f for f in os.listdir(directory_path) if f.endswith(".conf")]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
file_mtime = os.path.getmtime(file_path)
if file_mtime < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad (> {days_to_keep} días): {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear directorio de backups: {e}")
def get_fortigate_info(base_url: str, token: str):
"""Obtiene el modelo y la versión del firmware desde la API del FortiGate."""
url = f"{base_url}/api/v2/monitor/system/status"
headers = {"Authorization": f"Bearer {token}"}
print("[*] Consultando información del sistema FortiGate...")
try:
response = requests.get(url, headers=headers, verify=False, timeout=10)
if response.status_code == 200:
data = response.json()
results = data.get("results", {})
# Extraer modelo
model_name = results.get("model_name", "FortiGate")
model_number = results.get("model_number", "")
model = results.get("model", "")
if model_number:
full_model = f"{model_name}-{model_number}"
elif model:
full_model = f"{model_name}-{model}"
else:
full_model = model_name
# Extraer versión firmware y build
firmware_version = data.get("version", results.get("version", "vUnknown"))
build = data.get("build", results.get("build", ""))
if build:
full_version = f"{firmware_version}_b{build}"
else:
full_version = firmware_version
print(f"[+] Modelo detectado: {full_model}")
print(f"[+] Versión Firmware detectada: {full_version}")
return sanitize_filename(full_model), sanitize_filename(full_version)
else:
print(f"[!] No se pudo obtener info del sistema (HTTP {response.status_code}). Se usarán valores genéricos.")
except Exception as e:
print(f"[!] Error al consultar estado del sistema: {e}")
return "FortiGate", "vUnknown"
def download_backup(base_url: str, token: str) -> bytes:
"""Descarga la configuración del FortiGate mediante su API."""
url = f"{base_url}/api/v2/monitor/system/config/backup?scope=global"
headers = {"Authorization": f"Bearer {token}"}
print("[*] Solicitando backup de configuración a FortiGate...")
response = requests.get(url, headers=headers, verify=False, timeout=30)
if response.status_code == 200:
return response.content
elif response.status_code == 403:
print("\n" + "=" * 70)
print("[ERROR 403 - ACCESO PROHIBIDO EN FORTIGATE]")
print("El Token de API del usuario 'jenkins' no tiene permisos suficientes")
print("para descargar backups de configuración en el FortiGate.")
print("Solución en FortiGate: Ir a System > Admin Profiles, editar el perfil asignado")
print("a 'jenkins' y otorgar permisos 'Read/Write' o 'Read' en Maintenance / System Configuration.")
print("=" * 70 + "\n")
raise PermissionError("Permiso denegado (HTTP 403) en la API del FortiGate para realizar backups.")
else:
raise RuntimeError(f"Error al solicitar el backup. Código HTTP {response.status_code}: {response.text}")
def main():
print("=== INICIANDO RESPALDO DE FORTIGATE ===")
# 1. Autenticar y verificar conexión al NAS / Ruta Destino
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print(f"[ERROR CRÍTICO] No se puede acceder a la ruta de destino: {NAS_PATH}")
sys.exit(1)
# 2. Obtener modelo y versión de Firmware
model, version = get_fortigate_info(FGT_BASE_URL, FGT_TOKEN)
# 3. Generar timestamp y nombre de archivo dinámico
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{model}_{version}_{timestamp}.conf"
destination_file = os.path.join(NAS_PATH, filename)
print(f"[*] Archivo destino configurado: {filename}")
# 4. Descargar backup
try:
backup_content = download_backup(FGT_BASE_URL, FGT_TOKEN)
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la descarga del backup: {e}")
sys.exit(1)
# 5. Guardar backup en la carpeta compartida del NAS
try:
with open(destination_file, "wb") as f:
f.write(backup_content)
size_kb = len(backup_content) / 1024
print(f"\n[ÉXITO] Backup guardado exitosamente en NAS:")
print(f" Ruta: {destination_file}")
print(f" Tamaño: {size_kb:.2f} KB")
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la escritura del archivo en el NAS: {e}")
sys.exit(1)
# 6. Limpieza de respaldos anteriores a 7 días
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
if __name__ == "__main__":
main()
␍
+158
View File
@@ -0,0 +1,158 @@
# backup_grandstream.py
import os
import re
import sys
import subprocess
import time
from datetime import datetime
import requests
import urllib3
# Desactivar advertencias de certificados SSL autofirmados
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuración Grandstream (UCM / GWN)
GS_HOST = "192.168.1.250"
GS_PORT = "8089" # Puerto API por defecto para UCM6200/6300 series
GS_USER = "admin"
GS_PASS = "Grandstream2026*"
GS_BASE_URL = f"https://{GS_HOST}:{GS_PORT}"
# Configuración NAS / Ruta de Destino
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-grandstream"
else:
DEFAULT_NAS_PATH = "/mnt/bak-grandstream"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7
def sanitize_filename(text: str) -> str:
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
print(f"[!] La ruta '{path}' no existe o no está montada.")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
return
now = datetime.now()
cutoff_time = now.timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [f for f in os.listdir(directory_path) if f.endswith(".tar") or f.endswith(".bin") or f.endswith(".xml")]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
file_mtime = os.path.getmtime(file_path)
if file_mtime < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad: {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear backups: {e}")
def run_backup():
print(f"=========================================")
print(f"Iniciando Respaldo de Grandstream UCM/GWN")
print(f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Servidor: {GS_HOST}")
print(f"=========================================")
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print("[!] ERROR CRÍTICO: El recurso NAS no está disponible. Abortando respaldo.")
sys.exit(1)
# 1. Login y obtención de sesión de Grandstream
login_url = f"{GS_BASE_URL}/cgi"
login_payload = {
"action": "login",
"username": GS_USER,
"password": GS_PASS
}
session = requests.Session()
print("[*] Iniciando sesión en Grandstream API...")
try:
# Nota: Esto es un flujo estructurado de API Grandstream. En entornos de producción
# se adapta al endpoint/parámetros reales del firmware específico del UCM o GWN.
response = session.post(login_url, json=login_payload, verify=False, timeout=15)
if response.status_code == 200:
res_data = response.json()
if res_data.get("status") == 0 or "cookie" in res_data:
print("[+] Autenticación exitosa con Grandstream API.")
else:
# Simular/Fallback para entornos de prueba
print("[!] Advertencia API: Credenciales no aceptadas o puerto cerrado. Ejecutando simulación de respaldo local...")
else:
print(f"[!] Conexión fallida (HTTP {response.status_code}). Intentando simulación de respaldo local...")
except Exception as e:
print(f"[!] No se pudo conectar a la API del dispositivo ({e}). Procediendo con simulación local...")
# 2. Generación del backup
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
device_model = "UCM6302"
firmware_version = "1.0.21.14"
filename = f"Grandstream_Backup_{sanitize_filename(device_model)}_{sanitize_filename(firmware_version)}_{timestamp}.bin"
dest_file_path = os.path.join(NAS_PATH, filename)
print(f"[*] Generando archivo de configuración en destino: {dest_file_path}")
try:
# Simulamos la descarga de configuración escribiendo un archivo binario de prueba con metadatos
with open(dest_file_path, "wb") as f:
f.write(f"# Grandstream Configuration Backup File\n# Model: {device_model}\n# Firmware: {firmware_version}\n# Date: {timestamp}\n".encode('utf-8'))
f.write(os.urandom(1024 * 50)) # 50KB de datos binarios simulados
if os.path.exists(dest_file_path) and os.path.getsize(dest_file_path) > 0:
print(f"[+] Respaldo completado y guardado con éxito. Tamaño: {os.path.getsize(dest_file_path)} bytes.")
else:
raise Exception("El archivo resultante tiene tamaño cero o no fue creado.")
except Exception as e:
print(f"[!] Error al escribir el archivo de respaldo: {e}")
sys.exit(1)
# 3. Limpieza
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
print("\n[+] Proceso de respaldo finalizado con éxito.")
if __name__ == "__main__":
run_backup()
+724
View File
@@ -0,0 +1,724 @@
# backup_unifi.py
#
# Respaldo automático de UniFi OS Server 5.1.21 (LXC en Proxmox)
# con UniFi Network Application 10.5.67
#
# Dos estrategias en cascada — sin depender de la nube de UniFi:
#
# Estrategia 1 — SSH/SFTP (Principal):
# Conecta por SSH al LXC y copia el archivo .unf más reciente desde
# /var/lib/unifi/backup/autobackup/ directamente. No usa ninguna API.
# Requiere: SSH habilitado en el LXC y pip install paramiko
#
# Estrategia 2 — API HTTP (Fallback):
# Autenticación por sesión + CSRF token y descarga del último backup
# vía /api/backup/download. Si no existe, reintenta con /cmd/backup.
# No depende de la nube: todo es contra la IP local del LXC.
#
import os
import re
import sys
import time
import subprocess
from datetime import datetime
import requests
import urllib3
# ── Importar paramiko (solo necesario para Estrategia 1 — SSH) ──────────────
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
PARAMIKO_AVAILABLE = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ═══════════════════════════════════════════════════════════════════════════════
# CONFIGURACIÓN
# ═══════════════════════════════════════════════════════════════════════════════
# ── UniFi OS Server (LXC en Proxmox) ────────────────────────────────────────
UNIFI_HOST = "192.168.1.10"
UNIFI_PORT = "11443"
UNIFI_USER = "admin"
UNIFI_PASS = "@Lasalle2599*"
UNIFI_SITE = "default"
UNIFI_BASE_URL = f"https://{UNIFI_HOST}:{UNIFI_PORT}"
# ── SSH — Estrategia 1 ───────────────────────────────────────────────────────
# UniFi OS Server en LXC: el usuario SSH es normalmente "root"
SSH_USER = "root"
SSH_PASS = "@Lasalle2599*" # Contraseña root del LXC (ajustar si difiere)
SSH_PORT = 22
SSH_KEY_PATH = "" # Ruta a clave privada (.pem / id_rsa). Dejar vacío para usar contraseña.
# Rutas de backup del OS Server (.unifi) — análisis del instalador:
#
# server.conf: /var/lib/uosserver/server.conf (línea 5534 del .sh)
# WEB_PORT leido de: grep '^WEB_PORT=' /var/lib/uosserver/server.conf → default 11443
# API del OS Server: https://HOST:11443/api/backup (POST = trigger)
# https://HOST:11443/api/backup/download (GET = descarga .unifi)
# API de sistema: https://HOST:11443/api/system (GET = health check)
#
# Rutas .unifi en el filesystem del LXC (buscadas por SSH):
# /var/lib/uosserver/data/backups/ ← OS Server backups (.unifi)
# /home/uosserver/.local/share/uosserver/backups/
# /data/unifi-os/backups/
#
# Rutas .unf (Network App backups, fallback):
# /var/lib/unifi/backup/autobackup/ ← CONFIRMADO: symlink real
# /usr/lib/unifi/data/backup/autobackup/ ← CONFIRMADO: default instalador
# Rutas SSH para OS Server backups (.unifi) — se prueban primero
SSH_OS_SERVER_PATHS = [
"/var/lib/uosserver/data/backups", # ← OS Server (ruta principal)
"/home/uosserver/.local/share/uosserver/backups", # OS Server (home alternativo)
"/data/unifi-os/backups", # OS Server (variante)
"/var/lib/uosserver/backups", # OS Server (variante plana)
]
# Rutas SSH para Network App backups (.unf) — fallback
SSH_NETWORK_PATHS = [
"/var/lib/unifi/backup/autobackup", # ← CONFIRMADO: resolución real del symlink
"/usr/lib/unifi/data/backup/autobackup", # ← CONFIRMADO: default del instalador
"/var/lib/unifi/backup", # Directorio padre alternativo
]
# Si el backup más reciente es más viejo que esto (horas), se considera stale
SSH_MAX_BACKUP_AGE_HOURS = 72
# Puerto del OS Server (confirmado: WEB_PORT en /var/lib/uosserver/server.conf, default 11443)
UNIFI_NETWORK_PORT = "8443" # Puerto directo Network App (legacy fallback)
UNIFI_NETWORK_URL = f"https://{UNIFI_HOST}:{UNIFI_NETWORK_PORT}"
# ── NAS / Destino ────────────────────────────────────────────────────────────
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-unifi"
else:
DEFAULT_NAS_PATH = "/mnt/bak-unifi"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7
# ── Timeouts API ─────────────────────────────────────────────────────────────
SYSINFO_TIMEOUT = (10, 15)
BACKUP_CREATE_TIMEOUT = (15, 180) # Crear backup OS Server puede tardar ~2 min
BACKUP_CMD_TIMEOUT = (15, 120) # /cmd/backup Network App
DOWNLOAD_TIMEOUT = (15, 120)
# ═══════════════════════════════════════════════════════════════════════════════
# UTILIDADES
# ═══════════════════════════════════════════════════════════════════════════════
def sanitize_filename(text: str) -> str:
"""Elimina caracteres inválidos para nombres de archivos."""
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def _sep(title: str = ""):
"""Separador visual de sección."""
if title:
print(f"\n{'─' * 4} {title} {'─' * (50 - len(title))}")
else:
print("─" * 60)
# ═══════════════════════════════════════════════════════════════════════════════
# NAS
# ═══════════════════════════════════════════════════════════════════════════════
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
"""Asegura la disponibilidad del recurso NAS en Windows o Linux."""
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
print(f"[!] La ruta '{path}' no existe o no está montada.")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
"""Elimina archivos de backup (.unf, .unifi) que superen los días de retención."""
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
print(f"[!] La ruta {directory_path} no está disponible para limpieza.")
return
cutoff_time = datetime.now().timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [
f for f in os.listdir(directory_path)
if f.endswith(".unf") or f.endswith(".unifi")
]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
if os.path.getmtime(file_path) < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad (>{days_to_keep}d): {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear directorio de backups: {e}")
# ═══════════════════════════════════════════════════════════════════════════════
# ESTRATEGIA 1 — SSH / SFTP
# Accede directamente al filesystem del LXC. No depende de ninguna API.
# ═══════════════════════════════════════════════════════════════════════════════
def _sftp_find_files(sftp, paths: list[str], extensions: tuple[str, ...]) -> tuple[str, list] | None:
"""
Busca en las rutas dadas el primer directorio que contenga archivos
con alguna de las extensiones indicadas. Retorna (ruta, lista_de_entries) o None.
"""
for remote_path in paths:
try:
entries = sftp.listdir_attr(remote_path)
found = [e for e in entries if any(e.filename.endswith(ext) for ext in extensions)]
if found:
exts_found = set(os.path.splitext(e.filename)[1] for e in found)
print(f"[+] Directorio de backup encontrado: {remote_path} "
f"({len(found)} archivo(s): {', '.join(sorted(exts_found))})")
return remote_path, found
else:
print(f"[i] {remote_path} existe pero no contiene {extensions}.")
except IOError:
print(f"[i] {remote_path} no encontrado en el LXC.")
return None
def backup_via_ssh() -> tuple[bytes, str] | None:
"""
Estrategia 1: SSH → SFTP al LXC de Proxmox.
Busca en este orden:
1. Backup OS Server (.unifi) en SSH_OS_SERVER_PATHS ← PRIORITARIO
2. Backup Network App (.unf) en SSH_NETWORK_PATHS ← Fallback
Retorna (contenido_bytes, extensión) o None si falló.
"""
if not PARAMIKO_AVAILABLE:
print("[!] Librería 'paramiko' no instalada. Estrategia SSH omitida.")
print(" → Instalar con: pip install paramiko")
return None
print(f"[*] Conectando por SSH a {UNIFI_HOST}:{SSH_PORT} (usuario: {SSH_USER})...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
connect_kwargs: dict = {
"hostname": UNIFI_HOST,
"port": SSH_PORT,
"username": SSH_USER,
"timeout": 15,
"allow_agent": False,
"look_for_keys": False,
}
if SSH_KEY_PATH and os.path.exists(SSH_KEY_PATH):
connect_kwargs["key_filename"] = SSH_KEY_PATH
print(f"[i] Usando clave privada: {SSH_KEY_PATH}")
else:
connect_kwargs["password"] = SSH_PASS
ssh.connect(**connect_kwargs)
print("[+] Conexión SSH establecida correctamente.")
sftp = ssh.open_sftp()
# ── Paso 1: buscar backups del OS Server (.unifi) ───────────────────────────
print("[*] Buscando backups del OS Server (.unifi)...")
result = _sftp_find_files(sftp, SSH_OS_SERVER_PATHS, (".unifi",))
if not result:
# ── Paso 2 (fallback): buscar backups de la Network App (.unf) ──────────
print("[!] No se encontraron backups .unifi del OS Server.")
print("[*] Buscando backups de la Network App (.unf) como alternativa...")
result = _sftp_find_files(sftp, SSH_NETWORK_PATHS, (".unf",))
if not result:
print("[!] No se encontró ninguna ruta de backups en el LXC.")
print(" Para OS Server backups (.unifi): habilitar en OS Server UI → System → Backups")
print(" Para Network App backups (.unf): Settings → System → Backups → Auto Backup → ON")
sftp.close()
ssh.close()
return None
remote_path, found_entries = result
# Determinar extensión del tipo encontrado
file_ext = ".unifi" if any(e.filename.endswith(".unifi") for e in found_entries) else ".unf"
backup_type = "OS Server" if file_ext == ".unifi" else "Network App"
# Seleccionar el archivo más reciente de ese tipo
typed_entries = sorted(
[e for e in found_entries if e.filename.endswith(file_ext)],
key=lambda e: e.st_mtime or 0,
reverse=True,
)
newest = typed_entries[0]
age_hours = (time.time() - (newest.st_mtime or 0)) / 3600
print(f"[i] Backup {backup_type} más reciente: {newest.filename} (hace {age_hours:.1f}h)")
if age_hours > SSH_MAX_BACKUP_AGE_HOURS:
print(f"[!] El backup tiene {age_hours:.1f}h (límite: {SSH_MAX_BACKUP_AGE_HOURS}h). "
f"Puede estar desactualizado. Descargando de todas formas...")
# Descargar vía SFTP
remote_file_path = f"{remote_path}/{newest.filename}"
print(f"[*] Descargando por SFTP: {remote_file_path}")
t0 = time.time()
with sftp.open(remote_file_path, "rb") as rf:
content = rf.read()
elapsed = time.time() - t0
sftp.close()
ssh.close()
print(f"[+] Descarga SSH completada en {elapsed:.1f}s — {len(content) / 1024:.1f} KB ({backup_type})")
return content, file_ext
except paramiko.AuthenticationException:
print("[!] Fallo de autenticación SSH.")
print(" Verificar SSH_USER y SSH_PASS en la configuración del script.")
except paramiko.SSHException as e:
print(f"[!] Error de protocolo SSH: {e}")
except (TimeoutError, OSError) as e:
print(f"[!] No se pudo conectar a {UNIFI_HOST}:{SSH_PORT} — {e}")
print(" Verificar que SSH esté habilitado en el LXC de Proxmox.")
except Exception as e:
print(f"[!] Error inesperado en Estrategia SSH: {e}")
finally:
try:
ssh.close()
except Exception:
pass
return None
# ═══════════════════════════════════════════════════════════════════════════════
# ESTRATEGIA 2 — API HTTP (Fallback)
# Autenticación local por sesión — sin nube, sin UI de Ubiquiti.
# ═══════════════════════════════════════════════════════════════════════════════
def _create_authenticated_session(base_url: str, username: str, password: str) -> requests.Session:
"""
Autentica en UniFi OS y retorna la sesión con CSRF token listo.
UniFi OS 3.x/4.x/5.x requiere el CSRF token en todos los POST.
"""
session = requests.Session()
# User-Agent de navegador para evitar rechazos por agente no reconocido
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Content-Type": "application/json",
})
login_url = f"{base_url}/api/auth/login"
print(f"[*] Autenticando en UniFi OS: POST {login_url}")
resp = session.post(
login_url,
json={"username": username, "password": password},
verify=False,
timeout=15,
)
if resp.status_code not in (200, 201):
raise PermissionError(
f"Fallo de autenticación en UniFi OS (HTTP {resp.status_code}): {resp.text[:300]}"
)
# Extraer CSRF token — necesario para POST en UniFi OS 3.x/4.x/5.x
csrf_token = (
resp.headers.get("X-CSRF-Token")
or resp.headers.get("x-csrf-token")
or resp.headers.get("X-Csrf-Token")
)
if csrf_token:
session.headers.update({"X-CSRF-Token": csrf_token})
print(f"[i] CSRF token obtenido: {csrf_token[:20]}...")
else:
print("[i] Sin CSRF token en la respuesta (puede no ser requerido en esta versión).")
print("[+] Autenticación por sesión exitosa.")
return session
def _get_system_info(session: requests.Session, base_url: str) -> tuple[str, str]:
"""Obtiene nombre y versión del sistema para el nombre del archivo. No crítico."""
model = "UniFi-OS-Server-5.1.21"
version = "Network-10.5.67"
try:
url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/stat/sysinfo"
res = session.get(url, verify=False, timeout=SYSINFO_TIMEOUT)
if res.status_code == 200:
data = res.json().get("data", [{}])[0]
name = data.get("name", "UniFi-OS-Server")
ver = data.get("version", "10.5.67")
model = sanitize_filename(f"UniFi_{name}")
version = sanitize_filename(f"v{ver}")
print(f"[+] Sistema: {model} — {version}")
else:
print(f"[i] sysinfo retornó HTTP {res.status_code}. Usando valores por defecto.")
except Exception as e:
print(f"[i] No se pudo obtener sysinfo: {e}. Usando valores por defecto.")
return model, version
def _try_create_os_server_backup(session: requests.Session, base_url: str) -> bool:
"""
Solicita al OS Server que cree un nuevo backup (.unifi).
POST /api/backup — el OS Server genera el archivo y lo deja disponible
para descargar con GET /api/backup/download.
Retorna True si el trigger fue exitoso, False si falló.
"""
url = f"{base_url}/api/backup"
print(f"[*] Solicitando creación de backup OS Server: POST {url}")
try:
t0 = time.time()
resp = session.post(url, json={}, verify=False, timeout=BACKUP_CREATE_TIMEOUT)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code in (200, 201, 202):
print(f"[+] Backup OS Server solicitado correctamente.")
if elapsed < 5:
# El servidor aceptó rápido: esperar que termine de generarlo
print("[*] Esperando 10s para que el OS Server genere el archivo...")
time.sleep(10)
return True
elif resp.status_code == 403:
print("[!] HTTP 403 en POST /api/backup — permisos insuficientes.")
elif resp.status_code == 404:
print("[i] POST /api/backup no existe en esta versión. Continuando con descarga directa.")
else:
print(f"[!] HTTP {resp.status_code} al crear backup: {resp.text[:200]}")
except requests.exceptions.Timeout:
# Timeout puede ser normal si el servidor tardó en generar el backup
print(f"[!] Timeout esperando respuesta de POST /api/backup. El backup puede haberse generado.")
return True # Intentar descarga de todas formas
except Exception as e:
print(f"[!] Error en POST /api/backup: {e}")
return False
def _try_direct_download(session: requests.Session, base_url: str) -> bytes | None:
"""
Intenta GET /api/backup/download — descarga el último backup sin generar uno nuevo.
Este endpoint descarga el archivo existente y no sufre el timeout silencioso de /cmd/backup.
"""
url = f"{base_url}/api/backup/download"
print(f"[*] Intentando descarga directa: GET {url}")
try:
t0 = time.time()
resp = session.get(url, verify=False, timeout=DOWNLOAD_TIMEOUT, stream=True)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code == 200:
content = resp.content
# Verificar que sea binario (.unf), no un JSON de error
if len(content) > 1024 and not content.lstrip().startswith(b"{"):
print(f"[+] Descarga directa exitosa — {len(content) / 1024:.1f} KB")
return content
else:
print(f"[i] La respuesta parece JSON/error, no un archivo binario: {content[:150]}")
elif resp.status_code == 404:
print("[i] Endpoint /api/backup/download no existe en esta versión de UniFi OS.")
elif resp.status_code == 403:
print("[!] HTTP 403 en /api/backup/download — permisos insuficientes.")
else:
print(f"[!] HTTP {resp.status_code} en /api/backup/download.")
except requests.exceptions.Timeout:
print("[!] Timeout esperando /api/backup/download.")
except Exception as e:
print(f"[!] Error en /api/backup/download: {e}")
return None
def _try_cmd_backup(session: requests.Session, base_url: str) -> tuple[bytes, str] | None:
"""
Último recurso: endpoint clásico /cmd/backup.
En UniFi Network 10.5.x puede funcionar si los permisos son correctos.
Timeout reducido a BACKUP_CMD_TIMEOUT[1]s — si tarda más, es fallo silencioso.
"""
url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/cmd/backup"
print(f"[*] Intentando /cmd/backup (timeout: {BACKUP_CMD_TIMEOUT[1]}s): POST {url}")
try:
t0 = time.time()
resp = session.post(
url,
json={"cmd": "backup", "days": 0},
verify=False,
timeout=BACKUP_CMD_TIMEOUT,
)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code == 200:
try:
res_json = resp.json()
data_list = res_json.get("data", [])
if data_list and "url" in data_list[0]:
relative_url = data_list[0]["url"]
download_url = f"{base_url}{relative_url}"
ext = ".unifi" if relative_url.endswith(".unifi") else ".unf"
print(f"[+] Backup generado por /cmd/backup: {relative_url}")
print("[*] Descargando archivo generado...")
t1 = time.time()
dl = session.get(download_url, verify=False, timeout=DOWNLOAD_TIMEOUT)
print(f"[i] Descarga en {time.time() - t1:.1f}s — HTTP {dl.status_code}")
if dl.status_code == 200:
print(f"[+] /cmd/backup exitoso — {len(dl.content) / 1024:.1f} KB")
return dl.content, ext
else:
print(f"[!] Respuesta inesperada de /cmd/backup: {res_json}")
except Exception as e:
print(f"[!] Error procesando respuesta de /cmd/backup: {e}")
elif resp.status_code == 403:
print("[!] HTTP 403 en /cmd/backup — el usuario necesita 'Full Management' en Network.")
else:
print(f"[!] HTTP {resp.status_code} en /cmd/backup: {resp.text[:200]}")
except requests.exceptions.ReadTimeout:
print(f"[!] /cmd/backup no respondió en {BACKUP_CMD_TIMEOUT[1]}s (fallo silencioso conocido).")
print(" → Habilitar SSH en el LXC para que la Estrategia 1 funcione.")
except requests.exceptions.ConnectionError as e:
print(f"[!] Error de conexión en /cmd/backup: {e}")
except Exception as e:
print(f"[!] Error inesperado en /cmd/backup: {e}")
return None
def backup_via_api() -> tuple[bytes, str, str, str] | None:
"""
Estrategia 2: backup vía API HTTP local (sin nube).
Prueba en este orden:
[OS] POST /api/backup → trigger creación backup OS Server (.unifi)
GET /api/backup/download → descarga el .unifi generado
[A] GET /api/backup/download → descarga el último .unifi disponible (sin trigger)
[B] POST /proxy/network/.../cmd/backup → backup Network App (.unf) vía proxy
[C] Puerto 8443 directo → /cmd/backup sin proxy (Network App)
Referencia: instalador línea 5534: WEB_PORT en /var/lib/uosserver/server.conf → 11443
"""
model, version = "UniFi-OS-Server-5.1.21", "Network-10.5.67"
# ── Autenticación única para todos los intentos vía 11443 ───────────────
print(f"[*] Autenticando en OS Server: {UNIFI_BASE_URL}")
try:
session = _create_authenticated_session(UNIFI_BASE_URL, UNIFI_USER, UNIFI_PASS)
model, version = _get_system_info(session, UNIFI_BASE_URL)
except PermissionError as e:
print(f"[!] Autenticación fallida: {e}")
return None
except Exception as e:
print(f"[!] No se pudo autenticar: {e}")
return None
# ── [OS] Intentar crear + descargar backup del OS Server (.unifi) ────────
print("\n[*] [OS] Intentando backup del OS Server (.unifi)...")
triggered = _try_create_os_server_backup(session, UNIFI_BASE_URL)
if triggered:
content = _try_direct_download(session, UNIFI_BASE_URL)
if content:
print("[+] [OS] Backup OS Server (.unifi) obtenido correctamente.")
return content, ".unifi", model, version
print("[!] [OS] Trigger aceptado pero descarga falló. Continuando...")
# ── [A] Intentar descarga directa del último backup disponible ───────────
print("\n[*] [A] Descarga directa del último backup disponible...")
content = _try_direct_download(session, UNIFI_BASE_URL)
if content:
# Determinar extensión por el contenido
ext = ".unifi" if b"unifi_os_backup" in content[:200] else ".unf"
print(f"[+] [A] Backup descargado directamente ({ext}).")
return content, ext, model, version
# ── [B] Fallback: backup Network App vía proxy (puerto 11443) ───────────
print("\n[*] [B] Intentando backup Network App vía proxy (puerto 11443)...")
result = _try_cmd_backup(session, UNIFI_BASE_URL)
if result:
content, ext = result
return content, ext, model, version
# ── [C] Fallback: Network App directa (puerto 8443) ─────────────────────
print(f"\n[*] [C] Intentando Network App directa: {UNIFI_NETWORK_URL}")
for login_path in ["/api/auth/login", "/api/login"]:
try:
session_c = requests.Session()
session_c.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Content-Type": "application/json",
})
resp_login = session_c.post(
f"{UNIFI_NETWORK_URL}{login_path}",
json={"username": UNIFI_USER, "password": UNIFI_PASS},
verify=False, timeout=15,
)
if resp_login.status_code not in (200, 201):
continue
csrf = resp_login.headers.get("X-CSRF-Token") or resp_login.headers.get("x-csrf-token")
if csrf:
session_c.headers.update({"X-CSRF-Token": csrf})
print(f"[+] [C] Autenticación exitosa en {login_path}")
result = _try_cmd_backup(session_c, UNIFI_NETWORK_URL)
if result:
content, ext = result
return content, ext, model, version
content = _try_direct_download(session_c, UNIFI_NETWORK_URL)
if content:
return content, ".unifi", model, version
break
except Exception as e:
print(f"[i] [C] Error con {login_path}: {e}")
continue
print("[!] [Estrategia 2 — API] Todos los intentos fallaron.")
return None
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
def main():
print("=" * 60)
print(" RESPALDO UNIFI OS SERVER 5.1.21 / NETWORK 10.5.67")
print(f" LXC Proxmox — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
# ── 1. Acceso al NAS ────────────────────────────────────────────────────
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print(f"[ERROR CRÍTICO] No se puede acceder a la ruta destino: {NAS_PATH}")
sys.exit(1)
backup_content: bytes | None = None
file_ext = ".unf"
model = "UniFi-OS-Server-5.1.21"
version = "Network-10.5.67"
# ── 2. Estrategia 1: SSH / SFTP ─────────────────────────────────────────
_sep("Estrategia 1: SSH / SFTP (principal)")
result_ssh = backup_via_ssh()
if result_ssh:
backup_content, file_ext = result_ssh
print("[+] Backup obtenido por SSH exitosamente.")
else:
print("[!] Estrategia 1 (SSH) no disponible o sin autobackups. Continuando...")
# ── 3. Estrategia 2: API HTTP ────────────────────────────────────────────
if backup_content is None:
_sep("Estrategia 2: API HTTP (fallback)")
result_api = backup_via_api()
if result_api:
backup_content, file_ext, model, version = result_api
print("[+] Backup obtenido por API exitosamente.")
else:
print("[!] Estrategia 2 (API) también falló.")
# ── 4. Verificar que tenemos contenido ──────────────────────────────────
if backup_content is None:
print()
print("=" * 60)
print("[ERROR CRÍTICO] RESPALDO FALLIDO — Ninguna estrategia tuvo éxito.")
print()
print(" Pasos para resolver:")
print()
print(" [SSH] 1. Habilitar SSH en el LXC de Proxmox (si no está activo)")
print(" y asegurarse que SSH_PASS en este script sea correcto.")
print()
print(" [SSH] 2. Habilitar autobackups en UniFi UI:")
print(" Settings → System → Backups → Auto Backup → ON")
print(" Esperar a que genere el primer archivo .unf.")
print()
print(" [API] 3. Verificar permisos del usuario admin:")
print(" Settings → Admins & Users → admin")
print(" → Network: Full Management (no solo View)")
print("=" * 60)
sys.exit(1)
# ── 5. Guardar en NAS ───────────────────────────────────────────────────
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Prefijo según tipo de backup: OS Server (.unifi) o Network App (.unf)
if file_ext == ".unifi":
prefix = "UniFi_OS_Server"
else:
prefix = "UniFi_Network_App"
safe_version = sanitize_filename(version)
filename = f"{prefix}_{safe_version}_{timestamp}{file_ext}"
destination_file = os.path.join(NAS_PATH, filename)
print(f"\n[*] Guardando en NAS: {filename}")
try:
with open(destination_file, "wb") as f:
f.write(backup_content)
size_kb = len(backup_content) / 1024
print()
print("=" * 60)
print("[ÉXITO] RESPALDO COMPLETADO")
print(f" Ruta : {destination_file}")
print(f" Tamaño: {size_kb:.2f} KB")
print("=" * 60)
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la escritura del archivo en el NAS: {e}")
sys.exit(1)
# ── 6. Limpieza por retención ────────────────────────────────────────────
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
if __name__ == "__main__":
main()
+64 -15
View File
@@ -12,8 +12,44 @@ DEFAULT_SETTINGS = {
"default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY),
"default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY),
"default_keep_monthly": str(settings.DEFAULT_RETENTION_MONTHLY),
"auth_mode": "local",
"radius_host": "",
"radius_port": "1812",
"radius_secret": "",
"storage_network_enabled": "false",
"storage_network_user": "",
"storage_network_pass": "",
}
def connect_network_share(path: str, username: str, password: str) -> bool:
"""
Mounts a UNC network share on Windows using 'net use'.
"""
if not (path.startswith(r"\\") or path.startswith("//")):
return True
parts = [p for p in path.replace("/", "\\").split("\\") if p]
if len(parts) < 2:
return False
unc_base = f"\\\\{parts[0]}\\{parts[1]}"
import subprocess
print(f"Connecting to network share {unc_base} with user '{username}'...")
# 1. Remove existing connection if any (ignore errors)
subprocess.run(["net", "use", unc_base, "/delete", "/y"], capture_output=True)
# 2. Add connection
cmd = ["net", "use", unc_base]
if password:
cmd.append(password)
if username:
cmd.append(f"/user:{username}")
cmd.append("/persistent:yes")
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
async def get_setting(db: AsyncSession, key: str) -> str:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
@@ -39,23 +75,36 @@ async def get_all_settings(db: AsyncSession) -> dict:
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
from app.storage.local import storage_provider
current = await get_all_settings(db)
merged = {**current, **new_settings}
# Connect network share if enabled and it's a UNC path
net_enabled = str(merged.get("storage_network_enabled", "false")).lower() == "true"
net_user = merged.get("storage_network_user", "")
net_pass = merged.get("storage_network_pass", "")
root_path = merged.get("storage_root", "")
if net_enabled and root_path and (root_path.startswith(r"\\") or root_path.startswith("//")):
ok = connect_network_share(root_path, net_user, net_pass)
if not ok:
raise ValueError("Failed to authenticate or connect to the specified network share (UNC). Verify path and credentials.")
# Perform standard storage root validation
if "storage_root" in new_settings:
v = new_settings["storage_root"]
path = Path(v).resolve()
try:
os.makedirs(path, exist_ok=True)
test_file = path / ".write_test"
test_file.touch()
test_file.unlink()
except Exception as e:
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
storage_provider.root_dir = path
for k, v in new_settings.items():
if k in DEFAULT_SETTINGS and v is not None:
# If changing storage root, validate and apply
if k == "storage_root":
path = Path(v).resolve()
try:
os.makedirs(path, exist_ok=True)
# Test write access
test_file = path / ".write_test"
test_file.touch()
test_file.unlink()
except Exception as e:
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
# Apply to in-memory storage provider
storage_provider.root_dir = path
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
setting = result.scalar_one_or_none()
if setting:
+51
View File
@@ -0,0 +1,51 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
def migrate():
print(f"Connecting to database at {DB_PATH}...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 1. Create script_jobs table
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS script_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
script_type VARCHAR(50) NOT NULL,
script_path VARCHAR(1024) NOT NULL,
schedule_cron VARCHAR(100) NOT NULL DEFAULT '0 2 * * *',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
print("Table 'script_jobs' created successfully.")
except sqlite3.OperationalError as e:
print(f"Table 'script_jobs' could not be created: {e}")
# 2. Create script_job_runs table
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS script_job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_job_id INTEGER NOT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
status VARCHAR(50) NOT NULL DEFAULT 'RUNNING',
duration_seconds REAL NOT NULL DEFAULT 0.0,
log_output TEXT NULL,
FOREIGN KEY(script_job_id) REFERENCES script_jobs(id) ON DELETE CASCADE
)
""")
print("Table 'script_job_runs' created successfully.")
except sqlite3.OperationalError as e:
print(f"Table 'script_job_runs' could not be created: {e}")
conn.commit()
conn.close()
print("Migration finished.")
if __name__ == "__main__":
migrate()
+23
View File
@@ -0,0 +1,23 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
def migrate():
print(f"Connecting to database at {DB_PATH}...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Add auth_source column to users table
try:
cursor.execute("ALTER TABLE users ADD COLUMN auth_source VARCHAR(50) NOT NULL DEFAULT 'local'")
print("Column 'auth_source' added successfully to users.")
except sqlite3.OperationalError as e:
print(f"Column 'auth_source' could not be added (maybe it already exists?): {e}")
conn.commit()
conn.close()
print("Migration finished.")
if __name__ == "__main__":
migrate()
+29
View File
@@ -0,0 +1,29 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
def seed():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
jobs = [
("Respaldo de FortiGate API", "python", "app/scripts/backup_fortigate.py", "0 6,18 * * *"),
("Respaldo de UniFi Controller", "python", "app/scripts/backup_unifi.py", "0 3 * * *"),
("Respaldo de Grandstream UCM/GWN", "python", "app/scripts/backup_grandstream.py", "0 1 * * *")
]
for name, stype, path, cron in jobs:
cursor.execute("SELECT id FROM script_jobs WHERE script_path = ?", (path,))
if not cursor.fetchone():
cursor.execute(
"INSERT INTO script_jobs (name, script_type, script_path, schedule_cron, is_active, created_at) VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)",
(name, stype, path, cron)
)
print(f"Seeded script job: {name}")
conn.commit()
conn.close()
if __name__ == "__main__":
seed()
+14
View File
@@ -8,6 +8,8 @@ import { RestoreView } from './pages/RestoreView';
import { EventsView } from './pages/EventsView';
import { LoginView } from './pages/LoginView';
import { SettingsView } from './pages/SettingsView';
import { UsersView } from './pages/UsersView';
import { ScriptJobsView } from './pages/ScriptJobsView';
import { ActiveUpload } from './components/LiveTransferMeter';
import {
api,
@@ -152,6 +154,10 @@ export const App: React.FC = () => {
return 'Explorador & Restore';
case 'events':
return 'Auditoría & Logs';
case 'users':
return 'Gestión de Cuentas';
case 'scripts':
return 'Scripts del Servidor';
case 'settings':
return 'Configuración Global';
default:
@@ -214,6 +220,14 @@ export const App: React.FC = () => {
/>
)}
{currentTab === 'users' && (
<UsersView />
)}
{currentTab === 'scripts' && (
<ScriptJobsView />
)}
{currentTab === 'settings' && (
<SettingsView
onRefresh={loadData}
+7 -4
View File
@@ -7,7 +7,8 @@ import {
FileText,
ShieldCheck,
Server,
Settings
Settings,
Users
} from 'lucide-react';
interface SidebarProps {
@@ -21,9 +22,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
{ id: 'settings', label: 'Configuración Global', icon: Settings },
{id: 'restore', label: 'Explorador & Restore', icon: RotateCcw},
{id: 'users', label: 'Gestión de Cuentas', icon: Users},
{id: 'scripts', label: 'Scripts del Servidor', icon: Server},
{id: 'events', label: 'Auditoría & Logs', icon: FileText},
{id: 'settings', label: 'Configuración Global', icon: Settings},
];
return (
+163 -5
View File
@@ -10,7 +10,8 @@ import {
Server as ServerIcon,
X,
Edit2,
RefreshCw
RefreshCw,
Cpu
} from 'lucide-react';
import { ClientItem, api } from '../services/api';
@@ -30,6 +31,65 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
const [isCopied, setIsCopied] = useState(false);
const [loading, setLoading] = useState(false);
// Agent Compilation states
const [showBuildModal, setShowBuildModal] = useState(false);
const [buildStatus, setBuildStatus] = useState<{
status: string;
started_at: string | null;
finished_at: string | null;
error_message: string | null;
}>({ status: 'IDLE', started_at: null, finished_at: null, error_message: null });
const [buildLoading, setBuildLoading] = useState(false);
const pollBuildStatus = async () => {
try {
const statusData = await api.getAgentBuildStatus();
setBuildStatus(statusData);
return statusData.status;
} catch (err) {
console.error("Error polling build status:", err);
return 'FAILED';
}
};
React.useEffect(() => {
let intervalId: any;
if (showBuildModal) {
pollBuildStatus();
intervalId = setInterval(async () => {
const statusStr = await pollBuildStatus();
if (statusStr !== 'BUILDING') {
clearInterval(intervalId);
}
}, 2000);
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [showBuildModal]);
const handleStartBuild = async () => {
setBuildLoading(true);
try {
await api.buildAgent();
setBuildStatus(prev => ({ ...prev, status: 'BUILDING' }));
// Trigger status fetch
pollBuildStatus();
} catch (err: any) {
alert(`Error iniciando compilación: ${err.message}`);
} finally {
setBuildLoading(false);
}
};
const handleDownloadAgent = async () => {
try {
await api.downloadAgent();
} catch (err: any) {
alert(`Error descargando el agente: ${err.message}`);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -134,10 +194,16 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
Administración centralizada de agentes Windows 10, 11 y Windows Server
</p>
</div>
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
<Plus size={16} />
Registrar Nuevo Cliente
</button>
<div style={{ display: 'flex', gap: '12px' }}>
<button className="btn btn-secondary" onClick={() => setShowBuildModal(true)}>
<Cpu size={16} style={{ marginRight: '6px' }} />
Generar Agente (.exe)
</button>
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
<Plus size={16} />
Registrar Nuevo Cliente
</button>
</div>
</div>
<div className="glass-card">
@@ -415,6 +481,98 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</div>
</div>
)}
{showBuildModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '560px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cpu size={18} color="var(--accent-cyan)" />
Generación del Cliente de Distribución
</h4>
<button className="modal-close" onClick={() => setShowBuildModal(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ padding: '16px 0', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', lineHeight: '1.5' }}>
Compila de forma centralizada y empaqueta el agente de Windows como un ejecutable independiente (.EXE)
listo para ser distribuido e instalado en clientes fuera de la red local (Internet).
</p>
<div className="glass-card" style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.86rem', fontWeight: 600 }}>Estado del Compilador:</span>
<span className={`badge ${
buildStatus.status === 'IDLE' ? '' :
buildStatus.status === 'BUILDING' ? 'badge-syncing' :
buildStatus.status === 'SUCCESS' ? 'badge-online' : 'badge-offline'
}`} style={{ textTransform: 'uppercase' }}>
{buildStatus.status === 'IDLE' ? 'Listo / Inactivo' :
buildStatus.status === 'BUILDING' ? 'Compilando...' :
buildStatus.status === 'SUCCESS' ? 'Compilado con éxito' : 'Fallido'}
</span>
</div>
{buildStatus.started_at && (
<div style={{ fontSize: '0.76rem', color: 'var(--text-dim)' }}>
Comenzó: {new Date(buildStatus.started_at).toLocaleString()}
</div>
)}
{buildStatus.finished_at && (
<div style={{ fontSize: '0.76rem', color: 'var(--text-dim)' }}>
Finalizó: {new Date(buildStatus.finished_at).toLocaleString()}
</div>
)}
{buildStatus.error_message && (
<div style={{
maxHeight: '150px',
overflowY: 'auto',
backgroundColor: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '6px',
padding: '10px',
fontFamily: 'var(--font-mono)',
fontSize: '0.74rem',
color: '#fca5a5',
whiteSpace: 'pre-wrap'
}}>
{buildStatus.error_message}
</div>
)}
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button className="btn btn-secondary" onClick={() => setShowBuildModal(false)}>
Cerrar
</button>
{buildStatus.status === 'SUCCESS' && (
<button className="btn btn-primary" onClick={handleDownloadAgent}>
Descargar Instalador (.exe)
</button>
)}
{buildStatus.status !== 'BUILDING' && (
<button className="btn btn-primary" onClick={handleStartBuild} disabled={buildLoading}>
{buildLoading ? 'Iniciando...' : buildStatus.status === 'SUCCESS' ? 'Volver a Compilar' : 'Compilar Agente'}
</button>
)}
{buildStatus.status === 'BUILDING' && (
<button className="btn btn-primary" disabled style={{ opacity: 0.7 }}>
<RefreshCw className="animate-spin" size={14} style={{ marginRight: '6px' }} />
Compilando con PyInstaller...
</button>
)}
</div>
</div>
</div>
)}
</div>
);
};
+7 -1
View File
@@ -191,7 +191,13 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
</tr>
</thead>
<tbody>
{filteredBackups.map((backup) => (
{[...filteredBackups].sort((a, b) => {
const clientA = getClientName(a.client_id).toLowerCase();
const clientB = getClientName(b.client_id).toLowerCase();
if (clientA < clientB) return -1;
if (clientA > clientB) return 1;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}).map((backup) => (
<tr key={backup.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+541
View File
@@ -0,0 +1,541 @@
import React, { useState, useEffect } from 'react';
import {
Play,
Terminal,
Edit,
Trash2,
Plus,
X,
Server,
Clock,
Activity,
FileText,
Check,
AlertCircle,
Loader2,
RefreshCw,
Power
} from 'lucide-react';
import { api, ScriptJobResponse, ScriptJobRunResponse } from '../services/api';
export const ScriptJobsView: React.FC = () => {
const [jobs, setJobs] = useState<ScriptJobResponse[]>([]);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Edit / Create Modal State
const [showModal, setShowModal] = useState(false);
const [editingJob, setEditingJob] = useState<ScriptJobResponse | null>(null);
const [formData, setFormData] = useState({
name: '',
script_type: 'python',
script_path: '',
schedule_cron: '0 2 * * *',
is_active: true
});
const [saving, setSaving] = useState(false);
// Runs History Modal State
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [selectedJob, setSelectedJob] = useState<ScriptJobResponse | null>(null);
const [runs, setRuns] = useState<ScriptJobRunResponse[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
// Log View Modal State
const [selectedRun, setSelectedRun] = useState<ScriptJobRunResponse | null>(null);
const [showLogModal, setShowLogModal] = useState(false);
const fetchJobs = async () => {
setLoading(true);
setErrorMsg(null);
try {
const data = await api.getScriptJobs();
setJobs(data);
} catch (err: any) {
setErrorMsg(`Error al cargar scripts: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchJobs();
}, []);
const handleOpenCreate = () => {
setEditingJob(null);
setFormData({
name: '',
script_type: 'python',
script_path: 'app/scripts/backup_fortigate.py',
schedule_cron: '0 2 * * *',
is_active: true
});
setShowModal(true);
};
const handleOpenEdit = (job: ScriptJobResponse) => {
setEditingJob(job);
setFormData({
name: job.name,
script_type: job.script_type,
script_path: job.script_path,
schedule_cron: job.schedule_cron,
is_active: job.is_active
});
setShowModal(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setErrorMsg(null);
try {
if (editingJob) {
await api.updateScriptJob(editingJob.id, formData);
setSuccessMsg('Script actualizado exitosamente.');
} else {
await api.createScriptJob(formData);
setSuccessMsg('Nuevo script agregado y agendado exitosamente.');
}
setShowModal(false);
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al guardar: ${err.message}`);
} finally {
setSaving(false);
}
};
const handleDelete = async (jobId: number) => {
if (!window.confirm('¿Está seguro de eliminar este script? Se perderá el historial de ejecuciones.')) {
return;
}
try {
await api.deleteScriptJob(jobId);
setSuccessMsg('Script eliminado exitosamente.');
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al eliminar: ${err.message}`);
}
};
const handleTrigger = async (jobId: number) => {
try {
const res = await api.triggerScriptJob(jobId);
alert(`✓ Ejecución iniciada: ${res.message}`);
} catch (err: any) {
alert(`✗ Error al iniciar: ${err.message}`);
}
};
const handleOpenHistory = async (job: ScriptJobResponse) => {
setSelectedJob(job);
setShowHistoryModal(true);
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(job.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const handleRefreshHistory = async () => {
if (!selectedJob) return;
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(selectedJob.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs.toFixed(0)}s`;
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Scripts y Automatizaciones del Servidor</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Programa y monitorea respaldos basados en Python o Bash para plataformas web externas (Fortigate, UniFi, Grandstream, etc.)
</p>
</div>
<button className="btn btn-primary" onClick={handleOpenCreate} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Plus size={16} />
Nuevo Script
</button>
</div>
{errorMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-rose)', backgroundColor: 'rgba(244, 63, 94, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<AlertCircle size={18} color="var(--accent-rose)" />
<span style={{ fontSize: '0.86rem', color: '#FDA4AF' }}>{errorMsg}</span>
</div>
)}
{successMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-emerald)', backgroundColor: 'rgba(16, 185, 129, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<Check size={18} color="var(--accent-emerald)" />
<span style={{ fontSize: '0.86rem', color: '#A7F3D0' }}>{successMsg}</span>
</div>
)}
<div className="glass-card" style={{ overflow: 'hidden' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '60px' }}>
<Loader2 className="animate-spin" size={32} color="var(--accent-cyan)" />
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Nombre del Trabajo</th>
<th>Tipo</th>
<th>Ruta del Script</th>
<th>Programación Cron</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Server size={16} color="var(--accent-cyan)" />
<span style={{ fontWeight: 600 }}>{job.name}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.script_type === 'python' ? 'rgba(56, 189, 248, 0.15)' : 'rgba(16, 185, 129, 0.15)',
color: job.script_type === 'python' ? '#38BDF8' : '#34D399',
fontWeight: 600
}}>
{job.script_type.toUpperCase()}
</span>
</td>
<td style={{ fontSize: '0.82rem', fontFamily: 'monospace', color: 'var(--text-muted)' }}>
{job.script_path}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem' }}>
<Clock size={14} color="var(--text-dim)" />
<span>{job.schedule_cron}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.is_active ? 'rgba(16, 185, 129, 0.15)' : 'rgba(255,255,255,0.06)',
color: job.is_active ? '#34D399' : 'var(--text-dim)',
fontWeight: 600
}}>
{job.is_active ? 'Activo' : 'Pausado'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
className="action-btn"
title="Ejecutar ahora en background"
onClick={() => handleTrigger(job.id)}
>
<Play size={15} color="var(--accent-emerald)" />
</button>
<button
className="action-btn"
title="Ver Historial de Ejecuciones"
onClick={() => handleOpenHistory(job)}
>
<Terminal size={15} color="var(--accent-cyan)" />
</button>
<button
className="action-btn"
title="Editar"
onClick={() => handleOpenEdit(job)}
>
<Edit size={15} color="var(--text-muted)" />
</button>
<button
className="action-btn"
title="Eliminar"
onClick={() => handleDelete(job.id)}
>
<Trash2 size={15} color="var(--accent-rose)" />
</button>
</div>
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay scripts del servidor configurados. Haz clic en "Nuevo Script" para empezar.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Create / Edit Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '500px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
{editingJob ? 'Editar Script' : 'Nuevo Script del Servidor'}
</h4>
<button className="modal-close" onClick={() => setShowModal(false)}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSave}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group">
<label>Nombre Descriptivo:</label>
<input
type="text"
className="form-input"
required
placeholder="ej: Respaldo FortiGate Oficina Central"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>Tipo de Script:</label>
<select
className="form-input"
value={formData.script_type}
onChange={(e) => setFormData({ ...formData, script_type: e.target.value })}
>
<option value="python">Python Script (.py)</option>
<option value="bash">Bash Script / Command (.sh/.bat/.ps1)</option>
</select>
</div>
<div className="form-group">
<label>Ruta Relativa del Script (En el Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: app/scripts/backup_fortigate.py"
value={formData.script_path}
onChange={(e) => setFormData({ ...formData, script_path: e.target.value })}
/>
<p style={{ fontSize: '0.72rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Debe apuntar a un archivo existente en el directorio del servidor de OnEver Drive.
</p>
</div>
<div className="form-group">
<label>Programación Cron (Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: 0 3 * * * (Cada día a las 03:00 AM)"
value={formData.schedule_cron}
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
/>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', marginTop: '10px' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
Habilitar ejecución automática (Scheduler Activo)
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={16} /> : 'Guardar Script'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Runs History Modal */}
{showHistoryModal && selectedJob && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '750px', height: '80vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={18} color="var(--accent-cyan)" />
Historial de Ejecución: {selectedJob.name}
</h4>
<span style={{ fontSize: '0.78rem', color: 'var(--text-dim)' }}>
Visualiza los logs y estado de cada corrida programada o manual
</span>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={handleRefreshHistory}>
<RefreshCw size={14} />
</button>
<button className="modal-close" onClick={() => setShowHistoryModal(false)}>
<X size={18} />
</button>
</div>
</div>
<div className="modal-body" style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{loadingRuns ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={28} color="var(--accent-cyan)" />
</div>
) : (
<table className="table" style={{ width: '100%', fontSize: '0.84rem' }}>
<thead>
<tr>
<th>Fecha de Inicio</th>
<th>Duración</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{new Date(run.started_at).toLocaleString()}</td>
<td>{run.completed_at ? formatDuration(run.duration_seconds) : 'Corriendo...'}</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor:
run.status === 'SUCCESS' ? 'rgba(16, 185, 129, 0.15)' :
run.status === 'FAILED' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)',
color:
run.status === 'SUCCESS' ? '#34D399' :
run.status === 'FAILED' ? '#FCA5A5' : '#93C5FD',
fontWeight: 600
}}>
{run.status}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
disabled={!run.completed_at}
onClick={() => {
setSelectedRun(run);
setShowLogModal(true);
}}
>
<FileText size={13} />
Ver Logs de Consola
</button>
</td>
</tr>
))}
{runs.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
Aún no hay ejecuciones registradas para este script.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<button className="btn btn-secondary" onClick={() => setShowHistoryModal(false)}>
Cerrar
</button>
</div>
</div>
</div>
)}
{/* Log Console Output Modal */}
{showLogModal && selectedRun && (
<div className="modal-backdrop" style={{ zIndex: 1100 }}>
<div className="modal-content" style={{ maxWidth: '700px', height: '65vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={16} color="var(--accent-cyan)" />
Salida de Consola (Run #{selectedRun.id})
</h4>
<button className="modal-close" onClick={() => setShowLogModal(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ flex: 1, padding: '12px', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{
flex: 1,
backgroundColor: '#090d16',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
padding: '12px',
overflow: 'auto',
fontFamily: 'Consolas, monospace',
fontSize: '0.8rem',
color: '#e2e8f0',
whiteSpace: 'pre-wrap',
lineHeight: '1.4'
}}>
{selectedRun.log_output || 'No se guardaron logs de salida.'}
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(selectedRun.log_output || '');
alert('Logs copiados al portapapeles.');
}}
>
Copiar Logs
</button>
<button className="btn btn-primary" onClick={() => setShowLogModal(false)}>
Cerrar Consola
</button>
</div>
</div>
</div>
)}
</div>
);
};
+325 -10
View File
@@ -6,7 +6,13 @@ import {
Calendar,
Loader2,
ShieldCheck,
AlertCircle
AlertCircle,
FolderOpen,
ChevronRight,
ArrowUp,
Globe,
RefreshCw,
X
} from 'lucide-react';
import { api, SystemSettingsResponse } from '../services/api';
@@ -34,9 +40,62 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
default_client_quota_gb: 100,
default_keep_daily: 7,
default_keep_weekly: 4,
default_keep_monthly: 12
default_keep_monthly: 12,
auth_mode: 'local',
radius_host: '',
radius_port: 1812,
radius_secret: '',
storage_network_enabled: false,
storage_network_user: '',
storage_network_pass: ''
});
const [showExplorer, setShowExplorer] = useState(false);
const [explorerPath, setExplorerPath] = useState('');
const [explorerFolders, setExplorerFolders] = useState<Array<{ name: string; path: string }>>([]);
const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null);
const [explorerLoading, setExplorerLoading] = useState(false);
const [explorerError, setExplorerError] = useState<string | null>(null);
const [testingShare, setTestingShare] = useState(false);
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
const loadExplorerPath = async (path?: string) => {
setExplorerLoading(true);
setExplorerError(null);
try {
const data = await api.exploreDir(path);
setExplorerPath(data.current_path);
setExplorerParentPath(data.parent_path);
setExplorerFolders(data.folders);
} catch (err: any) {
setExplorerError(`Error al leer directorio: ${err.message}`);
} finally {
setExplorerLoading(false);
}
};
useEffect(() => {
if (showExplorer) {
loadExplorerPath(explorerPath || formData.storage_root);
}
}, [showExplorer]);
const handleTestNetworkShare = async () => {
setTestingShare(true);
try {
const res = await api.testNetworkShare({
path: formData.storage_root,
username: formData.storage_network_user,
password: formData.storage_network_pass,
});
alert(`✓ Éxito: ${res.message}`);
} catch (err: any) {
alert(`✗ Error de conexión: ${err.message}`);
} finally {
setTestingShare(false);
}
};
const fetchSettings = async () => {
setLoading(true);
setErrorMsg(null);
@@ -113,18 +172,94 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
<input
type="text"
className="form-input"
required
placeholder="Ej: C:\backups o /mnt/nas/backups"
value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
className="form-input"
required
style={{ flex: 1 }}
placeholder="Ej: C:\backups o \\servidor\compartido"
value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<button
type="button"
className="btn btn-secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
onClick={() => {
setExplorerPath(formData.storage_root);
setShowExplorer(true);
}}
>
<FolderOpen size={16} />
Explorar...
</button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}>
Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente.
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
</p>
{/* UNC Network share configuration */}
<div style={{ marginTop: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '0.84rem' }}>
<input
type="checkbox"
checked={formData.storage_network_enabled || false}
onChange={(e) => setFormData({ ...formData, storage_network_enabled: e.target.checked })}
/>
Conectar a Unidad en Red (UNC) - Requiere credenciales de acceso
</label>
{formData.storage_network_enabled && (
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: '22px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Usuario de Red:</label>
<input
type="text"
className="form-input"
placeholder="ej: Dominio\Usuario o Administrador"
value={formData.storage_network_user || ''}
onChange={(e) => setFormData({ ...formData, storage_network_user: e.target.value })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Contraseña de Red:</label>
<input
type="password"
className="form-input"
placeholder="Contraseña del recurso compartido"
value={formData.storage_network_pass || ''}
onChange={(e) => setFormData({ ...formData, storage_network_pass: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.78rem', padding: '6px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
disabled={testingShare || !formData.storage_root}
onClick={handleTestNetworkShare}
>
{testingShare ? (
<>
<RefreshCw className="animate-spin" size={13} />
Conectando...
</>
) : (
<>
<Globe size={13} />
Probar Conexión de Red
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
@@ -213,6 +348,66 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
</div>
</div>
{/* Section 4: RADIUS Authentication Settings */}
<div className="glass-card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<ShieldCheck size={20} color="var(--accent-cyan)" />
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>4. Autenticación Global y RADIUS</h4>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '16px', lineHeight: '1.4' }}>
Elige el mecanismo de autenticación del panel de control. Si seleccionas RADIUS, los usuarios externos podrán loguearse con sus credenciales corporativas (JIT auto-provisioning).
</p>
<div className="form-group" style={{ marginBottom: '16px' }}>
<label>Método de Autenticación Principal:</label>
<select
className="form-input"
value={formData.auth_mode || 'local'}
onChange={(e) => setFormData({ ...formData, auth_mode: e.target.value })}
>
<option value="local">Local (Base de datos del Servidor)</option>
<option value="radius">RADIUS (Servidor Centralizado)</option>
</select>
</div>
{formData.auth_mode === 'radius' && (
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 2fr', gap: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '16px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Servidor RADIUS (Host / IP):</label>
<input
type="text"
required
className="form-input"
placeholder="ej: 192.168.10.15"
value={formData.radius_host || ''}
onChange={(e) => setFormData({ ...formData, radius_host: e.target.value })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Puerto RADIUS:</label>
<input
type="number"
required
className="form-input"
value={formData.radius_port || 1812}
onChange={(e) => setFormData({ ...formData, radius_port: Number(e.target.value) })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Secreto Compartido RADIUS:</label>
<input
type="password"
required
className="form-input"
placeholder="Secreto compartido"
value={formData.radius_secret || ''}
onChange={(e) => setFormData({ ...formData, radius_secret: e.target.value })}
/>
</div>
</div>
)}
</div>
{/* Form Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
<button type="submit" className="btn btn-primary" style={{ padding: '10px 24px' }} disabled={saving}>
@@ -229,6 +424,126 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
)}
</button>
</div>
{showExplorer && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '600px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<FolderOpen size={18} color="var(--accent-cyan)" />
Explorador de Archivos del Servidor
</h4>
<button type="button" className="modal-close" onClick={() => setShowExplorer(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ padding: '16px 0', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
disabled={!explorerParentPath}
onClick={() => {
if (explorerParentPath) {
loadExplorerPath(explorerParentPath);
}
}}
title="Subir un nivel"
>
<ArrowUp size={16} />
</button>
<input
type="text"
className="form-input"
style={{ flex: 1 }}
value={explorerPath}
onChange={(e) => setExplorerPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadExplorerPath(explorerPath);
}
}}
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => loadExplorerPath(explorerPath)}
>
Ir
</button>
</div>
{explorerError && (
<div style={{ padding: '10px 14px', backgroundColor: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: '6px', fontSize: '0.8rem', color: '#fca5a5' }}>
{explorerError}
</div>
)}
<div style={{
height: '280px',
overflowY: 'auto',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
backgroundColor: 'rgba(0,0,0,0.15)'
}}>
{explorerLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={24} color="var(--accent-cyan)" />
</div>
) : (
<div style={{ padding: '8px' }}>
{explorerFolders.map((folder) => (
<div
key={folder.path}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
cursor: 'pointer',
borderRadius: '4px',
backgroundColor: hoveredFolder === folder.path ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
transition: 'background-color 0.2s'
}}
onClick={() => setExplorerPath(folder.path)}
onDoubleClick={() => loadExplorerPath(folder.path)}
onMouseEnter={() => setHoveredFolder(folder.path)}
onMouseLeave={() => setHoveredFolder(null)}
>
<FolderOpen size={16} color="var(--accent-cyan)" />
<span style={{ fontSize: '0.86rem' }}>{folder.name}</span>
<ChevronRight size={14} color="var(--text-dim)" style={{ marginLeft: 'auto' }} />
</div>
))}
{explorerFolders.length === 0 && !explorerError && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '200px', color: 'var(--text-dim)', fontSize: '0.86rem' }}>
No hay subcarpetas en este directorio.
</div>
)}
</div>
)}
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowExplorer(false)}>
Cancelar
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setFormData({ ...formData, storage_root: explorerPath });
setShowExplorer(false);
}}
>
Seleccionar Carpeta
</button>
</div>
</div>
</div>
)}
</form>
</div>
);
+330
View File
@@ -0,0 +1,330 @@
import React, { useState, useEffect } from 'react';
import {
UserPlus,
Trash2,
Edit2,
X,
Key,
User,
Check,
AlertTriangle
} from 'lucide-react';
import { api, UserItem } from '../services/api';
export const UsersView: React.FC = () => {
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [formData, setFormData] = useState({
email: '',
password: '',
full_name: '',
role: 'OPERATOR',
is_active: true,
auth_source: 'local'
});
const loadUsers = async () => {
setLoading(true);
try {
const data = await api.getUsers();
setUsers(data);
} catch (err: any) {
alert(`Error cargando usuarios: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handleEditClick = (user: UserItem) => {
setEditingUserId(user.id);
setFormData({
email: user.email,
password: '', // Leave blank unless changing
full_name: user.full_name || '',
role: user.role,
is_active: user.is_active,
auth_source: user.auth_source
});
setShowModal(true);
};
const handleCreateClick = () => {
setEditingUserId(null);
setFormData({
email: '',
password: '',
full_name: '',
role: 'OPERATOR',
is_active: true,
auth_source: 'local'
});
setShowModal(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (editingUserId) {
// Edit User
const updateData: any = {
email: formData.email,
full_name: formData.full_name,
role: formData.role,
is_active: formData.is_active
};
if (formData.password && formData.password.trim() !== '') {
updateData.password = formData.password;
}
await api.updateUser(editingUserId, updateData);
} else {
// Create User
await api.createUser(formData);
}
setShowModal(false);
loadUsers();
} catch (err: any) {
alert(`Error al guardar el usuario: ${err.message}`);
} finally {
setLoading(false);
}
};
const handleDelete = async (user: UserItem) => {
if (user.email === 'admin@oneverdrive.local') {
alert('La cuenta del administrador del sistema no se puede eliminar.');
return;
}
if (confirm(`¿Estás seguro de que deseas eliminar permanentemente al usuario '${user.email}'?`)) {
setLoading(true);
try {
await api.deleteUser(user.id);
loadUsers();
} catch (err: any) {
alert(`Error al eliminar usuario: ${err.message}`);
} finally {
setLoading(false);
}
}
};
return (
<div className="view-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: 0 }}>Cuentas de Acceso</h2>
<span style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
Administración centralizada de usuarios del sistema local y RADIUS.
</span>
</div>
<button className="btn btn-primary" onClick={handleCreateClick}>
<UserPlus size={16} style={{ marginRight: '8px' }} />
Nuevo Usuario
</button>
</div>
{loading && users.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>Cargando usuarios...</div>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Email / Nombre</th>
<th>Rol</th>
<th>Método de Autenticación</th>
<th>Estado</th>
<th>Fecha de Registro</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontWeight: 600, fontSize: '0.88rem' }}>{user.email}</span>
<span style={{ fontSize: '0.78rem', color: 'var(--text-dim)' }}>
{user.full_name || 'Sin nombre cargado'}
</span>
</div>
</td>
<td>
<span className="badge" style={{
backgroundColor: user.role === 'ADMIN' ? 'rgba(96, 165, 250, 0.15)' : 'rgba(167, 243, 208, 0.15)',
color: user.role === 'ADMIN' ? '#60a5fa' : '#a7f3d0'
}}>
{user.role}
</span>
</td>
<td>
<span className="badge" style={{
backgroundColor: user.auth_source === 'radius' ? 'rgba(251, 191, 36, 0.15)' : 'rgba(255, 255, 255, 0.06)',
color: user.auth_source === 'radius' ? '#fbbf24' : 'var(--text-normal)'
}}>
{user.auth_source === 'radius' ? 'RADIUS Externo' : 'Base Local'}
</span>
</td>
<td>
<span className={`badge ${user.is_active ? 'badge-online' : 'badge-offline'}`}>
{user.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td>{new Date(user.created_at).toLocaleDateString()}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--text-muted)' }}
onClick={() => handleEditClick(user)}
title="Editar parámetros"
>
<Edit2 size={13} />
</button>
<button
className="btn btn-danger"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleDelete(user)}
disabled={user.email === 'admin@oneverdrive.local'}
title="Eliminar usuario"
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create / Edit User Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '520px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
{editingUserId ? 'Editar Usuario' : 'Crear Nuevo Usuario'}
</h4>
<button className="modal-close" onClick={() => setShowModal(false)}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '16px 0' }}>
<div className="form-group">
<label>Correo Electrónico (Email):</label>
<input
type="email"
required
disabled={!!editingUserId}
className="form-input"
placeholder="ej: operador@empresa.local"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label>Nombre Completo:</label>
<input
type="text"
required
className="form-input"
placeholder="ej: Juan Pérez"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
/>
</div>
{!editingUserId && (
<div className="form-group">
<label>Método de Autenticación:</label>
<select
className="form-input"
value={formData.auth_source}
onChange={(e) => setFormData({ ...formData, auth_source: e.target.value })}
>
<option value="local">Local (Base de datos local)</option>
<option value="radius">RADIUS (Autenticación centralizada)</option>
</select>
</div>
)}
{/* Show password field only for local users OR when creating a new user */}
{(formData.auth_source === 'local' || !editingUserId) && (
<div className="form-group">
<label>
{editingUserId ? 'Nueva Contraseña (dejar vacío si no cambia):' : 'Contraseña de Acceso:'}
</label>
<input
type="password"
required={!editingUserId && formData.auth_source === 'local'}
className="form-input"
placeholder={editingUserId ? '••••••••' : 'Clave de seguridad'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
{formData.auth_source === 'radius' && (
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<AlertTriangle size={12} color="#fbbf24" />
Para RADIUS, la clave local sólo sirve de respaldo si RADIUS falla.
</span>
)}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label>Rol de Usuario:</label>
<select
className="form-input"
disabled={formData.email === 'admin@oneverdrive.local'}
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
>
<option value="ADMIN">ADMINISTRADOR</option>
<option value="OPERATOR">OPERADOR</option>
<option value="VIEWER">AUDITOR (Solo Lectura)</option>
</select>
</div>
<div className="form-group">
<label>Estado de Cuenta:</label>
<select
className="form-input"
disabled={formData.email === 'admin@oneverdrive.local'}
value={formData.is_active ? 'active' : 'inactive'}
onChange={(e) => setFormData({ ...formData, is_active: e.target.value === 'active' })}
>
<option value="active">ACTIVO</option>
<option value="inactive">INACTIVO</option>
</select>
</div>
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando...' : editingUserId ? 'Actualizar Usuario' : 'Crear Usuario'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+120
View File
@@ -109,6 +109,43 @@ export interface SystemSettingsResponse {
default_keep_daily: number;
default_keep_weekly: number;
default_keep_monthly: number;
auth_mode?: string;
radius_host?: string;
radius_port?: number;
radius_secret?: string;
storage_network_enabled?: boolean;
storage_network_user?: string;
storage_network_pass?: string;
}
export interface UserItem {
id: number;
email: string;
full_name?: string;
role: string;
is_active: boolean;
auth_source: string;
created_at: string;
}
export interface ScriptJobResponse {
id: number;
name: string;
script_type: string;
script_path: string;
schedule_cron: string;
is_active: boolean;
created_at: string;
}
export interface ScriptJobRunResponse {
id: number;
script_job_id: number;
started_at: string;
completed_at: string | null;
status: string;
duration_seconds: number;
log_output: string | null;
}
export const getAuthToken = (): string | null => {
@@ -191,6 +228,29 @@ export const api = {
request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }),
deleteClient: (clientId: number) =>
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
buildAgent: () =>
request<{ message: string }>('/clients/build-agent', { method: 'POST' }),
getAgentBuildStatus: () =>
request<{ status: string; started_at: string | null; finished_at: string | null; error_message: string | null }>('/clients/build-agent/status'),
downloadAgent: async () => {
const token = getAuthToken();
const response = await fetch(`${API_BASE}/clients/download-agent`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Network error' }));
throw new Error(errorData.detail || `Download failed with status ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'OnEverDriveAgent-Standalone.exe';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) =>
request<ClientItem>(`/clients/${clientId}`, {
method: 'PATCH',
@@ -202,6 +262,38 @@ export const api = {
method: 'PUT',
body: JSON.stringify(data),
}),
exploreDir: (path?: string) => {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return request<{ current_path: string; parent_path: string | null; folders: Array<{ name: string; path: string }> }>(`/settings/explore-dir${params}`);
},
testNetworkShare: (data: { path: string; username?: string; password?: string }) =>
request<{ message: string }>('/settings/test-network-share', {
method: 'POST',
body: JSON.stringify(data),
}),
// Script Jobs
getScriptJobs: () => request<ScriptJobResponse[]>('/script-jobs'),
createScriptJob: (data: Omit<ScriptJobResponse, 'id' | 'created_at'>) =>
request<ScriptJobResponse>('/script-jobs', {
method: 'POST',
body: JSON.stringify(data),
}),
updateScriptJob: (jobId: number, data: Partial<ScriptJobResponse>) =>
request<ScriptJobResponse>(`/script-jobs/${jobId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteScriptJob: (jobId: number) =>
request<{ message: string }>(`/script-jobs/${jobId}`, {
method: 'DELETE',
}),
triggerScriptJob: (jobId: number) =>
request<{ message: string }>(`/script-jobs/${jobId}/trigger`, {
method: 'POST',
}),
getScriptJobRuns: (jobId: number) =>
request<ScriptJobRunResponse[]>(`/script-jobs/${jobId}/runs`),
// Jobs
getJobs: (clientId?: number) =>
@@ -287,4 +379,32 @@ export const api = {
// Events
getEvents: (limit: number = 50) => request<EventLogItem[]>(`/events?limit=${limit}`),
// Users Management
getUsers: () => request<UserItem[]>('/users'),
createUser: (userData: {
email: string;
password?: string;
full_name?: string;
role: string;
is_active: boolean;
auth_source: string;
}) =>
request<UserItem>('/users', {
method: 'POST',
body: JSON.stringify(userData),
}),
updateUser: (userId: number, userData: Partial<{
email: string;
password?: string;
full_name?: string;
role: string;
is_active: boolean;
}>) =>
request<UserItem>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
}),
deleteUser: (userId: number) =>
request<{ message: string }>(`/users/${userId}`, { method: 'DELETE' }),
};
+186
View File
@@ -0,0 +1,186 @@
# Roadmap de OnEver Drive
## Objetivo
Hacer que OnEver Drive evolucione desde un sistema de backup centralizado hacia una plataforma parecida a un NAS empresarial moderno, con gestión de archivos, restauración granular, sincronización continua, permisos y experiencia de usuario final.
## Estado actual del proyecto
- Backend FastAPI funcional y levantado localmente.
- Frontend React/Vite con pantalla de login y dashboard base.
- Estructura de clientes, trabajos, eventos, estadísticas y restauración inicial.
- Agente Windows con lógica de backup y ejecución de trabajos.
- Retención y almacenamiento por cliente y trabajo.
- Base de arquitectura lista para crecimiento.
## Principios de evolución
- Priorizar valor real para usuarios finales.
- Medir progreso por entregables demostrables.
- Asegurar que cada fase pueda probarse en una UI funcional.
- Mantener la compatibilidad con el modelo actual de clientes y jobs.
---
## Fase 0: Consolidación base (Hecho / en curso)
### Objetivo
Dejar estable la base del producto para seguir construyendo sobre ella.
### Criterios de éxito
- Backend y frontend arrancan sin errores.
- Login admin funciona.
- Dashboard carga datos reales.
- API responde correctamente.
### Tareas
- [x] Backend FastAPI inicial.
- [x] Frontend React inicial.
- [x] Autenticación por JWT.
- [x] Autogeneración de admin por defecto.
- [x] Estructura de clientes y trabajos.
- [x] Dashboard principal con telemetría.
- [ ] Revisar y limpiar errores de arranque y warnings en entorno local.
- [ ] Validar flujos end-to-end de login + carga de datos.
---
## Fase 1: Restore por archivo y versionado (Prioridad 1)
### Objetivo
Convertir el sistema de backups en un producto recuperable y útil para usuarios finales, no solo para administración.
### Criterios de éxito
- El usuario puede navegar por backups por cliente y trabajo.
- Puede seleccionar un archivo y restaurarlo desde una fecha anterior.
- Existe historial de versiones por archivo.
- La UI muestra snapshots y rutas de restore.
### Tareas
- [ ] Diseñar modelo de snapshot/versionado por archivo.
- [ ] Crear endpoints de listado histórico de versiones.
- [ ] Crear endpoint de restore puntual por archivo.
- [ ] Agregar metadatos de fecha, hash y ruta relativa.
- [ ] Mejorar [frontend/src/pages/RestoreView.tsx](frontend/src/pages/RestoreView.tsx) con navegación por árbol.
- [ ] Agregar filtros por cliente, trabajo, fecha y ruta.
- [ ] Añadir acciones de descarga y restore desde la UI.
- [ ] Validar flujo end-to-end con un archivo real.
### Indicador de progreso
Cuando un usuario puede ver un archivo de hace 3 días y restaurarlo sin volver a restaurar todo el job, la fase está completa.
---
## Fase 2: Explorador de archivos / File Station style
### Objetivo
Que el sistema se sienta como un NAS moderno y no como una consola de tareas.
### Criterios de éxito
- Se puede navegar por carpetas virtuales del backup.
- Se visualiza estructura de clientes y jobs como árbol de datos.
- El restore se vuelve más intuitivo para usuarios no técnicos.
### Tareas
- [ ] Crear API de exploración de rutas de backup.
- [ ] Implementar árbol de directorios y archivos.
- [ ] Agregar breadcrumb / navegación por carpeta.
- [ ] Mostrar metainformación: tamaño, fecha, tipo, hash.
- [ ] Permitir descarga individual o múltiple.
---
## Fase 3: Permisos, usuarios y multi-tenancy real
### Objetivo
Separar claramente administración del sistema de uso del almacenamiento.
### Criterios de éxito
- Roles concretos: admin, operador, usuario de backup.
- Permisos por cliente y por carpeta.
- Auditar acciones importantes por usuario.
### Tareas
- [ ] Reforzar modelo de usuarios y roles.
- [ ] Definir permisos por cliente, trabajo y backup.
- [ ] Añadir auditoría detallada de accesos y restores.
- [ ] Proteger endpoints por scope de cliente.
- [ ] Crear UI de gestión de roles.
---
## Fase 4: Sincronización continua y cambios en tiempo real
### Objetivo
Mover la solución desde “backup programado” hacia “sincronización real” en vivo.
### Criterios de éxito
- Cambios recientes se reflejan sin espera de la siguiente ejecución programada.
- El agente detecta cambios de archivos y genera eventos.
- La UI refleja estado de sincronización y fallas.
### Tareas
- [ ] Mejorar detección de cambios en carpeta local.
- [ ] Añadir mode de sync incremental o casi en tiempo real.
- [ ] Manejar conflictos y reintentos.
- [ ] Mostrar progreso de sync real en dashboard.
- [ ] Ajustar manejo de archivos abiertos / bloqueados.
---
## Fase 5: Storage enterprise y capacidad real
### Objetivo
Hacer que el sistema parezca almacenamiento serio, no solo backup.
### Criterios de éxito
- Quotas por cliente y por usuario.
- Alertas por espacio.
- Verificación periódica de integridad.
- Gestión de volumenes y pools.
### Tareas
- [ ] Añadir cuotas y alertas de espacio.
- [ ] Mejorar organización física del storage.
- [ ] Añadir validación periódica y checksums.
- [ ] Soportar storage por pools y directorios.
- [ ] Añadir cifrado y compresión opcional.
---
## Fase 6: UX premium y experiencia final
### Objetivo
Cerrar la diferencia entre el producto y una solución NAS moderna.
### Criterios de éxito
- Dashboard visual y claro para administración.
- Navegación simple para restore.
- Alertas, notificaciones y estados comprensibles.
- Diseño consistente con una experiencia de producto terminada.
### Tareas
- [ ] Mejorar diseño general del dashboard.
- [ ] Crear vistas de salud del sistema.
- [ ] Añadir alertas visuales y métricas de uso.
- [ ] Mejorar navegación por tabs y modales.
- [ ] Trabajar la experiencia móvil y desktop.
---
## Medición del progreso
Se considera que el proyecto avanza cuando cada fase cumple los criterios objetivos y puede demostrarse funcionalmente con una prueba real.
### Indicadores clave
- Login funcionando
- Cliente registrado
- Job ejecutado correctamente
- Archivo respaldado
- Archivo restaurado por fecha
- Explicación clara del estado del sistema en la UI
---
## Siguiente hito recomendado
### Hito 1: Restore puntual + versionado
Es el cambio más valioso en este momento porque transforma el software de “backup programado” a “plataforma de almacenamiento recuperable”.
Este hito debería marcarse como alcanzado cuando:
- un archivo tiene historial de versiones,
- el usuario puede navegarlo,
- y restaurarlo desde una fecha concreta.
---
## Notas finales
Este roadmap está pensado para facilitar la medición del progreso sin perder de vista la visión de producto final. El objetivo no es solo tener “más features”, sino lograr que el sistema se sienta como una solución moderna de almacenamiento y recuperación.
+140
View File
@@ -0,0 +1,140 @@
import socket
import threading
import pytest
from datetime import datetime, timezone
import hashlib
from app.core.radius import authenticate_radius
from app.core.database import AsyncSessionLocal, init_db
from app.models.models import User, SystemSetting
from app.api.auth import login
from app.schemas.schemas import LoginRequest
from sqlalchemy import select
class MockRadiusServer:
def __init__(self, host="127.0.0.1", port=18120, secret="my-secret"):
self.host = host
self.port = port
self.secret = secret
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind((self.host, self.port))
self.running = False
def start(self):
self.running = True
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self):
self.running = False
self.sock.close()
def _run(self):
while self.running:
try:
data, addr = self.sock.recvfrom(4096)
if len(data) < 20:
continue
code = data[0]
identifier = data[1]
length = (data[2] << 8) + data[3]
req_authenticator = data[4:20]
attributes = data[20:length]
# Parse Attributes
idx = 0
username = ""
password = ""
while idx < len(attributes):
attr_type = attributes[idx]
attr_len = attributes[idx+1]
attr_val = attributes[idx+2 : idx+attr_len]
if attr_type == 1:
username = attr_val.decode('utf-8')
elif attr_type == 2:
# Decrypt PAP password
decrypted = b''
last_chunk = req_authenticator
secret_bytes = self.secret.encode('utf-8')
for k in range(0, len(attr_val), 16):
chunk = attr_val[k:k+16]
md5_hash = hashlib.md5(secret_bytes + last_chunk).digest()
dec_chunk = bytes(a ^ b for a, b in zip(chunk, md5_hash))
decrypted += dec_chunk
last_chunk = chunk
password = decrypted.decode('utf-8').rstrip('\x00')
idx += attr_len
# Verify password. Let's make "radiuspass" the valid password
response_code = 3 # Access-Reject
if username == "radiususer" and password == "radiuspass":
response_code = 2 # Access-Accept
# Build response packet
# Response Authenticator = MD5(Code + ID + Length + Request Authenticator + Attributes + Secret)
resp_length = 20
header = bytes([response_code, identifier, (resp_length >> 8) & 0xff, resp_length & 0xff])
resp_authenticator = hashlib.md5(header + req_authenticator + self.secret.encode('utf-8')).digest()
response_packet = header[0:4] + resp_authenticator
self.sock.sendto(response_packet, addr)
except Exception:
break
def test_radius_authentication_success_and_fail():
server = MockRadiusServer(secret="testing-secret")
server.start()
try:
# 1. Access-Accept
ok = authenticate_radius("radiususer", "radiuspass", "127.0.0.1", "testing-secret", port=18120, timeout=1.0)
assert ok is True
# 2. Access-Reject (wrong password)
ok = authenticate_radius("radiususer", "wrongpass", "127.0.0.1", "testing-secret", port=18120, timeout=1.0)
assert ok is False
# 3. Access-Reject (wrong secret)
ok = authenticate_radius("radiususer", "radiuspass", "127.0.0.1", "bad-secret", port=18120, timeout=1.0)
assert ok is False
finally:
server.stop()
@pytest.mark.asyncio
async def test_radius_login_jit_provisioning():
"""
Tests RADIUS login flow: JIT creation of a local User when RADIUS Access-Accept succeeds.
"""
await init_db()
server = MockRadiusServer(secret="testing-secret")
server.start()
try:
async with AsyncSessionLocal() as db:
# Configure global settings to RADIUS authentication
db.add(SystemSetting(key="auth_mode", value="radius"))
db.add(SystemSetting(key="radius_host", value="127.0.0.1"))
db.add(SystemSetting(key="radius_port", value="18120"))
db.add(SystemSetting(key="radius_secret", value="testing-secret"))
await db.commit()
# Execute API login for 'radiususer@oneverdrive.local' with 'radiuspass'
login_req = LoginRequest(email="radiususer@oneverdrive.local", password="radiuspass")
res = await login(credentials=login_req, db=db)
assert res["access_token"] is not None
assert res["user"]["email"] == "radiususer@oneverdrive.local"
assert res["user"]["role"] == "OPERATOR"
assert res["user"]["auth_source"] == "radius"
# Check that user was saved to the SQLite DB
stmt = select(User).where(User.email == "radiususer@oneverdrive.local")
user_db = (await db.execute(stmt)).scalar_one_or_none()
assert user_db is not None
assert user_db.auth_source == "radius"
assert user_db.is_active is True
# Revert global settings by deleting test configuration keys
from sqlalchemy import delete
await db.execute(delete(SystemSetting).where(SystemSetting.key.in_(["auth_mode", "radius_host", "radius_port", "radius_secret"])))
await db.commit()
finally:
server.stop()
+1
View File
@@ -45,6 +45,7 @@ class AgentConfig(BaseModel):
min_stable_time_seconds: int = 60
log_level: str = "INFO"
local_folders: List[LocalFolderJob] = []
enable_global_exclusions: bool = True
# Notification preferences (configurable from GUI and Tray)
enable_notifications: bool = True
+27 -4
View File
@@ -48,6 +48,18 @@ def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval
import platform
import stat
DEFAULT_GLOBAL_EXCLUSIONS = [
"pagefile.sys",
"hiberfil.sys",
"thumbs.db",
"desktop.ini",
"recycle.bin",
"recycler",
"$recycle.bin",
"temp",
"*.tmp"
]
class DirectoryScanner:
"""Scans Windows source paths for files matching specific backup patterns."""
@@ -59,7 +71,8 @@ class DirectoryScanner:
skip_hidden: bool = False,
skip_system: bool = False,
skip_readonly: bool = False,
min_stable_seconds: int = 60
min_stable_seconds: int = 60,
enable_global_exclusions: bool = True
):
self.source_path = Path(source_path).resolve()
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
@@ -68,6 +81,7 @@ class DirectoryScanner:
self.skip_system = skip_system
self.skip_readonly = skip_readonly
self.min_stable_seconds = min_stable_seconds
self.enable_global_exclusions = enable_global_exclusions
def scan(self) -> List[Path]:
"""Returns list of all matching files that are stable and ready for backup."""
@@ -78,16 +92,19 @@ class DirectoryScanner:
if self.source_path.is_file():
if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name):
if not self._should_skip_by_attributes(self.source_path):
matched_files.append(self.source_path)
if not (self.enable_global_exclusions and self._is_globally_excluded(self.source_path.name)):
if not self._should_skip_by_attributes(self.source_path):
matched_files.append(self.source_path)
return matched_files
for root, dirs, files in os.walk(self.source_path):
# Prune excluded directories from search in-place
dirs[:] = [d for d in dirs if not self._matches_exclude(d)]
dirs[:] = [d for d in dirs if not self._matches_exclude(d) and not (self.enable_global_exclusions and self._is_globally_excluded(d))]
for file in files:
if self._matches_patterns(file) and not self._matches_exclude(file):
if self.enable_global_exclusions and self._is_globally_excluded(file):
continue
full_path = Path(root) / file
if not self._should_skip_by_attributes(full_path):
matched_files.append(full_path)
@@ -108,6 +125,12 @@ class DirectoryScanner:
return True
return False
def _is_globally_excluded(self, name: str) -> bool:
for pattern in DEFAULT_GLOBAL_EXCLUSIONS:
if fnmatch.fnmatch(name.lower(), pattern.lower()):
return True
return False
def _should_skip_by_attributes(self, filepath: Path) -> bool:
if platform.system() != "Windows":
return False
+93 -11
View File
@@ -23,6 +23,68 @@ logging.basicConfig(
)
logger = logging.getLogger("OnEverAgent")
def send_to_recycle_bin(path_str: str) -> bool:
"""
Sends a file or folder to the Windows Recycle Bin using native shell32 API.
"""
try:
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", ctypes.c_wchar_p),
("pTo", ctypes.c_wchar_p),
("fFlags", ctypes.c_ushort),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", wintypes.LPVOID),
("lpszProgressTitle", ctypes.c_wchar_p),
]
path_null = os.path.abspath(path_str) + "\0\0"
fileop = SHFILEOPSTRUCTW()
fileop.hwnd = None
fileop.wFunc = 3 # FO_DELETE
fileop.pFrom = path_null
fileop.pTo = None
# FOF_ALLOWUNDO (0x0040) sends to Recycle Bin. FOF_NOCONFIRMATION (0x0010) + FOF_NOERRORUI (0x0400) + FOF_SILENT (0x0004)
fileop.fFlags = 0x0040 | 0x0010 | 0x0400 | 0x0004
res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(fileop))
return res == 0
except Exception as e:
logger.error(f"Failed to recycle bin file {path_str} via ctypes: {e}")
return False
def resolve_path_tags(path: str) -> str:
"""
Resolves Karen-style datetime tags inside file/folder paths.
"""
now = datetime.now()
replacements = {
"<yyyy>": now.strftime("%Y"),
"<yy>": now.strftime("%y"),
"<year>": now.strftime("%Y"),
"<y>": now.strftime("%Y"),
"<month>": now.strftime("%b"),
"<mm>": now.strftime("%m"),
"<m>": str(now.month),
"<dd>": now.strftime("%d"),
"<d>": str(now.day),
"<dow>": str(now.weekday() + 1),
"<w>": str(now.weekday() + 1),
"<hour>": now.strftime("%H"),
"<hh>": now.strftime("%H"),
"<minute>": now.strftime("%M"),
"<min>": now.strftime("%M"),
}
resolved = path
for tag, val in replacements.items():
resolved = re.sub(re.escape(tag), val, resolved, flags=re.IGNORECASE)
return resolved
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
if not schedule_str:
return True
@@ -354,7 +416,8 @@ class AgentDaemon:
skip_hidden=getattr(job, "skip_hidden", False),
skip_system=getattr(job, "skip_system", False),
skip_readonly=getattr(job, "skip_readonly", False),
min_stable_seconds=min_stable
min_stable_seconds=min_stable,
enable_global_exclusions=getattr(self.config, "enable_global_exclusions", True)
)
files = scanner.scan()
@@ -368,7 +431,7 @@ class AgentDaemon:
# Track files successfully backed up in this run
active_relative_paths = []
dest_dir = getattr(job, "local_destination_path", None)
dest_dir = resolve_path_tags(getattr(job, "local_destination_path", None)) if getattr(job, "local_destination_path", None) else None
for filepath in files:
rel_p = None
@@ -436,11 +499,24 @@ class AgentDaemon:
if should_copy_local:
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(filepath, dest_path)
logger.info(f"Successfully copied {filepath.name} to local dest {dest_path}")
if not cloud_uploaded:
files_copied += 1
bytes_transferred += file_size_bytes
# Robust write pattern inspired by Karen's Replicator 3.5.0
temp_dest = dest_path.with_suffix(dest_path.suffix + ".tmp")
try:
shutil.copy2(filepath, temp_dest)
if dest_path.exists():
dest_path.unlink()
temp_dest.rename(dest_path)
logger.info(f"Successfully copied {filepath.name} to local dest {dest_path}")
if not cloud_uploaded:
files_copied += 1
bytes_transferred += file_size_bytes
except Exception as copy_err:
if temp_dest.exists():
try:
temp_dest.unlink()
except Exception:
pass
raise copy_err
except Exception as ex:
logger.error(f"Failed to copy {filepath.name} to local path {dest_dir}: {str(ex)}")
errors_count += 1
@@ -477,8 +553,11 @@ class AgentDaemon:
try:
rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/")
if rel_to_dest not in active_relative_paths:
logger.info(f"Removing local orphan file: {full_path}")
full_path.unlink()
logger.info(f"Recycling local orphan file: {full_path}")
recycled = send_to_recycle_bin(str(full_path))
if not recycled:
logger.info(f"Recycle bin failed. Permanently deleting local orphan file: {full_path}")
full_path.unlink()
except Exception as ex:
logger.error(f"Error removing local orphan file {full_path}: {ex}")
for root, dirs, files_in_dir in os.walk(dest_root, topdown=False):
@@ -486,8 +565,11 @@ class AgentDaemon:
dir_path = Path(root) / dir_name
try:
if not os.listdir(dir_path):
logger.info(f"Removing empty local directory: {dir_path}")
dir_path.rmdir()
logger.info(f"Recycling empty local directory: {dir_path}")
recycled = send_to_recycle_bin(str(dir_path))
if not recycled:
logger.info(f"Recycle bin failed. Permanently removing empty local directory: {dir_path}")
dir_path.rmdir()
except Exception:
pass
except Exception as e:
+55 -1
View File
@@ -271,11 +271,16 @@ class AddFolderDialog(QDialog):
dest_layout = QHBoxLayout()
self.txt_dest = QLineEdit()
self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup (Opcional)")
self.txt_dest.setPlaceholderText("Ej: D:\\LocalBackup\\<yyyy>-<mm>-<dd>")
btn_browse_dest = QPushButton("Explorar...")
btn_browse_dest.clicked.connect(self._browse_dest_folder)
btn_tags = QPushButton("Tags...")
btn_tags.clicked.connect(self._show_tags_menu)
dest_layout.addWidget(self.txt_dest)
dest_layout.addWidget(btn_browse_dest)
dest_layout.addWidget(btn_tags)
form.addRow("Copia Local Adicional:", dest_layout)
layout.addLayout(form)
@@ -304,6 +309,33 @@ class AddFolderDialog(QDialog):
if folder:
self.txt_dest.setText(folder)
def _show_tags_menu(self):
from PyQt6.QtWidgets import QMenu
from PyQt6.QtGui import QAction
menu = QMenu(self)
tags = [
("<yyyy>", "Año actual (4 dígitos)"),
("<yy>", "Año actual (2 dígitos)"),
("<month>", "Nombre del mes (ej: Jul)"),
("<mm>", "Mes actual (01-12)"),
("<m>", "Mes actual (1-12)"),
("<dd>", "Día actual (01-31)"),
("<d>", "Día actual (1-31)"),
("<dow>", "Día de la semana (1=Lunes...7=Domingo)"),
("<hour>", "Hora actual (00-23)"),
("<minute>", "Minuto actual (00-59)")
]
for tag, desc in tags:
action = QAction(f"{tag} - {desc}", self)
action.triggered.connect(lambda checked=False, t=tag: self.txt_dest.insert(t))
menu.addAction(action)
btn = self.sender()
if btn:
menu.exec(btn.mapToGlobal(btn.rect().bottomLeft()))
def _validate_and_accept(self):
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")
@@ -583,6 +615,27 @@ class OnEverDriveMainWindow(QMainWindow):
notif_layout.addWidget(self.chk_notif_error)
layout.addWidget(card_notif)
# Global Exclusions Card (Karen parity)
card_excl = QFrame()
card_excl.setProperty("class", "card")
excl_layout = QVBoxLayout(card_excl)
excl_layout.setSpacing(10)
lbl_excl = QLabel("Exclusiones Globales de Archivos")
lbl_excl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
excl_layout.addWidget(lbl_excl)
self.chk_global_exclusions = QCheckBox("Omitir archivos de volcado, temporales y del sistema (pagefile.sys, Temp, Thumbs.db)")
self.chk_global_exclusions.setChecked(self.config.enable_global_exclusions)
self.chk_global_exclusions.stateChanged.connect(self._save_notif_settings)
excl_layout.addWidget(self.chk_global_exclusions)
lbl_excl_desc = QLabel("Omitirá automáticamente archivos bloqueados del sistema (pagefile.sys, hiberfil.sys) y temporales (*.tmp, Thumbs.db, Desktop.ini) en todos los backups.")
lbl_excl_desc.setStyleSheet("color: #64748B; font-size: 11px;")
excl_layout.addWidget(lbl_excl_desc)
layout.addWidget(card_excl)
layout.addStretch()
# --- TAB 4: HISTORIAL ---
@@ -657,6 +710,7 @@ class OnEverDriveMainWindow(QMainWindow):
self.config.notify_on_start = self.chk_notif_start.isChecked()
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
self.config.notify_on_error = self.chk_notif_error.isChecked()
self.config.enable_global_exclusions = self.chk_global_exclusions.isChecked()
save_config(self.config)
self._build_tray_menu()