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