feat: add interactive terminal support for containers via WebSocket
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s

- 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.
This commit is contained in:
jeanotx32
2026-08-01 01:49:26 -04:00
parent f37b639226
commit e277e99155
15 changed files with 732 additions and 24 deletions

View File

@@ -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_API_KEY` | *(généré)* | Clé secrète partagée avec le backend |
| `AGENT_PORT` | `8001` | Port TCP d'écoute | | `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 : 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 <conteneur> 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 ## Dépôt source
[https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS) [https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS)

View File

@@ -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. Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker.
""" """
import asyncio
import json
import os import os
import socket as socket_module
import subprocess import subprocess
import threading import threading
import time import time
@@ -13,13 +16,13 @@ from datetime import datetime, timezone
import docker import docker
import psutil import psutil
from docker.errors import DockerException, NotFound 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.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
# ─── Config ─────────────────────────────────────────────────────────────────── # ─── 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") 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") 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") 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") @app.get("/system")
def system_info(_: None = Depends(require_api_key)): def system_info(_: None = Depends(require_api_key)):
"""Retourne les informations système : CPU, RAM et bande passante.""" """Retourne les informations système : CPU, RAM et bande passante."""

View File

@@ -20,7 +20,7 @@ from typing import Annotated
import bcrypt as _bcrypt import bcrypt as _bcrypt
import aiohttp 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.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -265,6 +265,9 @@ def init_db() -> None:
conn.execute(""" conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('pushover_user_key', '') 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS container_events ( CREATE TABLE IF NOT EXISTS container_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -507,6 +510,49 @@ def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> d
return current_user 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: def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For") forwarded = request.headers.get("X-Forwarded-For")
if forwarded: if forwarded:
@@ -803,6 +849,7 @@ def auth_status():
return { return {
"has_users": len(load_users()) > 0, "has_users": len(load_users()) > 0,
"passkey_enabled": _get_setting("passkey_enabled") == "true", "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)], _: Annotated[dict, Depends(require_admin)],
): ):
"""Met à jour un paramètre d'administration.""" """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: if key not in allowed_keys:
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
with get_db() as conn: with get_db() as conn:
@@ -1424,6 +1472,105 @@ async def container_action(
raise HTTPException(status_code=502, detail=str(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") @app.post("/api/vps/{vps_id}/compose/update")
async def compose_update( async def compose_update(
vps_id: str, body: ComposeUpdateRequest, vps_id: str, body: ComposeUpdateRequest,

View File

@@ -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 { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
@@ -8,6 +15,16 @@ server {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; 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 # SPA fallback

View File

@@ -498,6 +498,21 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -900,6 +915,21 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

View File

@@ -8,6 +8,8 @@
"name": "vps-monitor-frontend", "name": "vps-monitor-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^0.396.0", "lucide-react": "^0.396.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^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" "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": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",

View File

@@ -9,6 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^0.396.0", "lucide-react": "^0.396.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View File

@@ -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 { ServerCrash, SearchX, Plus, RefreshCw } from 'lucide-react'
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client' import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
import Header from './components/Header' import Header from './components/Header'
@@ -16,6 +16,9 @@ import { useToast } from './components/ui/Toast'
import { Button, EmptyState, Skeleton } from './components/ui/controls' import { Button, EmptyState, Skeleton } from './components/ui/controls'
import { copyText, downloadText } from './lib/clipboard' 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 = [ const INTERVAL_OPTIONS = [
{ label: '10 s', value: 10_000 }, { label: '10 s', value: 10_000 },
{ label: '30 s', value: 30_000 }, { label: '30 s', value: 30_000 },
@@ -60,6 +63,7 @@ export default function App() {
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
const [isFirstUser, setIsFirstUser] = useState(false) const [isFirstUser, setIsFirstUser] = useState(false)
const [passkeyEnabled, setPasskeyEnabled] = useState(false) const [passkeyEnabled, setPasskeyEnabled] = useState(false)
const [terminalEnabled, setTerminalEnabled] = useState(true)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [vpsList, setVpsList] = useState([]) const [vpsList, setVpsList] = useState([])
@@ -117,13 +121,15 @@ export default function App() {
const [updateLoading, setUpdateLoading] = useState(false) const [updateLoading, setUpdateLoading] = useState(false)
const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } 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) // Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => { useEffect(() => {
authStatus() authStatus()
.then(({ has_users, passkey_enabled }) => { .then(({ has_users, passkey_enabled, terminal_enabled }) => {
setIsFirstUser(!has_users) setIsFirstUser(!has_users)
setPasskeyEnabled(!!passkey_enabled) setPasskeyEnabled(!!passkey_enabled)
setTerminalEnabled(terminal_enabled !== false)
}) })
.catch(() => setIsFirstUser(false)) .catch(() => setIsFirstUser(false))
.finally(() => setAuthChecked(true)) .finally(() => setAuthChecked(true))
@@ -200,7 +206,7 @@ export default function App() {
}, [token, username]) }, [token, username])
// Raccourcis clavier : « / » cible la recherche, « r » actualise. // 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(() => { useEffect(() => {
if (!token || page !== 'main' || modalOpen) return if (!token || page !== 'main' || modalOpen) return
const onKeyDown = (e) => { 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 // Statistiques globales
const totalOnline = vpsList.filter(v => v.online).length const totalOnline = vpsList.filter(v => v.online).length
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) 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 })} onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
onUpdateAgent={handleUpdateAgent} onUpdateAgent={handleUpdateAgent}
onExport={handleExportVps} onExport={handleExportVps}
onTerminal={canUseTerminal ? (target, containerRow) => setTerminalModal({ vps: target, container: containerRow }) : undefined}
/> />
))} ))}
</div> </div>
@@ -563,6 +574,21 @@ export default function App() {
/> />
)} )}
{/* Terminal interactif */}
{terminalModal && (
<Suspense fallback={
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm text-sm text-gray-400">
Chargement du terminal
</div>
}>
<TerminalModal
vps={terminalModal.vps}
container={terminalModal.container}
onClose={() => setTerminalModal(null)}
/>
</Suspense>
)}
{/* Confirmation de suppression */} {/* Confirmation de suppression */}
{deleteTarget && ( {deleteTarget && (
<ConfirmDialog <ConfirmDialog

View File

@@ -127,6 +127,27 @@ export async function fetchVpsStats(vpsId, duration = 600) {
return handleResponse(res) return handleResponse(res)
} }
// ─── Terminal conteneur ───────────────────────────────────────────────────────
/** Échange le JWT contre un ticket à usage unique (60 s) pour ouvrir le terminal. */
export async function requestExecTicket(vpsId, containerId) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/exec/ticket`, {
method: 'POST',
headers: authHeaders(),
})
return handleResponse(res)
}
/**
* URL du WebSocket de terminal. Le token n'y figure jamais : seul un ticket
* jetable transite en query string.
*/
export function execSocketUrl(vpsId, containerId, ticket, cols, rows) {
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ ticket, cols: String(cols), rows: String(rows) })
return `${scheme}://${window.location.host}${BASE}/vps/${vpsId}/containers/${containerId}/exec?${params}`
}
// ─── Profile ────────────────────────────────────────────────────────────────── // ─── Profile ──────────────────────────────────────────────────────────────────
export async function changePassword(oldPassword, newPassword) { export async function changePassword(oldPassword, newPassword) {

View File

@@ -103,6 +103,20 @@ export default function AdminPage({ onBack }) {
} }
} }
const toggleTerminal = async () => {
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) ──────────────────────────────────────────── // ─── Notifications (Pushover) ────────────────────────────────────────────
const [pushoverToken, setPushoverToken] = useState('') const [pushoverToken, setPushoverToken] = useState('')
const [pushoverUserKey, setPushoverUserKey] = useState('') const [pushoverUserKey, setPushoverUserKey] = useState('')
@@ -411,6 +425,13 @@ export default function AdminPage({ onBack }) {
onChange={togglePasskeys} onChange={togglePasskeys}
loading={toggleLoading} loading={toggleLoading}
/> />
<ToggleRow
label="Terminal des conteneurs"
description="Permet aux administrateurs d'ouvrir un shell dans un conteneur (agent 1.3.0+)."
enabled={settings?.terminal_enabled !== 'false'}
onChange={toggleTerminal}
loading={toggleLoading}
/>
</div> </div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react' 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' import StatusBadge from './StatusBadge'
const HEALTH_STYLES = { const HEALTH_STYLES = {
@@ -28,7 +28,7 @@ function hostPorts(ports) {
return [...found].sort((a, b) => Number(a) - Number(b)) 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 [pending, setPending] = useState(null)
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const ports = useMemo(() => hostPorts(container.ports), [container.ports]) const ports = useMemo(() => hostPorts(container.ports), [container.ports])
@@ -91,6 +91,15 @@ export default function ContainerRow({ container, onAction, onLogs }) {
<ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}> <ActionBtn title={`Redémarrer ${container.name}`} onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} /> <RotateCcw size={13} />
</ActionBtn> </ActionBtn>
{onTerminal && isRunning && (
<ActionBtn
title={execDisabledReason ?? `Ouvrir un terminal dans ${container.name}`}
onClick={onTerminal}
disabled={!!execDisabledReason}
>
<TerminalSquare size={13} />
</ActionBtn>
)}
<ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}> <ActionBtn title={`Logs de ${container.name}`} onClick={onLogs}>
<FileText size={13} /> <FileText size={13} />
</ActionBtn> </ActionBtn>
@@ -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 ( return (
<button <button
onClick={onClick} onClick={onClick}
title={title} title={title}
aria-label={title} aria-label={title}
disabled={loading} disabled={loading || disabled}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${ className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger danger
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400' ? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react'
import { TerminalSquare, RotateCcw } from 'lucide-react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import Modal from './ui/Modal'
import { Button } from './ui/controls'
import { requestExecTicket, execSocketUrl } from '../api/client'
const THEME = {
background: '#030712',
foreground: '#e5e7eb',
cursor: '#818cf8',
cursorAccent: '#030712',
selectionBackground: '#312e81',
black: '#1f2937', red: '#f87171', green: '#34d399', yellow: '#fbbf24',
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e5e7eb',
brightBlack: '#4b5563', brightRed: '#fca5a5', brightGreen: '#6ee7b7',
brightYellow:'#fcd34d', brightBlue: '#93c5fd', brightMagenta:'#d8b4fe',
brightCyan: '#67e8f9', brightWhite: '#f9fafb',
}
const STATUS = {
connecting: { label: 'Connexion…', dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400' },
open: { label: 'Connecté', dot: 'bg-emerald-400', text: 'text-emerald-400' },
closed: { label: 'Session terminée', dot: 'bg-gray-500', text: 'text-gray-500' },
error: { label: 'Erreur', dot: 'bg-red-400', text: 'text-red-400' },
}
export default function TerminalModal({ vps, container, onClose }) {
const hostRef = useRef(null)
const [status, setStatus] = useState('connecting')
const [attempt, setAttempt] = useState(0) // incrémenté pour relancer la session
useEffect(() => {
const term = new Terminal({
theme: THEME,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
cursorBlink: true,
scrollback: 5000,
convertEol: false,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(hostRef.current)
term.focus()
let socket = null
let cancelled = false
// Ajuster la grille demande que le nœud ait déjà une taille et que le moteur
// de rendu de xterm soit initialisé : on passe donc toujours par une frame.
const safeFit = () => {
const host = hostRef.current
if (cancelled || !host || host.clientWidth === 0 || host.clientHeight === 0) return
try { fit.fit() } catch { /* rendu pas encore prêt */ }
}
requestAnimationFrame(safeFit)
const observer = new ResizeObserver(() => requestAnimationFrame(safeFit))
observer.observe(hostRef.current)
const send = (payload) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload))
}
const dataSub = term.onData(data => send({ t: 'i', d: data }))
const resizeSub = term.onResize(({ cols, rows }) => send({ t: 'r', cols, rows }))
;(async () => {
setStatus('connecting')
try {
const { ticket } = await requestExecTicket(vps.id, container.id)
if (cancelled) return
socket = new WebSocket(execSocketUrl(vps.id, container.id, ticket, term.cols, term.rows))
socket.binaryType = 'arraybuffer'
socket.onopen = () => {
setStatus('open')
send({ t: 'r', cols: term.cols, rows: term.rows })
term.focus()
}
socket.onmessage = (event) => {
// Binaire = sortie brute du TTY ; texte = message de contrôle JSON.
if (typeof event.data !== 'string') {
term.write(new Uint8Array(event.data))
return
}
try {
const message = JSON.parse(event.data)
if (message.t === 'error') {
setStatus('error')
term.writeln(`\r\n\x1b[31m${message.m}\x1b[0m`)
} else if (message.t === 'exit') {
term.writeln(`\r\n\x1b[90m— shell terminé (code ${message.code ?? '?'})\x1b[0m`)
}
} catch { /* message non JSON : ignoré */ }
}
socket.onerror = () => {
if (!cancelled) setStatus(current => (current === 'open' ? current : 'error'))
}
socket.onclose = () => {
if (cancelled) return
setStatus(current => (current === 'error' ? current : 'closed'))
term.writeln('\r\n\x1b[90m— connexion fermée\x1b[0m')
}
} catch (e) {
if (cancelled) return
setStatus('error')
term.writeln(`\r\n\x1b[31mImpossible d'ouvrir le terminal : ${e.message}\x1b[0m`)
}
})()
return () => {
cancelled = true
observer.disconnect()
dataSub.dispose()
resizeSub.dispose()
socket?.close()
// xterm garde ses propres callbacks de redimensionnement en file : les
// laisser s'exécuter sur une instance vivante, sinon ils lèvent une erreur
// sur un cœur déjà libéré (visible au double montage de StrictMode).
requestAnimationFrame(() => requestAnimationFrame(() => term.dispose()))
}
}, [vps.id, container.id, attempt])
const state = STATUS[status] ?? STATUS.connecting
return (
<Modal
size="xl"
title={`${vps.name} / ${container.name}`}
subtitle={container.image}
icon={<TerminalSquare size={16} className="text-indigo-400 flex-shrink-0" />}
onClose={onClose}
// Le shell a besoin d'Échap (vim) et de Tab (complétion) : la modale les
// laisse passer et se ferme via la croix ou le bouton du pied de page.
closeOnEscape={false}
trapTab={false}
bodyClassName="flex flex-col overflow-hidden p-0"
headerRight={
<span className={`hidden sm:flex items-center gap-1.5 text-xs ${state.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${state.dot}`} />
{state.label}
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-600">
Échap et Tab sont transmis au shell fermez avec .
</p>
<div className="flex items-center gap-2">
{(status === 'closed' || status === 'error') && (
<Button variant="secondary" size="sm" icon={RotateCcw} onClick={() => setAttempt(a => a + 1)}>
Reconnecter
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose}>Fermer</Button>
</div>
</div>
}
>
<div
ref={hostRef}
className="h-[60vh] bg-gray-950 px-3 py-2"
aria-label={`Terminal du conteneur ${container.name}`}
/>
</Modal>
)
}

View File

@@ -5,6 +5,14 @@ import { tagColor } from './TagInput'
import { IconButton, ProgressBar } from './ui/controls' import { IconButton, ProgressBar } from './ui/controls'
import { formatBps, formatRam, loadColor, loadBarColor } from '../lib/format' 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). */ /** Métrique système avec barre de progression (CPU, RAM). */
function Metric({ icon: Icon, label, percent: rawPercent, detail }) { function Metric({ icon: Icon, label, percent: rawPercent, detail }) {
const percent = Number.isFinite(rawPercent) ? rawPercent : 0 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 storageKey = `vps:${vps.id}:collapsed`
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1') const [collapsed, setCollapsed] = useState(() => localStorage.getItem(storageKey) === '1')
const [updatingProject, setUpdatingProject] = useState(null) const [updatingProject, setUpdatingProject] = useState(null)
@@ -294,6 +302,12 @@ export default function VpsCard({ vps, query = '', onAction, onLogs, onDelete, o
container={c} container={c}
onAction={(action) => onAction(vps.id, c.id, action)} onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)} 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.`
}
/> />
))} ))}
</> </>

View File

@@ -33,15 +33,20 @@ export default function Modal({
children, children,
bodyClassName = 'overflow-y-auto p-4', bodyClassName = 'overflow-y-auto p-4',
panelClassName = '', 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 panelRef = useRef(null)
const onCloseRef = useRef(onClose)
const titleId = useId() const titleId = useId()
// `onClose` est souvent une lambda recréée à chaque rendu du parent : on la // `onClose` est souvent une lambda recréée à chaque rendu du parent : le
// lit via une ref pour que l'effet ne se rejoue pas (sinon le focus sauterait // gestionnaire de touches lit ces valeurs via une ref pour que l'effet ne se
// au premier champ à chaque rafraîchissement automatique). // rejoue pas (sinon le focus sauterait au premier champ à chaque
useEffect(() => { onCloseRef.current = onClose }) // rafraîchissement automatique).
const handlersRef = useRef({ onClose, trapTab, closeOnEscape })
useEffect(() => { handlersRef.current = { onClose, trapTab, closeOnEscape } })
useEffect(() => { useEffect(() => {
const previouslyFocused = document.activeElement const previouslyFocused = document.activeElement
@@ -50,12 +55,12 @@ export default function Modal({
;(firstFocusable ?? panel)?.focus({ preventScroll: true }) ;(firstFocusable ?? panel)?.focus({ preventScroll: true })
const onKeyDown = (e) => { const onKeyDown = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape' && handlersRef.current.closeOnEscape) {
e.stopPropagation() e.stopPropagation()
onCloseRef.current?.() handlersRef.current.onClose?.()
return 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) const items = [...panel.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null)
if (items.length === 0) return if (items.length === 0) return

View File

@@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8000', // `ws: true` : le terminal des conteneurs passe par un WebSocket sur /api
'/api': { target: 'http://localhost:8000', ws: true },
}, },
}, },
}) })