378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""
|
|
main.py — Backup Microservice
|
|
Centraliza el backup de todos los componentes del sistema TIP:
|
|
- Base de datos (mysqldump de feeder y users)
|
|
- Archivos de configuración (nginx.conf, docker-compose.yml)
|
|
- Certificados SSL
|
|
- Sesiones de Telegram
|
|
- Logs de auditoría (export JSON)
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from jose import JWTError, jwt
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuración
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
|
if not SECRET_KEY:
|
|
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
|
|
if len(SECRET_KEY) < 20:
|
|
raise RuntimeError("SECRET_KEY demasiado corta (mínimo 20 caracteres).")
|
|
|
|
ALGORITHM = "HS256"
|
|
|
|
# Bases de datos
|
|
FEEDER_DB_URL = os.getenv("FEEDER_DATABASE_URL", "")
|
|
USERS_DB_URL = os.getenv("USERS_DATABASE_URL", "")
|
|
|
|
# Rutas montadas en el contenedor (ver Dockerfile / docker-compose)
|
|
PATH_NGINX_CONF = Path(os.getenv("PATH_NGINX_CONF", "/config/nginx/nginx.conf"))
|
|
PATH_DOCKER_COMPOSE = Path(os.getenv("PATH_DOCKER_COMPOSE", "/config/docker-compose.yml"))
|
|
PATH_SSL_DIR = Path(os.getenv("PATH_SSL_DIR", "/config/ssl"))
|
|
PATH_TELEGRAM_SESS = Path(os.getenv("PATH_TELEGRAM_SESSIONS", "/config/telegram_sessions"))
|
|
|
|
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/backups"))
|
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
|
|
|
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
|
"""
|
|
Valida el JWT firmado con SECRET_KEY compartida.
|
|
Devuelve el user_id (puede ser 'system' o un entero).
|
|
"""
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
user_id = payload.get("user_id")
|
|
if user_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Token inválido: falta user_id",
|
|
)
|
|
return str(user_id)
|
|
except JWTError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Token inválido o expirado",
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="TIP — Backup Service",
|
|
description="Microservicio centralizado de backups del sistema Threat Intelligence Platform.",
|
|
version="1.0.0",
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers de DB dump
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_db_url(url: str) -> dict:
|
|
"""
|
|
Parsea una URL tipo:
|
|
mariadb+pymysql://user:pass@host:port/dbname
|
|
y devuelve un dict con los componentes.
|
|
"""
|
|
# Quitar prefijo de driver
|
|
if "://" in url:
|
|
url = url.split("://", 1)[1]
|
|
|
|
user_pass, rest = url.split("@", 1)
|
|
user, password = user_pass.split(":", 1)
|
|
host_port, dbname = rest.split("/", 1)
|
|
|
|
if ":" in host_port:
|
|
host, port = host_port.split(":", 1)
|
|
else:
|
|
host, port = host_port, "3306"
|
|
|
|
return {
|
|
"user": user,
|
|
"password": password,
|
|
"host": host,
|
|
"port": port,
|
|
"dbname": dbname,
|
|
}
|
|
|
|
|
|
def _mysqldump(db_url: str, label: str) -> Optional[bytes]:
|
|
"""
|
|
Ejecuta mysqldump y devuelve los bytes del dump.
|
|
Devuelve None si la URL está vacía o el comando falla.
|
|
|
|
Nota: se pasa --skip-ssl / --ssl=FALSE para evitar el error
|
|
"SSL is required but the server does not support it" que aparece
|
|
cuando el cliente mysqldump intenta negociar TLS con un MariaDB
|
|
local que no tiene SSL habilitado.
|
|
"""
|
|
if not db_url:
|
|
return None
|
|
|
|
try:
|
|
cfg = _parse_db_url(db_url)
|
|
except Exception as e:
|
|
print(f"[BACKUP] No se pudo parsear {label} DB URL: {e}")
|
|
return None
|
|
|
|
cmd = [
|
|
"mysqldump",
|
|
f"-h{cfg['host']}",
|
|
f"-P{cfg['port']}",
|
|
f"-u{cfg['user']}",
|
|
f"-p{cfg['password']}",
|
|
# Deshabilitar SSL — MariaDB en el compose no tiene TLS configurado
|
|
"--skip-ssl",
|
|
"--single-transaction",
|
|
"--routines",
|
|
"--triggers",
|
|
cfg["dbname"],
|
|
]
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
timeout=300,
|
|
)
|
|
if result.returncode != 0:
|
|
stderr_msg = result.stderr.decode(errors="replace").strip()
|
|
print(f"[BACKUP] mysqldump {label} falló: {stderr_msg}")
|
|
return None
|
|
print(f"[BACKUP] mysqldump {label} OK ({len(result.stdout)} bytes)")
|
|
return result.stdout
|
|
except FileNotFoundError:
|
|
print("[BACKUP] mysqldump no encontrado en el contenedor.")
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
print(f"[BACKUP] mysqldump {label} superó el timeout (300 s).")
|
|
return None
|
|
except Exception as e:
|
|
print(f"[BACKUP] Error inesperado en mysqldump {label}: {e}")
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers de archivos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _add_file_to_zip(zf: zipfile.ZipFile, src: Path, arcname: str):
|
|
"""Agrega un archivo al zip si existe. Registra advertencia si no."""
|
|
if src.exists() and src.is_file():
|
|
zf.write(src, arcname)
|
|
else:
|
|
print(f"[BACKUP] Archivo no encontrado, se omite: {src}")
|
|
|
|
|
|
def _add_dir_to_zip(zf: zipfile.ZipFile, src: Path, prefix: str):
|
|
"""Agrega recursivamente un directorio al zip."""
|
|
if not src.exists() or not src.is_dir():
|
|
print(f"[BACKUP] Directorio no encontrado, se omite: {src}")
|
|
return
|
|
for file in src.rglob("*"):
|
|
if file.is_file():
|
|
arcname = str(Path(prefix) / file.relative_to(src))
|
|
zf.write(file, arcname)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoint principal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.get("/backup", tags=["Backup"])
|
|
def create_backup(
|
|
include_db: bool = Query(True, description="Incluir dump de bases de datos"),
|
|
include_config: bool = Query(True, description="Incluir nginx.conf y docker-compose.yml"),
|
|
include_ssl: bool = Query(True, description="Incluir certificados SSL"),
|
|
include_sessions: bool = Query(True, description="Incluir archivos de sesión de Telegram"),
|
|
current_user: str = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Genera un archivo ZIP con los componentes seleccionados del sistema y lo descarga.
|
|
|
|
Componentes disponibles:
|
|
- **DB**: mysqldump de la BD del feeder y de usuarios.
|
|
- **Config**: nginx.conf y docker-compose.yml.
|
|
- **SSL**: directorio de certificados montado en /config/ssl.
|
|
- **Sessions**: archivos de sesión de Telegram de /config/telegram_sessions.
|
|
"""
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
zip_name = f"tip_backup_{timestamp}.zip"
|
|
|
|
buffer = io.BytesIO()
|
|
manifest = {
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
"created_by": current_user,
|
|
"components": [],
|
|
"warnings": [],
|
|
}
|
|
|
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
# ---- Bases de datos ----
|
|
if include_db:
|
|
for label, url in [("feeder", FEEDER_DB_URL), ("users", USERS_DB_URL)]:
|
|
dump = _mysqldump(url, label)
|
|
if dump:
|
|
arcname = f"databases/{label}_{timestamp}.sql"
|
|
zf.writestr(arcname, dump)
|
|
manifest["components"].append(f"database:{label}")
|
|
print(f"[BACKUP] DB {label} OK ({len(dump)} bytes)")
|
|
else:
|
|
warn = f"DB {label}: dump no disponible (URL vacía o mysqldump falló)"
|
|
manifest["warnings"].append(warn)
|
|
print(f"[BACKUP] WARN — {warn}")
|
|
|
|
# ---- Configuración ----
|
|
if include_config:
|
|
_add_file_to_zip(zf, PATH_NGINX_CONF, "config/nginx.conf")
|
|
_add_file_to_zip(zf, PATH_DOCKER_COMPOSE, "config/docker-compose.yml")
|
|
|
|
existing = []
|
|
if PATH_NGINX_CONF.exists():
|
|
existing.append("nginx.conf")
|
|
if PATH_DOCKER_COMPOSE.exists():
|
|
existing.append("docker-compose.yml")
|
|
|
|
if existing:
|
|
manifest["components"].append(f"config:{','.join(existing)}")
|
|
else:
|
|
manifest["warnings"].append("Config: ningún archivo de configuración encontrado")
|
|
|
|
# ---- Certificados SSL ----
|
|
if include_ssl:
|
|
_add_dir_to_zip(zf, PATH_SSL_DIR, "ssl")
|
|
if PATH_SSL_DIR.exists():
|
|
manifest["components"].append("ssl")
|
|
else:
|
|
manifest["warnings"].append(f"SSL: directorio no encontrado ({PATH_SSL_DIR})")
|
|
|
|
# ---- Sesiones de Telegram ----
|
|
if include_sessions:
|
|
_add_dir_to_zip(zf, PATH_TELEGRAM_SESS, "telegram_sessions")
|
|
if PATH_TELEGRAM_SESS.exists():
|
|
manifest["components"].append("telegram_sessions")
|
|
else:
|
|
manifest["warnings"].append(
|
|
f"Sessions: directorio no encontrado ({PATH_TELEGRAM_SESS})"
|
|
)
|
|
|
|
# ---- Manifiesto ----
|
|
zf.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
|
|
buffer.seek(0)
|
|
print(f"[BACKUP] ZIP generado: {zip_name} — componentes: {manifest['components']}")
|
|
|
|
return StreamingResponse(
|
|
buffer,
|
|
media_type="application/zip",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={zip_name}",
|
|
"X-Backup-Timestamp": timestamp,
|
|
"X-Backup-Components": ",".join(manifest["components"]),
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoint de estado
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.get("/backup/status", tags=["Backup"])
|
|
def backup_status(current_user: str = Depends(get_current_user)):
|
|
"""
|
|
Devuelve el estado de disponibilidad de cada componente de backup
|
|
sin generar el archivo.
|
|
"""
|
|
|
|
def _db_reachable(url: str) -> bool:
|
|
if not url:
|
|
return False
|
|
try:
|
|
cfg = _parse_db_url(url)
|
|
except Exception:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"mysqladmin",
|
|
f"-h{cfg['host']}",
|
|
f"-P{cfg['port']}",
|
|
f"-u{cfg['user']}",
|
|
f"-p{cfg['password']}",
|
|
"--skip-ssl",
|
|
"ping",
|
|
],
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
return {
|
|
"service": "backup",
|
|
"checked_at": datetime.utcnow().isoformat(),
|
|
"components": {
|
|
"db_feeder": {
|
|
"url_configured": bool(FEEDER_DB_URL),
|
|
"reachable": _db_reachable(FEEDER_DB_URL),
|
|
},
|
|
"db_users": {
|
|
"url_configured": bool(USERS_DB_URL),
|
|
"reachable": _db_reachable(USERS_DB_URL),
|
|
},
|
|
"nginx_conf": {
|
|
"path": str(PATH_NGINX_CONF),
|
|
"exists": PATH_NGINX_CONF.exists(),
|
|
},
|
|
"docker_compose": {
|
|
"path": str(PATH_DOCKER_COMPOSE),
|
|
"exists": PATH_DOCKER_COMPOSE.exists(),
|
|
},
|
|
"ssl": {
|
|
"path": str(PATH_SSL_DIR),
|
|
"exists": PATH_SSL_DIR.exists(),
|
|
"files": [f.name for f in PATH_SSL_DIR.iterdir()] if PATH_SSL_DIR.exists() else [],
|
|
},
|
|
"telegram_sessions": {
|
|
"path": str(PATH_TELEGRAM_SESS),
|
|
"exists": PATH_TELEGRAM_SESS.exists(),
|
|
"files": [
|
|
f.name for f in PATH_TELEGRAM_SESS.iterdir()
|
|
if f.suffix == ".session"
|
|
] if PATH_TELEGRAM_SESS.exists() else [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@app.get("/health", tags=["Health"])
|
|
def health():
|
|
return {"status": "healthy", "service": "backup"}
|
|
|
|
|
|
@app.get("/", tags=["Health"])
|
|
def root():
|
|
return {"message": "TIP Backup Service — use GET /backup para generar un backup"} |