Compare commits
10
Commits
85b6bb4100
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0fbba4d5 | ||
|
|
b6ae2bf92e | ||
|
|
bf68cd2a74 | ||
|
|
2cb23591ee | ||
|
|
8b2260bf14 | ||
|
|
f3b6d8ef71 | ||
|
|
7badc34816 | ||
|
|
bdce625767 | ||
|
|
d010abe05c | ||
|
|
e15a7a0362 |
@@ -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.
|
- **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`).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 📦 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.
|
- **`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.
|
||||||
|
|||||||
+44
-8
@@ -13,22 +13,57 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger("uvicorn.error")
|
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)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
email_clean = credentials.email.strip().lower()
|
email_clean = credentials.email.strip().lower()
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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))
|
result = await db.execute(select(User).where(User.email == email_clean))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning(f"Login failed: User not found with email '{email_clean}'")
|
# Auto-provision (JIT) RADIUS User
|
||||||
raise HTTPException(
|
user = User(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
email=email_clean,
|
||||||
detail="Incorrect email or password"
|
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 verify_password(credentials.password, user.hashed_password):
|
if not is_authenticated:
|
||||||
logger.warning(f"Login failed: Password mismatch for email '{email_clean}' (sent password length: {len(credentials.password)})")
|
logger.warning(f"Login failed: Invalid credentials for '{email_clean}'")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect email or password"
|
detail="Incorrect email or password"
|
||||||
@@ -57,7 +92,8 @@ async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
"id": user.id,
|
"id": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
"role": user.role
|
"role": user.role,
|
||||||
|
"auth_source": user.auth_source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
@@ -99,10 +103,8 @@ async def register_client(
|
|||||||
client.last_seen_at = now
|
client.last_seen_at = now
|
||||||
client.is_active = True
|
client.is_active = True
|
||||||
else:
|
else:
|
||||||
# Create a new Client
|
# Create a new Client with a unique generated client_code
|
||||||
max_id_res = await db.execute(select(func.max(Client.id)))
|
client_code = f"CLI-{uuid.uuid4().hex[:8].upper()}"
|
||||||
max_id = max_id_res.scalar() or 0
|
|
||||||
client_code = f"CLIENT-{max_id + 1:04d}"
|
|
||||||
|
|
||||||
# Load default client quota from settings
|
# Load default client quota from settings
|
||||||
from app.services.settings_service import get_setting
|
from app.services.settings_service import get_setting
|
||||||
@@ -356,3 +358,83 @@ async def delete_client(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {"message": f"Client {client.client_code} deleted"}
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.database import get_db
|
||||||
from app.api.deps import require_admin
|
from app.api.deps import require_admin
|
||||||
from app.models.models import User
|
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"])
|
router = APIRouter(prefix="/settings", tags=["System Settings"])
|
||||||
|
|
||||||
|
class TestShareRequest(BaseModel):
|
||||||
|
path: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
@router.get("", response_model=SystemSettingsResponse)
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
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_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
"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)
|
@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_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
"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)}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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."}
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -15,13 +17,90 @@ from app.api.backups import router as backups_router
|
|||||||
from app.api.events import router as events_router
|
from app.api.events import router as events_router
|
||||||
from app.api.stats import router as stats_router
|
from app.api.stats import router as stats_router
|
||||||
from app.api.settings import router as settings_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
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Initialize database tables
|
# Initialize database tables
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
# Start scheduler daemon task
|
||||||
|
scheduler_task = asyncio.create_task(script_scheduler_daemon())
|
||||||
|
|
||||||
# Seed default administrator and initialize settings
|
# Seed default administrator and initialize settings
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# Load and apply settings to storage_provider
|
# Load and apply settings to storage_provider
|
||||||
@@ -58,7 +137,15 @@ async def lifespan(app: FastAPI):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(">> [OnEver Drive] Default admin verified & password reset to: Admin1234!")
|
print(">> [OnEver Drive] Default admin verified & password reset to: Admin1234!")
|
||||||
|
|
||||||
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
scheduler_task.cancel()
|
||||||
|
try:
|
||||||
|
await scheduler_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
print("[*] Script Scheduler Daemon stopped.")
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
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(events_router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(stats_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(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")
|
@app.websocket("/ws/telemetry")
|
||||||
async def websocket_telemetry(websocket: WebSocket):
|
async def websocket_telemetry(websocket: WebSocket):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, String, Integer, BigInteger, Boolean, DateTime,
|
Column, String, Integer, BigInteger, Boolean, DateTime,
|
||||||
ForeignKey, Text, Index
|
ForeignKey, Text, Index, Float
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -19,6 +19,7 @@ class User(Base):
|
|||||||
full_name = Column(String(255), nullable=True)
|
full_name = Column(String(255), nullable=True)
|
||||||
role = Column(String(50), default="ADMIN", nullable=False) # ADMIN, OPERATOR, VIEWER
|
role = Column(String(50), default="ADMIN", nullable=False) # ADMIN, OPERATOR, VIEWER
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
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)
|
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
||||||
|
|
||||||
class Client(Base):
|
class Client(Base):
|
||||||
@@ -212,3 +213,29 @@ class SystemSetting(Base):
|
|||||||
|
|
||||||
key = Column(String(100), primary_key=True, index=True)
|
key = Column(String(100), primary_key=True, index=True)
|
||||||
value = Column(String(1024), nullable=False)
|
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")
|
||||||
|
|||||||
@@ -18,10 +18,26 @@ class UserResponse(BaseModel):
|
|||||||
full_name: Optional[str]
|
full_name: Optional[str]
|
||||||
role: str
|
role: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
auth_source: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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 ---
|
# --- Client Schemas ---
|
||||||
class RegistrationCodeCreate(BaseModel):
|
class RegistrationCodeCreate(BaseModel):
|
||||||
client_name_hint: Optional[str] = None
|
client_name_hint: Optional[str] = None
|
||||||
@@ -240,6 +256,13 @@ class SystemSettingsResponse(BaseModel):
|
|||||||
default_keep_daily: int
|
default_keep_daily: int
|
||||||
default_keep_weekly: int
|
default_keep_weekly: int
|
||||||
default_keep_monthly: 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):
|
class SystemSettingsUpdate(BaseModel):
|
||||||
storage_root: Optional[str] = None
|
storage_root: Optional[str] = None
|
||||||
@@ -248,6 +271,13 @@ class SystemSettingsUpdate(BaseModel):
|
|||||||
default_keep_daily: Optional[int] = None
|
default_keep_daily: Optional[int] = None
|
||||||
default_keep_weekly: Optional[int] = None
|
default_keep_weekly: Optional[int] = None
|
||||||
default_keep_monthly: 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):
|
class AgentJobRegister(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -296,3 +326,40 @@ class JobRunResponse(BaseModel):
|
|||||||
error_summary: Optional[str]
|
error_summary: Optional[str]
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
␍
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -12,8 +12,44 @@ DEFAULT_SETTINGS = {
|
|||||||
"default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY),
|
"default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY),
|
||||||
"default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY),
|
"default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY),
|
||||||
"default_keep_monthly": str(settings.DEFAULT_RETENTION_MONTHLY),
|
"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:
|
async def get_setting(db: AsyncSession, key: str) -> str:
|
||||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||||
setting = result.scalar_one_or_none()
|
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:
|
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
|
||||||
from app.storage.local import storage_provider
|
from app.storage.local import storage_provider
|
||||||
|
|
||||||
for k, v in new_settings.items():
|
current = await get_all_settings(db)
|
||||||
if k in DEFAULT_SETTINGS and v is not None:
|
merged = {**current, **new_settings}
|
||||||
# If changing storage root, validate and apply
|
|
||||||
if k == "storage_root":
|
# 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()
|
path = Path(v).resolve()
|
||||||
try:
|
try:
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
# Test write access
|
|
||||||
test_file = path / ".write_test"
|
test_file = path / ".write_test"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
test_file.unlink()
|
test_file.unlink()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
|
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
|
||||||
|
|
||||||
# Apply to in-memory storage provider
|
|
||||||
storage_provider.root_dir = path
|
storage_provider.root_dir = path
|
||||||
|
|
||||||
|
for k, v in new_settings.items():
|
||||||
|
if k in DEFAULT_SETTINGS and v is not None:
|
||||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
|
||||||
setting = result.scalar_one_or_none()
|
setting = result.scalar_one_or_none()
|
||||||
if setting:
|
if setting:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -8,6 +8,8 @@ import { RestoreView } from './pages/RestoreView';
|
|||||||
import { EventsView } from './pages/EventsView';
|
import { EventsView } from './pages/EventsView';
|
||||||
import { LoginView } from './pages/LoginView';
|
import { LoginView } from './pages/LoginView';
|
||||||
import { SettingsView } from './pages/SettingsView';
|
import { SettingsView } from './pages/SettingsView';
|
||||||
|
import { UsersView } from './pages/UsersView';
|
||||||
|
import { ScriptJobsView } from './pages/ScriptJobsView';
|
||||||
import { ActiveUpload } from './components/LiveTransferMeter';
|
import { ActiveUpload } from './components/LiveTransferMeter';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -152,6 +154,10 @@ export const App: React.FC = () => {
|
|||||||
return 'Explorador & Restore';
|
return 'Explorador & Restore';
|
||||||
case 'events':
|
case 'events':
|
||||||
return 'Auditoría & Logs';
|
return 'Auditoría & Logs';
|
||||||
|
case 'users':
|
||||||
|
return 'Gestión de Cuentas';
|
||||||
|
case 'scripts':
|
||||||
|
return 'Scripts del Servidor';
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return 'Configuración Global';
|
return 'Configuración Global';
|
||||||
default:
|
default:
|
||||||
@@ -214,6 +220,14 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'users' && (
|
||||||
|
<UsersView />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'scripts' && (
|
||||||
|
<ScriptJobsView />
|
||||||
|
)}
|
||||||
|
|
||||||
{currentTab === 'settings' && (
|
{currentTab === 'settings' && (
|
||||||
<SettingsView
|
<SettingsView
|
||||||
onRefresh={loadData}
|
onRefresh={loadData}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Server,
|
Server,
|
||||||
Settings
|
Settings,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -22,6 +23,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
|
|||||||
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
|
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
|
||||||
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
||||||
{id: 'restore', label: 'Explorador & Restore', icon: RotateCcw},
|
{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: 'events', label: 'Auditoría & Logs', icon: FileText},
|
||||||
{id: 'settings', label: 'Configuración Global', icon: Settings},
|
{id: 'settings', label: 'Configuración Global', icon: Settings},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Server as ServerIcon,
|
Server as ServerIcon,
|
||||||
X,
|
X,
|
||||||
Edit2,
|
Edit2,
|
||||||
RefreshCw
|
RefreshCw,
|
||||||
|
Cpu
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ClientItem, api } from '../services/api';
|
import { ClientItem, api } from '../services/api';
|
||||||
|
|
||||||
@@ -30,6 +31,65 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const [loading, setLoading] = 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) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -134,11 +194,17 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
Administración centralizada de agentes Windows 10, 11 y Windows Server
|
Administración centralizada de agentes Windows 10, 11 y Windows Server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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); }}>
|
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Registrar Nuevo Cliente
|
Registrar Nuevo Cliente
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="glass-card">
|
<div className="glass-card">
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
@@ -415,6 +481,98 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,7 +191,13 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}>
|
<tr key={backup.id}>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Loader2,
|
Loader2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
FolderOpen,
|
||||||
|
ChevronRight,
|
||||||
|
ArrowUp,
|
||||||
|
Globe,
|
||||||
|
RefreshCw,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api, SystemSettingsResponse } from '../services/api';
|
import { api, SystemSettingsResponse } from '../services/api';
|
||||||
|
|
||||||
@@ -34,9 +40,62 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
default_client_quota_gb: 100,
|
default_client_quota_gb: 100,
|
||||||
default_keep_daily: 7,
|
default_keep_daily: 7,
|
||||||
default_keep_weekly: 4,
|
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 () => {
|
const fetchSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
@@ -113,18 +172,94 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
|
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
required
|
required
|
||||||
placeholder="Ej: C:\backups o /mnt/nas/backups"
|
style={{ flex: 1 }}
|
||||||
|
placeholder="Ej: C:\backups o \\servidor\compartido"
|
||||||
value={formData.storage_root}
|
value={formData.storage_root}
|
||||||
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
|
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' }}>
|
<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.
|
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.
|
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,6 +348,66 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Form Actions */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
||||||
<button type="submit" className="btn btn-primary" style={{ padding: '10px 24px' }} disabled={saving}>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -109,6 +109,43 @@ export interface SystemSettingsResponse {
|
|||||||
default_keep_daily: number;
|
default_keep_daily: number;
|
||||||
default_keep_weekly: number;
|
default_keep_weekly: number;
|
||||||
default_keep_monthly: 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 => {
|
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' }),
|
request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }),
|
||||||
deleteClient: (clientId: number) =>
|
deleteClient: (clientId: number) =>
|
||||||
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
|
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 }) =>
|
updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) =>
|
||||||
request<ClientItem>(`/clients/${clientId}`, {
|
request<ClientItem>(`/clients/${clientId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -202,6 +262,38 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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
|
// Jobs
|
||||||
getJobs: (clientId?: number) =>
|
getJobs: (clientId?: number) =>
|
||||||
@@ -287,4 +379,32 @@ export const api = {
|
|||||||
|
|
||||||
// Events
|
// Events
|
||||||
getEvents: (limit: number = 50) => request<EventLogItem[]>(`/events?limit=${limit}`),
|
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
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -45,6 +45,7 @@ class AgentConfig(BaseModel):
|
|||||||
min_stable_time_seconds: int = 60
|
min_stable_time_seconds: int = 60
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
local_folders: List[LocalFolderJob] = []
|
local_folders: List[LocalFolderJob] = []
|
||||||
|
enable_global_exclusions: bool = True
|
||||||
|
|
||||||
# Notification preferences (configurable from GUI and Tray)
|
# Notification preferences (configurable from GUI and Tray)
|
||||||
enable_notifications: bool = True
|
enable_notifications: bool = True
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval
|
|||||||
import platform
|
import platform
|
||||||
import stat
|
import stat
|
||||||
|
|
||||||
|
DEFAULT_GLOBAL_EXCLUSIONS = [
|
||||||
|
"pagefile.sys",
|
||||||
|
"hiberfil.sys",
|
||||||
|
"thumbs.db",
|
||||||
|
"desktop.ini",
|
||||||
|
"recycle.bin",
|
||||||
|
"recycler",
|
||||||
|
"$recycle.bin",
|
||||||
|
"temp",
|
||||||
|
"*.tmp"
|
||||||
|
]
|
||||||
|
|
||||||
class DirectoryScanner:
|
class DirectoryScanner:
|
||||||
"""Scans Windows source paths for files matching specific backup patterns."""
|
"""Scans Windows source paths for files matching specific backup patterns."""
|
||||||
|
|
||||||
@@ -59,7 +71,8 @@ class DirectoryScanner:
|
|||||||
skip_hidden: bool = False,
|
skip_hidden: bool = False,
|
||||||
skip_system: bool = False,
|
skip_system: bool = False,
|
||||||
skip_readonly: 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.source_path = Path(source_path).resolve()
|
||||||
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
|
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_system = skip_system
|
||||||
self.skip_readonly = skip_readonly
|
self.skip_readonly = skip_readonly
|
||||||
self.min_stable_seconds = min_stable_seconds
|
self.min_stable_seconds = min_stable_seconds
|
||||||
|
self.enable_global_exclusions = enable_global_exclusions
|
||||||
|
|
||||||
def scan(self) -> List[Path]:
|
def scan(self) -> List[Path]:
|
||||||
"""Returns list of all matching files that are stable and ready for backup."""
|
"""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.source_path.is_file():
|
||||||
if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name):
|
if self._matches_patterns(self.source_path.name) and not self._matches_exclude(self.source_path.name):
|
||||||
|
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):
|
if not self._should_skip_by_attributes(self.source_path):
|
||||||
matched_files.append(self.source_path)
|
matched_files.append(self.source_path)
|
||||||
return matched_files
|
return matched_files
|
||||||
|
|
||||||
for root, dirs, files in os.walk(self.source_path):
|
for root, dirs, files in os.walk(self.source_path):
|
||||||
# Prune excluded directories from search in-place
|
# 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:
|
for file in files:
|
||||||
if self._matches_patterns(file) and not self._matches_exclude(file):
|
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
|
full_path = Path(root) / file
|
||||||
if not self._should_skip_by_attributes(full_path):
|
if not self._should_skip_by_attributes(full_path):
|
||||||
matched_files.append(full_path)
|
matched_files.append(full_path)
|
||||||
@@ -108,6 +125,12 @@ class DirectoryScanner:
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
def _should_skip_by_attributes(self, filepath: Path) -> bool:
|
||||||
if platform.system() != "Windows":
|
if platform.system() != "Windows":
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -23,6 +23,68 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("OnEverAgent")
|
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:
|
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
|
||||||
if not schedule_str:
|
if not schedule_str:
|
||||||
return True
|
return True
|
||||||
@@ -354,7 +416,8 @@ class AgentDaemon:
|
|||||||
skip_hidden=getattr(job, "skip_hidden", False),
|
skip_hidden=getattr(job, "skip_hidden", False),
|
||||||
skip_system=getattr(job, "skip_system", False),
|
skip_system=getattr(job, "skip_system", False),
|
||||||
skip_readonly=getattr(job, "skip_readonly", 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()
|
files = scanner.scan()
|
||||||
|
|
||||||
@@ -368,7 +431,7 @@ class AgentDaemon:
|
|||||||
|
|
||||||
# Track files successfully backed up in this run
|
# Track files successfully backed up in this run
|
||||||
active_relative_paths = []
|
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:
|
for filepath in files:
|
||||||
rel_p = None
|
rel_p = None
|
||||||
@@ -436,11 +499,24 @@ class AgentDaemon:
|
|||||||
|
|
||||||
if should_copy_local:
|
if should_copy_local:
|
||||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(filepath, dest_path)
|
# 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}")
|
logger.info(f"Successfully copied {filepath.name} to local dest {dest_path}")
|
||||||
if not cloud_uploaded:
|
if not cloud_uploaded:
|
||||||
files_copied += 1
|
files_copied += 1
|
||||||
bytes_transferred += file_size_bytes
|
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:
|
except Exception as ex:
|
||||||
logger.error(f"Failed to copy {filepath.name} to local path {dest_dir}: {str(ex)}")
|
logger.error(f"Failed to copy {filepath.name} to local path {dest_dir}: {str(ex)}")
|
||||||
errors_count += 1
|
errors_count += 1
|
||||||
@@ -477,7 +553,10 @@ class AgentDaemon:
|
|||||||
try:
|
try:
|
||||||
rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/")
|
rel_to_dest = str(full_path.relative_to(dest_root)).replace("\\", "/")
|
||||||
if rel_to_dest not in active_relative_paths:
|
if rel_to_dest not in active_relative_paths:
|
||||||
logger.info(f"Removing local orphan file: {full_path}")
|
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()
|
full_path.unlink()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"Error removing local orphan file {full_path}: {ex}")
|
logger.error(f"Error removing local orphan file {full_path}: {ex}")
|
||||||
@@ -486,7 +565,10 @@ class AgentDaemon:
|
|||||||
dir_path = Path(root) / dir_name
|
dir_path = Path(root) / dir_name
|
||||||
try:
|
try:
|
||||||
if not os.listdir(dir_path):
|
if not os.listdir(dir_path):
|
||||||
logger.info(f"Removing empty local directory: {dir_path}")
|
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()
|
dir_path.rmdir()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -271,11 +271,16 @@ class AddFolderDialog(QDialog):
|
|||||||
|
|
||||||
dest_layout = QHBoxLayout()
|
dest_layout = QHBoxLayout()
|
||||||
self.txt_dest = QLineEdit()
|
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 = QPushButton("Explorar...")
|
||||||
btn_browse_dest.clicked.connect(self._browse_dest_folder)
|
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(self.txt_dest)
|
||||||
dest_layout.addWidget(btn_browse_dest)
|
dest_layout.addWidget(btn_browse_dest)
|
||||||
|
dest_layout.addWidget(btn_tags)
|
||||||
form.addRow("Copia Local Adicional:", dest_layout)
|
form.addRow("Copia Local Adicional:", dest_layout)
|
||||||
|
|
||||||
layout.addLayout(form)
|
layout.addLayout(form)
|
||||||
@@ -304,6 +309,33 @@ class AddFolderDialog(QDialog):
|
|||||||
if folder:
|
if folder:
|
||||||
self.txt_dest.setText(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):
|
def _validate_and_accept(self):
|
||||||
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
|
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.")
|
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)
|
notif_layout.addWidget(self.chk_notif_error)
|
||||||
|
|
||||||
layout.addWidget(card_notif)
|
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()
|
layout.addStretch()
|
||||||
|
|
||||||
# --- TAB 4: HISTORIAL ---
|
# --- TAB 4: HISTORIAL ---
|
||||||
@@ -657,6 +710,7 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
self.config.notify_on_start = self.chk_notif_start.isChecked()
|
self.config.notify_on_start = self.chk_notif_start.isChecked()
|
||||||
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
|
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
|
||||||
self.config.notify_on_error = self.chk_notif_error.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)
|
save_config(self.config)
|
||||||
self._build_tray_menu()
|
self._build_tray_menu()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user