#!/usr/bin/env python3 """ VPS Monitor Agent — à déployer sur chaque VPS. Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker. """ import os import subprocess import threading import time from datetime import datetime, timezone import docker import psutil from docker.errors import DockerException, NotFound from fastapi import Depends, FastAPI, HTTPException, Security from fastapi.middleware.cors import CORSMiddleware from fastapi.security import APIKeyHeader # ─── Config ─────────────────────────────────────────────────────────────────── AGENT_VERSION = "1.2.0" REPO_BASE = os.getenv("AGENT_REPO_BASE", "https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main") INSTALL_DIR = os.getenv("AGENT_INSTALL_DIR", "/opt/vps-monitor-agent") API_KEY = os.getenv("AGENT_API_KEY", "changeme-please") AGENT_PORT = int(os.getenv("AGENT_PORT", "8001")) # ─── App ────────────────────────────────────────────────────────────────────── app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_url=None) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True) def require_api_key(key: str = Security(api_key_header)) -> None: if key != API_KEY: raise HTTPException(status_code=403, detail="Clé API invalide") def get_docker_client(): try: return docker.from_env() except DockerException as e: raise HTTPException(status_code=503, detail=f"Docker inaccessible : {e}") # ─── Routes ─────────────────────────────────────────────────────────────────── @app.get("/health") def health(): """Vérification de disponibilité — sans authentification.""" return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} @app.get("/version") def get_version(): """Retourne la version de l'agent — sans authentification.""" return {"version": AGENT_VERSION} @app.post("/self-update") def self_update(_: None = Depends(require_api_key)): """Télécharge la dernière version de l'agent depuis le dépôt et redémarre le service.""" def _do_update(): time.sleep(0.5) # laisse la réponse HTTP partir try: for filename in ("agent.py", "requirements.txt"): src = f"{REPO_BASE}/vps-monitor/agent/{filename}" dst = f"{INSTALL_DIR}/{filename}" subprocess.run( ["curl", "-fsSL", src, "-o", dst], timeout=60, check=True, ) subprocess.run( [f"{INSTALL_DIR}/venv/bin/pip", "install", "-r", f"{INSTALL_DIR}/requirements.txt", "-q"], timeout=120, check=True, ) subprocess.run( ["systemctl", "restart", "vps-monitor-agent"], timeout=30, check=True, ) except Exception: pass threading.Thread(target=_do_update, daemon=True).start() return {"status": "update_started"} @app.get("/containers") def list_containers(_: None = Depends(require_api_key)): """Retourne tous les conteneurs (actifs et arrêtés).""" client = get_docker_client() result = [] for c in client.containers.list(all=True): image_tag = c.image.tags[0] if c.image.tags else c.image.short_id health_state = c.attrs.get("State", {}).get("Health", {}) health = health_state.get("Status", "none") if health_state else "none" result.append({ "id": c.short_id, "name": c.name.lstrip("/"), "status": c.status, "health": health, "image": image_tag, "created": c.attrs.get("Created", ""), "compose_project": c.labels.get("com.docker.compose.project", ""), "compose_service": c.labels.get("com.docker.compose.service", ""), "compose_working_dir": c.labels.get("com.docker.compose.project.working_dir", ""), "ports": { host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings] for host, bindings in (c.ports or {}).items() if bindings }, }) return sorted(result, key=lambda x: x["name"]) @app.get("/containers/{container_id}/logs") def get_logs(container_id: str, lines: int = 100, _: None = Depends(require_api_key)): """Retourne les N dernières lignes de logs d'un conteneur.""" client = get_docker_client() try: c = client.containers.get(container_id) raw = c.logs(tail=lines, timestamps=True) return {"logs": raw.decode("utf-8", errors="replace")} except NotFound: raise HTTPException(status_code=404, detail="Conteneur introuvable") @app.post("/containers/{container_id}/action") def container_action(container_id: str, action: str, _: None = Depends(require_api_key)): """Effectue une action sur un conteneur : start, stop, restart.""" if action not in ("start", "stop", "restart"): raise HTTPException(status_code=400, detail=f"Action invalide : {action}") client = get_docker_client() try: c = client.containers.get(container_id) getattr(c, action)() return {"status": "ok", "action": action, "container": container_id} except NotFound: raise HTTPException(status_code=404, detail="Conteneur introuvable") @app.get("/system") def system_info(_: None = Depends(require_api_key)): """Retourne les informations système : CPU, RAM et bande passante.""" cpu_percent = psutil.cpu_percent(interval=0.5) mem = psutil.virtual_memory() net1 = psutil.net_io_counters() time.sleep(0.5) net2 = psutil.net_io_counters() net_sent_per_sec = (net2.bytes_sent - net1.bytes_sent) * 2 net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2 return { "cpu_percent": cpu_percent, "ram_used": mem.used, "ram_total": mem.total, "ram_percent": mem.percent, "net_sent_per_sec": net_sent_per_sec, "net_recv_per_sec": net_recv_per_sec, "net_bytes_sent": net2.bytes_sent, "net_bytes_recv": net2.bytes_recv, } @app.post("/compose/update") def compose_update(project: str, _: None = Depends(require_api_key)): """Pull les nouvelles images et recrée les conteneurs d'un projet compose.""" client = get_docker_client() working_dir = None for c in client.containers.list(all=True): if c.labels.get("com.docker.compose.project") == project: working_dir = c.labels.get("com.docker.compose.project.working_dir") if working_dir: break if not working_dir: raise HTTPException( status_code=404, detail=f"Projet compose '{project}' introuvable ou sans répertoire de travail", ) output = "" pull = subprocess.run( ["docker", "compose", "pull"], cwd=working_dir, capture_output=True, text=True, timeout=300, ) output += pull.stdout + pull.stderr up = subprocess.run( ["docker", "compose", "up", "-d", "--remove-orphans"], cwd=working_dir, capture_output=True, text=True, timeout=120, ) output += up.stdout + up.stderr return {"output": output, "project": project, "working_dir": working_dir} @app.get("/services") def list_services(_: None = Depends(require_api_key)): """Retourne la liste des services systemd (hors Docker) avec leur état.""" try: result = subprocess.run( ["systemctl", "list-units", "--type=service", "--no-legend", "--no-pager", "--all"], capture_output=True, text=True, timeout=10, ) except FileNotFoundError: raise HTTPException(status_code=501, detail="systemctl introuvable — système non-systemd") except subprocess.TimeoutExpired: raise HTTPException(status_code=504, detail="systemctl a expiré") _DOCKER_SERVICES = {"docker.service", "containerd.service", "docker.socket"} services = [] for line in result.stdout.strip().splitlines(): # Supprime les puces (● ○) et les espaces de début line = line.lstrip("●○").strip() if not line: continue parts = line.split(None, 4) if len(parts) < 4: continue name = parts[0] if not name.endswith(".service"): continue if name.lower() in _DOCKER_SERVICES or name.lower().startswith("docker"): continue services.append({ "name": name, "load": parts[1], "active": parts[2], "sub": parts[3], "description": parts[4].strip() if len(parts) > 4 else "", }) return sorted(services, key=lambda s: s["name"]) # ─── Entrée ─────────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=AGENT_PORT)