""" Routes API pour les sessions terminal SSH web. Provides endpoints for: - Creating terminal sessions - Listing active sessions - Closing sessions - Serving terminal popout page """ import logging from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.dependencies import get_db, get_current_user 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, HostNotReadyError, SessionLimitExceededError, 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() 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) @router.get("/status") async def get_terminal_feature_status(): """ Check if terminal feature is available. Returns availability status and configuration info. """ ttyd_available = terminal_service.check_ttyd_available() return { "available": ttyd_available, "ttyd_installed": ttyd_available, "max_sessions_per_user": TERMINAL_MAX_SESSIONS_PER_USER, "session_ttl_minutes": TERMINAL_SESSION_TTL_MINUTES, "session_idle_timeout_seconds": TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS, "heartbeat_interval_seconds": TERMINAL_HEARTBEAT_INTERVAL_SECONDS, "active_sessions": terminal_service.get_active_session_count(), } @router.get("/metrics", response_model=SessionMetrics) async def get_terminal_metrics( current_user: dict = Depends(get_current_user), ): """ Get terminal session service metrics. Returns counts of sessions created, reused, closed, etc. """ metrics = terminal_service.get_metrics() return SessionMetrics(**metrics) def _build_session_limit_error( active_sessions: list, reusable_session=None, ) -> dict: """ Build a rich error response for session limit exceeded. """ now = datetime.now(timezone.utc) active_info = [] for s in active_sessions: created_at = _as_utc_aware(s.created_at) or now last_seen_at = _as_utc_aware(getattr(s, 'last_seen_at', None)) or created_at active_info.append(ActiveSessionInfo( session_id=s.id, host_id=s.host_id, host_name=s.host_name, mode=s.mode, age_seconds=int((now - created_at).total_seconds()), last_seen_seconds=int((now - last_seen_at).total_seconds()), )) suggested_actions = ["close_oldest"] for s in active_sessions: suggested_actions.append(f"close_session:{s.id}") can_reuse = reusable_session is not None if can_reuse: suggested_actions.insert(0, "reuse_existing") return SessionLimitError( message=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first.", max_active=TERMINAL_MAX_SESSIONS_PER_USER, current_count=len(active_sessions), active_sessions=active_info, suggested_actions=suggested_actions, can_reuse=can_reuse, reusable_session_id=reusable_session.id if reusable_session else None, ).model_dump() @router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse) async def create_terminal_session( host_id: str, http_request: Request, request: TerminalSessionRequest = TerminalSessionRequest(), current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Create or reuse a terminal session for a host. Session reuse policy: - If an active, healthy session exists for same user/host/mode, reuse it - Otherwise create a new session if quota allows - If quota exceeded, return rich error with active sessions list Returns session URL and metadata. """ # Check if ttyd is available if not terminal_service.check_ttyd_available(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Terminal feature unavailable: ttyd is not installed" ) # Get host information host_repo = HostRepository(db_session) bs_repo = BootstrapStatusRepository(db_session) session_repo = TerminalSessionRepository(db_session) # Try to get host by ID first, then by name 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" ) # Check host status if host.status == "offline": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Host '{host.name}' is offline. Cannot open terminal." ) # Check bootstrap status 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." ) # Get user info user_id = current_user.get("user_id") or current_user.get("type", "api_key") username = current_user.get("username", "api_user") # Check for reusable session first existing_session = await session_repo.find_reusable_session( user_id=user_id, host_id=host.id, mode=request.mode, idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS ) if existing_session: # Reuse existing session - update last_seen and return 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) now = datetime.now(timezone.utc) remaining = max(0, int(((expires_at or now) - now).total_seconds())) # Note: We don't have the original token, so client must use the existing connection # or we generate a new token and update the session 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 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.hostname if http_request else "localhost") return TerminalSessionResponse( session_id=existing_session.id, url=session_url, websocket_url=f"{ws_scheme}://{ws_host}:{existing_session.ttyd_port}/ws", 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, ) # No reusable session - check quota active_sessions = await session_repo.list_active_for_user(user_id) active_count = len(active_sessions) if active_count >= TERMINAL_MAX_SESSIONS_PER_USER: # Check if any session is for the same host (can be reused with different mode) 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}") # Return rich error with session list 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), ) # Create new session session_id = terminal_service.generate_session_id() token, token_hash = terminal_service.generate_session_token() # Allocate port 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" ) # Calculate expiration expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES) # Spawn ttyd process try: pid = await terminal_service.spawn_ttyd( session_id=session_id, host_ip=host.ip_address, port=port, token=token, ) if pid is None: await terminal_service.release_port(port) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to start terminal process" ) 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: await terminal_service.release_port(port) logger.exception(f"Failed to spawn ttyd: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to start terminal: {str(e)}" ) # Create session in database 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=request.mode, ) await db_session.commit() except Exception as e: # Cleanup on failure await terminal_service.terminate_session(session_id) await terminal_service.release_port(port) 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" ) 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 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.hostname if http_request else "localhost") return TerminalSessionResponse( session_id=session_id, url=session_url, websocket_url=f"{ws_scheme}://{ws_host}:{port}/ws", expires_at=expires_at, ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60, mode=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.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), ): """ List active terminal sessions for the current user. Args: status_filter: Optional filter by status (default: active only) """ user_id = current_user.get("user_id") or current_user.get("type", "api_key") session_repo = TerminalSessionRepository(db_session) 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 = max(0, int(((expires_at or now) - now).total_seconds())) 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, current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Close a terminal session. Terminates the ttyd process and marks session as closed. Idempotent: closing an already closed session returns 204. """ user_id = current_user.get("user_id") or current_user.get("type", "api_key") username = current_user.get("username", "api_user") session_repo = TerminalSessionRepository(db_session) session = await session_repo.get(session_id) if not session: # Idempotent: session doesn't exist, consider it closed return {"message": "Session not found or already closed", "session_id": session_id} # Already closed - idempotent success if session.status in [SESSION_STATUS_CLOSED, "expired", "error"]: return {"message": "Session already closed", "session_id": session_id} # Check ownership (unless admin) if session.user_id != user_id and current_user.get("role") != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot close another user's session" ) # Terminate ttyd process and cleanup await terminal_service.close_session_with_cleanup( session_id=session_id, port=session.ttyd_port, reason=CLOSE_REASON_USER ) # Update database await session_repo.close_session(session_id, reason=CLOSE_REASON_USER) await db_session.commit() logger.info(f"session_closed session={session_id[:8]}... user={username} 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, current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Send heartbeat to keep session alive. Updates last_seen_at timestamp. Should be called every 10-20 seconds while terminal is visible/active. """ user_id = current_user.get("user_id") or current_user.get("type", "api_key") 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" ) # Check ownership if session.user_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot heartbeat another user's session" ) # Update last_seen 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 = max(0, int(((expires_at or now) - now).total_seconds())) 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, db_session: AsyncSession = Depends(get_db), ): """ Close session via sendBeacon (for browser close/navigation). This endpoint is designed to be called via navigator.sendBeacon() when the browser tab is closing or navigating away. It does not require authentication since the session_id itself is the credential. Always returns 204 No Content for sendBeacon compatibility. """ session_repo = TerminalSessionRepository(db_session) session = await session_repo.get(session_id) if session and session.status == SESSION_STATUS_ACTIVE: # Terminate ttyd process and cleanup await terminal_service.close_session_with_cleanup( session_id=session_id, port=session.ttyd_port, reason=CLOSE_REASON_CLIENT_LOST ) # Update database 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") # Always return 204 for sendBeacon from fastapi.responses import Response return Response(status_code=204) @router.post("/cleanup") async def cleanup_terminal_sessions( current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Force cleanup of all terminal sessions. Closes all active sessions in DB and terminates any orphaned ttyd processes. Useful when sessions get stuck or to reset the terminal feature. """ session_repo = TerminalSessionRepository(db_session) # Get all active sessions to terminate their processes active_sessions = await session_repo.list_active_for_user( current_user.get("user_id") or current_user.get("type", "api_key") ) terminated = 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}") # Close all active sessions in DB closed = await session_repo.close_all_active() await db_session.commit() # Also cleanup expired sessions expired = await session_repo.list_expired() for session in expired: await session_repo.mark_expired(session.id) 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": len(expired), } @router.get("/connect/{session_id}") async def get_terminal_connect_page( session_id: str, token: str, request: Request, db_session: AsyncSession = Depends(get_db), ): """ Serve the terminal connection page. This page embeds the ttyd terminal in an iframe or directly connects. Token is verified before serving the page. """ session_repo = TerminalSessionRepository(db_session) session = await session_repo.get_active_by_id(session_id) if not session: return HTMLResponse( content="""
Token de session invalide.
Le service terminal (ttyd) ne répond pas pour cette session.
Essayez de reconnecter ou de recréer une session depuis le dashboard.
Retour au Dashboardchrome --app=URL