feat: add interactive terminal support for containers via WebSocket
All checks were successful
Build and Push Docker Images / docker (push) Successful in 30s
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:
@@ -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 <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
|
||||
|
||||
[https://git.jeanbonapp.com/jeanbon/ScriptVPS](https://git.jeanbonapp.com/jeanbon/ScriptVPS)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user