Files
ScriptVPS/vps-monitor/agent/agent.py
jeanotx32 e277e99155
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
feat: add interactive terminal support for containers via WebSocket
- Implemented WebSocket endpoint for executing interactive shells in Docker containers.
- Added ticket-based authentication for terminal access to enhance security.
- Updated frontend to support terminal interactions, including lazy loading of terminal components.
- Enhanced backend to manage terminal session tickets and settings for enabling/disabling terminal access.
- Updated Nginx configuration to support WebSocket connections for terminal sessions.
- Added necessary dependencies for terminal functionality in the frontend.
- Improved user interface to include terminal access controls and feedback on terminal status.
2026-08-01 01:49:26 -04:00

454 lines
16 KiB
Python

#!/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 asyncio
import json
import os
import socket as socket_module
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, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader
# ─── Config ───────────────────────────────────────────────────────────────────
AGENT_VERSION = "1.3.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")
# ─── Terminal interactif (WebSocket) ─────────────────────────────────────────
# Choisit bash s'il est présent dans l'image, sinon sh — beaucoup d'images
# (alpine, distroless-ish) n'embarquent pas bash.
_SHELL_PICKER = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
# Nombre maximal de sessions terminal simultanées, pour éviter qu'un client
# emballé n'ouvre des exec à l'infini.
_MAX_EXEC_SESSIONS = int(os.getenv("AGENT_MAX_EXEC_SESSIONS", "10"))
_exec_sessions = 0
_exec_sessions_lock = threading.Lock()
def _sock_recv(sock, size=4096):
"""Lecture bloquante — `recv` sur une vraie socket, `read` sur un SocketIO."""
try:
if hasattr(sock, "recv"):
return sock.recv(size)
return sock.read(size)
except OSError:
return b""
def _sock_send(sock, data: bytes) -> None:
if hasattr(sock, "sendall"):
sock.sendall(data)
else:
sock.write(data)
sock.flush()
def _sock_close(sock) -> None:
"""Ferme la socket : débloque aussi le thread resté dans recv()."""
try:
if hasattr(sock, "shutdown"):
sock.shutdown(socket_module.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
@app.websocket("/containers/{container_id}/exec")
async def exec_terminal(websocket: WebSocket, container_id: str, cols: int = 80, rows: int = 24):
"""Shell interactif dans un conteneur (équivalent `docker exec -it`).
Protocole :
• client → agent : JSON texte {"t":"i","d":""} (saisie) ou
{"t":"r","cols":N,"rows":N} (redimensionnement) ;
• agent → client : trames binaires (sortie brute du TTY) et JSON texte
pour les messages d'état ({"t":"error"|"exit"}).
L'authentification se fait par l'en-tête X-API-Key : le navigateur ne peut
pas en poser sur un WebSocket, mais c'est le backend central qui se connecte
ici, et lui le peut.
"""
global _exec_sessions
if websocket.headers.get("x-api-key") != API_KEY:
await websocket.close(code=1008, reason="Clé API invalide")
return
with _exec_sessions_lock:
if _exec_sessions >= _MAX_EXEC_SESSIONS:
await websocket.close(code=1013, reason="Trop de sessions terminal ouvertes")
return
_exec_sessions += 1
stream = None
sock = None
try:
await websocket.accept()
try:
client = docker.from_env()
container = client.containers.get(container_id)
except NotFound:
await websocket.send_text(json.dumps({"t": "error", "m": "Conteneur introuvable."}))
return
except DockerException as e:
await websocket.send_text(json.dumps({"t": "error", "m": f"Docker inaccessible : {e}"}))
return
if container.status != "running":
await websocket.send_text(json.dumps({
"t": "error",
"m": f"Le conteneur est « {container.status} » — démarrez-le pour ouvrir un terminal.",
}))
return
api = client.api
exec_id = api.exec_create(
container.id,
cmd=["/bin/sh", "-c", _SHELL_PICKER],
stdin=True, stdout=True, stderr=True, tty=True,
)["Id"]
stream = api.exec_start(exec_id, tty=True, socket=True, demux=False)
sock = getattr(stream, "_sock", stream)
try:
api.exec_resize(exec_id, height=rows, width=cols)
except Exception:
pass # non bloquant : le shell tourne, seule la taille est approximative
loop = asyncio.get_running_loop()
async def pump_output():
"""Sortie du TTY → client, en binaire (xterm gère l'UTF-8 partiel)."""
while True:
chunk = await loop.run_in_executor(None, _sock_recv, sock)
if not chunk:
break
await websocket.send_bytes(chunk)
async def pump_input():
"""Saisie et redimensionnements du client → TTY."""
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
text = message.get("text")
if text is not None:
try:
payload = json.loads(text)
except json.JSONDecodeError:
continue
kind = payload.get("t")
if kind == "i":
await loop.run_in_executor(
None, _sock_send, sock, payload.get("d", "").encode("utf-8")
)
elif kind == "r":
try:
api.exec_resize(
exec_id,
height=int(payload.get("rows", rows)),
width=int(payload.get("cols", cols)),
)
except Exception:
pass
continue
data = message.get("bytes")
if data:
await loop.run_in_executor(None, _sock_send, sock, data)
tasks = [asyncio.create_task(pump_output()), asyncio.create_task(pump_input())]
try:
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
# Fermer la socket débloque le thread encore dans recv().
_sock_close(sock)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
try:
code = api.exec_inspect(exec_id).get("ExitCode")
await websocket.send_text(json.dumps({"t": "exit", "code": code}))
except Exception:
pass
except WebSocketDisconnect:
pass
finally:
if sock is not None:
_sock_close(sock)
if stream is not None and stream is not sock:
try:
stream.close()
except Exception:
pass
with _exec_sessions_lock:
_exec_sessions -= 1
try:
await websocket.close()
except Exception:
pass
@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)