diff --git a/vps-monitor/agent/INSTALL.md b/vps-monitor/agent/INSTALL.md index 420a569..7794657 100644 --- a/vps-monitor/agent/INSTALL.md +++ b/vps-monitor/agent/INSTALL.md @@ -140,6 +140,7 @@ Le fichier `.env` supporte les variables suivantes : |----------|--------|-------------| | `AGENT_API_KEY` | *(généré)* | Clé secrète partagée avec le backend | | `AGENT_PORT` | `8001` | Port TCP d'écoute | +| `AGENT_MAX_EXEC_SESSIONS` | `10` | Nombre de terminaux ouverts simultanément (voir ci-dessous) | Pour modifier la configuration sans réinstaller : @@ -150,6 +151,41 @@ sudo systemctl restart vps-monitor-agent --- +## Terminal interactif (agent 1.3.0+) + +Depuis la version 1.3.0, l'agent expose un WebSocket `/containers/{id}/exec` qui +ouvre un shell dans un conteneur, l'équivalent de `docker exec -it bash` +(repli automatique sur `sh` si l'image n'a pas bash). L'interface web s'en sert +pour le bouton « terminal » de chaque conteneur démarré. + +À savoir : + +- **Réservé aux administrateurs.** Le backend n'ouvre le WebSocket que pour un + compte de rôle `admin`, après échange du JWT contre un ticket à usage unique + valable 60 secondes — le jeton d'authentification ne transite donc jamais en + query string. +- **Désactivable** globalement depuis *Administration → Paramètres → Terminal des + conteneurs*. +- **Portée réelle des droits** : le shell s'exécute avec l'utilisateur par défaut + de l'image, souvent `root` *dans le conteneur*. Un conteneur privilégié ou avec + le socket Docker monté permet d'atteindre l'hôte — n'ouvrez ce droit qu'à des + comptes de confiance. +- **Mise à jour** : bouton « Mettre à jour l'agent » dans l'interface, ou + `sudo bash install.sh --update`. Les agents < 1.3.0 affichent un bouton terminal + désactivé expliquant qu'une mise à jour est nécessaire. + +Si l'interface est servie derrière un reverse proxy maison (autre que le nginx +fourni), pensez à y relayer les WebSockets : + +```nginx +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +proxy_read_timeout 3600s; +``` + +--- + ## Dépôt source [https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS) diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index a07a53a..650e983 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -4,7 +4,10 @@ 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 @@ -13,13 +16,13 @@ from datetime import datetime, timezone import docker import psutil from docker.errors import DockerException, NotFound -from fastapi import Depends, FastAPI, HTTPException, Security +from fastapi import Depends, FastAPI, HTTPException, Security, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.security import APIKeyHeader # ─── Config ─────────────────────────────────────────────────────────────────── -AGENT_VERSION = "1.2.0" +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") @@ -154,6 +157,190 @@ def container_action(container_id: str, action: str, _: None = Depends(require_a 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.""" diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py index 5b16f03..78bab67 100644 --- a/vps-monitor/backend/main.py +++ b/vps-monitor/backend/main.py @@ -20,7 +20,7 @@ from typing import Annotated import bcrypt as _bcrypt import aiohttp -from fastapi import Depends, FastAPI, HTTPException, Request +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 @@ -265,6 +265,9 @@ def init_db() -> None: 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, @@ -507,6 +510,49 @@ def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> d 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: @@ -801,8 +847,9 @@ async def _cleanup_old_stats() -> None: def auth_status(): """Indique si des utilisateurs existent déjà (pour le frontend).""" return { - "has_users": len(load_users()) > 0, + "has_users": len(load_users()) > 0, "passkey_enabled": _get_setting("passkey_enabled") == "true", + "terminal_enabled": _get_setting("terminal_enabled") != "false", } @@ -887,7 +934,8 @@ def admin_update_setting( _: 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"} + 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: @@ -1424,6 +1472,105 @@ async def container_action( 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, diff --git a/vps-monitor/frontend/nginx.conf b/vps-monitor/frontend/nginx.conf index f21bf9f..2a4e58c 100644 --- a/vps-monitor/frontend/nginx.conf +++ b/vps-monitor/frontend/nginx.conf @@ -1,3 +1,10 @@ +# Bascule l'en-tête Connection selon qu'il s'agit d'un WebSocket ou non — +# nécessaire pour le terminal des conteneurs, qui passe par /api/…. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; root /usr/share/nginx/html; @@ -8,6 +15,16 @@ server { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + + # Relais WebSocket (terminal interactif) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Un terminal ouvert peut rester inactif longtemps sans être coupé + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; } # SPA fallback diff --git a/vps-monitor/frontend/node_modules/.package-lock.json b/vps-monitor/frontend/node_modules/.package-lock.json index 216aa80..d6c39fd 100644 --- a/vps-monitor/frontend/node_modules/.package-lock.json +++ b/vps-monitor/frontend/node_modules/.package-lock.json @@ -498,6 +498,21 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -900,6 +915,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/vps-monitor/frontend/package-lock.json b/vps-monitor/frontend/package-lock.json index fca5fee..bbe223f 100644 --- a/vps-monitor/frontend/package-lock.json +++ b/vps-monitor/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "vps-monitor-frontend", "version": "1.0.0", "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "lucide-react": "^0.396.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -1224,6 +1226,21 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", diff --git a/vps-monitor/frontend/package.json b/vps-monitor/frontend/package.json index 925eec9..70820d9 100644 --- a/vps-monitor/frontend/package.json +++ b/vps-monitor/frontend/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "lucide-react": "^0.396.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index 414251d..c2d3ae0 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react' import { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react' import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client' import Header from './components/Header' @@ -16,6 +16,9 @@ import { useToast } from './components/ui/Toast' import { Button, EmptyState, Skeleton } from './components/ui/controls' import { copyText, downloadText } from './lib/clipboard' +// xterm.js pèse ~300 kB : chargé seulement à l'ouverture d'un terminal. +const TerminalModal = lazy(() => import('./components/TerminalModal')) + const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, { label: '30 s', value: 30_000 }, @@ -60,6 +63,7 @@ export default function App() { const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [isFirstUser, setIsFirstUser] = useState(false) const [passkeyEnabled, setPasskeyEnabled] = useState(false) + const [terminalEnabled, setTerminalEnabled] = useState(true) const [authChecked, setAuthChecked] = useState(false) const [vpsList, setVpsList] = useState([]) @@ -117,13 +121,15 @@ export default function App() { const [updateLoading, setUpdateLoading] = useState(false) const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } + const [terminalModal, setTerminalModal] = useState(null) // { vps, container } // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { authStatus() - .then(({ has_users, passkey_enabled }) => { + .then(({ has_users, passkey_enabled, terminal_enabled }) => { setIsFirstUser(!has_users) setPasskeyEnabled(!!passkey_enabled) + setTerminalEnabled(terminal_enabled !== false) }) .catch(() => setIsFirstUser(false)) .finally(() => setAuthChecked(true)) @@ -200,7 +206,7 @@ export default function App() { }, [token, username]) // Raccourcis clavier : « / » cible la recherche, « r » actualise. - const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget) + const modalOpen = !!(logsModal || updateModal || showAddVps || editVps || statsModal || deleteTarget || terminalModal) useEffect(() => { if (!token || page !== 'main' || modalOpen) return const onKeyDown = (e) => { @@ -369,6 +375,10 @@ export default function App() { ) } + // Le terminal donne un shell root dans le conteneur : réservé aux admins, et + // désactivable globalement depuis la page d'administration. + const canUseTerminal = role === 'admin' && terminalEnabled + // Statistiques globales const totalOnline = vpsList.filter(v => v.online).length const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) @@ -511,6 +521,7 @@ export default function App() { onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} onUpdateAgent={handleUpdateAgent} onExport={handleExportVps} + onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined} /> ))} @@ -563,6 +574,21 @@ export default function App() { /> )} + {/* Terminal interactif */} + {terminalModal && ( + + Chargement du terminal… + + }> + setTerminalModal(null)} + /> + + )} + {/* Confirmation de suppression */} {deleteTarget && ( { + if (!settings) return + const newValue = settings.terminal_enabled === 'false' ? 'true' : 'false' + setToggleLoading(true) + try { + await setAdminSetting('terminal_enabled', newValue) + setSettings(prev => ({ ...prev, terminal_enabled: newValue })) + } catch (err) { + setSettingsError(err.message) + } finally { + setToggleLoading(false) + } + } + // ─── Notifications (Pushover) ──────────────────────────────────────────── const [pushoverToken, setPushoverToken] = useState('') const [pushoverUserKey, setPushoverUserKey] = useState('') @@ -411,6 +425,13 @@ export default function AdminPage({ onBack }) { onChange={togglePasskeys} loading={toggleLoading} /> + ) } diff --git a/vps-monitor/frontend/src/components/ContainerRow.jsx b/vps-monitor/frontend/src/components/ContainerRow.jsx index 51c263f..b0ca13c 100644 --- a/vps-monitor/frontend/src/components/ContainerRow.jsx +++ b/vps-monitor/frontend/src/components/ContainerRow.jsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react' +import { Play, Square, RotateCcw, FileText, Loader2, Heart, TerminalSquare } from 'lucide-react' import StatusBadge from './StatusBadge' const HEALTH_STYLES = { @@ -28,7 +28,7 @@ function hostPorts(ports) { return [...found].sort((a, b) => Number(a) - Number(b)) } -export default function ContainerRow({ container, onAction, onLogs }) { +export default function ContainerRow({ container, onAction, onLogs, onTerminal, execDisabledReason }) { const [pending, setPending] = useState(null) const isRunning = container.status === 'running' const ports = useMemo(() => hostPorts(container.ports), [container.ports]) @@ -91,6 +91,15 @@ export default function ContainerRow({ container, onAction, onLogs }) { handle('restart')} loading={pending === 'restart'}> + {onTerminal && isRunning && ( + + + + )} @@ -99,13 +108,13 @@ export default function ContainerRow({ container, onAction, onLogs }) { ) } -function ActionBtn({ children, onClick, title, danger = false, loading = false }) { +function ActionBtn({ children, onClick, title, danger = false, loading = false, disabled = false }) { return ( + )} + + + + } + > +
+ + ) +} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index cb1f6a3..fb1679f 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -5,6 +5,14 @@ import { tagColor } from './TagInput' import { IconButton, ProgressBar } from './ui/controls' import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format' +/** Le terminal interactif n'existe qu'à partir de l'agent 1.3.0. */ +function agentSupportsExec(version) { + if (!version || version === 'unknown') return true // version inconnue : on laisse tenter + const [major, minor] = version.split('.').map(n => parseInt(n, 10)) + if (Number.isNaN(major)) return true + return major > 1 || (major === 1 && (minor || 0) >= 3) +} + /** Métrique système avec barre de progression (CPU, RAM). */ function Metric({ icon: Icon, label, percent: rawPercent, detail }) { const percent = Number.isFinite(rawPercent) ? rawPercent : 0 @@ -23,7 +31,7 @@ function Metric({ icon: Icon, label, percent: rawPercent, detail }) { ) } -export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) { +export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport, onTerminal }) { const storageKey = `vps:${vps.id}:collapsed` const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1') const [updatingProject, setUpdatingProject] = useState(null) @@ -294,6 +302,12 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o container={c} onAction={(action) => onAction(vps.id, c.id, action)} onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)} + onTerminal={onTerminal ? () => onTerminal(vps, c) : undefined} + execDisabledReason={ + agentSupportsExec(vps.agent_version) + ? undefined + : `Terminal indisponible : agent v${vps.agent_version} — mettez à jour vers v1.3.0 ou plus.` + } /> ))} diff --git a/vps-monitor/frontend/src/components/ui/Modal.jsx b/vps-monitor/frontend/src/components/ui/Modal.jsx index f200969..2b2101f 100644 --- a/vps-monitor/frontend/src/components/ui/Modal.jsx +++ b/vps-monitor/frontend/src/components/ui/Modal.jsx @@ -33,15 +33,20 @@ export default function Modal({ children, bodyClassName = 'overflow-y-auto p-4', panelClassName = '', + // Le terminal a besoin de Tab (complétion) et d'Échap (vim) : il désactive + // ces deux raccourcis et se ferme via la croix. + trapTab = true, + closeOnEscape = true, }) { - const panelRef = useRef(null) - const onCloseRef = useRef(onClose) - const titleId = useId() + const panelRef = useRef(null) + const titleId = useId() - // `onClose` est souvent une lambda recréée à chaque rendu du parent : on la - // lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait - // au premier champ à chaque rafraîchissement automatique). - useEffect(() => { onCloseRef.current = onClose }) + // `onClose` est souvent une lambda recréée à chaque rendu du parent : le + // gestionnaire de touches lit ces valeurs via une ref pour que l'effet ne se + // rejoue pas (sinon le focus sauterait au premier champ à chaque + // rafraîchissement automatique). + const handlersRef = useRef({ onClose, trapTab, closeOnEscape }) + useEffect(() => { handlersRef.current = { onClose, trapTab, closeOnEscape } }) useEffect(() => { const previouslyFocused = document.activeElement @@ -50,12 +55,12 @@ export default function Modal({ ;(firstFocusable ?? panel)?.focus({ preventScroll: true }) const onKeyDown = (e) => { - if (e.key === 'Escape') { + if (e.key === 'Escape' && handlersRef.current.closeOnEscape) { e.stopPropagation() - onCloseRef.current?.() + handlersRef.current.onClose?.() return } - if (e.key !== 'Tab' || !panel) return + if (e.key !== 'Tab' || !handlersRef.current.trapTab || !panel) return const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null) if (items.length === 0) return diff --git a/vps-monitor/frontend/vite.config.js b/vps-monitor/frontend/vite.config.js index 69163ad..ad06c26 100644 --- a/vps-monitor/frontend/vite.config.js +++ b/vps-monitor/frontend/vite.config.js @@ -5,7 +5,8 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': 'http://localhost:8000', + // `ws: true` : le terminal des conteneurs passe par un WebSocket sur /api + '/api': { target: 'http://localhost:8000', ws: true }, }, }, })