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

@@ -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,