Bruno Charest 57bfe02e32
Some checks failed
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
feat: Implement web-based SSH terminal with session management, command logging, and host interaction.
2026-03-03 13:37:52 -05:00

1726 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Routes API pour les sessions terminal SSH web.
Provides endpoints for:
- Creating terminal sessions
- Listing active sessions
- Closing sessions
- Serving terminal popout page
"""
import asyncio
import json
import hashlib
import httpx
import logging
import socket
import html
from urllib.parse import urlencode, urlparse, parse_qs
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel
from app.services.shell_history_service import shell_history_service, ShellHistoryError
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_db, get_current_user, get_current_user_optional, require_debug_mode
_templates = Jinja2Templates(directory=str(settings.base_dir / "templates"))
from app.crud.host import HostRepository
from app.crud.bootstrap_status import BootstrapStatusRepository
from app.crud.terminal_session import TerminalSessionRepository
from app.crud.terminal_command_log import TerminalCommandLogRepository
from app.schemas.terminal import (
TerminalSessionRequest,
TerminalSessionResponse,
TerminalSessionHost,
TerminalSessionStatus,
TerminalSessionList,
CommandHistoryItem,
CommandHistoryResponse,
UniqueCommandItem,
UniqueCommandsResponse,
ActiveSessionInfo,
SessionLimitError,
HeartbeatResponse,
SessionMetrics,
)
from app.services.terminal_service import (
terminal_service,
TERMINAL_SESSION_TTL_MINUTES,
TERMINAL_SESSION_TTL_SECONDS,
TERMINAL_MAX_SESSIONS_PER_USER,
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
TERMINAL_TTYD_INTERFACE,
TtydNotAvailableError,
)
from app.models.terminal_session import (
SESSION_STATUS_ACTIVE,
SESSION_STATUS_CLOSED,
CLOSE_REASON_USER,
CLOSE_REASON_CLIENT_LOST,
)
logger = logging.getLogger(__name__)
router = APIRouter()
_PORT_REACHABILITY_TIMEOUT_SECONDS = 1.0
_MAX_TERMINAL_COMMAND_LENGTH = 10000
def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _compute_remaining_seconds(expires_at: Optional[datetime], now: Optional[datetime] = None) -> int:
now = now or datetime.now(timezone.utc)
expires_at = _as_utc_aware(expires_at)
if not expires_at:
return 0
delta = (expires_at - now).total_seconds()
return max(0, int(delta))
async def _is_port_reachable(request: Request, port: int) -> bool:
candidates = ["127.0.0.1", "localhost"]
try:
if request.url.hostname:
candidates.append(request.url.hostname)
except Exception:
pass
ttyd_iface = TERMINAL_TTYD_INTERFACE
if ttyd_iface and ttyd_iface not in {"0.0.0.0", "::"}:
candidates.append(ttyd_iface)
for host_candidate in dict.fromkeys(candidates):
try:
with socket.create_connection((host_candidate, int(port)), timeout=_PORT_REACHABILITY_TIMEOUT_SECONDS):
return True
except Exception:
continue
return False
def _get_session_token_from_headers(auth_header: Optional[str]) -> Optional[str]:
if not auth_header:
return None
if not auth_header.startswith("Bearer "):
return None
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
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
if cookie_token:
return cookie_token
hdr_token = _get_session_token_from_headers(request.headers.get("authorization"))
if hdr_token:
return hdr_token
referer = request.headers.get("referer")
if referer:
try:
parsed = urlparse(referer)
qs = parse_qs(parsed.query)
if "token" in qs:
return qs["token"][0]
except Exception:
pass
return None
async def _verify_history_access(
host_id: str,
request: Request,
db_session: AsyncSession,
current_user: Optional[dict] = None
) -> bool:
"""Verify access to history for a specific host (JWT or Session Token)."""
if current_user:
return True
# Try query param first (generic)
q_token = request.query_params.get("token")
session_repo = TerminalSessionRepository(db_session)
# Try by ID first
sessions = await session_repo.list_active_for_host(host_id)
# Try by name if no sessions found (host_id might be a name)
if not sessions:
host_repo = HostRepository(db_session)
host = await host_repo.get_by_name(host_id)
if host:
sessions = await session_repo.list_active_for_host(host.id)
for s in sessions:
# Check if token is in query param OR in the session-specific cookie
token = q_token or _get_session_token_from_request(request, s.id)
if token and terminal_service.verify_token(token, s.token_hash):
return True
return False
def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
token = websocket.query_params.get("token")
if token:
return token
cookie_key = f"terminal_token_{session_id}"
cookie_token = websocket.cookies.get(cookie_key) or websocket.cookies.get("terminal_token")
return cookie_token
@router.get("/status")
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")
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)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
if not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
alive = False
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = False
reachable = await _is_port_reachable(request, session.ttyd_port)
return {
"session_id": session.id,
"ttyd_port": session.ttyd_port,
"process_alive": alive,
"port_reachable": reachable,
"ttyd_interface": TERMINAL_TTYD_INTERFACE,
"dashboard_scheme": request.url.scheme,
"dashboard_host": request.url.hostname,
}
@router.get("/proxy/{session_id}")
async def proxy_ttyd_http(
session_id: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
effective_token = _get_session_token_from_request(request, session_id, token=token)
if not effective_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
if not terminal_service.verify_token(effective_token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
prefix = f"/api/terminal/proxy/{session_id}"
path = request.url.path
if path.startswith(prefix):
path = path[len(prefix):]
if not path:
path = "/"
query_params = list(request.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
query_string = urlencode(query_params, doseq=True) if query_params else ""
ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
if query_string:
ttyd_url += f"?{query_string}"
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
ttyd_response = await client.request(
method=request.method,
url=ttyd_url,
headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
follow_redirects=True,
)
drop_headers = {
"content-encoding",
"transfer-encoding",
"content-security-policy",
"x-frame-options",
"content-length",
}
response_headers = {
k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
}
response = StreamingResponse(
iter([ttyd_response.content]),
status_code=ttyd_response.status_code,
headers=response_headers,
media_type=ttyd_response.headers.get("content-type"),
)
if effective_token and effective_token != cookie_token:
response.set_cookie(
key=cookie_key,
value=effective_token,
httponly=True,
samesite="lax",
secure=(request.url.scheme == "https"),
path=f"/api/terminal/proxy/{session_id}/",
max_age=TERMINAL_SESSION_TTL_SECONDS,
)
return response
except Exception as e:
logger.error(f"Error proxying request to ttyd: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to proxy request to ttyd: {str(e)}",
)
@router.api_route(
"/proxy/{session_id}/{proxy_path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy_ttyd_http_assets(
session_id: str,
proxy_path: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
effective_token = _get_session_token_from_request(request, session_id, token=token)
if not effective_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
if not terminal_service.verify_token(effective_token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
path = "/" + (proxy_path or "")
query_params = list(request.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
query_string = urlencode(query_params, doseq=True) if query_params else ""
ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
if query_string:
ttyd_url += f"?{query_string}"
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
ttyd_response = await client.request(
method=request.method,
url=ttyd_url,
headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
follow_redirects=True,
)
drop_headers = {
"content-encoding",
"transfer-encoding",
"content-security-policy",
"x-frame-options",
"content-length",
}
response_headers = {
k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
}
response = StreamingResponse(
iter([ttyd_response.content]),
status_code=ttyd_response.status_code,
headers=response_headers,
media_type=ttyd_response.headers.get("content-type"),
)
if effective_token and effective_token != cookie_token:
response.set_cookie(
key=cookie_key,
value=effective_token,
httponly=True,
samesite="lax",
secure=(request.url.scheme == "https"),
path=f"/api/terminal/proxy/{session_id}/",
max_age=TERMINAL_SESSION_TTL_SECONDS,
)
return response
except Exception as e:
logger.error(f"Error proxying request to ttyd: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to proxy request to ttyd: {str(e)}",
)
@router.websocket("/proxy/{session_id}/ws")
async def proxy_ttyd_websocket(
websocket: WebSocket,
session_id: str,
db_session: AsyncSession = Depends(get_db),
):
token = _get_session_token_from_websocket(websocket, session_id)
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not found")
return
if not terminal_service.verify_token(token, session.token_hash):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid token")
return
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(getattr(session, "expires_at", None))
if expires_at and expires_at <= now:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session expired")
return
subprotocol = None
proto_hdr = websocket.headers.get("sec-websocket-protocol")
if proto_hdr and "tty" in proto_hdr:
subprotocol = "tty"
await websocket.accept(subprotocol=subprotocol)
query_params = list(websocket.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
upstream_qs = urlencode(query_params, doseq=True) if query_params else ""
upstream_url = f"ws://127.0.0.1:{session.ttyd_port}/ws" + (f"?{upstream_qs}" if upstream_qs else "")
import websockets
logger.info(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_connect_upstream",
"upstream_url": upstream_url,
"ttyd_port": session.ttyd_port,
}
)
)
try:
# Ttyd checks Origin by default. We must provide a valid Origin matching the host/port
# or ttyd will reject the WebSocket handshake (403).
upstream_headers = {
"Origin": f"http://127.0.0.1:{session.ttyd_port}",
}
async with websockets.connect(
upstream_url,
subprotocols=["tty"],
open_timeout=5,
ping_interval=None,
max_size=None,
additional_headers=upstream_headers,
) as upstream:
logger.info(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_connected_upstream",
"ttyd_port": session.ttyd_port,
}
)
)
async def _client_to_upstream():
try:
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
data = message.get("bytes")
if data is not None:
await upstream.send(data)
continue
text_data = message.get("text")
if text_data is not None:
await upstream.send(text_data)
except WebSocketDisconnect:
pass
finally:
try:
await upstream.close()
except Exception:
pass
async def _upstream_to_client():
try:
async for data in upstream:
if isinstance(data, bytes):
await websocket.send_bytes(data)
else:
await websocket.send_text(data)
finally:
try:
await websocket.close()
except Exception:
pass
await asyncio.gather(_client_to_upstream(), _upstream_to_client(), return_exceptions=True)
except Exception as e:
logger.exception(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_error",
"error": str(e),
"ttyd_port": getattr(session, "ttyd_port", None),
}
)
)
try:
await websocket.close(code=1011, reason="Proxy error")
except Exception:
pass
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session(
host_id: str,
session_request: TerminalSessionRequest,
http_request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
if not terminal_service.check_ttyd_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed",
)
session_repo = TerminalSessionRepository(db_session)
host_repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
if host.status == "offline":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' is offline. Cannot open terminal."
)
bootstrap = await bs_repo.latest_for_host(host.id)
bootstrap_ok = bootstrap and bootstrap.status == "success"
if not bootstrap_ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' has not been bootstrapped. Run bootstrap first to configure SSH access."
)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
existing_session = await session_repo.find_reusable_session(
user_id=user_id,
host_id=host.id,
mode=session_request.mode,
idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
)
if existing_session:
await session_repo.update_last_seen(existing_session.id)
await db_session.commit()
terminal_service.record_session_created(reused=True)
logger.info(f"session_reused session={existing_session.id[:8]}... user={username} host={host.name}")
expires_at = _as_utc_aware(existing_session.expires_at)
remaining = _compute_remaining_seconds(expires_at)
token, token_hash = terminal_service.generate_session_token()
existing_session.token_hash = token_hash
await db_session.commit()
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 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")
ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=existing_session.id,
url=session_url,
websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{existing_session.id}/ws?{ws_token}",
expires_at=expires_at,
ttl_seconds=remaining,
mode=existing_session.mode,
host=TerminalSessionHost(
id=host.id,
name=host.name,
ip=host.ip_address,
status=host.status or "unknown",
bootstrap_ok=bootstrap_ok,
),
reused=True,
token=token,
)
active_sessions = await session_repo.list_active_for_user(user_id)
active_count = len(active_sessions)
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
same_host_session = next((s for s in active_sessions if s.host_id == host.id), None)
terminal_service.record_session_limit_hit()
logger.warning(f"session_limit_hit user={username} count={active_count} max={TERMINAL_MAX_SESSIONS_PER_USER}")
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content=_build_session_limit_error(active_sessions, same_host_session),
)
session_id = terminal_service.generate_session_id()
token, token_hash = terminal_service.generate_session_token()
port = None
pid = None
last_spawn_error = None
for _ in range(3):
try:
port = await terminal_service.allocate_port(session_id)
except Exception as e:
logger.error(f"Failed to allocate port: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No available ports for terminal session",
)
try:
pid = await terminal_service.spawn_ttyd(
session_id=session_id,
host_ip=host.ip_address,
port=port,
token=token,
)
if pid is None:
last_spawn_error = "Failed to start terminal process"
await terminal_service.release_port(port)
port = None
continue
break
except TtydNotAvailableError:
await terminal_service.release_port(port)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed",
)
except Exception as e:
last_spawn_error = str(e)
await terminal_service.release_port(port)
port = None
continue
if pid is None or port is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start terminal: {last_spawn_error or 'unknown error'}",
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES)
session = None
try:
session = await session_repo.create(
id=session_id,
host_id=host.id,
host_name=host.name,
host_ip=host.ip_address,
token_hash=token_hash,
ttyd_port=port,
ttyd_pid=pid,
expires_at=expires_at,
user_id=user_id,
username=username,
mode=session_request.mode,
)
await db_session.commit()
except Exception as e:
logger.exception(f"Failed to create session in DB: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create terminal session"
)
finally:
if session is None:
try:
await terminal_service.terminate_session(session_id)
except Exception:
pass
try:
await terminal_service.release_port(port)
except Exception:
pass
terminal_service.record_session_created(reused=False)
logger.info(f"session_created session={session_id[:8]}... user={username} host={host.name}")
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 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")
ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=session_id,
url=session_url,
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=session_request.mode,
host=TerminalSessionHost(
id=host.id,
name=host.name,
ip=host.ip_address,
status=host.status or "unknown",
bootstrap_ok=bootstrap_ok,
),
reused=False,
token=token,
)
@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)
session = await session_repo.get(session_id)
if not session:
return {"message": "Session not found or already closed", "session_id": session_id}
if session.status in [SESSION_STATUS_CLOSED, "expired", "error"]:
return {"message": "Session already closed", "session_id": session_id}
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
await terminal_service.close_session_with_cleanup(
session_id=session_id,
port=session.ttyd_port,
reason=CLOSE_REASON_USER
)
await session_repo.close_session(session_id, reason=CLOSE_REASON_USER)
await db_session.commit()
logger.info(f"session_closed session={session_id[:8]}... host={session.host_name} reason=user_close")
return {"message": "Session closed", "session_id": session_id}
@router.post("/sessions/{session_id}/heartbeat", response_model=HeartbeatResponse)
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)
session = await session_repo.get_active_by_id(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found or expired"
)
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
await session_repo.update_last_seen(session_id)
await db_session.commit()
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(session.expires_at)
remaining = _compute_remaining_seconds(expires_at, now=now)
return HeartbeatResponse(
session_id=session_id,
status=session.status,
last_seen_at=now,
remaining_seconds=remaining,
healthy=True,
)
@router.post("/sessions/{session_id}/close-beacon")
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)
session = await session_repo.get(session_id)
if session and session.status == SESSION_STATUS_ACTIVE:
await terminal_service.close_session_with_cleanup(
session_id=session_id,
port=session.ttyd_port,
reason=CLOSE_REASON_CLIENT_LOST
)
await session_repo.close_session(session_id, reason=CLOSE_REASON_CLIENT_LOST)
await db_session.commit()
logger.info(f"session_closed session={session_id[:8]}... host={session.host_name} reason=client_lost")
from fastapi.responses import Response
return Response(status_code=204)
@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),
):
session_repo = TerminalSessionRepository(db_session)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
active_sessions = await session_repo.list_active_for_user(user_id)
terminated = 0
closed = 0
for session in active_sessions:
try:
await terminal_service.terminate_session(session.id)
await terminal_service.release_port(session.ttyd_port)
terminated += 1
except Exception as e:
logger.warning(f"Failed to terminate session {session.id[:8]}: {e}")
try:
await session_repo.close_session(session.id, reason=CLOSE_REASON_USER)
closed += 1
except Exception as e:
logger.warning(f"Failed to close session {session.id[:8]} in DB: {e}")
await db_session.commit()
logger.info(f"Terminal cleanup: {terminated} processes terminated, {closed} sessions closed")
return {
"message": "Cleanup completed",
"processes_terminated": terminated,
"sessions_closed": closed,
"expired_marked": 0,
}
class LogCommandRequest(BaseModel):
command: str
@router.post("/sessions/{session_id}/command")
async def log_terminal_command(
session_id: str,
payload: LogCommandRequest,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
try:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get_active_by_id(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session token
actual_token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not actual_token or not terminal_service.verify_token(actual_token, session.token_hash):
raise HTTPException(status_code=403, detail="Invalid session token")
cmd = payload.command.strip()
if not cmd:
return {"status": "ignored", "reason": "empty_command"}
if len(cmd) > _MAX_TERMINAL_COMMAND_LENGTH:
cmd = cmd[:_MAX_TERMINAL_COMMAND_LENGTH]
try:
from app.security.command_policy import get_command_policy
policy = get_command_policy()
result = policy.evaluate(cmd)
# If not allowed and not blocked, we ignore it (UNKNOWN)
if not result.should_log and not result.is_blocked:
return {"status": "ignored", "reason": "not_allowed_to_log"}
is_blocked = result.is_blocked
reason = result.reason
masked_cmd = result.masked_command or ("[BLOCKED]" if is_blocked else cmd)
cmd_hash = result.command_hash or hashlib.sha256(masked_cmd.encode('utf-8')).hexdigest()
except Exception as pe:
logger.warning(f"Policy evaluation failure: {pe}")
is_blocked = False
reason = str(pe)
masked_cmd = cmd
cmd_hash = hashlib.sha256(cmd.encode('utf-8')).hexdigest()
cmd_repo = TerminalCommandLogRepository(db_session)
await cmd_repo.create(
host_id=session.host_id,
host_name=session.host_name,
user_id=session.user_id,
username=session.username,
terminal_session_id=session.id,
command=masked_cmd,
command_hash=cmd_hash,
source="terminal_dynamic",
is_blocked=is_blocked,
blocked_reason=reason,
)
await db_session.commit()
return {"status": "success", "blocked": is_blocked}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error logging terminal command: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get_active_by_id(session_id)
if not session:
return _templates.TemplateResponse(
"terminal/error.html",
{
"request": request,
"title": "Session Expirée",
"heading": "Session Expirée ou Invalide",
"messages": ["Cette session terminal n'existe pas ou a expiré."],
"show_back_link": True,
},
status_code=404,
)
if not terminal_service.verify_token(token, session.token_hash):
return _templates.TemplateResponse(
"terminal/error.html",
{
"request": request,
"title": html.escape(session.host_name or "Terminal"),
"heading": "Accès Refusé",
"messages": ["Token de session invalide."],
"show_back_link": False,
},
status_code=403,
)
alive = True
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = True
if not alive:
if await _is_port_reachable(request, session.ttyd_port):
alive = True
else:
try:
pid = await terminal_service.spawn_ttyd(
session_id=session_id,
host_ip=session.host_ip,
port=session.ttyd_port,
token=token,
)
if pid:
session.ttyd_pid = pid
await db_session.commit()
alive = True
except Exception:
alive = False
if not alive:
return _templates.TemplateResponse(
"terminal/error.html",
{
"request": request,
"title": html.escape(session.host_name or "Terminal"),
"heading": "Terminal indisponible",
"messages": [
"Le service terminal (ttyd) ne répond pas pour cette session.",
"Essayez de reconnecter ou de recréer une session depuis le dashboard.",
],
"show_back_link": True,
},
status_code=503,
)
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(session.expires_at)
remaining_seconds = _compute_remaining_seconds(expires_at, now=now)
ttyd_port = session.ttyd_port
embed_mode = request.query_params.get("embed") in {"1", "true", "yes"}
js_session_id = json.dumps(session_id)
js_token = json.dumps(token)
js_host_id = json.dumps(session.host_id)
js_host_name = json.dumps(session.host_name)
js_host_ip = json.dumps(session.host_ip)
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 ""
)
# JavaScript blocks are built server-side so SESSION_ID/TOKEN can be JSON-encoded
debug_js = (
"" if not debug_mode_enabled else
" let debugVisible = false;\n\n"
" function toggleDebug(force) {\n"
" debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n"
" const el = document.getElementById('terminalDebug');\n"
" if (el) el.style.display = debugVisible ? 'block' : 'none';\n"
" }\n\n"
" window.addEventListener('keydown', (e) => { if ((e.ctrlKey||e.metaKey)&&e.shiftKey&&(e.key==='D'||e.key==='d')) { e.preventDefault(); toggleDebug(); } });\n\n"
)
history_js = """
// ---- History panel state ----
let historyPanelOpen = false, historyPanelPinned = false;
let historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
let historyPinnedOnly = false;
// ---- Panel open/close ----
function toggleHistory() {
if (historyPanelOpen) { closeHistoryPanel(true); return; }
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
historyPanelOpen = true; historySelectedIndex = -1;
const si = document.getElementById('terminalHistorySearch');
if (si) { si.focus(); si.select(); }
if (historyData.length === 0) loadHistory();
}
function closeHistoryPanel(force = false) {
if (historyPanelPinned && !force) return;
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.classList.remove('open'); btn.classList.remove('active');
historyPanelOpen = false; historySelectedIndex = -1;
setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200);
document.getElementById('terminalFrame').focus();
}
function toggleHistoryPin() {
historyPanelPinned = !historyPanelPinned;
const btn = document.getElementById('btnHistoryPin');
const panel = document.getElementById('terminalHistoryPanel');
btn?.classList.toggle('active', historyPanelPinned);
panel?.classList.toggle('docked', historyPanelPinned);
}
function togglePinnedOnly() {
historyPinnedOnly = !historyPinnedOnly;
const btn = document.getElementById('btnPinnedOnly');
btn?.classList.toggle('active', historyPinnedOnly);
historySelectedIndex = -1; loadHistory();
}
// ---- Data loading ----
async function loadHistory() {
const list = document.getElementById('terminalHistoryList');
list.innerHTML = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const query = historySearchQuery;
let ep = allHosts
? `/api/terminal/command-history?limit=100&token=${encodeURIComponent(TOKEN)}`
: `/api/terminal/${HOST_ID}/command-history/unique?limit=100&token=${encodeURIComponent(TOKEN)}`;
if (query) ep += '&query=' + encodeURIComponent(query);
try {
const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } });
if (!res.ok) { list.innerHTML = `<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur ${res.status}</div>`; return; }
const data = await res.json();
let cmds = data.commands || [];
if (historyTimeFilter !== 'all') {
const now = new Date();
let cutoff;
if (historyTimeFilter === 'today') cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
else if (historyTimeFilter === 'week') cutoff = new Date(now.getTime() - 7*86400000);
else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000);
if (cutoff) cmds = cmds.filter(c => new Date(c.last_used||c.created_at) >= cutoff);
}
if (historyPinnedOnly) cmds = cmds.filter(c => c.is_pinned);
historyData = cmds; historySelectedIndex = -1; renderHistory();
} catch(e) { list.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur réseau</div>'; }
}
async function togglePinH(i) {
const cmd = historyData[i];
if (!cmd || !cmd.command_hash) return;
const newPinned = !cmd.is_pinned;
const hId = cmd.host_id || HOST_ID;
try {
const res = await fetch(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
body: JSON.stringify({ is_pinned: newPinned })
});
if (res.ok) { cmd.is_pinned = newPinned; renderHistory(); }
} catch(e) {}
}
// ---- Helpers ----
function escH(t){return (t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');}
function escRE(s){return s.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&');}
function relTime(d){if(!d)return'';const dt=new Date(d),now=new Date(),ds=Math.floor((now-dt)/1000);if(ds<60)return'À l instant';const dm=Math.floor(ds/60),dh=Math.floor(dm/60),dd=Math.floor(dh/24);if(dm<60)return`Il y a ${dm}min`;if(dh<24)return`Il y a ${dh}h`;if(dd<7)return`Il y a ${dd}j`;return dt.toLocaleDateString('fr-FR',{day:'numeric',month:'short'});}
// ---- Execution ----
function execH(i){
const cmd = historyData[i];
if(cmd && cmd.command) {
const fr = document.getElementById('terminalFrame');
const cw = fr ? fr.contentWindow : null;
if(cw && cw.term) {
cw.term.focus();
cw.term.paste(cmd.command + '\\r');
closeHistoryPanel();
} else if (cw) {
cw.postMessage({ type: 'terminal:paste', text: cmd.command + '\\r' }, '*');
closeHistoryPanel();
} else {
copyH(i);
}
}
}
function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});}
// ---- Rendering ----
function renderHistory(){
const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||'';
if(!historyData.length){list.innerHTML=`<div class="terminal-history-empty"><i class="fas fa-terminal"></i><span>${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}</span></div>`;return;}
list.innerHTML=historyData.map((cmd,i)=>{
const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned,hn=cmd.host_name||'';
let dc=escH(c.length>80?c.substring(0,80)+'...':c);
if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'<mark>$1</mark>');
return `<div class="terminal-history-item${sel?' selected':''}" data-index="${i}" onclick="execH(${i})" title="${escH(c)}"><div class="terminal-history-cmd"><code>${pinned?'<i class="fas fa-thumbtack" style="color:#fbbf24;margin-right:4px;"></i>':''}${dc}</code></div><div class="terminal-history-meta"><span class="terminal-history-time">${ta}</span>${ec>1?`<span class="terminal-history-count">×${ec}</span>`:''}${hn?`<span class="terminal-history-host">${escH(hn)}</span>`:''}</div><div class="terminal-history-actions-inline"><button class="terminal-history-action" onclick="event.stopPropagation();togglePinH(${i})" title="${pinned?'Désépingler':'Épingler'}"><i class="fas fa-thumbtack" style="${pinned?'color:#fbbf24':''}"></i></button><button class="terminal-history-action" onclick="event.stopPropagation();copyH(${i})" title="Copier"><i class="fas fa-copy"></i></button><button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation();execH(${i})" title="Exécuter"><i class="fas fa-play"></i></button></div></div>`;
}).join('');
if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});}
}
function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)execH(historySelectedIndex);else if(l>0)execH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}}
let _st=null;
function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);}
function clearHistorySearch(){const i=document.getElementById('terminalHistorySearch');if(i){i.value='';i.focus();}historySearchQuery='';historySelectedIndex=-1;loadHistory();}
function setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();}
function toggleHistoryScope(){historySelectedIndex=-1;loadHistory();}
document.addEventListener('keydown',(e)=>{
if(e.key==='Escape'&&historyPanelOpen&&!historyPanelPinned)closeHistoryPanel();
if((e.ctrlKey||e.metaKey)&&e.key==='r'){e.preventDefault();toggleHistory();}
});
// postMessage handler so parent can paste into this terminal
window.addEventListener('message', (ev) => {
if (ev.data && ev.data.type === 'terminal:paste') {
const fr = document.getElementById('terminalFrame');
const cw = fr ? fr.contentWindow : null;
if (cw && cw.term) { cw.term.focus(); cw.term.paste(ev.data.text); }
}
});
"""
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"
" const headers = { 'Content-Type': 'application/json' };\n"
" headers['Authorization'] = 'Bearer ' + TOKEN;\n"
" const resp = await fetch('/api/terminal/sessions/' + SESSION_ID + '/heartbeat', { method: 'POST', headers, body: JSON.stringify({}) });\n"
" if (resp.status === 404 || resp.status === 403) { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }\n"
" } catch (e) {}\n"
" }\n\n"
" function startHeartbeat() {\n"
" if (EMBED) return;\n"
" if (heartbeatTimer) return;\n"
" sendHeartbeat();\n"
" heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);\n"
" }\n\n"
" async function copyTextToClipboard(text) {\n"
" const value = String(text ?? '');\n"
" if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { try { await navigator.clipboard.writeText(value); return; } catch (e) {} }\n"
" const ta = document.createElement('textarea'); ta.value = value; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '0';\n"
" document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta);\n"
" if (!ok) throw new Error('copy_failed');\n"
" }\n\n"
" function goToDashboard() {\n"
" if (EMBED) { try { window.parent.postMessage({ type: 'terminal:closeDrawer' }, '*'); } catch (e) {} return; }\n"
" if (window.opener) window.close(); else window.location.href = '/';\n"
" }\n\n"
" function reconnect() {\n"
" if (EMBED) { try { window.parent.postMessage({ type: 'terminal:reconnect' }, '*'); } catch (e) {} return; }\n"
" try { document.getElementById('terminalLoading').classList.remove('hidden'); } catch (e) {}\n"
" const url = new URL(window.location.href); url.searchParams.set('_ts', Date.now().toString()); window.location.replace(url.toString());\n"
" }\n\n"
" function closeSession() {\n"
" if (confirm('Fermer cette session terminal?')) {\n"
" fetch('/api/terminal/sessions/' + SESSION_ID, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + TOKEN } }).then(() => { goToDashboard(); }).catch(() => { goToDashboard(); });\n"
" }\n"
" }\n\n"
" function copySSHCommand() { const cmd = 'ssh automation@' + HOST_IP; copyTextToClipboard(cmd).catch(() => {}); }\n\n"
" function dismissPwaHint() { document.getElementById('pwaHint').classList.add('hidden'); localStorage.setItem('pwaHintDismissed', 'true'); }\n"
" if (localStorage.getItem('pwaHintDismissed') === 'true' || window.matchMedia('(display-mode: standalone)').matches) { document.getElementById('pwaHint').classList.add('hidden'); }\n\n"
" (function initTerminalIframe() {\n"
" const frame = document.getElementById('terminalFrame');\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (!frame) return;\n"
" frame.addEventListener('load', () => { \n"
" iframeLoaded = true; if (loading) loading.classList.add('hidden');\n"
" setTimeout(() => { \n"
" try { \n"
" const cw = frame.contentWindow; \n"
" if (cw && cw.term) { \n"
" let cmdBuf = ''; \n"
" cw.term.onData(data => { \n"
" if (data === '\\r') { \n"
" const c = cmdBuf.trim(); \n"
" if (c) { \n"
" fetch('/api/terminal/sessions/' + SESSION_ID + '/command', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN }, body: JSON.stringify({ command: c }) }).catch(()=>{}); \n"
" } \n"
" cmdBuf = ''; \n"
" } else if (data === '\\x7f') { \n"
" cmdBuf = cmdBuf.slice(0, -1); \n"
" } else if (data >= ' ' && data <= '~') { \n"
" cmdBuf += data; \n"
" } else if (data === '\\u0003') { \n"
" cmdBuf = ''; \n"
" } \n"
" }); \n"
" } \n"
" } catch(e) {} \n"
" }, 1500); \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"
" setTimeout(() => {\n"
" if (iframeLoaded) return;\n"
" if (loading) {\n"
" loading.classList.remove('hidden');\n"
" loading.innerHTML = '<div style=\"text-align:center;max-width:520px;\"><div style=\"color:#ef4444;font-weight:600;margin-bottom:.5rem;\">Terminal injoignable</div><div style=\"color:#e5e7eb;margin-bottom:.75rem;\">Le navigateur n a reçu aucune réponse du service ttyd.</div><div style=\"display:flex;justify-content:center;gap:.5rem;\"><button class=\"btn btn-secondary\" onclick=\"reconnect()\">Reconnecter</button><button class=\"btn btn-danger\" onclick=\"goToDashboard()\">Fermer</button></div></div>';\n"
" }\n"
" if (typeof updateDebug === 'function') updateDebug('iframe_timeout');\n"
" }, 8000);\n"
" })();\n\n"
+ history_js
+ "\n startHeartbeat();\n</script>"
)
return _templates.TemplateResponse(
"terminal/connect.html",
{
"request": request,
"safe_title": html.escape(session.host_name or "Terminal"),
"safe_host_name": html.escape(session.host_name or ""),
"safe_host_ip": html.escape(session.host_ip or ""),
"session_id_short": session_id[:8],
"ttyd_port": ttyd_port,
"timer_display": f"{remaining_seconds // 60}:{remaining_seconds % 60:02d}",
"embed_mode": embed_mode,
"debug_panel_html": debug_panel_html,
"script_block": script_block,
},
headers={"Cache-Control": "no-store"},
)
@router.get("/popout/{session_id}")
async def get_terminal_popout_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
"""
Serve the terminal popout page (fullscreen, minimal UI).
This is designed to be opened in a popup window or PWA.
"""
# Reuse the same connect page logic but with minimal header
return await get_terminal_connect_page(session_id, token, request, db_session)
# ============================================================================
# Command History Endpoints
# ============================================================================
@router.get("/{host_id}/command-history", response_model=CommandHistoryResponse)
async def get_host_command_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""Get command history for a specific host."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
# Get command history
cmd_repo = TerminalCommandLogRepository(db_session)
logs = await cmd_repo.list_for_host(
host_id=host.id,
query=query,
limit=min(limit, 100), # Cap at 100
offset=offset,
)
total = await cmd_repo.count_for_host(host.id)
commands = [
CommandHistoryItem(
id=log.id,
command=log.command,
command_hash=log.command_hash,
created_at=log.created_at,
host_id=log.host_id,
host_name=log.host_name,
username=log.username,
is_pinned=log.is_pinned,
)
for log in logs
]
return CommandHistoryResponse(
commands=commands,
total=total,
host_id=host.id,
query=query,
)
@router.get("/{host_id}/shell-history")
async def get_host_shell_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 100,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""
Get shell history directly from the remote host via SSH.
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
try:
# Fetch history from the remote host via SSH
commands = await shell_history_service.fetch_combined_history(
host_ip=host.ip_address,
limit=min(limit, 200),
user="automation"
)
# Apply search filter if provided
if query:
q = query.lower()
commands = [c for c in commands if q in c.get("command", "").lower()]
return {
"commands": commands,
"total": len(commands),
"host_id": host.id,
"host_name": host.name,
"source": "remote_shell",
}
except ShellHistoryError as e:
logger.warning(f"Failed to fetch shell history from {host.name}: {e}")
# Fallback to database history
cmd_repo = TerminalCommandLogRepository(db_session)
unique_cmds = await cmd_repo.get_unique_commands_for_host(
host_id=host.id,
query=query,
limit=min(limit, 100),
)
return {
"commands": unique_cmds,
"total": len(unique_cmds),
"host_id": host.id,
"host_name": host.name,
"source": "database",
"error": str(e),
}
@router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse)
async def get_host_unique_commands(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""Get unique commands for a host (deduplicated)."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
# Get unique commands
cmd_repo = TerminalCommandLogRepository(db_session)
unique_cmds = await cmd_repo.get_unique_commands_for_host(
host_id=host.id,
query=query,
limit=min(limit, 100),
)
commands = [
UniqueCommandItem(
command=cmd["command"],
command_hash=cmd["command_hash"],
last_used=cmd["last_used"],
execution_count=cmd["execution_count"],
is_pinned=cmd.get("is_pinned", False),
)
for cmd in unique_cmds
]
return UniqueCommandsResponse(
commands=commands,
total=len(commands),
host_id=host.id,
)
class TogglePinRequest(BaseModel):
is_pinned: bool
@router.post("/{host_id}/command-history/{command_hash}/pin")
async def toggle_command_pin(
host_id: str,
command_hash: str,
payload: TogglePinRequest,
request: Request,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""
Toggle the pinned status of a specific command across history.
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
from sqlalchemy import update
from app.models.terminal_command_log import TerminalCommandLog
# Verify host
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(status_code=404, detail=f"Host '{host_id}' not found")
# Update pinned state for all occurrences of this command on this host
stmt = (
update(TerminalCommandLog)
.where(
TerminalCommandLog.host_id == host.id,
TerminalCommandLog.command_hash == command_hash
)
.values(is_pinned=payload.is_pinned)
)
result = await db_session.execute(stmt)
await db_session.commit()
return {"status": "success", "is_pinned": payload.is_pinned, "updated_count": result.rowcount}
@router.get("/command-history", response_model=CommandHistoryResponse)
async def get_global_command_history(
request: Request,
query: Optional[str] = None,
time_filter: str = "all",
pinned_only: bool = False,
host_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""
Get command history globally (across all hosts).
Requires JWT auth OR a valid terminal session token.
"""
# Allow access with a session token (for pop-out windows)
if not current_user:
token = _get_session_token_from_request(request, "", token=request.query_params.get("token"))
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
# Verify against any active session
session_repo = TerminalSessionRepository(db_session)
sessions = await session_repo.list_all_active()
verified = any(terminal_service.verify_token(token, s.token_hash) for s in sessions)
if not verified:
raise HTTPException(status_code=401, detail="Invalid session token")
user_id = current_user.get("user_id") or current_user.get("type", "api_key") if current_user else None
cmd_repo = TerminalCommandLogRepository(db_session)
logs = await cmd_repo.list_global(
query=query,
time_filter=time_filter,
pinned_only=pinned_only,
host_id=host_id,
user_id=str(user_id) if user_id else None,
limit=min(limit, 100),
offset=offset,
)
commands = [
CommandHistoryItem(
id=log.id,
command=log.command,
command_hash=log.command_hash,
created_at=log.created_at,
host_id=log.host_id,
host_name=log.host_name,
username=log.username,
is_pinned=log.is_pinned,
)
for log in logs
]
return CommandHistoryResponse(
commands=commands,
total=len(commands),
host_id=host_id,
query=query,
)
@router.delete("/{host_id}/command-history")
async def clear_host_command_history(
host_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Clear command history for a specific host.
Requires admin role.
"""
# Check admin permission
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can clear command history"
)
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
cmd_repo = TerminalCommandLogRepository(db_session)
deleted = await cmd_repo.delete_for_host(host.id)
await db_session.commit()
logger.info(f"Cleared {deleted} command logs for host {host.name}")
return {"message": f"Cleared {deleted} command logs", "host_id": host.id}
@router.post("/command-history/purge")
async def purge_old_command_history(
days: int = 30,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Purge command history older than specified days.
Requires admin role.
"""
# Check admin permission
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can purge command history"
)
cmd_repo = TerminalCommandLogRepository(db_session)
deleted = await cmd_repo.purge_old_logs(days=days)
await db_session.commit()
logger.info(f"Purged {deleted} command logs older than {days} days")
return {"message": f"Purged {deleted} command logs", "retention_days": days}