First commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user