Add debug mode feature flag with environment variable parsing, UI badge indicator, secret redaction utility, and enhanced terminal session management with status checks and session limit error handling
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-21 17:22:36 -05:00
parent 421446bd13
commit 70c15c9b6f
16 changed files with 606 additions and 181 deletions
+2
View File
@@ -27,6 +27,7 @@ from app.routes.alerts import router as alerts_router
from app.routes.docker import router as docker_router
from app.routes.lint import router as lint_router
from app.routes.terminal import router as terminal_router
from app.routes.config import router as config_router
# Router principal qui agrège tous les sous-routers
api_router = APIRouter()
@@ -52,6 +53,7 @@ api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
api_router.include_router(config_router, tags=["Config"])
__all__ = [
"api_router",
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter()
@router.get("/config")
async def get_app_config():
return {
"debug_mode": bool(settings.debug_mode),
}
+142 -160
View File
@@ -24,7 +24,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, get_current_user
from app.core.config import settings
from app.core.dependencies import get_db, get_current_user, require_debug_mode
from app.crud.host import HostRepository
from app.crud.bootstrap_status import BootstrapStatusRepository
from app.crud.terminal_session import TerminalSessionRepository
@@ -111,6 +112,56 @@ def _get_session_token_from_headers(auth_header: Optional[str]) -> Optional[str]
parts = auth_header.split(" ", 1)
return parts[1].strip() if len(parts) == 2 else None
def _build_session_limit_error(active_sessions, same_host_session=None) -> dict:
now = datetime.now(timezone.utc)
infos = []
for s in (active_sessions or []):
created_at = _as_utc_aware(getattr(s, "created_at", None))
last_seen_at = _as_utc_aware(getattr(s, "last_seen_at", None))
age_seconds = int(max(0, (now - created_at).total_seconds())) if created_at else 0
last_seen_seconds = int(max(0, (now - last_seen_at).total_seconds())) if last_seen_at else 0
infos.append(
ActiveSessionInfo(
session_id=str(getattr(s, "id", "")),
host_id=str(getattr(s, "host_id", "")),
host_name=str(getattr(s, "host_name", "")),
mode=str(getattr(s, "mode", "")),
age_seconds=age_seconds,
last_seen_seconds=last_seen_seconds,
)
)
infos.sort(key=lambda x: x.last_seen_seconds, reverse=True)
suggested_actions = []
can_reuse = False
reusable_session_id = None
if same_host_session is not None:
can_reuse = True
reusable_session_id = str(getattr(same_host_session, "id", ""))
if reusable_session_id:
suggested_actions.append("reuse_existing")
suggested_actions.append(f"reuse_session:{reusable_session_id}")
suggested_actions.append("close_oldest")
if infos:
suggested_actions.append(f"close_session:{infos[-1].session_id}")
err = SessionLimitError(
message="Maximum active terminal sessions reached",
max_active=TERMINAL_MAX_SESSIONS_PER_USER,
current_count=len(active_sessions or []),
active_sessions=infos,
suggested_actions=suggested_actions,
can_reuse=can_reuse,
reusable_session_id=reusable_session_id,
)
return err.model_dump()
def _get_session_token_from_request(request: Request, session_id: str, token: Optional[str] = None) -> Optional[str]:
if token:
return token
@@ -149,6 +200,7 @@ async def get_terminal_status(current_user: dict = Depends(get_current_user)):
available = bool(terminal_service.check_ttyd_available())
return {
"available": available,
"debug_mode": bool(settings.debug_mode),
}
@router.get("/sessions/{session_id}/probe")
@@ -156,6 +208,7 @@ async def probe_terminal_session(
session_id: str,
token: str,
request: Request,
debug_enabled: bool = Depends(require_debug_mode),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
@@ -483,8 +536,8 @@ async def proxy_ttyd_websocket(
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session(
host_id: str,
session_request: TerminalSessionRequest,
http_request: Request,
request: TerminalSessionRequest = TerminalSessionRequest(),
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
@@ -528,7 +581,7 @@ async def create_terminal_session(
existing_session = await session_repo.find_reusable_session(
user_id=user_id,
host_id=host.id,
mode=request.mode,
mode=session_request.mode,
idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
)
@@ -548,7 +601,7 @@ async def create_terminal_session(
connect_path = f"/api/terminal/connect/{existing_session.id}?token={token}"
popout_path = f"/api/terminal/popout/{existing_session.id}?token={token}"
session_url = popout_path if request.mode == "popout" else connect_path
session_url = popout_path if session_request.mode == "popout" else connect_path
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
ws_host = (http_request.url.netloc if http_request else "localhost")
@@ -648,7 +701,7 @@ async def create_terminal_session(
expires_at=expires_at,
user_id=user_id,
username=username,
mode=request.mode,
mode=session_request.mode,
)
await db_session.commit()
except Exception as e:
@@ -673,7 +726,7 @@ async def create_terminal_session(
connect_path = f"/api/terminal/connect/{session_id}?token={token}"
popout_path = f"/api/terminal/popout/{session_id}?token={token}"
session_url = popout_path if request.mode == "popout" else connect_path
session_url = popout_path if session_request.mode == "popout" else connect_path
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
ws_host = (http_request.url.netloc if http_request else "localhost")
@@ -685,7 +738,7 @@ async def create_terminal_session(
websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{session_id}/ws?{ws_token}",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
mode=session_request.mode,
host=TerminalSessionHost(
id=host.id,
name=host.name,
@@ -697,51 +750,11 @@ async def create_terminal_session(
token=token,
)
@router.get("/sessions", response_model=TerminalSessionList)
async def list_terminal_sessions(
status_filter: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
sessions = await session_repo.list_active_for_user(user_id)
now = datetime.now(timezone.utc)
session_list = []
for session in sessions:
expires_at = _as_utc_aware(session.expires_at)
created_at = _as_utc_aware(session.created_at)
last_seen_at = _as_utc_aware(getattr(session, 'last_seen_at', None)) or created_at
remaining = _compute_remaining_seconds(expires_at, now=now)
age_seconds = int((now - created_at).total_seconds())
last_seen_seconds = int((now - last_seen_at).total_seconds())
session_list.append(TerminalSessionStatus(
session_id=session.id,
status=session.status,
host_id=session.host_id,
host_name=session.host_name,
mode=session.mode,
created_at=created_at,
expires_at=expires_at,
last_seen_at=last_seen_at,
remaining_seconds=remaining,
age_seconds=age_seconds,
last_seen_seconds=last_seen_seconds,
))
return TerminalSessionList(
sessions=session_list,
total=len(session_list),
max_per_user=TERMINAL_MAX_SESSIONS_PER_USER,
)
@router.delete("/sessions/{session_id}")
async def close_terminal_session(
session_id: str,
request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
@@ -774,6 +787,7 @@ async def close_terminal_session(
async def heartbeat_terminal_session(
session_id: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
@@ -808,6 +822,7 @@ async def heartbeat_terminal_session(
async def close_beacon_terminal_session(
session_id: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
@@ -830,6 +845,7 @@ async def close_beacon_terminal_session(
@router.post("/cleanup")
async def cleanup_terminal_sessions(
debug_enabled: bool = Depends(require_debug_mode),
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
@@ -870,6 +886,7 @@ async def log_terminal_command(
session_id: str,
command_data: dict,
request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
@@ -1424,21 +1441,24 @@ function executeHistoryCommand(index) {
}
});
"""
debug_mode_enabled = bool(settings.debug_mode)
debug_panel_html = (
"""
<div id=\"terminalDebug\" style=\"display:none; position:absolute; bottom:14px; right:14px; z-index:9998; background:rgba(17,24,39,0.92); border:1px solid rgba(55,65,81,0.8); color:#e5e7eb; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;\">
<div style=\"display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:6px;\">
<div style=\"font-weight:600;\">Debug Terminal</div>
<button class=\"btn btn-secondary\" style=\"padding:0.2rem 0.5rem; font-size:0.75rem;\" onclick=\"toggleDebug(false)\">Fermer</button>
</div>
<div id=\"terminalDebugBody\" style=\"white-space:pre-wrap; color:#9ca3af; line-height:1.35;\">boot: page_rendered\nsession_id: {session_id}\nttyd_port: {ttyd_port}\n</div>
</div>
""".format(session_id=html.escape(session_id), ttyd_port=ttyd_port)
if debug_mode_enabled
else ""
)
script_block = (
"<script>\n"
f" const SESSION_ID = {js_session_id};\n"
f" const TOKEN = {js_token};\n"
f" const HOST_ID = {js_host_id};\n"
f" const HOST_NAME = {js_host_name};\n"
f" const HOST_IP = {js_host_ip};\n"
f" let remainingSeconds = {remaining_seconds};\n"
f" const EMBED = {str(embed_mode).lower()};\n"
f" const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};\n"
" let historyData = [];\n"
" let heartbeatTimer = null;\n"
" let iframeLoaded = false;\n"
" let ttydUrl = null;\n"
debug_js = (
"" if not debug_mode_enabled else
" let debugVisible = false;\n\n"
" function toggleDebug(force) {\n"
" debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n"
@@ -1481,94 +1501,23 @@ function executeHistoryCommand(index) {
" updateDebug('manual');\n"
" }\n"
" });\n\n"
" window.addEventListener('error', (event) => {\n"
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n"
" body.textContent += '\\njs_error: ' + (event && event.message ? event.message : 'unknown');\n"
" if (event && event.filename) body.textContent += '\\njs_error_file: ' + event.filename;\n"
" if (event && event.lineno) body.textContent += '\\njs_error_line: ' + event.lineno;\n"
" toggleDebug(true);\n"
" });\n\n"
" window.addEventListener('unhandledrejection', (event) => {\n"
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n"
" const reason = event && event.reason ? event.reason : null;\n"
" body.textContent += '\\nunhandled_rejection: ' + (reason && reason.message ? reason.message : String(reason));\n"
" toggleDebug(true);\n"
" });\n\n"
" (function setTerminalSrc() {\n"
f" const port = {ttyd_port};\n"
" const host = window.location.host;\n"
" const frame = document.getElementById('terminalFrame');\n"
" toggleDebug(true);\n"
" ttydUrl = window.location.protocol + '//' + host + '/api/terminal/proxy/' + SESSION_ID + '/?token=' + encodeURIComponent(TOKEN) + '&_t=' + Date.now();\n"
" const debugBody = document.getElementById('terminalDebugBody');\n"
" if (debugBody) {\n"
" debugBody.textContent += '\\ncomputed_ttyd_url (via proxy): ' + ttydUrl;\n"
" debugBody.textContent += '\\nSESSION_ID: ' + SESSION_ID;\n"
" debugBody.textContent += '\\nTOKEN: (masked)';\n"
" debugBody.textContent += '\\nttyd_port (from template): ' + port;\n"
" debugBody.textContent += '\\nwindow.location.port: ' + window.location.port;\n"
" debugBody.textContent += '\\nwindow.location.host: ' + host;\n"
" }\n"
" setTimeout(() => {\n"
" updateDebug('immediate_boot');\n"
" }, 100);\n"
" frame.addEventListener('load', () => {\n"
" iframeLoaded = true;\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (loading) loading.classList.add('hidden');\n"
" try { frame.focus(); } catch (e) {}\n"
" });\n"
" frame.addEventListener('error', () => {\n"
" updateDebug('iframe_error');\n"
" });\n"
" const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\n"
" const wsProxyUrl = wsScheme + '://' + host + '/api/terminal/proxy/' + SESSION_ID + '/ws?token=' + encodeURIComponent(TOKEN);\n"
" if (debugBody) {\n"
" debugBody.textContent += '\\nws_proxy_url: ' + wsProxyUrl;\n"
" }\n"
" frame.src = ttydUrl;\n"
" })();\n\n"
" const iframeTimeoutMs = 8000;\n"
" setTimeout(() => {\n"
" if (iframeLoaded) return;\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (loading) {\n"
" loading.classList.remove('hidden');\n"
" loading.innerHTML = `\n"
" <div style=\"text-align:center; max-width:520px;\">\n"
" <div style=\"font-size:1.1rem; font-weight:600; color:#ef4444; margin-bottom:0.5rem;\">Terminal injoignable</div>\n"
" <div style=\"color:#e5e7eb; margin-bottom:0.75rem;\">Le navigateur n'a reçu aucune réponse du service ttyd.</div>\n"
" <div style=\"color:#9ca3af; font-size:0.85rem; margin-bottom:0.75rem;\">URL: ${ttydUrl || '(non définie)'} </div>\n"
" <div style=\"display:flex; justify-content:center; gap:0.5rem;\">\n"
" <button class=\"btn btn-secondary\" onclick=\"reconnect()\">Reconnecter</button>\n"
" <button class=\"btn btn-danger\" onclick=\"goToDashboard()\">Fermer</button>\n"
" </div>\n"
" </div>\n"
" `;\n"
" }\n"
" updateDebug('iframe_timeout');\n"
" }, iframeTimeoutMs);\n\n"
" function updateTimer() {\n"
" remainingSeconds--;\n"
" if (remainingSeconds <= 0) {\n"
" document.getElementById('timerDisplay').textContent = 'Expiré';\n"
" document.getElementById('sessionTimer').classList.add('critical');\n"
" return;\n"
" }\n"
" const minutes = Math.floor(remainingSeconds / 60);\n"
" const seconds = remainingSeconds % 60;\n"
" document.getElementById('timerDisplay').textContent = minutes + ':' + seconds.toString().padStart(2, '0');\n"
" const timer = document.getElementById('sessionTimer');\n"
" if (remainingSeconds < 60) {\n"
" timer.classList.add('critical');\n"
" timer.classList.remove('warning');\n"
" } else if (remainingSeconds < 300) {\n"
" timer.classList.add('warning');\n"
" }\n"
" }\n"
" setInterval(updateTimer, 1000);\n\n"
)
script_block = (
"<script>\n"
f" const SESSION_ID = {js_session_id};\n"
f" const TOKEN = {js_token};\n"
f" const HOST_ID = {js_host_id};\n"
f" const HOST_NAME = {js_host_name};\n"
f" const HOST_IP = {js_host_ip};\n"
f" let remainingSeconds = {remaining_seconds};\n"
f" const EMBED = {str(embed_mode).lower()};\n"
f" const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};\n"
" let historyData = [];\n"
" let heartbeatTimer = null;\n"
" let iframeLoaded = false;\n"
" let ttydUrl = null;\n\n"
f"{debug_js}"
" async function sendHeartbeat() {\n"
" if (EMBED) return;\n"
" try {\n"
@@ -1650,6 +1599,42 @@ function executeHistoryCommand(index) {
" document.getElementById('pwaHint').classList.add('hidden');\n"
" }\n"
"\n"
" (function initTerminalIframe() {\n"
" const frame = document.getElementById('terminalFrame');\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (!frame) return;\n"
"\n"
" frame.addEventListener('load', () => {\n"
" iframeLoaded = true;\n"
" if (loading) loading.classList.add('hidden');\n"
" });\n"
"\n"
" const qs = new URLSearchParams();\n"
" qs.set('token', TOKEN);\n"
" ttydUrl = '/api/terminal/proxy/' + encodeURIComponent(SESSION_ID) + '?' + qs.toString();\n"
" frame.src = ttydUrl;\n"
"\n"
" const iframeTimeoutMs = 8000;\n"
" setTimeout(() => {\n"
" if (iframeLoaded) return;\n"
" if (loading) {\n"
" loading.classList.remove('hidden');\n"
" loading.innerHTML = `\n"
" <div style=\"text-align:center; max-width:520px;\">\n"
" <div style=\"font-size:1.1rem; font-weight:600; color:#ef4444; margin-bottom:0.5rem;\">Terminal injoignable</div>\n"
" <div style=\"color:#e5e7eb; margin-bottom:0.75rem;\">Le navigateur n'a reçu aucune réponse du service ttyd.</div>\n"
" <div style=\"color:#9ca3af; font-size:0.85rem; margin-bottom:0.75rem;\">URL: ${ttydUrl || '(non définie)'} </div>\n"
" <div style=\"display:flex; justify-content:center; gap:0.5rem;\">\n"
" <button class=\"btn btn-secondary\" onclick=\"reconnect()\">Reconnecter</button>\n"
" <button class=\"btn btn-danger\" onclick=\"goToDashboard()\">Fermer</button>\n"
" </div>\n"
" </div>\n"
" `;\n"
" }\n"
" if (typeof updateDebug === 'function') updateDebug('iframe_timeout');\n"
" }, iframeTimeoutMs);\n"
" })();\n"
"\n"
+ history_script_block
+ "\n"
" startHeartbeat();\n"
@@ -2110,16 +2095,7 @@ function executeHistoryCommand(index) {
JavaScript est désactivé: le terminal web ne peut pas se charger.
</div>
</noscript>
<div id="terminalDebug" style="display:none; position:absolute; bottom:14px; right:14px; z-index:9998; background:rgba(17,24,39,0.92); border:1px solid rgba(55,65,81,0.8); color:#e5e7eb; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:6px;">
<div style="font-weight:600;">Debug Terminal</div>
<button class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" onclick="toggleDebug(false)">Fermer</button>
</div>
<div id="terminalDebugBody" style="white-space:pre-wrap; color:#9ca3af; line-height:1.35;">boot: page_rendered
session_id: {session_id}
ttyd_port: {ttyd_port}
</div>
</div>
{debug_panel_html}
<iframe
id="terminalFrame"
src="about:blank"
@@ -2201,6 +2177,7 @@ async def get_host_command_history(
offset: int = 0,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history for a specific host.
@@ -2262,6 +2239,7 @@ async def get_host_shell_history(
limit: int = 100,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get shell history directly from the remote host via SSH.
@@ -2336,6 +2314,7 @@ async def get_host_unique_commands(
limit: int = 50,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get unique commands for a host (deduplicated).
@@ -2387,6 +2366,7 @@ async def get_global_command_history(
offset: int = 0,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history globally (across all hosts).
@@ -2435,6 +2415,7 @@ async def clear_host_command_history(
host_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Clear command history for a specific host.
@@ -2473,6 +2454,7 @@ async def purge_old_command_history(
days: int = 30,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Purge command history older than specified days.
+3 -6
View File
@@ -8,6 +8,8 @@ from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
from app.core.config import settings
from app.models.database import async_session_maker
from app.services import ws_manager
from app.crud.terminal_session import TerminalSessionRepository
from app.services.terminal_service import terminal_service
@@ -45,18 +47,13 @@ async def terminal_websocket_proxy(
The session_id is used to look up the ttyd port from the database.
Authentication is handled via session token in the query string.
"""
import socket
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return
# We need a DB session to look up the terminal session
# Since we're in a WebSocket endpoint, we need to create one manually
from app.core.database import SessionLocal
async with SessionLocal() as db_session:
async with async_session_maker() as db_session:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)