Bruno Charest 5ff7017aaa
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
Enhance terminal drawer UI with text overflow handling, implement embed mode for seamless iframe integration, replace ttyd --once flag with --max-clients=1 to enable reconnection, add heartbeat mechanism for popout sessions, implement session reuse logic when opening popouts, and add postMessage communication between embedded terminal and dashboard for close/reconnect actions
2025-12-19 00:09:44 -05:00

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}