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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user