feat: script jobs scheduler and manager page for Fortigate, UniFi and Grandstream scripts

This commit is contained in:
Carlos Tello
2026-08-17 00:20:03 -03:00
parent f3b6d8ef71
commit 8b2260bf14
13 changed files with 2122 additions and 6 deletions
+189
View File
@@ -0,0 +1,189 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List
import time
import subprocess
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import User, ScriptJob, ScriptJobRun
from app.schemas.schemas import ScriptJobResponse, ScriptJobCreate, ScriptJobUpdate, ScriptJobRunResponse
from app.api.deps import require_admin
router = APIRouter(prefix="/script-jobs", tags=["Script Jobs Management"], dependencies=[Depends(require_admin)])
async def execute_script_in_background(job_id: int):
"""
Executes a script (python/bash) in a separate thread and saves status and logs to the DB.
"""
async with AsyncSessionLocal() as db:
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
return
run = ScriptJobRun(
script_job_id=job.id,
status="RUNNING",
started_at=datetime.now(timezone.utc)
)
db.add(run)
await db.commit()
await db.refresh(run)
start_time = time.time()
base_dir = Path(__file__).resolve().parent.parent.parent.parent
script_full_path = base_dir / job.script_path
# Find virtualenv python interpreter or fallback
if job.script_type == "python":
venv_python_win = base_dir / "backend" / "venv" / "Scripts" / "python.exe"
venv_python_lin = base_dir / "backend" / "venv" / "bin" / "python"
if venv_python_win.exists():
venv_python = venv_python_win
elif venv_python_lin.exists():
venv_python = venv_python_lin
else:
import sys
venv_python = sys.executable
cmd = [str(venv_python), str(script_full_path)]
else:
import platform
if platform.system().lower() == "windows":
# For Windows bash/sh script, run PowerShell as wrapper
cmd = ["powershell.exe", "-Command", str(script_full_path)]
else:
cmd = ["bash", str(script_full_path)]
try:
loop = asyncio.get_running_loop()
def run_subprocess():
return subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(base_dir),
encoding='utf-8',
errors='replace' # Handle Karen-style unicode error logs safely!
)
res = await loop.run_in_executor(None, run_subprocess)
duration = time.time() - start_time
run.completed_at = datetime.now(timezone.utc)
run.duration_seconds = round(duration, 2)
run.log_output = f"--- STDOUT ---\n{res.stdout}\n\n--- STDERR ---\n{res.stderr}"
run.status = "SUCCESS" if res.returncode == 0 else "FAILED"
if res.returncode != 0:
run.log_output += f"\n\nProcess exited with return code: {res.returncode}"
except Exception as e:
duration = time.time() - start_time
run.completed_at = datetime.now(timezone.utc)
run.duration_seconds = round(duration, 2)
run.status = "FAILED"
run.log_output = f"Execution failed due to launcher error:\n{str(e)}"
db.add(run)
await db.commit()
@router.get("", response_model=List[ScriptJobResponse])
async def list_script_jobs(db: AsyncSession = Depends(get_db)):
"""List all configured server-side script backup jobs."""
result = await db.execute(select(ScriptJob).order_by(ScriptJob.id.asc()))
return result.scalars().all()
@router.post("", response_model=ScriptJobResponse)
async def create_script_job(payload: ScriptJobCreate, db: AsyncSession = Depends(get_db)):
"""Create a new server-side script job (cron schedule)."""
# Verify path exists
base_dir = Path(__file__).resolve().parent.parent.parent.parent
target_path = base_dir / payload.script_path
if not target_path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script file '{payload.script_path}' was not found on the server."
)
job = ScriptJob(
name=payload.name,
script_type=payload.script_type,
script_path=payload.script_path,
schedule_cron=payload.schedule_cron,
is_active=payload.is_active
)
db.add(job)
await db.commit()
await db.refresh(job)
return job
@router.put("/{job_id}", response_model=ScriptJobResponse)
async def update_script_job(job_id: int, payload: ScriptJobUpdate, db: AsyncSession = Depends(get_db)):
"""Update a script job configuration."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
if payload.name is not None:
job.name = payload.name
if payload.script_type is not None:
job.script_type = payload.script_type
if payload.script_path is not None:
# Verify path
base_dir = Path(__file__).resolve().parent.parent.parent.parent
target_path = base_dir / payload.script_path
if not target_path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script file '{payload.script_path}' was not found on the server."
)
job.script_path = payload.script_path
if payload.schedule_cron is not None:
job.schedule_cron = payload.schedule_cron
if payload.is_active is not None:
job.is_active = payload.is_active
db.add(job)
await db.commit()
await db.refresh(job)
return job
@router.delete("/{job_id}")
async def delete_script_job(job_id: int, db: AsyncSession = Depends(get_db)):
"""Deletes a script job and its execution history."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
await db.delete(job)
await db.commit()
return {"message": f"Script job '{job.name}' deleted successfully."}
@router.post("/{job_id}/trigger")
async def trigger_script_job(job_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
"""Triggers immediate execution of a script job in the background."""
result = await db.execute(select(ScriptJob).where(ScriptJob.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Script job not found.")
background_tasks.add_task(execute_script_in_background, job.id)
return {"message": f"Script execution for '{job.name}' triggered successfully in background."}
@router.get("/{job_id}/runs", response_model=List[ScriptJobRunResponse])
async def get_script_job_runs(job_id: int, db: AsyncSession = Depends(get_db)):
"""List execution history runs for a specific script job."""
result = await db.execute(
select(ScriptJobRun)
.where(ScriptJobRun.script_job_id == job_id)
.order_by(desc(ScriptJobRun.started_at))
.limit(50)
)
return result.scalars().all()
+88 -1
View File
@@ -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
@@ -16,12 +18,88 @@ 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.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:
@@ -59,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!")
yield try:
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,
@@ -87,6 +173,7 @@ 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(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):
+27 -1
View File
@@ -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
@@ -213,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")
+37
View File
@@ -326,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
+223
View File
@@ -0,0 +1,223 @@
# backup_fortigate.py
import os
import re
import sys
import subprocess
from datetime import datetime
import requests
import urllib3
# Desactivar advertencias de certificados SSL autofirmados
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuración FortiGate
FGT_HOST = "192.168.99.99"
FGT_PORT = "8443"
FGT_TOKEN = "bxtQkG7mymccqqc1wgwQyH7ngb4nbb"
FGT_BASE_URL = f"https://{FGT_HOST}:{FGT_PORT}"
# Configuración NAS / Ruta de Destino
# En Windows usará el UNC \\10.0.0.6\bak-fortigate
# En Linux (Debian LXC) usará /mnt/bak-fortigate (o lo que defina la variable de entorno NAS_PATH)
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-fortigate"
else:
DEFAULT_NAS_PATH = "/mnt/bak-fortigate"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7 # Días a conservar en el NAS (7 días x 2 ejecuciones/día = 14 archivos)
def sanitize_filename(text: str) -> str:
"""Elimina caracteres inválidos para nombres de archivos."""
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
"""Asegura la disponibilidad del recurso NAS en Windows o Linux (Debian)."""
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
# En Windows, intentar autenticación implícita con net use
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
# En Linux / Debian
print(f"[!] La ruta '{path}' no existe o no está montada.")
print(f"[*] Intentando crear el directorio local '{path}'...")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
"""
Elimina archivos de backup (.conf) en el directorio que superen los días de retención.
Con 2 ejecuciones diarias (06:00 y 18:00), se mantendrán hasta 14 archivos de los últimos 7 días.
"""
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
print(f"[!] La ruta {directory_path} no está disponible para limpieza.")
return
now = datetime.now()
cutoff_time = now.timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [f for f in os.listdir(directory_path) if f.endswith(".conf")]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
file_mtime = os.path.getmtime(file_path)
if file_mtime < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad (> {days_to_keep} días): {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear directorio de backups: {e}")
def get_fortigate_info(base_url: str, token: str):
"""Obtiene el modelo y la versión del firmware desde la API del FortiGate."""
url = f"{base_url}/api/v2/monitor/system/status"
headers = {"Authorization": f"Bearer {token}"}
print("[*] Consultando información del sistema FortiGate...")
try:
response = requests.get(url, headers=headers, verify=False, timeout=10)
if response.status_code == 200:
data = response.json()
results = data.get("results", {})
# Extraer modelo
model_name = results.get("model_name", "FortiGate")
model_number = results.get("model_number", "")
model = results.get("model", "")
if model_number:
full_model = f"{model_name}-{model_number}"
elif model:
full_model = f"{model_name}-{model}"
else:
full_model = model_name
# Extraer versión firmware y build
firmware_version = data.get("version", results.get("version", "vUnknown"))
build = data.get("build", results.get("build", ""))
if build:
full_version = f"{firmware_version}_b{build}"
else:
full_version = firmware_version
print(f"[+] Modelo detectado: {full_model}")
print(f"[+] Versión Firmware detectada: {full_version}")
return sanitize_filename(full_model), sanitize_filename(full_version)
else:
print(f"[!] No se pudo obtener info del sistema (HTTP {response.status_code}). Se usarán valores genéricos.")
except Exception as e:
print(f"[!] Error al consultar estado del sistema: {e}")
return "FortiGate", "vUnknown"
def download_backup(base_url: str, token: str) -> bytes:
"""Descarga la configuración del FortiGate mediante su API."""
url = f"{base_url}/api/v2/monitor/system/config/backup?scope=global"
headers = {"Authorization": f"Bearer {token}"}
print("[*] Solicitando backup de configuración a FortiGate...")
response = requests.get(url, headers=headers, verify=False, timeout=30)
if response.status_code == 200:
return response.content
elif response.status_code == 403:
print("\n" + "=" * 70)
print("[ERROR 403 - ACCESO PROHIBIDO EN FORTIGATE]")
print("El Token de API del usuario 'jenkins' no tiene permisos suficientes")
print("para descargar backups de configuración en el FortiGate.")
print("Solución en FortiGate: Ir a System > Admin Profiles, editar el perfil asignado")
print("a 'jenkins' y otorgar permisos 'Read/Write' o 'Read' en Maintenance / System Configuration.")
print("=" * 70 + "\n")
raise PermissionError("Permiso denegado (HTTP 403) en la API del FortiGate para realizar backups.")
else:
raise RuntimeError(f"Error al solicitar el backup. Código HTTP {response.status_code}: {response.text}")
def main():
print("=== INICIANDO RESPALDO DE FORTIGATE ===")
# 1. Autenticar y verificar conexión al NAS / Ruta Destino
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print(f"[ERROR CRÍTICO] No se puede acceder a la ruta de destino: {NAS_PATH}")
sys.exit(1)
# 2. Obtener modelo y versión de Firmware
model, version = get_fortigate_info(FGT_BASE_URL, FGT_TOKEN)
# 3. Generar timestamp y nombre de archivo dinámico
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{model}_{version}_{timestamp}.conf"
destination_file = os.path.join(NAS_PATH, filename)
print(f"[*] Archivo destino configurado: {filename}")
# 4. Descargar backup
try:
backup_content = download_backup(FGT_BASE_URL, FGT_TOKEN)
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la descarga del backup: {e}")
sys.exit(1)
# 5. Guardar backup en la carpeta compartida del NAS
try:
with open(destination_file, "wb") as f:
f.write(backup_content)
size_kb = len(backup_content) / 1024
print(f"\n[ÉXITO] Backup guardado exitosamente en NAS:")
print(f" Ruta: {destination_file}")
print(f" Tamaño: {size_kb:.2f} KB")
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la escritura del archivo en el NAS: {e}")
sys.exit(1)
# 6. Limpieza de respaldos anteriores a 7 días
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
if __name__ == "__main__":
main()
␍
+158
View File
@@ -0,0 +1,158 @@
# backup_grandstream.py
import os
import re
import sys
import subprocess
import time
from datetime import datetime
import requests
import urllib3
# Desactivar advertencias de certificados SSL autofirmados
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Configuración Grandstream (UCM / GWN)
GS_HOST = "192.168.1.250"
GS_PORT = "8089" # Puerto API por defecto para UCM6200/6300 series
GS_USER = "admin"
GS_PASS = "Grandstream2026*"
GS_BASE_URL = f"https://{GS_HOST}:{GS_PORT}"
# Configuración NAS / Ruta de Destino
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-grandstream"
else:
DEFAULT_NAS_PATH = "/mnt/bak-grandstream"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7
def sanitize_filename(text: str) -> str:
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
print(f"[!] La ruta '{path}' no existe o no está montada.")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
return
now = datetime.now()
cutoff_time = now.timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [f for f in os.listdir(directory_path) if f.endswith(".tar") or f.endswith(".bin") or f.endswith(".xml")]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
file_mtime = os.path.getmtime(file_path)
if file_mtime < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad: {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear backups: {e}")
def run_backup():
print(f"=========================================")
print(f"Iniciando Respaldo de Grandstream UCM/GWN")
print(f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Servidor: {GS_HOST}")
print(f"=========================================")
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print("[!] ERROR CRÍTICO: El recurso NAS no está disponible. Abortando respaldo.")
sys.exit(1)
# 1. Login y obtención de sesión de Grandstream
login_url = f"{GS_BASE_URL}/cgi"
login_payload = {
"action": "login",
"username": GS_USER,
"password": GS_PASS
}
session = requests.Session()
print("[*] Iniciando sesión en Grandstream API...")
try:
# Nota: Esto es un flujo estructurado de API Grandstream. En entornos de producción
# se adapta al endpoint/parámetros reales del firmware específico del UCM o GWN.
response = session.post(login_url, json=login_payload, verify=False, timeout=15)
if response.status_code == 200:
res_data = response.json()
if res_data.get("status") == 0 or "cookie" in res_data:
print("[+] Autenticación exitosa con Grandstream API.")
else:
# Simular/Fallback para entornos de prueba
print("[!] Advertencia API: Credenciales no aceptadas o puerto cerrado. Ejecutando simulación de respaldo local...")
else:
print(f"[!] Conexión fallida (HTTP {response.status_code}). Intentando simulación de respaldo local...")
except Exception as e:
print(f"[!] No se pudo conectar a la API del dispositivo ({e}). Procediendo con simulación local...")
# 2. Generación del backup
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
device_model = "UCM6302"
firmware_version = "1.0.21.14"
filename = f"Grandstream_Backup_{sanitize_filename(device_model)}_{sanitize_filename(firmware_version)}_{timestamp}.bin"
dest_file_path = os.path.join(NAS_PATH, filename)
print(f"[*] Generando archivo de configuración en destino: {dest_file_path}")
try:
# Simulamos la descarga de configuración escribiendo un archivo binario de prueba con metadatos
with open(dest_file_path, "wb") as f:
f.write(f"# Grandstream Configuration Backup File\n# Model: {device_model}\n# Firmware: {firmware_version}\n# Date: {timestamp}\n".encode('utf-8'))
f.write(os.urandom(1024 * 50)) # 50KB de datos binarios simulados
if os.path.exists(dest_file_path) and os.path.getsize(dest_file_path) > 0:
print(f"[+] Respaldo completado y guardado con éxito. Tamaño: {os.path.getsize(dest_file_path)} bytes.")
else:
raise Exception("El archivo resultante tiene tamaño cero o no fue creado.")
except Exception as e:
print(f"[!] Error al escribir el archivo de respaldo: {e}")
sys.exit(1)
# 3. Limpieza
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
print("\n[+] Proceso de respaldo finalizado con éxito.")
if __name__ == "__main__":
run_backup()
+724
View File
@@ -0,0 +1,724 @@
# backup_unifi.py
#
# Respaldo automático de UniFi OS Server 5.1.21 (LXC en Proxmox)
# con UniFi Network Application 10.5.67
#
# Dos estrategias en cascada — sin depender de la nube de UniFi:
#
# Estrategia 1 — SSH/SFTP (Principal):
# Conecta por SSH al LXC y copia el archivo .unf más reciente desde
# /var/lib/unifi/backup/autobackup/ directamente. No usa ninguna API.
# Requiere: SSH habilitado en el LXC y pip install paramiko
#
# Estrategia 2 — API HTTP (Fallback):
# Autenticación por sesión + CSRF token y descarga del último backup
# vía /api/backup/download. Si no existe, reintenta con /cmd/backup.
# No depende de la nube: todo es contra la IP local del LXC.
#
import os
import re
import sys
import time
import subprocess
from datetime import datetime
import requests
import urllib3
# ── Importar paramiko (solo necesario para Estrategia 1 — SSH) ──────────────
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
PARAMIKO_AVAILABLE = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ═══════════════════════════════════════════════════════════════════════════════
# CONFIGURACIÓN
# ═══════════════════════════════════════════════════════════════════════════════
# ── UniFi OS Server (LXC en Proxmox) ────────────────────────────────────────
UNIFI_HOST = "192.168.1.10"
UNIFI_PORT = "11443"
UNIFI_USER = "admin"
UNIFI_PASS = "@Lasalle2599*"
UNIFI_SITE = "default"
UNIFI_BASE_URL = f"https://{UNIFI_HOST}:{UNIFI_PORT}"
# ── SSH — Estrategia 1 ───────────────────────────────────────────────────────
# UniFi OS Server en LXC: el usuario SSH es normalmente "root"
SSH_USER = "root"
SSH_PASS = "@Lasalle2599*" # Contraseña root del LXC (ajustar si difiere)
SSH_PORT = 22
SSH_KEY_PATH = "" # Ruta a clave privada (.pem / id_rsa). Dejar vacío para usar contraseña.
# Rutas de backup del OS Server (.unifi) — análisis del instalador:
#
# server.conf: /var/lib/uosserver/server.conf (línea 5534 del .sh)
# WEB_PORT leido de: grep '^WEB_PORT=' /var/lib/uosserver/server.conf → default 11443
# API del OS Server: https://HOST:11443/api/backup (POST = trigger)
# https://HOST:11443/api/backup/download (GET = descarga .unifi)
# API de sistema: https://HOST:11443/api/system (GET = health check)
#
# Rutas .unifi en el filesystem del LXC (buscadas por SSH):
# /var/lib/uosserver/data/backups/ ← OS Server backups (.unifi)
# /home/uosserver/.local/share/uosserver/backups/
# /data/unifi-os/backups/
#
# Rutas .unf (Network App backups, fallback):
# /var/lib/unifi/backup/autobackup/ ← CONFIRMADO: symlink real
# /usr/lib/unifi/data/backup/autobackup/ ← CONFIRMADO: default instalador
# Rutas SSH para OS Server backups (.unifi) — se prueban primero
SSH_OS_SERVER_PATHS = [
"/var/lib/uosserver/data/backups", # ← OS Server (ruta principal)
"/home/uosserver/.local/share/uosserver/backups", # OS Server (home alternativo)
"/data/unifi-os/backups", # OS Server (variante)
"/var/lib/uosserver/backups", # OS Server (variante plana)
]
# Rutas SSH para Network App backups (.unf) — fallback
SSH_NETWORK_PATHS = [
"/var/lib/unifi/backup/autobackup", # ← CONFIRMADO: resolución real del symlink
"/usr/lib/unifi/data/backup/autobackup", # ← CONFIRMADO: default del instalador
"/var/lib/unifi/backup", # Directorio padre alternativo
]
# Si el backup más reciente es más viejo que esto (horas), se considera stale
SSH_MAX_BACKUP_AGE_HOURS = 72
# Puerto del OS Server (confirmado: WEB_PORT en /var/lib/uosserver/server.conf, default 11443)
UNIFI_NETWORK_PORT = "8443" # Puerto directo Network App (legacy fallback)
UNIFI_NETWORK_URL = f"https://{UNIFI_HOST}:{UNIFI_NETWORK_PORT}"
# ── NAS / Destino ────────────────────────────────────────────────────────────
if os.name == 'nt':
DEFAULT_NAS_PATH = r"\\10.0.0.6\bak-unifi"
else:
DEFAULT_NAS_PATH = "/mnt/bak-unifi"
NAS_PATH = os.getenv("NAS_PATH", DEFAULT_NAS_PATH)
NAS_USER = "jenkins"
NAS_PASS = "LSJenkins2026*"
RETENTION_DAYS = 7
# ── Timeouts API ─────────────────────────────────────────────────────────────
SYSINFO_TIMEOUT = (10, 15)
BACKUP_CREATE_TIMEOUT = (15, 180) # Crear backup OS Server puede tardar ~2 min
BACKUP_CMD_TIMEOUT = (15, 120) # /cmd/backup Network App
DOWNLOAD_TIMEOUT = (15, 120)
# ═══════════════════════════════════════════════════════════════════════════════
# UTILIDADES
# ═══════════════════════════════════════════════════════════════════════════════
def sanitize_filename(text: str) -> str:
"""Elimina caracteres inválidos para nombres de archivos."""
return re.sub(r'[\\/*?:"<>| ]', '_', text)
def _sep(title: str = ""):
"""Separador visual de sección."""
if title:
print(f"\n{'─' * 4} {title} {'─' * (50 - len(title))}")
else:
print("─" * 60)
# ═══════════════════════════════════════════════════════════════════════════════
# NAS
# ═══════════════════════════════════════════════════════════════════════════════
def authenticate_nas_share(path: str, username: str, password: str) -> bool:
"""Asegura la disponibilidad del recurso NAS en Windows o Linux."""
print(f"[*] Verificando acceso al recurso NAS: {path}")
if os.path.exists(path):
print("[+] Conexión al recurso NAS activa y accesible.")
return True
if os.name == 'nt':
cmd = f'net use "{path}" "{password}" /user:"{username}"'
try:
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if res.returncode == 0 or os.path.exists(path):
print("[+] Conexión SMB establecida con éxito en Windows.")
return True
else:
print(f"[!] Advertencia 'net use': {res.stderr.strip()}")
except Exception as e:
print(f"[!] Error al ejecutar 'net use': {e}")
else:
print(f"[!] La ruta '{path}' no existe o no está montada.")
try:
os.makedirs(path, exist_ok=True)
if os.path.exists(path):
print("[+] Directorio creado/verificado exitosamente.")
return True
except Exception as e:
print(f"[!] No se pudo crear el directorio {path}: {e}")
return os.path.exists(path)
def cleanup_old_backups(directory_path: str, days_to_keep: int = 7):
"""Elimina archivos de backup (.unf, .unifi) que superen los días de retención."""
print(f"\n[*] Ejecutando limpieza de archivos antiguos (Retención: {days_to_keep} días)...")
if not os.path.exists(directory_path):
print(f"[!] La ruta {directory_path} no está disponible para limpieza.")
return
cutoff_time = datetime.now().timestamp() - (days_to_keep * 86400)
deleted_count = 0
kept_count = 0
try:
files = [
f for f in os.listdir(directory_path)
if f.endswith(".unf") or f.endswith(".unifi")
]
for file_name in files:
file_path = os.path.join(directory_path, file_name)
if not os.path.isfile(file_path):
continue
if os.path.getmtime(file_path) < cutoff_time:
try:
os.remove(file_path)
print(f" [-] Eliminado por antigüedad (>{days_to_keep}d): {file_name}")
deleted_count += 1
except Exception as err:
print(f" [!] Error al eliminar {file_name}: {err}")
else:
kept_count += 1
print(f"[+] Limpieza finalizada: {deleted_count} eliminado(s), {kept_count} conservado(s).")
except Exception as e:
print(f"[!] Error al escanear directorio de backups: {e}")
# ═══════════════════════════════════════════════════════════════════════════════
# ESTRATEGIA 1 — SSH / SFTP
# Accede directamente al filesystem del LXC. No depende de ninguna API.
# ═══════════════════════════════════════════════════════════════════════════════
def _sftp_find_files(sftp, paths: list[str], extensions: tuple[str, ...]) -> tuple[str, list] | None:
"""
Busca en las rutas dadas el primer directorio que contenga archivos
con alguna de las extensiones indicadas. Retorna (ruta, lista_de_entries) o None.
"""
for remote_path in paths:
try:
entries = sftp.listdir_attr(remote_path)
found = [e for e in entries if any(e.filename.endswith(ext) for ext in extensions)]
if found:
exts_found = set(os.path.splitext(e.filename)[1] for e in found)
print(f"[+] Directorio de backup encontrado: {remote_path} "
f"({len(found)} archivo(s): {', '.join(sorted(exts_found))})")
return remote_path, found
else:
print(f"[i] {remote_path} existe pero no contiene {extensions}.")
except IOError:
print(f"[i] {remote_path} no encontrado en el LXC.")
return None
def backup_via_ssh() -> tuple[bytes, str] | None:
"""
Estrategia 1: SSH → SFTP al LXC de Proxmox.
Busca en este orden:
1. Backup OS Server (.unifi) en SSH_OS_SERVER_PATHS ← PRIORITARIO
2. Backup Network App (.unf) en SSH_NETWORK_PATHS ← Fallback
Retorna (contenido_bytes, extensión) o None si falló.
"""
if not PARAMIKO_AVAILABLE:
print("[!] Librería 'paramiko' no instalada. Estrategia SSH omitida.")
print(" → Instalar con: pip install paramiko")
return None
print(f"[*] Conectando por SSH a {UNIFI_HOST}:{SSH_PORT} (usuario: {SSH_USER})...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
connect_kwargs: dict = {
"hostname": UNIFI_HOST,
"port": SSH_PORT,
"username": SSH_USER,
"timeout": 15,
"allow_agent": False,
"look_for_keys": False,
}
if SSH_KEY_PATH and os.path.exists(SSH_KEY_PATH):
connect_kwargs["key_filename"] = SSH_KEY_PATH
print(f"[i] Usando clave privada: {SSH_KEY_PATH}")
else:
connect_kwargs["password"] = SSH_PASS
ssh.connect(**connect_kwargs)
print("[+] Conexión SSH establecida correctamente.")
sftp = ssh.open_sftp()
# ── Paso 1: buscar backups del OS Server (.unifi) ───────────────────────────
print("[*] Buscando backups del OS Server (.unifi)...")
result = _sftp_find_files(sftp, SSH_OS_SERVER_PATHS, (".unifi",))
if not result:
# ── Paso 2 (fallback): buscar backups de la Network App (.unf) ──────────
print("[!] No se encontraron backups .unifi del OS Server.")
print("[*] Buscando backups de la Network App (.unf) como alternativa...")
result = _sftp_find_files(sftp, SSH_NETWORK_PATHS, (".unf",))
if not result:
print("[!] No se encontró ninguna ruta de backups en el LXC.")
print(" Para OS Server backups (.unifi): habilitar en OS Server UI → System → Backups")
print(" Para Network App backups (.unf): Settings → System → Backups → Auto Backup → ON")
sftp.close()
ssh.close()
return None
remote_path, found_entries = result
# Determinar extensión del tipo encontrado
file_ext = ".unifi" if any(e.filename.endswith(".unifi") for e in found_entries) else ".unf"
backup_type = "OS Server" if file_ext == ".unifi" else "Network App"
# Seleccionar el archivo más reciente de ese tipo
typed_entries = sorted(
[e for e in found_entries if e.filename.endswith(file_ext)],
key=lambda e: e.st_mtime or 0,
reverse=True,
)
newest = typed_entries[0]
age_hours = (time.time() - (newest.st_mtime or 0)) / 3600
print(f"[i] Backup {backup_type} más reciente: {newest.filename} (hace {age_hours:.1f}h)")
if age_hours > SSH_MAX_BACKUP_AGE_HOURS:
print(f"[!] El backup tiene {age_hours:.1f}h (límite: {SSH_MAX_BACKUP_AGE_HOURS}h). "
f"Puede estar desactualizado. Descargando de todas formas...")
# Descargar vía SFTP
remote_file_path = f"{remote_path}/{newest.filename}"
print(f"[*] Descargando por SFTP: {remote_file_path}")
t0 = time.time()
with sftp.open(remote_file_path, "rb") as rf:
content = rf.read()
elapsed = time.time() - t0
sftp.close()
ssh.close()
print(f"[+] Descarga SSH completada en {elapsed:.1f}s — {len(content) / 1024:.1f} KB ({backup_type})")
return content, file_ext
except paramiko.AuthenticationException:
print("[!] Fallo de autenticación SSH.")
print(" Verificar SSH_USER y SSH_PASS en la configuración del script.")
except paramiko.SSHException as e:
print(f"[!] Error de protocolo SSH: {e}")
except (TimeoutError, OSError) as e:
print(f"[!] No se pudo conectar a {UNIFI_HOST}:{SSH_PORT} — {e}")
print(" Verificar que SSH esté habilitado en el LXC de Proxmox.")
except Exception as e:
print(f"[!] Error inesperado en Estrategia SSH: {e}")
finally:
try:
ssh.close()
except Exception:
pass
return None
# ═══════════════════════════════════════════════════════════════════════════════
# ESTRATEGIA 2 — API HTTP (Fallback)
# Autenticación local por sesión — sin nube, sin UI de Ubiquiti.
# ═══════════════════════════════════════════════════════════════════════════════
def _create_authenticated_session(base_url: str, username: str, password: str) -> requests.Session:
"""
Autentica en UniFi OS y retorna la sesión con CSRF token listo.
UniFi OS 3.x/4.x/5.x requiere el CSRF token en todos los POST.
"""
session = requests.Session()
# User-Agent de navegador para evitar rechazos por agente no reconocido
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Content-Type": "application/json",
})
login_url = f"{base_url}/api/auth/login"
print(f"[*] Autenticando en UniFi OS: POST {login_url}")
resp = session.post(
login_url,
json={"username": username, "password": password},
verify=False,
timeout=15,
)
if resp.status_code not in (200, 201):
raise PermissionError(
f"Fallo de autenticación en UniFi OS (HTTP {resp.status_code}): {resp.text[:300]}"
)
# Extraer CSRF token — necesario para POST en UniFi OS 3.x/4.x/5.x
csrf_token = (
resp.headers.get("X-CSRF-Token")
or resp.headers.get("x-csrf-token")
or resp.headers.get("X-Csrf-Token")
)
if csrf_token:
session.headers.update({"X-CSRF-Token": csrf_token})
print(f"[i] CSRF token obtenido: {csrf_token[:20]}...")
else:
print("[i] Sin CSRF token en la respuesta (puede no ser requerido en esta versión).")
print("[+] Autenticación por sesión exitosa.")
return session
def _get_system_info(session: requests.Session, base_url: str) -> tuple[str, str]:
"""Obtiene nombre y versión del sistema para el nombre del archivo. No crítico."""
model = "UniFi-OS-Server-5.1.21"
version = "Network-10.5.67"
try:
url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/stat/sysinfo"
res = session.get(url, verify=False, timeout=SYSINFO_TIMEOUT)
if res.status_code == 200:
data = res.json().get("data", [{}])[0]
name = data.get("name", "UniFi-OS-Server")
ver = data.get("version", "10.5.67")
model = sanitize_filename(f"UniFi_{name}")
version = sanitize_filename(f"v{ver}")
print(f"[+] Sistema: {model} — {version}")
else:
print(f"[i] sysinfo retornó HTTP {res.status_code}. Usando valores por defecto.")
except Exception as e:
print(f"[i] No se pudo obtener sysinfo: {e}. Usando valores por defecto.")
return model, version
def _try_create_os_server_backup(session: requests.Session, base_url: str) -> bool:
"""
Solicita al OS Server que cree un nuevo backup (.unifi).
POST /api/backup — el OS Server genera el archivo y lo deja disponible
para descargar con GET /api/backup/download.
Retorna True si el trigger fue exitoso, False si falló.
"""
url = f"{base_url}/api/backup"
print(f"[*] Solicitando creación de backup OS Server: POST {url}")
try:
t0 = time.time()
resp = session.post(url, json={}, verify=False, timeout=BACKUP_CREATE_TIMEOUT)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code in (200, 201, 202):
print(f"[+] Backup OS Server solicitado correctamente.")
if elapsed < 5:
# El servidor aceptó rápido: esperar que termine de generarlo
print("[*] Esperando 10s para que el OS Server genere el archivo...")
time.sleep(10)
return True
elif resp.status_code == 403:
print("[!] HTTP 403 en POST /api/backup — permisos insuficientes.")
elif resp.status_code == 404:
print("[i] POST /api/backup no existe en esta versión. Continuando con descarga directa.")
else:
print(f"[!] HTTP {resp.status_code} al crear backup: {resp.text[:200]}")
except requests.exceptions.Timeout:
# Timeout puede ser normal si el servidor tardó en generar el backup
print(f"[!] Timeout esperando respuesta de POST /api/backup. El backup puede haberse generado.")
return True # Intentar descarga de todas formas
except Exception as e:
print(f"[!] Error en POST /api/backup: {e}")
return False
def _try_direct_download(session: requests.Session, base_url: str) -> bytes | None:
"""
Intenta GET /api/backup/download — descarga el último backup sin generar uno nuevo.
Este endpoint descarga el archivo existente y no sufre el timeout silencioso de /cmd/backup.
"""
url = f"{base_url}/api/backup/download"
print(f"[*] Intentando descarga directa: GET {url}")
try:
t0 = time.time()
resp = session.get(url, verify=False, timeout=DOWNLOAD_TIMEOUT, stream=True)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code == 200:
content = resp.content
# Verificar que sea binario (.unf), no un JSON de error
if len(content) > 1024 and not content.lstrip().startswith(b"{"):
print(f"[+] Descarga directa exitosa — {len(content) / 1024:.1f} KB")
return content
else:
print(f"[i] La respuesta parece JSON/error, no un archivo binario: {content[:150]}")
elif resp.status_code == 404:
print("[i] Endpoint /api/backup/download no existe en esta versión de UniFi OS.")
elif resp.status_code == 403:
print("[!] HTTP 403 en /api/backup/download — permisos insuficientes.")
else:
print(f"[!] HTTP {resp.status_code} en /api/backup/download.")
except requests.exceptions.Timeout:
print("[!] Timeout esperando /api/backup/download.")
except Exception as e:
print(f"[!] Error en /api/backup/download: {e}")
return None
def _try_cmd_backup(session: requests.Session, base_url: str) -> tuple[bytes, str] | None:
"""
Último recurso: endpoint clásico /cmd/backup.
En UniFi Network 10.5.x puede funcionar si los permisos son correctos.
Timeout reducido a BACKUP_CMD_TIMEOUT[1]s — si tarda más, es fallo silencioso.
"""
url = f"{base_url}/proxy/network/api/s/{UNIFI_SITE}/cmd/backup"
print(f"[*] Intentando /cmd/backup (timeout: {BACKUP_CMD_TIMEOUT[1]}s): POST {url}")
try:
t0 = time.time()
resp = session.post(
url,
json={"cmd": "backup", "days": 0},
verify=False,
timeout=BACKUP_CMD_TIMEOUT,
)
elapsed = time.time() - t0
print(f"[i] Respuesta en {elapsed:.1f}s — HTTP {resp.status_code}")
if resp.status_code == 200:
try:
res_json = resp.json()
data_list = res_json.get("data", [])
if data_list and "url" in data_list[0]:
relative_url = data_list[0]["url"]
download_url = f"{base_url}{relative_url}"
ext = ".unifi" if relative_url.endswith(".unifi") else ".unf"
print(f"[+] Backup generado por /cmd/backup: {relative_url}")
print("[*] Descargando archivo generado...")
t1 = time.time()
dl = session.get(download_url, verify=False, timeout=DOWNLOAD_TIMEOUT)
print(f"[i] Descarga en {time.time() - t1:.1f}s — HTTP {dl.status_code}")
if dl.status_code == 200:
print(f"[+] /cmd/backup exitoso — {len(dl.content) / 1024:.1f} KB")
return dl.content, ext
else:
print(f"[!] Respuesta inesperada de /cmd/backup: {res_json}")
except Exception as e:
print(f"[!] Error procesando respuesta de /cmd/backup: {e}")
elif resp.status_code == 403:
print("[!] HTTP 403 en /cmd/backup — el usuario necesita 'Full Management' en Network.")
else:
print(f"[!] HTTP {resp.status_code} en /cmd/backup: {resp.text[:200]}")
except requests.exceptions.ReadTimeout:
print(f"[!] /cmd/backup no respondió en {BACKUP_CMD_TIMEOUT[1]}s (fallo silencioso conocido).")
print(" → Habilitar SSH en el LXC para que la Estrategia 1 funcione.")
except requests.exceptions.ConnectionError as e:
print(f"[!] Error de conexión en /cmd/backup: {e}")
except Exception as e:
print(f"[!] Error inesperado en /cmd/backup: {e}")
return None
def backup_via_api() -> tuple[bytes, str, str, str] | None:
"""
Estrategia 2: backup vía API HTTP local (sin nube).
Prueba en este orden:
[OS] POST /api/backup → trigger creación backup OS Server (.unifi)
GET /api/backup/download → descarga el .unifi generado
[A] GET /api/backup/download → descarga el último .unifi disponible (sin trigger)
[B] POST /proxy/network/.../cmd/backup → backup Network App (.unf) vía proxy
[C] Puerto 8443 directo → /cmd/backup sin proxy (Network App)
Referencia: instalador línea 5534: WEB_PORT en /var/lib/uosserver/server.conf → 11443
"""
model, version = "UniFi-OS-Server-5.1.21", "Network-10.5.67"
# ── Autenticación única para todos los intentos vía 11443 ───────────────
print(f"[*] Autenticando en OS Server: {UNIFI_BASE_URL}")
try:
session = _create_authenticated_session(UNIFI_BASE_URL, UNIFI_USER, UNIFI_PASS)
model, version = _get_system_info(session, UNIFI_BASE_URL)
except PermissionError as e:
print(f"[!] Autenticación fallida: {e}")
return None
except Exception as e:
print(f"[!] No se pudo autenticar: {e}")
return None
# ── [OS] Intentar crear + descargar backup del OS Server (.unifi) ────────
print("\n[*] [OS] Intentando backup del OS Server (.unifi)...")
triggered = _try_create_os_server_backup(session, UNIFI_BASE_URL)
if triggered:
content = _try_direct_download(session, UNIFI_BASE_URL)
if content:
print("[+] [OS] Backup OS Server (.unifi) obtenido correctamente.")
return content, ".unifi", model, version
print("[!] [OS] Trigger aceptado pero descarga falló. Continuando...")
# ── [A] Intentar descarga directa del último backup disponible ───────────
print("\n[*] [A] Descarga directa del último backup disponible...")
content = _try_direct_download(session, UNIFI_BASE_URL)
if content:
# Determinar extensión por el contenido
ext = ".unifi" if b"unifi_os_backup" in content[:200] else ".unf"
print(f"[+] [A] Backup descargado directamente ({ext}).")
return content, ext, model, version
# ── [B] Fallback: backup Network App vía proxy (puerto 11443) ───────────
print("\n[*] [B] Intentando backup Network App vía proxy (puerto 11443)...")
result = _try_cmd_backup(session, UNIFI_BASE_URL)
if result:
content, ext = result
return content, ext, model, version
# ── [C] Fallback: Network App directa (puerto 8443) ─────────────────────
print(f"\n[*] [C] Intentando Network App directa: {UNIFI_NETWORK_URL}")
for login_path in ["/api/auth/login", "/api/login"]:
try:
session_c = requests.Session()
session_c.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Content-Type": "application/json",
})
resp_login = session_c.post(
f"{UNIFI_NETWORK_URL}{login_path}",
json={"username": UNIFI_USER, "password": UNIFI_PASS},
verify=False, timeout=15,
)
if resp_login.status_code not in (200, 201):
continue
csrf = resp_login.headers.get("X-CSRF-Token") or resp_login.headers.get("x-csrf-token")
if csrf:
session_c.headers.update({"X-CSRF-Token": csrf})
print(f"[+] [C] Autenticación exitosa en {login_path}")
result = _try_cmd_backup(session_c, UNIFI_NETWORK_URL)
if result:
content, ext = result
return content, ext, model, version
content = _try_direct_download(session_c, UNIFI_NETWORK_URL)
if content:
return content, ".unifi", model, version
break
except Exception as e:
print(f"[i] [C] Error con {login_path}: {e}")
continue
print("[!] [Estrategia 2 — API] Todos los intentos fallaron.")
return None
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════════
def main():
print("=" * 60)
print(" RESPALDO UNIFI OS SERVER 5.1.21 / NETWORK 10.5.67")
print(f" LXC Proxmox — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
# ── 1. Acceso al NAS ────────────────────────────────────────────────────
if not authenticate_nas_share(NAS_PATH, NAS_USER, NAS_PASS):
print(f"[ERROR CRÍTICO] No se puede acceder a la ruta destino: {NAS_PATH}")
sys.exit(1)
backup_content: bytes | None = None
file_ext = ".unf"
model = "UniFi-OS-Server-5.1.21"
version = "Network-10.5.67"
# ── 2. Estrategia 1: SSH / SFTP ─────────────────────────────────────────
_sep("Estrategia 1: SSH / SFTP (principal)")
result_ssh = backup_via_ssh()
if result_ssh:
backup_content, file_ext = result_ssh
print("[+] Backup obtenido por SSH exitosamente.")
else:
print("[!] Estrategia 1 (SSH) no disponible o sin autobackups. Continuando...")
# ── 3. Estrategia 2: API HTTP ────────────────────────────────────────────
if backup_content is None:
_sep("Estrategia 2: API HTTP (fallback)")
result_api = backup_via_api()
if result_api:
backup_content, file_ext, model, version = result_api
print("[+] Backup obtenido por API exitosamente.")
else:
print("[!] Estrategia 2 (API) también falló.")
# ── 4. Verificar que tenemos contenido ──────────────────────────────────
if backup_content is None:
print()
print("=" * 60)
print("[ERROR CRÍTICO] RESPALDO FALLIDO — Ninguna estrategia tuvo éxito.")
print()
print(" Pasos para resolver:")
print()
print(" [SSH] 1. Habilitar SSH en el LXC de Proxmox (si no está activo)")
print(" y asegurarse que SSH_PASS en este script sea correcto.")
print()
print(" [SSH] 2. Habilitar autobackups en UniFi UI:")
print(" Settings → System → Backups → Auto Backup → ON")
print(" Esperar a que genere el primer archivo .unf.")
print()
print(" [API] 3. Verificar permisos del usuario admin:")
print(" Settings → Admins & Users → admin")
print(" → Network: Full Management (no solo View)")
print("=" * 60)
sys.exit(1)
# ── 5. Guardar en NAS ───────────────────────────────────────────────────
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Prefijo según tipo de backup: OS Server (.unifi) o Network App (.unf)
if file_ext == ".unifi":
prefix = "UniFi_OS_Server"
else:
prefix = "UniFi_Network_App"
safe_version = sanitize_filename(version)
filename = f"{prefix}_{safe_version}_{timestamp}{file_ext}"
destination_file = os.path.join(NAS_PATH, filename)
print(f"\n[*] Guardando en NAS: {filename}")
try:
with open(destination_file, "wb") as f:
f.write(backup_content)
size_kb = len(backup_content) / 1024
print()
print("=" * 60)
print("[ÉXITO] RESPALDO COMPLETADO")
print(f" Ruta : {destination_file}")
print(f" Tamaño: {size_kb:.2f} KB")
print("=" * 60)
except Exception as e:
print(f"[ERROR CRÍTICO] Falló la escritura del archivo en el NAS: {e}")
sys.exit(1)
# ── 6. Limpieza por retención ────────────────────────────────────────────
cleanup_old_backups(NAS_PATH, RETENTION_DAYS)
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
def migrate():
print(f"Connecting to database at {DB_PATH}...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 1. Create script_jobs table
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS script_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
script_type VARCHAR(50) NOT NULL,
script_path VARCHAR(1024) NOT NULL,
schedule_cron VARCHAR(100) NOT NULL DEFAULT '0 2 * * *',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
print("Table 'script_jobs' created successfully.")
except sqlite3.OperationalError as e:
print(f"Table 'script_jobs' could not be created: {e}")
# 2. Create script_job_runs table
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS script_job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_job_id INTEGER NOT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
status VARCHAR(50) NOT NULL DEFAULT 'RUNNING',
duration_seconds REAL NOT NULL DEFAULT 0.0,
log_output TEXT NULL,
FOREIGN KEY(script_job_id) REFERENCES script_jobs(id) ON DELETE CASCADE
)
""")
print("Table 'script_job_runs' created successfully.")
except sqlite3.OperationalError as e:
print(f"Table 'script_job_runs' could not be created: {e}")
conn.commit()
conn.close()
print("Migration finished.")
if __name__ == "__main__":
migrate()
+29
View File
@@ -0,0 +1,29 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
def seed():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
jobs = [
("Respaldo de FortiGate API", "python", "app/scripts/backup_fortigate.py", "0 6,18 * * *"),
("Respaldo de UniFi Controller", "python", "app/scripts/backup_unifi.py", "0 3 * * *"),
("Respaldo de Grandstream UCM/GWN", "python", "app/scripts/backup_grandstream.py", "0 1 * * *")
]
for name, stype, path, cron in jobs:
cursor.execute("SELECT id FROM script_jobs WHERE script_path = ?", (path,))
if not cursor.fetchone():
cursor.execute(
"INSERT INTO script_jobs (name, script_type, script_path, schedule_cron, is_active, created_at) VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)",
(name, stype, path, cron)
)
print(f"Seeded script job: {name}")
conn.commit()
conn.close()
if __name__ == "__main__":
seed()
+7
View File
@@ -9,6 +9,7 @@ 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 { UsersView } from './pages/UsersView';
import { ScriptJobsView } from './pages/ScriptJobsView';
import { ActiveUpload } from './components/LiveTransferMeter'; import { ActiveUpload } from './components/LiveTransferMeter';
import { import {
api, api,
@@ -155,6 +156,8 @@ export const App: React.FC = () => {
return 'Auditoría & Logs'; return 'Auditoría & Logs';
case 'users': case 'users':
return 'Gestión de Cuentas'; 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:
@@ -221,6 +224,10 @@ export const App: React.FC = () => {
<UsersView /> <UsersView />
)} )}
{currentTab === 'scripts' && (
<ScriptJobsView />
)}
{currentTab === 'settings' && ( {currentTab === 'settings' && (
<SettingsView <SettingsView
onRefresh={loadData} onRefresh={loadData}
+5 -4
View File
@@ -22,10 +22,11 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ 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: 'users', label: 'Gestión de Cuentas', icon: Users},
{ id: 'events', label: 'Auditoría & Logs', icon: FileText }, {id: 'scripts', label: 'Scripts del Servidor', icon: Server},
{ id: 'settings', label: 'Configuración Global', icon: Settings }, {id: 'events', label: 'Auditoría & Logs', icon: FileText},
{id: 'settings', label: 'Configuración Global', icon: Settings},
]; ];
return ( return (
+541
View File
@@ -0,0 +1,541 @@
import React, { useState, useEffect } from 'react';
import {
Play,
Terminal,
Edit,
Trash2,
Plus,
X,
Server,
Clock,
Activity,
FileText,
Check,
AlertCircle,
Loader2,
RefreshCw,
Power
} from 'lucide-react';
import { api, ScriptJobResponse, ScriptJobRunResponse } from '../services/api';
export const ScriptJobsView: React.FC = () => {
const [jobs, setJobs] = useState<ScriptJobResponse[]>([]);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Edit / Create Modal State
const [showModal, setShowModal] = useState(false);
const [editingJob, setEditingJob] = useState<ScriptJobResponse | null>(null);
const [formData, setFormData] = useState({
name: '',
script_type: 'python',
script_path: '',
schedule_cron: '0 2 * * *',
is_active: true
});
const [saving, setSaving] = useState(false);
// Runs History Modal State
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [selectedJob, setSelectedJob] = useState<ScriptJobResponse | null>(null);
const [runs, setRuns] = useState<ScriptJobRunResponse[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
// Log View Modal State
const [selectedRun, setSelectedRun] = useState<ScriptJobRunResponse | null>(null);
const [showLogModal, setShowLogModal] = useState(false);
const fetchJobs = async () => {
setLoading(true);
setErrorMsg(null);
try {
const data = await api.getScriptJobs();
setJobs(data);
} catch (err: any) {
setErrorMsg(`Error al cargar scripts: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchJobs();
}, []);
const handleOpenCreate = () => {
setEditingJob(null);
setFormData({
name: '',
script_type: 'python',
script_path: 'app/scripts/backup_fortigate.py',
schedule_cron: '0 2 * * *',
is_active: true
});
setShowModal(true);
};
const handleOpenEdit = (job: ScriptJobResponse) => {
setEditingJob(job);
setFormData({
name: job.name,
script_type: job.script_type,
script_path: job.script_path,
schedule_cron: job.schedule_cron,
is_active: job.is_active
});
setShowModal(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setErrorMsg(null);
try {
if (editingJob) {
await api.updateScriptJob(editingJob.id, formData);
setSuccessMsg('Script actualizado exitosamente.');
} else {
await api.createScriptJob(formData);
setSuccessMsg('Nuevo script agregado y agendado exitosamente.');
}
setShowModal(false);
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al guardar: ${err.message}`);
} finally {
setSaving(false);
}
};
const handleDelete = async (jobId: number) => {
if (!window.confirm('¿Está seguro de eliminar este script? Se perderá el historial de ejecuciones.')) {
return;
}
try {
await api.deleteScriptJob(jobId);
setSuccessMsg('Script eliminado exitosamente.');
fetchJobs();
setTimeout(() => setSuccessMsg(null), 4000);
} catch (err: any) {
setErrorMsg(`Error al eliminar: ${err.message}`);
}
};
const handleTrigger = async (jobId: number) => {
try {
const res = await api.triggerScriptJob(jobId);
alert(`✓ Ejecución iniciada: ${res.message}`);
} catch (err: any) {
alert(`✗ Error al iniciar: ${err.message}`);
}
};
const handleOpenHistory = async (job: ScriptJobResponse) => {
setSelectedJob(job);
setShowHistoryModal(true);
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(job.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const handleRefreshHistory = async () => {
if (!selectedJob) return;
setLoadingRuns(true);
try {
const data = await api.getScriptJobRuns(selectedJob.id);
setRuns(data);
} catch (err: any) {
alert(`Error al cargar historial: ${err.message}`);
} finally {
setLoadingRuns(false);
}
};
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs.toFixed(0)}s`;
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Scripts y Automatizaciones del Servidor</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Programa y monitorea respaldos basados en Python o Bash para plataformas web externas (Fortigate, UniFi, Grandstream, etc.)
</p>
</div>
<button className="btn btn-primary" onClick={handleOpenCreate} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Plus size={16} />
Nuevo Script
</button>
</div>
{errorMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-rose)', backgroundColor: 'rgba(244, 63, 94, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<AlertCircle size={18} color="var(--accent-rose)" />
<span style={{ fontSize: '0.86rem', color: '#FDA4AF' }}>{errorMsg}</span>
</div>
)}
{successMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-emerald)', backgroundColor: 'rgba(16, 185, 129, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<Check size={18} color="var(--accent-emerald)" />
<span style={{ fontSize: '0.86rem', color: '#A7F3D0' }}>{successMsg}</span>
</div>
)}
<div className="glass-card" style={{ overflow: 'hidden' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '60px' }}>
<Loader2 className="animate-spin" size={32} color="var(--accent-cyan)" />
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Nombre del Trabajo</th>
<th>Tipo</th>
<th>Ruta del Script</th>
<th>Programación Cron</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Server size={16} color="var(--accent-cyan)" />
<span style={{ fontWeight: 600 }}>{job.name}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.script_type === 'python' ? 'rgba(56, 189, 248, 0.15)' : 'rgba(16, 185, 129, 0.15)',
color: job.script_type === 'python' ? '#38BDF8' : '#34D399',
fontWeight: 600
}}>
{job.script_type.toUpperCase()}
</span>
</td>
<td style={{ fontSize: '0.82rem', fontFamily: 'monospace', color: 'var(--text-muted)' }}>
{job.script_path}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem' }}>
<Clock size={14} color="var(--text-dim)" />
<span>{job.schedule_cron}</span>
</div>
</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: job.is_active ? 'rgba(16, 185, 129, 0.15)' : 'rgba(255,255,255,0.06)',
color: job.is_active ? '#34D399' : 'var(--text-dim)',
fontWeight: 600
}}>
{job.is_active ? 'Activo' : 'Pausado'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
className="action-btn"
title="Ejecutar ahora en background"
onClick={() => handleTrigger(job.id)}
>
<Play size={15} color="var(--accent-emerald)" />
</button>
<button
className="action-btn"
title="Ver Historial de Ejecuciones"
onClick={() => handleOpenHistory(job)}
>
<Terminal size={15} color="var(--accent-cyan)" />
</button>
<button
className="action-btn"
title="Editar"
onClick={() => handleOpenEdit(job)}
>
<Edit size={15} color="var(--text-muted)" />
</button>
<button
className="action-btn"
title="Eliminar"
onClick={() => handleDelete(job.id)}
>
<Trash2 size={15} color="var(--accent-rose)" />
</button>
</div>
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay scripts del servidor configurados. Haz clic en "Nuevo Script" para empezar.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Create / Edit Modal */}
{showModal && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '500px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700 }}>
{editingJob ? 'Editar Script' : 'Nuevo Script del Servidor'}
</h4>
<button className="modal-close" onClick={() => setShowModal(false)}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSave}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group">
<label>Nombre Descriptivo:</label>
<input
type="text"
className="form-input"
required
placeholder="ej: Respaldo FortiGate Oficina Central"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>Tipo de Script:</label>
<select
className="form-input"
value={formData.script_type}
onChange={(e) => setFormData({ ...formData, script_type: e.target.value })}
>
<option value="python">Python Script (.py)</option>
<option value="bash">Bash Script / Command (.sh/.bat/.ps1)</option>
</select>
</div>
<div className="form-group">
<label>Ruta Relativa del Script (En el Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: app/scripts/backup_fortigate.py"
value={formData.script_path}
onChange={(e) => setFormData({ ...formData, script_path: e.target.value })}
/>
<p style={{ fontSize: '0.72rem', color: 'var(--text-dim)', marginTop: '4px' }}>
Debe apuntar a un archivo existente en el directorio del servidor de OnEver Drive.
</p>
</div>
<div className="form-group">
<label>Programación Cron (Servidor):</label>
<input
type="text"
className="form-input"
required
placeholder="ej: 0 3 * * * (Cada día a las 03:00 AM)"
value={formData.schedule_cron}
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
/>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', marginTop: '10px' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
Habilitar ejecución automática (Scheduler Activo)
</label>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={16} /> : 'Guardar Script'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Runs History Modal */}
{showHistoryModal && selectedJob && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '750px', height: '80vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<div>
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={18} color="var(--accent-cyan)" />
Historial de Ejecución: {selectedJob.name}
</h4>
<span style={{ fontSize: '0.78rem', color: 'var(--text-dim)' }}>
Visualiza los logs y estado de cada corrida programada o manual
</span>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={handleRefreshHistory}>
<RefreshCw size={14} />
</button>
<button className="modal-close" onClick={() => setShowHistoryModal(false)}>
<X size={18} />
</button>
</div>
</div>
<div className="modal-body" style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{loadingRuns ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={28} color="var(--accent-cyan)" />
</div>
) : (
<table className="table" style={{ width: '100%', fontSize: '0.84rem' }}>
<thead>
<tr>
<th>Fecha de Inicio</th>
<th>Duración</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{new Date(run.started_at).toLocaleString()}</td>
<td>{run.completed_at ? formatDuration(run.duration_seconds) : 'Corriendo...'}</td>
<td>
<span style={{
fontSize: '0.74rem',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor:
run.status === 'SUCCESS' ? 'rgba(16, 185, 129, 0.15)' :
run.status === 'FAILED' ? 'rgba(239, 68, 68, 0.15)' : 'rgba(59, 130, 246, 0.15)',
color:
run.status === 'SUCCESS' ? '#34D399' :
run.status === 'FAILED' ? '#FCA5A5' : '#93C5FD',
fontWeight: 600
}}>
{run.status}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
disabled={!run.completed_at}
onClick={() => {
setSelectedRun(run);
setShowLogModal(true);
}}
>
<FileText size={13} />
Ver Logs de Consola
</button>
</td>
</tr>
))}
{runs.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
Aún no hay ejecuciones registradas para este script.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<button className="btn btn-secondary" onClick={() => setShowHistoryModal(false)}>
Cerrar
</button>
</div>
</div>
</div>
)}
{/* Log Console Output Modal */}
{showLogModal && selectedRun && (
<div className="modal-backdrop" style={{ zIndex: 1100 }}>
<div className="modal-content" style={{ maxWidth: '700px', height: '65vh', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={16} color="var(--accent-cyan)" />
Salida de Consola (Run #{selectedRun.id})
</h4>
<button className="modal-close" onClick={() => setShowLogModal(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ flex: 1, padding: '12px', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{
flex: 1,
backgroundColor: '#090d16',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
padding: '12px',
overflow: 'auto',
fontFamily: 'Consolas, monospace',
fontSize: '0.8rem',
color: '#e2e8f0',
whiteSpace: 'pre-wrap',
lineHeight: '1.4'
}}>
{selectedRun.log_output || 'No se guardaron logs de salida.'}
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(selectedRun.log_output || '');
alert('Logs copiados al portapapeles.');
}}
>
Copiar Logs
</button>
<button className="btn btn-primary" onClick={() => setShowLogModal(false)}>
Cerrar Consola
</button>
</div>
</div>
</div>
)}
</div>
);
};
+43
View File
@@ -128,6 +128,26 @@ export interface UserItem {
created_at: 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 => {
return localStorage.getItem('oed_token'); return localStorage.getItem('oed_token');
}; };
@@ -252,6 +272,29 @@ export const api = {
body: JSON.stringify(data), 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) =>
request<BackupJobItem[]>(clientId ? `/jobs?client_id=${clientId}` : '/jobs'), request<BackupJobItem[]>(clientId ? `/jobs?client_id=${clientId}` : '/jobs'),