""" 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.schemas.terminal import ( TerminalSessionRequest, TerminalSessionResponse, TerminalSessionHost, TerminalSessionStatus, TerminalSessionList, ) from app.services.terminal_service import ( terminal_service, TERMINAL_SESSION_TTL_MINUTES, TERMINAL_MAX_SESSIONS_PER_USER, HostNotReadyError, SessionLimitExceededError, TtydNotAvailableError, ) 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, "active_sessions": terminal_service.get_active_session_count(), } @router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse) async def create_terminal_session( host_id: str, request: TerminalSessionRequest = TerminalSessionRequest(), current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ Create a new terminal session for a host. Requires: - Host must exist and be online - Host must have bootstrap completed - User must not exceed max sessions limit 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 session limit active_count = await session_repo.count_active_for_user(user_id) if active_count >= TERMINAL_MAX_SESSIONS_PER_USER: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first." ) # Generate session credentials 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" ) # Log audit event logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...") 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 # Build response return TerminalSessionResponse( session_id=session_id, url=session_url, websocket_url=f"ws://localhost:{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, ), ) @router.get("/sessions", response_model=TerminalSessionList) async def list_terminal_sessions( current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): """ List active terminal sessions for the current user. """ 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) remaining = max(0, int(((expires_at or now) - now).total_seconds())) session_list.append(TerminalSessionStatus( session_id=session.id, status=session.status, host_name=session.host_name, created_at=created_at, expires_at=expires_at, remaining_seconds=remaining, )) 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. """ 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: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" ) # 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 await terminal_service.terminate_session(session_id) await terminal_service.release_port(session.ttyd_port) # Update database await session_repo.close_session(session_id) await db_session.commit() logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...") return {"message": "Session closed", "session_id": session_id} @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, 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 ) # 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())) # Generate the terminal page html_content = f""" Terminal - {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, 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, db_session)