Files
ScriptVPS/vps-monitor/backend/main.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

1608 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
VPS Monitor Backend — serveur central.
Agrège les données de tous les agents et expose une API REST pour le frontend.
"""
import asyncio
import base64
import json
import os
import re
import secrets
import sqlite3
import time
from collections import deque
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated
import bcrypt as _bcrypt
import aiohttp
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
AuthenticatorAttachment,
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
ResidentKeyRequirement,
PublicKeyCredentialDescriptor,
RegistrationCredential as WebAuthnRegistrationCredential,
AuthenticatorAttestationResponse,
AuthenticationCredential as WebAuthnAuthenticationCredential,
AuthenticatorAssertionResponse,
)
# ─── Config ───────────────────────────────────────────────────────────────────
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
# Version de référence : d'abord env var explicite, sinon récupérée dynamiquement depuis le dépôt
_FORCED_AGENT_VERSION = os.getenv("EXPECTED_AGENT_VERSION", "") # si non vide, court-circuite le fetch
REPO_AGENT_URL = os.getenv(
"REPO_AGENT_URL",
"https://git.jeanbonapp.com/jeanbon/ScriptVPS/raw/branch/main/vps-monitor/agent/agent.py",
)
_latest_agent_version: str = _FORCED_AGENT_VERSION or "unknown"
def _get_expected_version() -> str:
return _latest_agent_version
# ─── Ring buffer de stats (en mémoire) ───────────────────────────────────────
_STATS_MAX_POINTS = 120 # 10 min à 5 s d'intervalle
_stats_history: dict[str, deque] = {}
# État courant des conteneurs : vps_id → {container_name: effective_state}
_container_states: dict[str, dict[str, str]] = {}
SECRET_FILE = Path(os.getenv("SECRET_FILE", "data/.jwt_secret"))
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_MIN = int(os.getenv("JWT_EXPIRE_MINUTES", "1440")) # 24 h
DB_FILE.parent.mkdir(parents=True, exist_ok=True)
# Clé JWT : env var > fichier persisté > génération + sauvegarde
def _load_jwt_secret() -> str:
env = os.getenv("JWT_SECRET")
if env:
return env
if SECRET_FILE.exists():
return SECRET_FILE.read_text().strip()
secret = secrets.token_hex(32)
SECRET_FILE.write_text(secret)
SECRET_FILE.chmod(0o600)
return secret
JWT_SECRET = _load_jwt_secret()
# ─── WebAuthn / Passkeys config ───────────────────────────────────────────────
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "VPS Monitor")
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "http://localhost:3020")
# In-memory challenge store: session_id → {challenge, username, expires_at}
_webauthn_challenges: dict[str, dict] = {}
bearer_scheme = HTTPBearer()
# ─── Modèles ──────────────────────────────────────────────────────────────────
class VpsConfig(BaseModel):
id: str
name: str
host: str
port: int = 8001
api_key: str
description: str = ""
tags: list[str] = []
class ActionRequest(BaseModel):
action: str # start | stop | restart
class VpsUpdateRequest(BaseModel):
name: str
host: str
port: int = 8001
api_key: str = "" # vide = conserver la clé existante
description: str = ""
tags: list[str] = []
class ComposeUpdateRequest(BaseModel):
project: str
class RegisterRequest(BaseModel):
username: str
password: str
class LoginRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class SettingUpdateRequest(BaseModel):
value: str
class PurgeRequest(BaseModel):
table: str # 'vps_stats' | 'login_logs' | 'all'
period: str # 'last_24h' | 'last_7d' | 'last_30d' | 'all' | 'custom'
from_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
to_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
class PasskeyRegisterFinishRequest(BaseModel):
session_id: str
name: str = "Ma passkey"
credential: dict
class PasskeyLoginBeginRequest(BaseModel):
username: str | None = None
class PasskeyLoginFinishRequest(BaseModel):
session_id: str
credential: dict
# ─── SQLite ───────────────────────────────────────────────────────────────────
@contextmanager
def get_db():
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db() -> None:
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 8001,
api_key TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]'
)
""")
# Migration : ajoute la colonne tags si elle n'existe pas encore
try:
conn.execute("ALTER TABLE vps ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
except Exception:
pass # colonne déjà présente
conn.execute("""
CREATE TABLE IF NOT EXISTS vps_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vps_id TEXT NOT NULL,
ts INTEGER NOT NULL,
cpu REAL NOT NULL,
ram_percent REAL NOT NULL,
ram_used INTEGER NOT NULL,
ram_total INTEGER NOT NULL,
net_sent_per_sec REAL NOT NULL DEFAULT 0,
net_recv_per_sec REAL NOT NULL DEFAULT 0,
net_bytes_sent INTEGER NOT NULL DEFAULT 0,
net_bytes_recv INTEGER NOT NULL DEFAULT 0
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_vps_stats
ON vps_stats(vps_id, ts DESC)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS login_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
username TEXT NOT NULL,
ip TEXT NOT NULL,
success INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_login_logs_ts
ON login_logs(ts DESC)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false')
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true')
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_enabled', 'false')
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_app_token', '')
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '')
""")
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('terminal_enabled', 'true')
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS container_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
vps_id TEXT NOT NULL,
vps_name TEXT NOT NULL,
container TEXT NOT NULL,
image TEXT NOT NULL DEFAULT '',
old_state TEXT NOT NULL DEFAULT '',
new_state TEXT NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_container_events_ts
ON container_events(ts DESC)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
""")
init_db()
# ─── Persistance ──────────────────────────────────────────────────────────────
def load_users() -> list[dict]:
with get_db() as conn:
return [dict(r) for r in conn.execute("SELECT * FROM users").fetchall()]
def add_user(user: dict) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
(user["username"], user["password"], user["role"]),
)
def load_vps() -> list[dict]:
with get_db() as conn:
rows = [dict(r) for r in conn.execute("SELECT * FROM vps").fetchall()]
for row in rows:
try:
row["tags"] = json.loads(row.get("tags") or "[]")
except Exception:
row["tags"] = []
return rows
def insert_vps(vps: dict) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO vps (id, name, host, port, api_key, description, tags) VALUES (?, ?, ?, ?, ?, ?, ?)",
(vps["id"], vps["name"], vps["host"], vps["port"], vps["api_key"],
vps.get("description", ""), json.dumps(vps.get("tags", []))),
)
def remove_vps(vps_id: str) -> bool:
with get_db() as conn:
cur = conn.execute("DELETE FROM vps WHERE id = ?", (vps_id,))
return cur.rowcount > 0
def update_vps(vps_id: str, data: dict) -> bool:
with get_db() as conn:
cur = conn.execute(
"UPDATE vps SET name=?, host=?, port=?, api_key=?, description=?, tags=? WHERE id=?",
(data["name"], data["host"], data["port"], data["api_key"],
data["description"], json.dumps(data.get("tags", [])), vps_id),
)
return cur.rowcount > 0
# ─── Settings helpers ────────────────────────────────────────────────────────
def _get_setting(key: str) -> str:
with get_db() as conn:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else ""
# ─── Pushover notifications ───────────────────────────────────────────────────
def _effective_state(status: str, health: str) -> str:
if health and health not in ("none", ""):
return f"{status}:{health}"
return status
async def _send_pushover(title: str, message: str) -> bool:
token = _get_setting("pushover_app_token")
user = _get_setting("pushover_user_key")
if not token or not user:
return False
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.pushover.net/1/messages.json",
data={"token": token, "user": user, "title": title, "message": message},
timeout=timeout,
) as r:
return r.status == 200
except Exception:
return False
# ─── WebAuthn / Passkey helpers ───────────────────────────────────────────────
def _b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def _b64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s)
def _store_challenge(challenge: bytes, username: str | None = None) -> str:
"""Stocke un challenge WebAuthn en mémoire et retourne un session_id."""
session_id = secrets.token_hex(16)
_webauthn_challenges[session_id] = {
"challenge": challenge,
"username": username,
"expires_at": time.time() + 300,
}
# Nettoyage des challenges expirés
now = time.time()
expired = [k for k, v in list(_webauthn_challenges.items()) if v["expires_at"] < now]
for k in expired:
_webauthn_challenges.pop(k, None)
return session_id
def _pop_challenge(session_id: str) -> dict | None:
entry = _webauthn_challenges.pop(session_id, None)
if entry is None:
return None
if time.time() > entry["expires_at"]:
return None
return entry
def _get_passkeys_for_user(username: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM passkeys WHERE username = ? ORDER BY created_at DESC",
(username,),
).fetchall()
return [dict(r) for r in rows]
def _get_passkey_by_credential_id(credential_id: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM passkeys WHERE credential_id = ?", (credential_id,)
).fetchone()
return dict(row) if row else None
def _update_passkey_sign_count(credential_id: str, sign_count: int) -> None:
with get_db() as conn:
conn.execute(
"UPDATE passkeys SET sign_count = ? WHERE credential_id = ?",
(sign_count, credential_id),
)
def _parse_registration_credential(data: dict) -> WebAuthnRegistrationCredential:
resp = data["response"]
return WebAuthnRegistrationCredential(
id=data["id"],
raw_id=_b64url_decode(data.get("rawId", data["id"])),
response=AuthenticatorAttestationResponse(
client_data_json=_b64url_decode(resp["clientDataJSON"]),
attestation_object=_b64url_decode(resp["attestationObject"]),
),
type=data["type"],
)
def _parse_authentication_credential(data: dict) -> WebAuthnAuthenticationCredential:
resp = data["response"]
user_handle = resp.get("userHandle")
return WebAuthnAuthenticationCredential(
id=data["id"],
raw_id=_b64url_decode(data.get("rawId", data["id"])),
response=AuthenticatorAssertionResponse(
client_data_json=_b64url_decode(resp["clientDataJSON"]),
authenticator_data=_b64url_decode(resp["authenticatorData"]),
signature=_b64url_decode(resp["signature"]),
user_handle=_b64url_decode(user_handle) if user_handle else None,
),
type=data["type"],
)
# ─── Auth helpers ─────────────────────────────────────────────────────────────
def create_token(username: str, role: str) -> str:
payload = {
"sub": username,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MIN),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)]
) -> dict:
try:
payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM])
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Token invalide")
except JWTError:
raise HTTPException(status_code=401, detail="Token invalide ou expiré")
user = next((u for u in load_users() if u["username"] == username), None)
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user
def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict:
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs")
return current_user
# ─── Tickets de terminal ──────────────────────────────────────────────────────
#
# Un WebSocket ouvert par le navigateur ne peut pas porter d'en-tête
# Authorization. Plutôt que de faire transiter le JWT en query string (où il
# finirait dans les logs d'accès nginx et l'historique du navigateur), le client
# échange d'abord son JWT contre un ticket à usage unique et de courte durée.
EXEC_TICKET_TTL = 60 # secondes
_exec_tickets: dict[str, dict] = {}
def _prune_exec_tickets() -> None:
now = time.time()
for ticket, info in list(_exec_tickets.items()):
if info["expires_at"] < now:
del _exec_tickets[ticket]
def _issue_exec_ticket(vps_id: str, container_id: str, username: str) -> str:
_prune_exec_tickets()
ticket = secrets.token_urlsafe(32)
_exec_tickets[ticket] = {
"vps_id": vps_id,
"container_id": container_id,
"username": username,
"expires_at": time.time() + EXEC_TICKET_TTL,
}
return ticket
def _consume_exec_ticket(ticket: str, vps_id: str, container_id: str) -> dict | None:
"""Valide et invalide un ticket : il ne sert qu'une fois."""
_prune_exec_tickets()
info = _exec_tickets.pop(ticket, None)
if not info:
return None
if info["vps_id"] != vps_id or info["container_id"] != container_id:
return None
if info["expires_at"] < time.time():
return None
return info
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _log_login(username: str, ip: str, success: bool, reason: str) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO login_logs (ts, username, ip, success, reason) VALUES (?, ?, ?, ?, ?)",
(int(time.time()), username, ip, 1 if success else 0, reason),
)
# ─── App ──────────────────────────────────────────────────────────────────────
app = FastAPI(title="VPS Monitor Backend", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("CORS_ORIGINS", "*").split(","),
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers HTTP ─────────────────────────────────────────────────────────────
async def agent_get(vps: dict, path: str):
url = f"http://{vps['host']}:{vps['port']}{path}"
headers = {"X-API-Key": vps["api_key"]}
timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT)
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=timeout) as r:
r.raise_for_status()
return await r.json()
async def agent_post(vps: dict, path: str, payload: dict | None = None):
url = f"http://{vps['host']}:{vps['port']}{path}"
headers = {"X-API-Key": vps["api_key"]}
timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT)
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=timeout) as r:
r.raise_for_status()
return await r.json()
async def fetch_vps_status(vps: dict) -> dict:
"""Interroge un agent et retourne son état complet.
Les données système (CPU, RAM, réseau) proviennent en priorité du ring buffer
alimenté par le collecteur toutes les 5 s — même source que le modal de stats.
Si le buffer est encore vide (premier démarrage), un appel direct est fait.
"""
try:
containers_res = await agent_get(vps, "/containers")
# Services systemd (non-Docker) — optionnel, agent >= 1.2.0
try:
services_res = await agent_get(vps, "/services")
except Exception:
services_res = []
# Source unique : ring buffer → carte et modal affichent exactement la même valeur
history = _stats_history.get(vps["id"])
if history:
last = history[-1]
system = {
"cpu_percent": last["cpu"],
"ram_used": last["ram_used"],
"ram_total": last["ram_total"],
"ram_percent": last["ram_percent"],
"net_sent_per_sec": last["net_sent_per_sec"],
"net_recv_per_sec": last["net_recv_per_sec"],
}
else:
# Buffer vide (premier démarrage) — appel direct en fallback
try:
system = await agent_get(vps, "/system")
except Exception:
system = None
# Version de l'agent (endpoint sans auth)
try:
version_res = await agent_get(vps, "/version")
agent_version = version_res.get("version", "unknown")
except Exception:
agent_version = "unknown"
return {
"id": vps["id"],
"name": vps["name"],
"host": vps["host"],
"description": vps.get("description", ""),
"online": True,
"containers": containers_res,
"services": services_res,
"system": system,
"tags": vps.get("tags", []),
"agent_version": agent_version,
"expected_agent_version": _get_expected_version(),
"agent_up_to_date": agent_version == _get_expected_version() and _get_expected_version() != "unknown",
}
except Exception as e:
return {
"id": vps["id"],
"name": vps["name"],
"host": vps["host"],
"description": vps.get("description", ""),
"online": False,
"error": str(e),
"containers": [],
"services": [],
"system": None,
"tags": vps.get("tags", []),
"agent_version": None,
"expected_agent_version": _get_expected_version(),
"agent_up_to_date": False,
}
# ─── Collecteur de stats (tâche de fond) ─────────────────────────────────────
async def _fetch_system_stat(vps: dict) -> None:
"""Collecte un point de stats système pour un VPS et le persiste (ring buffer + SQLite)."""
try:
data = await agent_get(vps, "/system")
now = int(time.time())
# Ring buffer en mémoire (source pour la carte VPS — valeur courante)
buf = _stats_history.setdefault(vps["id"], deque(maxlen=_STATS_MAX_POINTS))
buf.append({
"ts": datetime.fromtimestamp(now, tz=timezone.utc).isoformat(),
"cpu": data["cpu_percent"],
"ram_percent": data["ram_percent"],
"ram_used": data["ram_used"],
"ram_total": data["ram_total"],
"net_sent_per_sec": data["net_sent_per_sec"],
"net_recv_per_sec": data["net_recv_per_sec"],
"net_bytes_sent": data.get("net_bytes_sent", 0),
"net_bytes_recv": data.get("net_bytes_recv", 0),
})
# Persistance SQLite (historique long terme)
with get_db() as conn:
conn.execute("""
INSERT INTO vps_stats
(vps_id, ts, cpu, ram_percent, ram_used, ram_total,
net_sent_per_sec, net_recv_per_sec, net_bytes_sent, net_bytes_recv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
vps["id"], now,
data["cpu_percent"], data["ram_percent"],
data["ram_used"], data["ram_total"],
data["net_sent_per_sec"], data["net_recv_per_sec"],
data.get("net_bytes_sent", 0), data.get("net_bytes_recv", 0),
))
except Exception:
pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement
async def _stats_collector() -> None:
"""Tâche de fond : collecte les stats de tous les VPS toutes les 5 secondes."""
await asyncio.sleep(3) # laisse l'app finir de démarrer
while True:
vps_list = load_vps()
await asyncio.gather(
*[_fetch_system_stat(v) for v in vps_list],
return_exceptions=True,
)
await asyncio.sleep(5)
# ─── Collecteur d'états des conteneurs ───────────────────────────────────────
async def _check_container_states(vps: dict) -> None:
try:
containers = await agent_get(vps, "/containers")
except Exception:
return
vps_id = vps["id"]
vps_name = vps["name"]
current = {
c["name"]: _effective_state(c["status"], c.get("health", "none"))
for c in containers
}
prev = _container_states.get(vps_id)
if prev is None:
_container_states[vps_id] = current
return
now = int(time.time())
notify = _get_setting("pushover_enabled") == "true"
events: list[dict] = []
for name, state in current.items():
prev_state = prev.get(name, "")
if state != prev_state:
image = next((c["image"] for c in containers if c["name"] == name), "")
events.append({
"ts": now, "vps_id": vps_id, "vps_name": vps_name,
"container": name, "image": image,
"old_state": prev_state, "new_state": state,
})
for name, state in prev.items():
if name not in current:
events.append({
"ts": now, "vps_id": vps_id, "vps_name": vps_name,
"container": name, "image": "",
"old_state": state, "new_state": "removed",
})
if events:
with get_db() as conn:
conn.executemany("""
INSERT INTO container_events
(ts, vps_id, vps_name, container, image, old_state, new_state)
VALUES (:ts, :vps_id, :vps_name, :container, :image, :old_state, :new_state)
""", events)
if notify:
for ev in events:
title = f"[{ev['vps_name']}] {ev['container']}"
msg = f"Etat : {ev['old_state'] or 'nouveau'} -> {ev['new_state']}"
asyncio.create_task(_send_pushover(title, msg))
_container_states[vps_id] = current
async def _container_state_collector() -> None:
"""Tache de fond : verifie les etats des conteneurs toutes les 15 secondes."""
await asyncio.sleep(5)
while True:
vps_list = load_vps()
await asyncio.gather(
*[_check_container_states(v) for v in vps_list],
return_exceptions=True,
)
await asyncio.sleep(15)
async def _refresh_latest_agent_version() -> None:
"""Récupère AGENT_VERSION depuis le dépôt Gitea toutes les heures.
Si EXPECTED_AGENT_VERSION est défini en env var, cette tâche n'écrase pas la valeur forcée.
"""
global _latest_agent_version
if _FORCED_AGENT_VERSION:
return # version forcée par env var — pas besoin de fetcher
while True:
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession() as session:
async with session.get(REPO_AGENT_URL, timeout=timeout) as r:
if r.status == 200:
text = await r.text()
m = re.search(
r'^AGENT_VERSION\s*=\s*["\']([^"\']+)["\']',
text,
re.MULTILINE,
)
if m:
_latest_agent_version = m.group(1)
except Exception:
pass # garder la dernière valeur connue en cas d'erreur réseau
await asyncio.sleep(3600) # rafraîchit toutes les heures
@app.on_event("startup")
async def startup_event() -> None:
asyncio.create_task(_stats_collector())
asyncio.create_task(_container_state_collector())
asyncio.create_task(_cleanup_old_stats())
asyncio.create_task(_refresh_latest_agent_version())
async def _cleanup_old_stats() -> None:
"""Supprime les statistiques et evenements de plus de 31 jours (toutes les heures)."""
while True:
await asyncio.sleep(3600)
cutoff = int(time.time()) - 31 * 24 * 3600
with get_db() as conn:
conn.execute("DELETE FROM vps_stats WHERE ts < ?", (cutoff,))
conn.execute("DELETE FROM container_events WHERE ts < ?", (cutoff,))
# ─── Routes Auth ──────────────────────────────────────────────────────────────
@app.get("/api/auth/status")
def auth_status():
"""Indique si des utilisateurs existent déjà (pour le frontend)."""
return {
"has_users": len(load_users()) > 0,
"passkey_enabled": _get_setting("passkey_enabled") == "true",
"terminal_enabled": _get_setting("terminal_enabled") != "false",
}
@app.post("/api/auth/register", status_code=201)
def register(body: RegisterRequest):
"""Enregistre un nouvel utilisateur. Ouvert uniquement si aucun utilisateur n'existe
ou si l'admin a activé les inscriptions."""
users = load_users()
if len(users) > 0:
if _get_setting("registration_open") != "true":
raise HTTPException(
status_code=403,
detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes."
)
if not body.username.strip() or len(body.password) < 6:
raise HTTPException(status_code=422, detail="Mot de passe trop court (6 caractères min.)")
if any(u["username"] == body.username.strip() for u in users):
raise HTTPException(status_code=409, detail="Ce nom d'utilisateur est déjà pris")
role = "admin" if len(users) == 0 else "user"
user = {
"username": body.username.strip(),
"password": _bcrypt.hashpw(body.password.encode(), _bcrypt.gensalt()).decode(),
"role": role,
}
add_user(user)
token = create_token(user["username"], user["role"])
return {"access_token": token, "token_type": "bearer", "role": user["role"]}
@app.post("/api/auth/login")
def login(body: LoginRequest, request: Request):
"""Authentifie un utilisateur et retourne un JWT."""
ip = _get_client_ip(request)
users = load_users()
user = next((u for u in users if u["username"] == body.username), None)
if not user or not _bcrypt.checkpw(body.password.encode(), user["password"].encode()):
_log_login(body.username, ip, False, "Identifiants incorrects")
raise HTTPException(status_code=401, detail="Identifiants incorrects")
_log_login(user["username"], ip, True, "")
token = create_token(user["username"], user["role"])
return {"access_token": token, "token_type": "bearer", "role": user["role"]}
@app.get("/api/auth/me")
def me(current_user: Annotated[dict, Depends(get_current_user)]):
return {"username": current_user["username"], "role": current_user["role"]}
@app.post("/api/auth/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Permet à l'utilisateur connecté de changer son mot de passe."""
if not _bcrypt.checkpw(body.old_password.encode(), current_user["password"].encode()):
raise HTTPException(status_code=400, detail="Ancien mot de passe incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=422, detail="Nouveau mot de passe trop court (6 caractères min.)")
new_hash = _bcrypt.hashpw(body.new_password.encode(), _bcrypt.gensalt()).decode()
with get_db() as conn:
conn.execute(
"UPDATE users SET password = ? WHERE username = ?",
(new_hash, current_user["username"]),
)
return {"status": "ok"}
# ─── Routes Admin ─────────────────────────────────────────────────────────────
@app.get("/api/admin/settings")
def admin_get_settings(_: Annotated[dict, Depends(require_admin)]):
"""Retourne les paramètres d'administration."""
with get_db() as conn:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
return {row["key"]: row["value"] for row in rows}
@app.put("/api/admin/settings/{key}")
def admin_update_setting(
key: str,
body: SettingUpdateRequest,
_: Annotated[dict, Depends(require_admin)],
):
"""Met à jour un paramètre d'administration."""
allowed_keys = {"registration_open", "passkey_enabled", "pushover_enabled",
"pushover_app_token", "pushover_user_key", "terminal_enabled"}
if key not in allowed_keys:
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, body.value),
)
return {"status": "ok"}
@app.get("/api/admin/login-logs")
def admin_login_logs(
limit: int = 100,
offset: int = 0,
_: Annotated[dict, Depends(require_admin)] = None,
):
"""Retourne les tentatives de connexion enregistrées."""
limit = max(1, min(limit, 500))
offset = max(0, offset)
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM login_logs ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
total = conn.execute("SELECT COUNT(*) FROM login_logs").fetchone()[0]
return {
"total": total,
"logs": [
{
"id": row["id"],
"ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(),
"username": row["username"],
"ip": row["ip"],
"success": bool(row["success"]),
"reason": row["reason"],
}
for row in rows
],
}
@app.get("/api/admin/users")
def admin_list_users(_: Annotated[dict, Depends(require_admin)]):
"""Liste les utilisateurs (sans mots de passe)."""
return [
{"username": u["username"], "role": u["role"]}
for u in load_users()
]
@app.get("/api/admin/db/info")
def admin_db_info(_: Annotated[dict, Depends(require_admin)]):
"""Retourne des informations sur le contenu de la base de données."""
with get_db() as conn:
def _table_info(table: str) -> dict:
row = conn.execute(
f"SELECT COUNT(*) AS cnt, MIN(ts) AS oldest, MAX(ts) AS newest FROM {table}"
).fetchone()
return {
"count": row["cnt"],
"oldest_ts": row["oldest"],
"newest_ts": row["newest"],
}
return {
"vps_stats": _table_info("vps_stats"),
"login_logs": _table_info("login_logs"),
"container_events": _table_info("container_events"),
}
_ALLOWED_TABLES = frozenset({"vps_stats", "login_logs", "container_events"})
_ALLOWED_PERIODS = frozenset({"last_24h", "last_7d", "last_30d", "all", "custom"})
@app.delete("/api/admin/db/purge")
def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin)]):
"""Supprime des entrées de la base de données selon la table et la période choisies."""
if body.table not in _ALLOWED_TABLES and body.table != "all":
raise HTTPException(status_code=400, detail="Table inconnue")
if body.period not in _ALLOWED_PERIODS:
raise HTTPException(status_code=400, detail="Période inconnue")
tables = list(_ALLOWED_TABLES) if body.table == "all" else [body.table]
now = int(time.time())
period_cutoff = {
"last_24h": now - 24 * 3600,
"last_7d": now - 7 * 24 * 3600,
"last_30d": now - 30 * 24 * 3600,
}
deleted: dict[str, int] = {}
with get_db() as conn:
for tbl in tables:
if body.period == "all":
cur = conn.execute(f"DELETE FROM {tbl}") # nosec tbl validated above
elif body.period == "custom":
if body.from_ts is None or body.to_ts is None:
raise HTTPException(
status_code=400,
detail="Période personnalisée : from_ts et to_ts sont requis",
)
if body.from_ts > body.to_ts:
raise HTTPException(status_code=400, detail="from_ts doit être ≤ to_ts")
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ts >= ? AND ts <= ?", # nosec
(body.from_ts, body.to_ts),
)
else:
cutoff = period_cutoff[body.period]
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ts >= ?", # nosec
(cutoff,),
)
deleted[tbl] = cur.rowcount
return {"deleted": deleted, "status": "ok"}
@app.get("/api/admin/container-events")
def admin_container_events(
limit: int = 100,
offset: int = 0,
_: Annotated[dict, Depends(require_admin)] = None,
):
"""Retourne l'historique des changements d'etat des conteneurs."""
limit = max(1, min(limit, 500))
offset = max(0, offset)
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM container_events ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
total = conn.execute("SELECT COUNT(*) FROM container_events").fetchone()[0]
return {
"total": total,
"events": [
{
"id": row["id"],
"ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(),
"vps_name": row["vps_name"],
"container": row["container"],
"image": row["image"],
"old_state": row["old_state"],
"new_state": row["new_state"],
}
for row in rows
],
}
@app.post("/api/admin/notifications/test")
async def admin_test_notification(_: Annotated[dict, Depends(require_admin)]):
"""Envoie une notification Pushover de test."""
if _get_setting("pushover_enabled") != "true":
raise HTTPException(status_code=400, detail="Les notifications Pushover sont desactivees")
ok = await _send_pushover("VPS Monitor - Test", "La notification de test a bien ete recue.")
if not ok:
raise HTTPException(
status_code=502,
detail="Echec de l'envoi - verifiez le token et la cle utilisateur",
)
return {"status": "ok"}
# ─── Routes Passkeys ──────────────────────────────────────────────────────────
@app.post("/api/auth/passkey/register/begin")
def passkey_register_begin(
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Démarre l'enregistrement d'une passkey pour l'utilisateur connecté."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
username = current_user["username"]
existing = _get_passkeys_for_user(username)
exclude_creds = [
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
for pk in existing
]
opts = generate_registration_options(
rp_id=WEBAUTHN_RP_ID,
rp_name=WEBAUTHN_RP_NAME,
user_id=username.encode(),
user_name=username,
user_display_name=username,
authenticator_selection=AuthenticatorSelectionCriteria(
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
exclude_credentials=exclude_creds,
)
session_id = _store_challenge(opts.challenge, username=username)
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
@app.post("/api/auth/passkey/register/finish")
def passkey_register_finish(
body: PasskeyRegisterFinishRequest,
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Finalise l'enregistrement d'une passkey."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
challenge_data = _pop_challenge(body.session_id)
if not challenge_data:
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
if challenge_data["username"] != current_user["username"]:
raise HTTPException(status_code=403, detail="Session invalide")
try:
cred = _parse_registration_credential(body.credential)
verification = verify_registration_response(
credential=cred,
expected_challenge=challenge_data["challenge"],
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Vérification échouée : {exc}")
cred_id = _b64url_encode(verification.credential_id)
pub_key = _b64url_encode(verification.credential_public_key)
if _get_passkey_by_credential_id(cred_id):
raise HTTPException(status_code=409, detail="Cette passkey est déjà enregistrée")
with get_db() as conn:
conn.execute(
"""INSERT INTO passkeys (username, credential_id, public_key, sign_count, name, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(current_user["username"], cred_id, pub_key,
verification.sign_count, body.name, int(time.time())),
)
return {"status": "ok"}
@app.post("/api/auth/passkey/login/begin")
def passkey_login_begin(body: PasskeyLoginBeginRequest):
"""Démarre l'authentification par passkey."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
allow_creds: list[PublicKeyCredentialDescriptor] = []
if body.username:
passkeys = _get_passkeys_for_user(body.username)
allow_creds = [
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
for pk in passkeys
]
opts = generate_authentication_options(
rp_id=WEBAUTHN_RP_ID,
allow_credentials=allow_creds,
user_verification=UserVerificationRequirement.REQUIRED,
)
session_id = _store_challenge(opts.challenge, username=body.username)
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
@app.post("/api/auth/passkey/login/finish")
def passkey_login_finish(body: PasskeyLoginFinishRequest, request: Request):
"""Vérifie la passkey et retourne un JWT."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
challenge_data = _pop_challenge(body.session_id)
if not challenge_data:
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
cred_id = body.credential.get("id", "")
passkey = _get_passkey_by_credential_id(cred_id)
if not passkey:
ip = _get_client_ip(request)
_log_login("unknown", ip, False, "Passkey introuvable")
raise HTTPException(status_code=401, detail="Passkey non reconnue")
try:
cred = _parse_authentication_credential(body.credential)
verification = verify_authentication_response(
credential=cred,
expected_challenge=challenge_data["challenge"],
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
credential_public_key=_b64url_decode(passkey["public_key"]),
credential_current_sign_count=passkey["sign_count"],
)
except Exception as exc:
ip = _get_client_ip(request)
_log_login(passkey["username"], ip, False, f"Passkey invalide : {exc}")
raise HTTPException(status_code=401, detail="Authentification échouée")
_update_passkey_sign_count(cred_id, verification.new_sign_count)
user = next((u for u in load_users() if u["username"] == passkey["username"]), None)
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
ip = _get_client_ip(request)
_log_login(user["username"], ip, True, "passkey")
token = create_token(user["username"], user["role"])
return {"access_token": token, "token_type": "bearer", "role": user["role"]}
@app.get("/api/auth/passkeys")
def list_my_passkeys(current_user: Annotated[dict, Depends(get_current_user)]):
"""Liste les passkeys de l'utilisateur connecté."""
passkeys = _get_passkeys_for_user(current_user["username"])
return [
{
"credential_id": pk["credential_id"],
"name": pk["name"],
"created_at": datetime.fromtimestamp(pk["created_at"], tz=timezone.utc).isoformat(),
}
for pk in passkeys
]
@app.delete("/api/auth/passkeys/{credential_id}")
def delete_my_passkey(
credential_id: str,
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Supprime une passkey de l'utilisateur connecté."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM passkeys WHERE credential_id = ? AND username = ?",
(credential_id, current_user["username"]),
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Passkey introuvable")
return {"status": "ok"}
@app.get("/api/admin/passkeys")
def admin_list_passkeys(_: Annotated[dict, Depends(require_admin)]):
"""Admin : liste toutes les passkeys enregistrées."""
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM passkeys ORDER BY created_at DESC"
).fetchall()
return [
{
"credential_id": row["credential_id"],
"username": row["username"],
"name": row["name"],
"created_at": datetime.fromtimestamp(row["created_at"], tz=timezone.utc).isoformat(),
}
for row in rows
]
@app.delete("/api/admin/passkeys/{credential_id}")
def admin_delete_passkey(
credential_id: str,
_: Annotated[dict, Depends(require_admin)],
):
"""Admin : révoque n'importe quelle passkey."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM passkeys WHERE credential_id = ?", (credential_id,)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Passkey introuvable")
return {"status": "ok"}
# ─── Routes VPS ───────────────────────────────────────────────────────────────
@app.get("/api/vps")
def list_vps(_: Annotated[dict, Depends(get_current_user)]):
"""Liste les VPS configurés (sans les clés API)."""
return [
{"id": v["id"], "name": v["name"], "host": v["host"],
"description": v.get("description", ""), "tags": v.get("tags", [])}
for v in load_vps()
]
@app.post("/api/vps", status_code=201)
def add_vps(vps: VpsConfig, _: Annotated[dict, Depends(get_current_user)]):
"""Ajoute un nouveau VPS."""
if any(v["id"] == vps.id for v in load_vps()):
raise HTTPException(status_code=409, detail="Un VPS avec cet ID existe déjà")
insert_vps(vps.model_dump())
return {"status": "ok", "id": vps.id}
@app.delete("/api/vps/{vps_id}")
def delete_vps(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
"""Supprime un VPS de la configuration."""
if not remove_vps(vps_id):
raise HTTPException(status_code=404, detail="VPS introuvable")
return {"status": "ok"}
@app.put("/api/vps/{vps_id}")
def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get_current_user)]):
"""Met à jour les paramètres d'un VPS (name, host, port, api_key, description)."""
existing = next((v for v in load_vps() if v["id"] == vps_id), None)
if not existing:
raise HTTPException(status_code=404, detail="VPS introuvable")
data = {
"name": body.name,
"host": body.host,
"port": body.port,
"api_key": body.api_key.strip() or existing["api_key"],
"description": body.description,
"tags": body.tags,
}
update_vps(vps_id, data)
return {"status": "ok"}
@app.get("/api/vps/{vps_id}/export")
def export_vps(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
"""Exporte la configuration complète d'un VPS (y compris la clé API) pour import ailleurs."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
return {
"id": vps["id"],
"name": vps["name"],
"host": vps["host"],
"port": vps["port"],
"api_key": vps["api_key"],
"description": vps.get("description", ""),
"tags": vps.get("tags", []),
}
@app.get("/api/vps/{vps_id}/stats")
def get_vps_stats(
vps_id: str,
duration: int = 600, # secondes, défaut 10 min
_: Annotated[dict, Depends(get_current_user)] = None,
):
"""Retourne l'historique des stats système d'un VPS avec downsampling automatique.
Le paramètre `duration` (en secondes) détermine la fenêtre temporelle.
La réponse contient toujours ~300 points maximum (agrégation par bucket SQL).
"""
if not any(v["id"] == vps_id for v in load_vps()):
raise HTTPException(status_code=404, detail="VPS introuvable")
duration = max(60, min(duration, 31 * 24 * 3600)) # clamp 1 min 31 j
since = int(time.time()) - duration
bucket = max(5, duration // 300) # ≤ 300 points retournés
with get_db() as conn:
rows = conn.execute("""
SELECT
(ts / :b) * :b AS ts,
AVG(cpu) AS cpu,
AVG(ram_percent) AS ram_percent,
CAST(AVG(ram_used) AS INTEGER) AS ram_used,
MAX(ram_total) AS ram_total,
AVG(net_sent_per_sec) AS net_sent_per_sec,
AVG(net_recv_per_sec) AS net_recv_per_sec,
MAX(net_bytes_sent) AS net_bytes_sent,
MAX(net_bytes_recv) AS net_bytes_recv
FROM vps_stats
WHERE vps_id = :vps_id AND ts >= :since
GROUP BY (ts / :b)
ORDER BY ts ASC
""", {"b": bucket, "vps_id": vps_id, "since": since}).fetchall()
return [
{
"ts": datetime.fromtimestamp(int(row["ts"]), tz=timezone.utc).isoformat(),
"cpu": row["cpu"],
"ram_percent": row["ram_percent"],
"ram_used": row["ram_used"],
"ram_total": row["ram_total"],
"net_sent_per_sec": row["net_sent_per_sec"],
"net_recv_per_sec": row["net_recv_per_sec"],
"net_bytes_sent": row["net_bytes_sent"],
"net_bytes_recv": row["net_bytes_recv"],
}
for row in rows
]
@app.get("/api/status")
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
"""Retourne l'état de tous les VPS en parallèle."""
vps_list = load_vps()
results = await asyncio.gather(*[fetch_vps_status(v) for v in vps_list])
return list(results)
@app.get("/api/vps/{vps_id}/status")
async def vps_status(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
"""Retourne l'état d'un VPS spécifique."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
return await fetch_vps_status(vps)
@app.get("/api/vps/{vps_id}/containers/{container_id}/logs")
async def container_logs(
vps_id: str, container_id: str, lines: int = 100,
_: Annotated[dict, Depends(get_current_user)] = None
):
"""Récupère les logs d'un conteneur via l'agent."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_get(vps, f"/containers/{container_id}/logs?lines={lines}")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/vps/{vps_id}/containers/{container_id}/action")
async def container_action(
vps_id: str, container_id: str, body: ActionRequest,
_: Annotated[dict, Depends(get_current_user)] = None
):
"""Effectue une action sur un conteneur via l'agent."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/vps/{vps_id}/containers/{container_id}/exec/ticket")
async def container_exec_ticket(
vps_id: str, container_id: str,
current_user: Annotated[dict, Depends(require_admin)],
):
"""Délivre un ticket à usage unique pour ouvrir un terminal sur un conteneur.
Réservé aux administrateurs : un shell dans un conteneur donne bien plus de
pouvoir que les actions start/stop.
"""
if _get_setting("terminal_enabled") == "false":
raise HTTPException(status_code=403, detail="Terminal désactivé par l'administrateur")
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
ticket = _issue_exec_ticket(vps_id, container_id, current_user["username"])
return {"ticket": ticket, "expires_in": EXEC_TICKET_TTL}
@app.websocket("/api/vps/{vps_id}/containers/{container_id}/exec")
async def container_exec(
websocket: WebSocket, vps_id: str, container_id: str,
ticket: str = "", cols: int = 80, rows: int = 24,
):
"""Relaie le terminal du navigateur vers l'agent du VPS.
Le backend ne fait que pomper les trames dans les deux sens : c'est l'agent
qui parle à Docker. Les trames binaires portent la sortie du TTY, les trames
texte les messages JSON (saisie, redimensionnement, erreurs).
"""
session = _consume_exec_ticket(ticket, vps_id, container_id)
if not session:
await websocket.close(code=1008, reason="Ticket invalide ou expiré")
return
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
await websocket.close(code=1011, reason="VPS introuvable")
return
await websocket.accept()
url = (f"ws://{vps['host']}:{vps['port']}/containers/{container_id}/exec"
f"?cols={cols}&rows={rows}")
headers = {"X-API-Key": vps["api_key"]}
try:
async with aiohttp.ClientSession(headers=headers) as http:
async with http.ws_connect(url, heartbeat=30) as agent_ws:
async def client_to_agent():
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if message.get("text") is not None:
await agent_ws.send_str(message["text"])
elif message.get("bytes") is not None:
await agent_ws.send_bytes(message["bytes"])
async def agent_to_client():
async for message in agent_ws:
if message.type == aiohttp.WSMsgType.BINARY:
await websocket.send_bytes(message.data)
elif message.type == aiohttp.WSMsgType.TEXT:
await websocket.send_text(message.data)
else:
break
tasks = [asyncio.create_task(client_to_agent()),
asyncio.create_task(agent_to_client())]
try:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
except WebSocketDisconnect:
pass
except Exception as e:
# Cas le plus fréquent : agent < 1.3.0, qui n'expose pas encore /exec.
try:
await websocket.send_text(json.dumps({
"t": "error",
"m": f"Connexion à l'agent impossible ({e}). "
f"Vérifiez que l'agent est en version 1.3.0 ou supérieure.",
}))
except Exception:
pass
finally:
try:
await websocket.close()
except Exception:
pass
@app.post("/api/vps/{vps_id}/compose/update")
async def compose_update(
vps_id: str, body: ComposeUpdateRequest,
_: Annotated[dict, Depends(get_current_user)] = None
):
"""Lance docker compose pull + up sur un projet via l'agent."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
url = f"http://{vps['host']}:{vps['port']}/compose/update?project={body.project}"
headers = {"X-API-Key": vps["api_key"]}
timeout = aiohttp.ClientTimeout(total=600)
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, timeout=timeout) as r:
r.raise_for_status()
return await r.json()
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/vps/{vps_id}/agent/update")
async def agent_self_update(
vps_id: str,
_: Annotated[dict, Depends(get_current_user)] = None,
):
"""Déclenche la mise à jour de l'agent sur le VPS."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_post(vps, "/self-update")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))