First commit

This commit is contained in:
unknown
2026-06-09 21:18:13 -03:00
commit 5bff6b938b
66 changed files with 10922 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.11-slim
WORKDIR /backup_service
# Instalar dependencias del sistema:
# - default-mysql-client → provee mysqldump y mysqladmin
# - gcc / pkg-config → para compilar algunas dependencias de Python
RUN apt-get update && apt-get install -y \
default-mysql-client \
gcc \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Copiar e instalar dependencias Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar el código
COPY main.py .
RUN mkdir -p \
/config/nginx \
/config/ssl \
/config/telegram_sessions \
/backups
# Usuario no-root
RUN useradd -m -u 1000 backupuser && \
chown -R backupuser:backupuser /backup_service /backups
USER backupuser
EXPOSE 8099
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8099"]
+378
View File
@@ -0,0 +1,378 @@
"""
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"}
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.104.1
uvicorn==0.24.0
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
python-dotenv==1.0.0