Bruno Charest 493668f746
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
Add comprehensive SSH terminal drawer feature with embedded and popout modes, integrate playbook lint results API with local cache fallback, and enhance host management UI with terminal access buttons
2025-12-17 23:59:17 -05:00

757 lines
27 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
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="""
<!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
)
# 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"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal - {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;
}}
.hidden {{ display: none !important; }}
</style>
</head>
<body>
<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="http://localhost:{session.ttyd_port}/"
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};
// 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() {{
document.getElementById('terminalLoading').classList.add('hidden');
// Focus the iframe
document.getElementById('terminalFrame').focus();
}}
function copySSHCommand() {{
const cmd = 'ssh automation@' + HOST_IP;
navigator.clipboard.writeText(cmd).then(() => {{
alert('Commande copiée: ' + cmd);
}});
}}
function reconnect() {{
document.getElementById('terminalLoading').classList.remove('hidden');
document.getElementById('terminalFrame').src =
document.getElementById('terminalFrame').src;
}}
function goToDashboard() {{
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');
}}
// 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,
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)