""" 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=""" Session Expirée

Session Expirée ou Invalide

Cette session terminal n'existe pas ou a expiré.

Retour au Dashboard

""", status_code=404 ) # Verify token if not terminal_service.verify_token(token, session.token_hash): return HTMLResponse( content=""" Accès Refusé

Accès Refusé

Token de session invalide.

""", status_code=403 ) # If ttyd process is not alive, return a friendly error page instead of a blank iframe. try: alive = await terminal_service.is_session_process_alive(session_id) except Exception: alive = True if not alive: return HTMLResponse( content=f""" {session.host_name}

Terminal indisponible

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 Dashboard
""", status_code=503, ) # Calculate remaining time now = datetime.now(timezone.utc) expires_at = _as_utc_aware(session.expires_at) remaining_seconds = max(0, int(((expires_at or now) - now).total_seconds())) forwarded_proto = request.headers.get("x-forwarded-proto") forwarded_host = request.headers.get("x-forwarded-host") forwarded_port = request.headers.get("x-forwarded-port") scheme = forwarded_proto or request.url.scheme host = forwarded_host or request.headers.get("host") or request.url.hostname or "localhost" if ":" in host: host_only = host else: host_only = host if forwarded_port and ":" not in host_only: host_only = f"{host_only}:{forwarded_port}" ttyd_url = f"{scheme}://{host_only.split(':')[0]}:{session.ttyd_port}/" # Generate the terminal page html_content = f""" {session.host_name}
💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez chrome --app=URL
{session.host_name} {session.host_ip} Connecté
{remaining_seconds // 60}:{remaining_seconds % 60:02d}
Connexion au terminal...
""" return HTMLResponse(content=html_content) @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, query: Optional[str] = None, limit: int = 50, offset: int = 0, current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Get command history for a specific host. Args: host_id: Host ID to get history for query: Optional search query to filter commands limit: Maximum number of results (default 50) offset: Number of results to skip for pagination Returns: List of commands with timestamps and metadata """ # 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, created_at=log.created_at, host_name=log.host_name, username=log.username, ) for log in logs ] return CommandHistoryResponse( commands=commands, total=total, host_id=host.id, query=query, ) @router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse) async def get_host_unique_commands( host_id: str, query: Optional[str] = None, limit: int = 50, current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Get unique commands for a host (deduplicated). Returns each unique command once with execution count and last used time. Useful for command suggestions/autocomplete. """ # 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"], ) for cmd in unique_cmds ] return UniqueCommandsResponse( commands=commands, total=len(commands), host_id=host.id, ) @router.get("/command-history", response_model=CommandHistoryResponse) async def get_global_command_history( query: Optional[str] = None, host_id: Optional[str] = None, limit: int = 50, offset: int = 0, current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Get command history globally (across all hosts). Args: query: Optional search query to filter commands host_id: Optional host ID to filter by limit: Maximum number of results (default 50) offset: Number of results to skip for pagination Returns: List of commands with timestamps and metadata """ user_id = current_user.get("user_id") or current_user.get("type", "api_key") cmd_repo = TerminalCommandLogRepository(db_session) logs = await cmd_repo.list_global( query=query, 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, created_at=log.created_at, host_name=log.host_name, username=log.username, ) 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}