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
1433 lines
51 KiB
Python
1433 lines
51 KiB
Python
"""
|
|
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
|
|
import socket
|
|
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,
|
|
TERMINAL_TTYD_INTERFACE,
|
|
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="""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Session Expirée</title>
|
|
<style>
|
|
body { font-family: system-ui; background: #1a1a2e; color: #fff;
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100vh; margin: 0; }
|
|
.error { text-align: center; padding: 2rem; }
|
|
.error h1 { color: #ef4444; }
|
|
.error a { color: #60a5fa; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error">
|
|
<h1>Session Expirée ou Invalide</h1>
|
|
<p>Cette session terminal n'existe pas ou a expiré.</p>
|
|
<p><a href="/">Retour au Dashboard</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
status_code=404
|
|
)
|
|
|
|
# Verify token
|
|
if not terminal_service.verify_token(token, session.token_hash):
|
|
return HTMLResponse(
|
|
content="""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Accès Refusé</title>
|
|
<style>
|
|
body { font-family: system-ui; background: #1a1a2e; color: #fff;
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100vh; margin: 0; }
|
|
.error { text-align: center; padding: 2rem; }
|
|
.error h1 { color: #ef4444; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error">
|
|
<h1>Accès Refusé</h1>
|
|
<p>Token de session invalide.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
status_code=403
|
|
)
|
|
|
|
# If ttyd process is not alive, try to avoid false negatives:
|
|
# - the process might be running but not tracked in-memory (reload/multi-worker)
|
|
# - or it might have died and needs a respawn.
|
|
async def _is_port_reachable(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=0.35):
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
alive = True
|
|
try:
|
|
alive = await terminal_service.is_session_process_alive(session_id)
|
|
except Exception:
|
|
alive = True
|
|
|
|
if not alive:
|
|
# If the port is reachable, assume ttyd is running but not tracked.
|
|
if await _is_port_reachable(session.ttyd_port):
|
|
alive = True
|
|
else:
|
|
# Try to respawn ttyd on the same allocated port.
|
|
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 HTMLResponse(
|
|
content=f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{session.host_name}</title>
|
|
<style>
|
|
body {{ font-family: system-ui; background: #1a1a2e; color: #fff;
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100vh; margin: 0; }}
|
|
.error {{ text-align: center; padding: 2rem; max-width: 520px; }}
|
|
.error h1 {{ color: #ef4444; margin-bottom: 0.75rem; }}
|
|
.error p {{ color: #e5e7eb; margin-bottom: 0.5rem; }}
|
|
.error a {{ color: #60a5fa; }}
|
|
.btn {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 0.9rem;
|
|
background: #7c3aed; color: #fff; text-decoration: none; border-radius: 0.5rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error">
|
|
<h1>Terminal indisponible</h1>
|
|
<p>Le service terminal (ttyd) ne répond pas pour cette session.</p>
|
|
<p>Essayez de reconnecter ou de recréer une session depuis le dashboard.</p>
|
|
<a class="btn" href="/">Retour au Dashboard</a>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
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}/"
|
|
|
|
embed_mode = request.query_params.get("embed") in {"1", "true", "yes"}
|
|
|
|
# Generate the terminal page
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{session.host_name}</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{
|
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
background: #0f0f1a;
|
|
color: #e5e7eb;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}}
|
|
.terminal-header {{
|
|
background: #1a1a2e;
|
|
padding: 0.5rem 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid #374151;
|
|
flex-shrink: 0;
|
|
}}
|
|
.terminal-header .host-info {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}}
|
|
.terminal-header .host-name {{
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}}
|
|
.terminal-header .host-ip {{
|
|
color: #9ca3af;
|
|
font-size: 0.875rem;
|
|
}}
|
|
.terminal-header .status-badge {{
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}}
|
|
.terminal-header .status-badge.online {{
|
|
background: rgba(34, 197, 94, 0.2);
|
|
color: #4ade80;
|
|
}}
|
|
.terminal-header .session-timer {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #9ca3af;
|
|
font-size: 0.875rem;
|
|
}}
|
|
.terminal-header .session-timer.warning {{
|
|
color: #fbbf24;
|
|
}}
|
|
.terminal-header .session-timer.critical {{
|
|
color: #ef4444;
|
|
}}
|
|
.terminal-header .actions {{
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}}
|
|
.terminal-header .btn {{
|
|
padding: 0.375rem 0.75rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
transition: all 0.15s;
|
|
}}
|
|
.terminal-header .btn-secondary {{
|
|
background: #374151;
|
|
color: #e5e7eb;
|
|
}}
|
|
.terminal-header .btn-secondary:hover {{
|
|
background: #4b5563;
|
|
}}
|
|
.terminal-header .btn-danger {{
|
|
background: #dc2626;
|
|
color: #fff;
|
|
}}
|
|
.terminal-header .btn-danger:hover {{
|
|
background: #b91c1c;
|
|
}}
|
|
.terminal-container {{
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}}
|
|
.terminal-container iframe {{
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
background: #000;
|
|
}}
|
|
.terminal-loading {{
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #0f0f1a;
|
|
}}
|
|
.terminal-loading .spinner {{
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 3px solid #374151;
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}}
|
|
@keyframes spin {{
|
|
to {{ transform: rotate(360deg); }}
|
|
}}
|
|
.terminal-loading .loading-text {{
|
|
margin-top: 1rem;
|
|
color: #9ca3af;
|
|
}}
|
|
.pwa-hint {{
|
|
background: #1e3a5f;
|
|
color: #93c5fd;
|
|
padding: 0.5rem 1rem;
|
|
font-size: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}}
|
|
.pwa-hint button {{
|
|
background: none;
|
|
border: none;
|
|
color: #60a5fa;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}}
|
|
body.embed #pwaHint {{
|
|
display: none !important;
|
|
}}
|
|
body.embed .terminal-header {{
|
|
display: none !important;
|
|
}}
|
|
.hidden {{ display: none !important; }}
|
|
</style>
|
|
</head>
|
|
<body class="{'embed' if embed_mode else ''}">
|
|
<div class="pwa-hint" id="pwaHint">
|
|
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
|
|
<button onclick="dismissPwaHint()">✕ Fermer</button>
|
|
</div>
|
|
|
|
<div class="terminal-header">
|
|
<div class="host-info">
|
|
<span class="host-name">{session.host_name}</span>
|
|
<span class="host-ip">{session.host_ip}</span>
|
|
<span class="status-badge online">Connecté</span>
|
|
</div>
|
|
<div class="session-timer" id="sessionTimer">
|
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
</svg>
|
|
<span id="timerDisplay">{remaining_seconds // 60}:{remaining_seconds % 60:02d}</span>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
|
|
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
|
|
</svg>
|
|
SSH
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
|
|
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
</svg>
|
|
Reconnecter
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
|
|
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
|
|
</svg>
|
|
Dashboard
|
|
</button>
|
|
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
|
|
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
|
|
</svg>
|
|
Fermer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="terminal-container">
|
|
<div class="terminal-loading" id="terminalLoading">
|
|
<div style="text-align: center;">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Connexion au terminal...</div>
|
|
</div>
|
|
</div>
|
|
<iframe
|
|
id="terminalFrame"
|
|
src="about:blank"
|
|
onload="onTerminalLoad()"
|
|
allow="clipboard-read; clipboard-write"
|
|
></iframe>
|
|
</div>
|
|
|
|
<script>
|
|
const SESSION_ID = '{session_id}';
|
|
const TOKEN = '{token}';
|
|
const HOST_NAME = '{session.host_name}';
|
|
const HOST_IP = '{session.host_ip}';
|
|
let remainingSeconds = {remaining_seconds};
|
|
const EMBED = {str(embed_mode).lower()};
|
|
|
|
// Keep the session alive when running in popout / standalone mode.
|
|
// In embedded mode, the dashboard owns the heartbeat.
|
|
const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};
|
|
let heartbeatTimer = null;
|
|
|
|
// Point iframe to ttyd using the current page hostname (avoid hardcoded localhost)
|
|
// This matters when accessing the dashboard remotely or behind a proxy.
|
|
(function setTerminalSrc() {{
|
|
const url = '{ttyd_url}';
|
|
document.getElementById('terminalFrame').src = url;
|
|
}})();
|
|
|
|
// If the iframe never loads, show a clear error instead of a blank page.
|
|
let iframeLoaded = false;
|
|
const iframeTimeoutMs = 8000;
|
|
setTimeout(() => {{
|
|
if (iframeLoaded) return;
|
|
const loading = document.getElementById('terminalLoading');
|
|
if (loading) {{
|
|
loading.classList.remove('hidden');
|
|
loading.innerHTML = `
|
|
<div style="text-align:center; max-width:520px;">
|
|
<div style="font-size:1.1rem; font-weight:600; color:#ef4444; margin-bottom:0.5rem;">Terminal injoignable</div>
|
|
<div style="color:#e5e7eb; margin-bottom:0.75rem;">Le navigateur n'a reçu aucune réponse du service ttyd.</div>
|
|
<div style="color:#9ca3af; margin-bottom:0.75rem;">WSL/Firewall/port non exposé ou process ttyd arrêté.</div>
|
|
<div style="display:flex; justify-content:center; gap:0.5rem;">
|
|
<button class="btn btn-secondary" onclick="reconnect()">Reconnecter</button>
|
|
<button class="btn btn-danger" onclick="goToDashboard()">Fermer</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}}
|
|
}}, iframeTimeoutMs);
|
|
|
|
// Timer countdown
|
|
function updateTimer() {{
|
|
remainingSeconds--;
|
|
if (remainingSeconds <= 0) {{
|
|
document.getElementById('timerDisplay').textContent = 'Expiré';
|
|
document.getElementById('sessionTimer').classList.add('critical');
|
|
return;
|
|
}}
|
|
|
|
const minutes = Math.floor(remainingSeconds / 60);
|
|
const seconds = remainingSeconds % 60;
|
|
document.getElementById('timerDisplay').textContent =
|
|
minutes + ':' + seconds.toString().padStart(2, '0');
|
|
|
|
const timer = document.getElementById('sessionTimer');
|
|
if (remainingSeconds < 60) {{
|
|
timer.classList.add('critical');
|
|
timer.classList.remove('warning');
|
|
}} else if (remainingSeconds < 300) {{
|
|
timer.classList.add('warning');
|
|
}}
|
|
}}
|
|
setInterval(updateTimer, 1000);
|
|
|
|
function onTerminalLoad() {{
|
|
iframeLoaded = true;
|
|
document.getElementById('terminalLoading').classList.add('hidden');
|
|
// Focus the iframe
|
|
document.getElementById('terminalFrame').focus();
|
|
}}
|
|
|
|
async function sendHeartbeat() {{
|
|
if (EMBED) return;
|
|
try {{
|
|
const headers = {{ 'Content-Type': 'application/json' }};
|
|
const token = localStorage.getItem('token');
|
|
if (token) {{
|
|
headers['Authorization'] = 'Bearer ' + token;
|
|
}}
|
|
|
|
await fetch('/api/terminal/sessions/' + SESSION_ID + '/heartbeat', {{
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({{}})
|
|
}});
|
|
}} catch (e) {{
|
|
// Best effort
|
|
}}
|
|
}}
|
|
|
|
function startHeartbeat() {{
|
|
if (EMBED) return;
|
|
if (heartbeatTimer) return;
|
|
sendHeartbeat();
|
|
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
}}
|
|
|
|
function copySSHCommand() {{
|
|
const cmd = 'ssh automation@' + HOST_IP;
|
|
navigator.clipboard.writeText(cmd).then(() => {{
|
|
alert('Commande copiée: ' + cmd);
|
|
}});
|
|
}}
|
|
|
|
function reconnect() {{
|
|
// Reload the wrapper page so the backend can check/respawn ttyd if needed.
|
|
// Reloading only the ttyd iframe can get stuck on connection refused.
|
|
if (EMBED) {{
|
|
try {{
|
|
window.parent.postMessage({{ type: 'terminal:reconnect' }}, '*');
|
|
}} catch (e) {{
|
|
// Ignore
|
|
}}
|
|
return;
|
|
}}
|
|
try {{
|
|
document.getElementById('terminalLoading').classList.remove('hidden');
|
|
}} catch (e) {{
|
|
// Ignore
|
|
}}
|
|
const url = new URL(window.location.href);
|
|
// Cache-buster to avoid stale state
|
|
url.searchParams.set('_ts', Date.now().toString());
|
|
window.location.replace(url.toString());
|
|
}}
|
|
|
|
function goToDashboard() {{
|
|
if (EMBED) {{
|
|
try {{
|
|
window.parent.postMessage({{ type: 'terminal:closeDrawer' }}, '*');
|
|
}} catch (e) {{
|
|
// Ignore
|
|
}}
|
|
return;
|
|
}}
|
|
if (window.opener) {{
|
|
window.close();
|
|
}} else {{
|
|
window.location.href = '/';
|
|
}}
|
|
}}
|
|
|
|
function closeSession() {{
|
|
if (confirm('Fermer cette session terminal?')) {{
|
|
fetch('/api/terminal/sessions/' + SESSION_ID, {{
|
|
method: 'DELETE',
|
|
headers: {{
|
|
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
|
}}
|
|
}}).then(() => {{
|
|
goToDashboard();
|
|
}}).catch(() => {{
|
|
goToDashboard();
|
|
}});
|
|
}}
|
|
}}
|
|
|
|
function dismissPwaHint() {{
|
|
document.getElementById('pwaHint').classList.add('hidden');
|
|
localStorage.setItem('pwaHintDismissed', 'true');
|
|
}}
|
|
|
|
// Check if PWA hint should be hidden
|
|
if (localStorage.getItem('pwaHintDismissed') === 'true' ||
|
|
window.matchMedia('(display-mode: standalone)').matches) {{
|
|
document.getElementById('pwaHint').classList.add('hidden');
|
|
}}
|
|
|
|
// Start heartbeat in popout mode
|
|
startHeartbeat();
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {{
|
|
if (e.key === 'Escape') {{
|
|
goToDashboard();
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
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}
|