@@ -10306,11 +10681,22 @@ class DashboardManager {
drawer.classList.add('open');
});
- // Add escape key handler
+ // Add keyboard handlers (Escape to close, Ctrl+R for history)
this._terminalEscHandler = (e) => {
if (e.key === 'Escape' && this.terminalDrawerOpen) {
+ // If history panel is open, close it first
+ if (this.terminalHistoryPanelOpen) {
+ this.closeTerminalHistoryPanel();
+ e.preventDefault();
+ return;
+ }
this.closeTerminalDrawer();
}
+ // Ctrl+R to open/focus history search
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r' && this.terminalDrawerOpen) {
+ e.preventDefault();
+ this.openTerminalHistoryPanel();
+ }
};
document.addEventListener('keydown', this._terminalEscHandler);
}
@@ -10496,7 +10882,7 @@ class DashboardManager {
}
try {
- await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat`, {
+ await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat?token=${encodeURIComponent(this.terminalSession.token)}`, {
method: 'POST'
});
} catch (e) {
@@ -10674,11 +11060,13 @@ class DashboardManager {
}
const cmd = `ssh automation@${this.terminalSession.host.ip}`;
- navigator.clipboard.writeText(cmd).then(() => {
- this.showNotification(`Commande copiée: ${cmd}`, 'success');
- }).catch(() => {
- this.showNotification('Impossible de copier dans le presse-papier', 'error');
- });
+ this.copyTextToClipboard(cmd)
+ .then(() => {
+ this.showNotification(`Commande copiée: ${cmd}`, 'success');
+ })
+ .catch(() => {
+ this.showNotification('Impossible de copier dans le presse-papier', 'error');
+ });
}
async reconnectTerminal() {
@@ -10697,27 +11085,78 @@ class DashboardManager {
}
// ===== TERMINAL COMMAND HISTORY =====
+
+ async logTerminalCommand(command) {
+ try {
+ if (!this.terminalSession) return;
+ const sessionId = this.terminalSession.session_id;
+ const token = this.terminalSession.token;
+ const cmd = String(command ?? '').trim();
+ if (!cmd) return;
+
+ const endpoint = `/api/terminal/sessions/${encodeURIComponent(sessionId)}/command?token=${encodeURIComponent(token)}`;
+ await this.apiCall(endpoint, {
+ method: 'POST',
+ body: JSON.stringify({ command: cmd })
+ });
+ } catch (e) {
+ // Best-effort: do not disrupt UX if logging fails
+ }
+ }
async toggleTerminalHistory() {
+ if (this.terminalHistoryPanelOpen) {
+ this.closeTerminalHistoryPanel();
+ } else {
+ this.openTerminalHistoryPanel();
+ }
+ }
+
+ async openTerminalHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
- const isVisible = panel.style.display !== 'none';
+ this.terminalHistoryPanelOpen = true;
+ this.terminalHistorySelectedIndex = -1;
- if (isVisible) {
+ panel.style.display = 'flex';
+ panel.classList.add('open');
+ btn?.classList.add('active');
+
+ // Load history if not loaded
+ if (this.terminalCommandHistory.length === 0) {
+ await this.loadTerminalCommandHistory();
+ }
+
+ // Focus search input
+ const searchInput = document.getElementById('terminalHistorySearch');
+ if (searchInput) {
+ searchInput.focus();
+ searchInput.select();
+ }
+ }
+
+ closeTerminalHistoryPanel() {
+ const panel = document.getElementById('terminalHistoryPanel');
+ const btn = document.getElementById('terminalHistoryBtn');
+
+ if (!panel) return;
+
+ this.terminalHistoryPanelOpen = false;
+ this.terminalHistorySelectedIndex = -1;
+
+ panel.classList.remove('open');
+ setTimeout(() => {
panel.style.display = 'none';
- btn?.classList.remove('active');
- } else {
- panel.style.display = 'block';
- btn?.classList.add('active');
- // Load history if not loaded
- if (this.terminalCommandHistory.length === 0) {
- await this.loadTerminalCommandHistory();
- }
- // Focus search input
- document.getElementById('terminalHistorySearch')?.focus();
+ }, 200);
+ btn?.classList.remove('active');
+
+ // Return focus to terminal
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe) {
+ iframe.focus();
}
}
@@ -10733,16 +11172,69 @@ class DashboardManager {
try {
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const hostId = this.terminalSession.host.id;
+ const timeFilter = this.terminalHistoryTimeFilter;
+
+ // Build query params
+ const params = new URLSearchParams();
+ params.set('limit', '100');
+ if (query) params.set('query', query);
+
+ // Add time filter
+ if (timeFilter !== 'all') {
+ const now = new Date();
+ let since;
+ switch (timeFilter) {
+ case 'today':
+ since = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ break;
+ case 'week':
+ since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ case 'month':
+ since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ if (since) {
+ params.set('since', since.toISOString());
+ }
+ }
let endpoint;
if (allHosts) {
- endpoint = `/api/terminal/command-history?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
+ endpoint = `/api/terminal/command-history?${params.toString()}`;
} else {
- endpoint = `/api/terminal/${hostId}/command-history/unique?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
+ // Use shell-history to fetch real commands from the remote host via SSH
+ endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`;
}
const response = await this.apiCall(endpoint);
- this.terminalCommandHistory = response.commands || [];
+ let commands = response.commands || [];
+
+ // Client-side time filtering if API doesn't support it
+ if (timeFilter !== 'all') {
+ const now = new Date();
+ let cutoff;
+ switch (timeFilter) {
+ case 'today':
+ cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ break;
+ case 'week':
+ cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ case 'month':
+ cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ }
+ if (cutoff) {
+ commands = commands.filter(cmd => {
+ const cmdDate = new Date(cmd.last_used || cmd.created_at);
+ return cmdDate >= cutoff;
+ });
+ }
+ }
+
+ this.terminalCommandHistory = commands;
+ this.terminalHistorySelectedIndex = -1;
this.renderTerminalHistory();
@@ -10754,15 +11246,21 @@ class DashboardManager {
}
}
- renderTerminalHistory() {
+ renderTerminalHistory(highlightQuery = '') {
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
+ const query = highlightQuery || this.terminalHistorySearchQuery || '';
+
if (this.terminalCommandHistory.length === 0) {
+ const emptyMessage = query
+ ? `Aucun résultat pour "${this.escapeHtml(query)}"`
+ : 'Aucune commande dans l\'historique';
listEl.innerHTML = `
`;
return;
@@ -10773,23 +11271,35 @@ class DashboardManager {
const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at);
const execCount = cmd.execution_count || 1;
const hostName = cmd.host_name || '';
+ const isSelected = index === this.terminalHistorySelectedIndex;
+
+ // Highlight search query in command
+ let displayCommand = this.escapeHtml(command.length > 80 ? command.substring(0, 80) + '...' : command);
+ if (query) {
+ const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
+ displayCommand = displayCommand.replace(regex, '
+
- ${this.escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command)}
+ ${displayCommand}
- ${timeAgo}
- ${execCount > 1 ? `×${execCount}` : ''}
+ ${timeAgo}
+ ${execCount > 1 ? `×${execCount}` : ''}
${hostName ? `${this.escapeHtml(hostName)}` : ''}
-
@@ -10797,10 +11307,77 @@ class DashboardManager {
}).join('');
listEl.innerHTML = items;
+
+ // Scroll selected item into view
+ if (this.terminalHistorySelectedIndex >= 0) {
+ const selectedEl = listEl.querySelector('.terminal-history-item.selected');
+ if (selectedEl) {
+ selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ }
+ }
+
+ escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ handleHistorySearchKeydown(event) {
+ const key = event.key;
+ const historyLength = this.terminalCommandHistory.length;
+
+ switch (key) {
+ case 'ArrowDown':
+ event.preventDefault();
+ if (historyLength > 0) {
+ this.terminalHistorySelectedIndex = Math.min(
+ this.terminalHistorySelectedIndex + 1,
+ historyLength - 1
+ );
+ this.renderTerminalHistory();
+ }
+ break;
+
+ case 'ArrowUp':
+ event.preventDefault();
+ if (historyLength > 0) {
+ this.terminalHistorySelectedIndex = Math.max(
+ this.terminalHistorySelectedIndex - 1,
+ 0
+ );
+ this.renderTerminalHistory();
+ }
+ break;
+
+ case 'Enter':
+ event.preventDefault();
+ if (this.terminalHistorySelectedIndex >= 0) {
+ this.selectAndInsertHistoryCommand(this.terminalHistorySelectedIndex);
+ } else if (historyLength > 0) {
+ this.selectAndInsertHistoryCommand(0);
+ }
+ break;
+
+ case 'Escape':
+ event.preventDefault();
+ this.closeTerminalHistoryPanel();
+ break;
+
+ case 'Tab':
+ // Tab to cycle through results
+ event.preventDefault();
+ if (event.shiftKey) {
+ this.terminalHistorySelectedIndex = Math.max(this.terminalHistorySelectedIndex - 1, 0);
+ } else {
+ this.terminalHistorySelectedIndex = Math.min(this.terminalHistorySelectedIndex + 1, historyLength - 1);
+ }
+ this.renderTerminalHistory();
+ break;
+ }
}
searchTerminalHistory(query) {
this.terminalHistorySearchQuery = query;
+ this.terminalHistorySelectedIndex = -1;
// Debounce search
if (this._historySearchTimeout) {
@@ -10809,54 +11386,81 @@ class DashboardManager {
this._historySearchTimeout = setTimeout(() => {
this.loadTerminalCommandHistory(query);
- }, 300);
+ }, 250);
}
clearTerminalHistorySearch() {
const input = document.getElementById('terminalHistorySearch');
if (input) {
input.value = '';
+ input.focus();
}
this.terminalHistorySearchQuery = '';
+ this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory('');
}
- toggleHistoryScope() {
- // Reload with current search query
+ setHistoryTimeFilter(value) {
+ this.terminalHistoryTimeFilter = value;
+ this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
- insertTerminalCommand(index) {
+ toggleHistoryScope() {
+ this.terminalHistorySelectedIndex = -1;
+ this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
+ }
+
+ selectAndInsertHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
- // Try to paste into the terminal iframe
- const iframe = document.getElementById('terminalIframe');
- if (iframe && iframe.contentWindow) {
- try {
- // Write to clipboard and show notification
- navigator.clipboard.writeText(command).then(() => {
- this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V dans le terminal', 'success');
-
- // Focus the iframe
- iframe.focus();
- iframe.contentWindow.focus();
- }).catch(() => {
- this.showNotification('Commande: ' + command, 'info');
- });
- } catch (e) {
- this.showNotification('Commande: ' + command, 'info');
+ // Copy to clipboard and show notification
+ this.copyTextToClipboard(command).then(() => {
+ this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
+
+ // Best-effort log
+ this.logTerminalCommand(command);
+
+ // Close history panel and focus terminal
+ this.closeTerminalHistoryPanel();
+
+ // Focus the iframe
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe && iframe.contentWindow) {
+ iframe.focus();
+ iframe.contentWindow.focus();
}
- } else {
- navigator.clipboard.writeText(command).then(() => {
- this.showNotification('Commande copiée dans le presse-papier', 'success');
- });
- }
+ }).catch(() => {
+ this.showNotification('Commande: ' + command, 'info');
+ });
+ }
+
+ executeHistoryCommand(index) {
+ const cmd = this.terminalCommandHistory[index];
+ if (!cmd) return;
- // Close history panel
- this.toggleTerminalHistory();
+ const command = cmd.command || '';
+
+ // Copy command + newline to execute it
+ this.copyTextToClipboard(command + '\n').then(() => {
+ this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success');
+
+ // Best-effort log
+ this.logTerminalCommand(command);
+
+ this.closeTerminalHistoryPanel();
+
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe && iframe.contentWindow) {
+ iframe.focus();
+ iframe.contentWindow.focus();
+ }
+ }).catch(() => {
+ this.showNotification('Commande: ' + command, 'info');
+ });
}
copyTerminalCommand(index) {
@@ -10865,8 +11469,11 @@ class DashboardManager {
const command = cmd.command || '';
- navigator.clipboard.writeText(command).then(() => {
+ this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée', 'success');
+
+ // Best-effort log
+ this.logTerminalCommand(command);
}).catch(() => {
this.showNotification('Impossible de copier', 'error');
});
diff --git a/app/models/host_metrics.py b/app/models/host_metrics.py
index 7089a0c..6ed9c41 100644
--- a/app/models/host_metrics.py
+++ b/app/models/host_metrics.py
@@ -59,6 +59,9 @@ class HostMetrics(Base):
lvm_info: Mapped[Optional[object]] = mapped_column(JSON)
zfs_info: Mapped[Optional[object]] = mapped_column(JSON)
+ # Stockage détaillé (JSON) - données normalisées avec métadonnées de collecte
+ storage_details: Mapped[Optional[dict]] = mapped_column(JSON)
+
# Informations système
os_name: Mapped[Optional[str]] = mapped_column(String(100))
os_version: Mapped[Optional[str]] = mapped_column(String(100))
diff --git a/app/routes/metrics.py b/app/routes/metrics.py
index 07c529f..475e575 100644
--- a/app/routes/metrics.py
+++ b/app/routes/metrics.py
@@ -61,12 +61,21 @@ async def get_all_hosts_metrics(
"cpu_load_1m": m.cpu_load_1m,
"cpu_model": m.cpu_model,
"cpu_count": m.cpu_count,
+ "cpu_cores": getattr(m, "cpu_cores", None),
+ "cpu_threads": getattr(m, "cpu_threads", None),
+ "cpu_max_mhz": getattr(m, "cpu_max_mhz", None),
+ "cpu_temperature": getattr(m, "cpu_temperature", None),
"memory_usage_percent": m.memory_usage_percent,
"memory_total_mb": m.memory_total_mb,
"memory_used_mb": m.memory_used_mb,
"disk_root_usage_percent": m.disk_root_usage_percent,
"disk_root_total_gb": m.disk_root_total_gb,
"disk_root_used_gb": m.disk_root_used_gb,
+ "disk_info": getattr(m, "disk_info", None),
+ "disk_devices": getattr(m, "disk_devices", None),
+ "lvm_info": getattr(m, "lvm_info", None),
+ "zfs_info": getattr(m, "zfs_info", None),
+ "storage_details": getattr(m, "storage_details", None),
"os_name": m.os_name,
"uptime_human": m.uptime_human,
"last_collected": collected_at,
diff --git a/app/routes/terminal.py b/app/routes/terminal.py
index bee968d..0a33ca3 100644
--- a/app/routes/terminal.py
+++ b/app/routes/terminal.py
@@ -7,13 +7,21 @@ Provides endpoints for:
- Closing sessions
- Serving terminal popout page
"""
+import asyncio
+import json
+import hashlib
+import httpx
import logging
import socket
+import html
+from urllib.parse import urlencode, urlparse, parse_qs
from datetime import datetime, timedelta, timezone
from typing import Optional
-from fastapi import APIRouter, Depends, HTTPException, Request, status
-from fastapi.responses import HTMLResponse
+from app.services.shell_history_service import shell_history_service, ShellHistoryError
+
+from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
+from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, get_current_user
@@ -44,8 +52,6 @@ from app.services.terminal_service import (
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
TERMINAL_TTYD_INTERFACE,
- HostNotReadyError,
- SessionLimitExceededError,
TtydNotAvailableError,
)
from app.models.terminal_session import (
@@ -59,6 +65,9 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+_PORT_REACHABILITY_TIMEOUT_SECONDS = 1.0
+_MAX_TERMINAL_COMMAND_LENGTH = 10000
+
def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
if dt is None:
@@ -67,81 +76,409 @@ def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
+def _compute_remaining_seconds(expires_at: Optional[datetime], now: Optional[datetime] = None) -> int:
+ now = now or datetime.now(timezone.utc)
+ expires_at = _as_utc_aware(expires_at)
+ if not expires_at:
+ return 0
+ delta = (expires_at - now).total_seconds()
+ return max(0, int(delta))
+
+async def _is_port_reachable(request: Request, 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=_PORT_REACHABILITY_TIMEOUT_SECONDS):
+ return True
+ except Exception:
+ continue
+ return False
+
+def _get_session_token_from_headers(auth_header: Optional[str]) -> Optional[str]:
+ if not auth_header:
+ return None
+ if not auth_header.startswith("Bearer "):
+ return None
+ parts = auth_header.split(" ", 1)
+ return parts[1].strip() if len(parts) == 2 else None
+
+def _get_session_token_from_request(request: Request, session_id: str, token: Optional[str] = None) -> Optional[str]:
+ if token:
+ return token
+
+ cookie_key = f"terminal_token_{session_id}"
+ cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
+ if cookie_token:
+ return cookie_token
+
+ hdr_token = _get_session_token_from_headers(request.headers.get("authorization"))
+ if hdr_token:
+ return hdr_token
+
+ referer = request.headers.get("referer")
+ if referer:
+ try:
+ referer_qs = parse_qs(urlparse(referer).query)
+ referer_token = referer_qs.get("token", [None])[0]
+ if referer_token:
+ return referer_token
+ except Exception:
+ pass
+
+ return None
+
+def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
+ token = websocket.query_params.get("token")
+ if token:
+ return token
+ cookie_key = f"terminal_token_{session_id}"
+ cookie_token = websocket.cookies.get(cookie_key) or websocket.cookies.get("terminal_token")
+ return cookie_token
@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()
-
+async def get_terminal_status(current_user: dict = Depends(get_current_user)):
+ available = bool(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(),
+ "available": available,
}
-
-@router.get("/metrics", response_model=SessionMetrics)
-async def get_terminal_metrics(
- current_user: dict = Depends(get_current_user),
+@router.get("/sessions/{session_id}/probe")
+async def probe_terminal_session(
+ session_id: str,
+ token: str,
+ request: Request,
+ db_session: AsyncSession = Depends(get_db),
):
- """
- Get terminal session service metrics.
-
- Returns counts of sessions created, reused, closed, etc.
- """
- metrics = terminal_service.get_metrics()
- return SessionMetrics(**metrics)
+ 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")
+ if not terminal_service.verify_token(token, session.token_hash):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
+
+ alive = False
+ try:
+ alive = await terminal_service.is_session_process_alive(session_id)
+ except Exception:
+ alive = False
+
+ reachable = await _is_port_reachable(request, session.ttyd_port)
+
+ return {
+ "session_id": session.id,
+ "ttyd_port": session.ttyd_port,
+ "process_alive": alive,
+ "port_reachable": reachable,
+ "ttyd_interface": TERMINAL_TTYD_INTERFACE,
+ "dashboard_scheme": request.url.scheme,
+ "dashboard_host": request.url.hostname,
+ }
+
+@router.get("/proxy/{session_id}")
+async def proxy_ttyd_http(
+ session_id: str,
+ request: Request,
+ token: Optional[str] = None,
+ db_session: AsyncSession = Depends(get_db),
+):
+ 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")
+
+ effective_token = _get_session_token_from_request(request, session_id, token=token)
+ if not effective_token:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
+
+ if not terminal_service.verify_token(effective_token, session.token_hash):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
+
+ prefix = f"/api/terminal/proxy/{session_id}"
+ path = request.url.path
+ if path.startswith(prefix):
+ path = path[len(prefix):]
+ if not path:
+ path = "/"
+
+ query_params = list(request.query_params.multi_items())
+ query_params = [(k, v) for (k, v) in query_params if k != "token"]
+ query_string = urlencode(query_params, doseq=True) if query_params else ""
+
+ ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
+ if query_string:
+ ttyd_url += f"?{query_string}"
+
+ cookie_key = f"terminal_token_{session_id}"
+ cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ ttyd_response = await client.request(
+ method=request.method,
+ url=ttyd_url,
+ headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
+ content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
+ follow_redirects=True,
+ )
+
+ drop_headers = {
+ "content-encoding",
+ "transfer-encoding",
+ "content-security-policy",
+ "x-frame-options",
+ "content-length",
+ }
+ response_headers = {
+ k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
+ }
+
+ response = StreamingResponse(
+ iter([ttyd_response.content]),
+ status_code=ttyd_response.status_code,
+ headers=response_headers,
+ media_type=ttyd_response.headers.get("content-type"),
+ )
+ if effective_token and effective_token != cookie_token:
+ response.set_cookie(
+ key=cookie_key,
+ value=effective_token,
+ httponly=True,
+ samesite="lax",
+ secure=(request.url.scheme == "https"),
+ path=f"/api/terminal/proxy/{session_id}/",
+ max_age=TERMINAL_SESSION_TTL_SECONDS,
+ )
+
+ return response
+ except Exception as e:
+ logger.error(f"Error proxying request to ttyd: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Failed to proxy request to ttyd: {str(e)}",
+ )
+
+@router.api_route(
+ "/proxy/{session_id}/{proxy_path:path}",
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
+)
+async def proxy_ttyd_http_assets(
+ session_id: str,
+ proxy_path: str,
+ request: Request,
+ token: Optional[str] = None,
+ db_session: AsyncSession = Depends(get_db),
+):
+ 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")
+
+ effective_token = _get_session_token_from_request(request, session_id, token=token)
+ if not effective_token:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
+ if not terminal_service.verify_token(effective_token, session.token_hash):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
+
+ path = "/" + (proxy_path or "")
+ query_params = list(request.query_params.multi_items())
+ query_params = [(k, v) for (k, v) in query_params if k != "token"]
+ query_string = urlencode(query_params, doseq=True) if query_params else ""
+
+ ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
+ if query_string:
+ ttyd_url += f"?{query_string}"
+
+ cookie_key = f"terminal_token_{session_id}"
+ cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ ttyd_response = await client.request(
+ method=request.method,
+ url=ttyd_url,
+ headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
+ content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
+ follow_redirects=True,
+ )
+
+ drop_headers = {
+ "content-encoding",
+ "transfer-encoding",
+ "content-security-policy",
+ "x-frame-options",
+ "content-length",
+ }
+ response_headers = {
+ k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
+ }
+
+ response = StreamingResponse(
+ iter([ttyd_response.content]),
+ status_code=ttyd_response.status_code,
+ headers=response_headers,
+ media_type=ttyd_response.headers.get("content-type"),
+ )
+ if effective_token and effective_token != cookie_token:
+ response.set_cookie(
+ key=cookie_key,
+ value=effective_token,
+ httponly=True,
+ samesite="lax",
+ secure=(request.url.scheme == "https"),
+ path=f"/api/terminal/proxy/{session_id}/",
+ max_age=TERMINAL_SESSION_TTL_SECONDS,
+ )
+ return response
+ except Exception as e:
+ logger.error(f"Error proxying request to ttyd: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail=f"Failed to proxy request to ttyd: {str(e)}",
+ )
+
+@router.websocket("/proxy/{session_id}/ws")
+async def proxy_ttyd_websocket(
+ websocket: WebSocket,
+ session_id: str,
+ db_session: AsyncSession = Depends(get_db),
+):
+ token = _get_session_token_from_websocket(websocket, session_id)
+ if not token:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
+ return
+
+ session_repo = TerminalSessionRepository(db_session)
+ session = await session_repo.get(session_id)
+ if not session:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not found")
+ return
+
+ if not terminal_service.verify_token(token, session.token_hash):
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid token")
+ return
-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()
+ expires_at = _as_utc_aware(getattr(session, "expires_at", None))
+ if expires_at and expires_at <= now:
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session expired")
+ return
+ subprotocol = None
+ proto_hdr = websocket.headers.get("sec-websocket-protocol")
+ if proto_hdr and "tty" in proto_hdr:
+ subprotocol = "tty"
+
+ await websocket.accept(subprotocol=subprotocol)
+
+ query_params = list(websocket.query_params.multi_items())
+ query_params = [(k, v) for (k, v) in query_params if k != "token"]
+ upstream_qs = urlencode(query_params, doseq=True) if query_params else ""
+ upstream_url = f"ws://127.0.0.1:{session.ttyd_port}/ws" + (f"?{upstream_qs}" if upstream_qs else "")
+
+ import websockets
+
+ logger.info(
+ json.dumps(
+ {
+ "component": "terminal",
+ "session_id": session_id,
+ "event": "ws_proxy_connect_upstream",
+ "upstream_url": upstream_url,
+ "ttyd_port": session.ttyd_port,
+ }
+ )
+ )
+ try:
+ # Ttyd checks Origin by default. We must provide a valid Origin matching the host/port
+ # or ttyd will reject the WebSocket handshake (403).
+ upstream_headers = {
+ "Origin": f"http://127.0.0.1:{session.ttyd_port}",
+ }
+
+ async with websockets.connect(
+ upstream_url,
+ subprotocols=["tty"],
+ open_timeout=5,
+ ping_interval=None,
+ max_size=None,
+ additional_headers=upstream_headers,
+ ) as upstream:
+
+ logger.info(
+ json.dumps(
+ {
+ "component": "terminal",
+ "session_id": session_id,
+ "event": "ws_proxy_connected_upstream",
+ "ttyd_port": session.ttyd_port,
+ }
+ )
+ )
+
+ async def _client_to_upstream():
+ try:
+ while True:
+ message = await websocket.receive()
+ if message.get("type") == "websocket.disconnect":
+ break
+
+ data = message.get("bytes")
+ if data is not None:
+ await upstream.send(data)
+ continue
+
+ text_data = message.get("text")
+ if text_data is not None:
+ await upstream.send(text_data)
+ except WebSocketDisconnect:
+ pass
+ finally:
+ try:
+ await upstream.close()
+ except Exception:
+ pass
+
+ async def _upstream_to_client():
+ try:
+ async for data in upstream:
+ if isinstance(data, bytes):
+ await websocket.send_bytes(data)
+ else:
+ await websocket.send_text(data)
+ finally:
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+ await asyncio.gather(_client_to_upstream(), _upstream_to_client(), return_exceptions=True)
+
+ except Exception as e:
+ logger.exception(
+ json.dumps(
+ {
+ "component": "terminal",
+ "session_id": session_id,
+ "event": "ws_proxy_error",
+ "error": str(e),
+ "ttyd_port": getattr(session, "ttyd_port", None),
+ }
+ )
+ )
+ try:
+ await websocket.close(code=1011, reason="Proxy error")
+ except Exception:
+ pass
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session(
@@ -151,29 +488,16 @@ async def create_terminal_session(
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"
+ detail="Terminal feature unavailable: ttyd is not installed",
)
-
- # Get host information
+
+ session_repo = TerminalSessionRepository(db_session)
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)
@@ -182,65 +506,58 @@ async def create_terminal_session(
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
+ 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
+ remaining = _compute_remaining_seconds(expires_at)
+
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")
+ ws_host = (http_request.url.netloc if http_request else "localhost")
+ ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=existing_session.id,
url=session_url,
- websocket_url=f"{ws_scheme}://{ws_host}:{existing_session.ttyd_port}/ws",
+ websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{existing_session.id}/ws?{ws_token}",
expires_at=expires_at,
ttl_seconds=remaining,
mode=existing_session.mode,
@@ -254,76 +571,71 @@ async def create_terminal_session(
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
- )
-
+ 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:
+
+ port = None
+ pid = None
+ last_spawn_error = None
+ for _ in range(3):
+ 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",
+ )
+
+ try:
+ pid = await terminal_service.spawn_ttyd(
+ session_id=session_id,
+ host_ip=host.ip_address,
+ port=port,
+ token=token,
+ )
+ if pid is None:
+ last_spawn_error = "Failed to start terminal process"
+ await terminal_service.release_port(port)
+ port = None
+ continue
+ break
+ except TtydNotAvailableError:
await terminal_service.release_port(port)
raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Failed to start terminal process"
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Terminal feature unavailable: ttyd is not installed",
)
-
- 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}")
+ except Exception as e:
+ last_spawn_error = str(e)
+ await terminal_service.release_port(port)
+ port = None
+ continue
+
+ if pid is None or port is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to start terminal: {str(e)}"
+ detail=f"Failed to start terminal: {last_spawn_error or 'unknown error'}",
)
-
- # Create session in database
+
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES)
+
+ session = None
try:
session = await session_repo.create(
id=session_id,
@@ -339,31 +651,38 @@ async def create_terminal_session(
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"
)
-
+ finally:
+ if session is None:
+ try:
+ await terminal_service.terminate_session(session_id)
+ except Exception:
+ pass
+ try:
+ await terminal_service.release_port(port)
+ except Exception:
+ pass
+
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")
+ ws_host = (http_request.url.netloc if http_request else "localhost")
+ ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=session_id,
url=session_url,
- websocket_url=f"{ws_scheme}://{ws_host}:{port}/ws",
+ websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{session_id}/ws?{ws_token}",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
@@ -378,35 +697,27 @@ async def create_terminal_session(
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)
+ user_id = current_user.get("user_id") or current_user.get("type", "api_key")
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()))
+ remaining = _compute_remaining_seconds(expires_at, now=now)
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,
@@ -420,101 +731,71 @@ async def list_terminal_sessions(
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),
+ request: Request,
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
+
+ token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
+ if not token or not terminal_service.verify_token(token, session.token_hash):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
+
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}
+ logger.info(f"session_closed session={session_id[:8]}... 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),
+ request: Request,
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
+
+ token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
+ if not token or not terminal_service.verify_token(token, session.token_hash):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
+
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()))
-
+ remaining = _compute_remaining_seconds(expires_at, now=now)
+
return HeartbeatResponse(
session_id=session_id,
status=session.status,
@@ -523,63 +804,42 @@ async def heartbeat_terminal_session(
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")
- )
-
+
+ user_id = current_user.get("user_id") or current_user.get("type", "api_key")
+ active_sessions = await session_repo.list_active_for_user(user_id)
+
terminated = 0
+ closed = 0
for session in active_sessions:
try:
await terminal_service.terminate_session(session.id)
@@ -587,26 +847,71 @@ async def cleanup_terminal_sessions(
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()
+
+ try:
+ await session_repo.close_session(session.id, reason=CLOSE_REASON_USER)
+ closed += 1
+ except Exception as e:
+ logger.warning(f"Failed to close session {session.id[:8]} in DB: {e}")
+
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),
+ "expired_marked": 0,
}
+@router.post("/sessions/{session_id}/command")
+async def log_terminal_command(
+ session_id: str,
+ command_data: dict,
+ request: Request,
+ db_session: AsyncSession = Depends(get_db),
+):
+ 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"
+ )
+
+ token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
+ if not token or not terminal_service.verify_token(token, session.token_hash):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Invalid session token"
+ )
+
+ command = command_data.get("command")
+ if not command:
+ return {"status": "ignored", "reason": "empty_command"}
+
+ if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
+ command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
+
+ cmd_repo = TerminalCommandLogRepository(db_session)
+ command_hash = hashlib.sha256(command.encode()).hexdigest()
+
+ await cmd_repo.create(
+ host_id=session.host_id,
+ host_name=session.host_name,
+ user_id=session.user_id,
+ username=session.username,
+ terminal_session_id=session.id,
+ command=command,
+ command_hash=command_hash,
+ source="terminal_interactive"
+ )
+
+ await db_session.commit()
+
+ return {"status": "logged", "command_len": len(command)}
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
@@ -615,29 +920,27 @@ async def get_terminal_connect_page(
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:
+ safe_title = "Session Expirée"
return HTMLResponse(
- content="""
+ content=f"""
-
Session Expirée
+
{safe_title}
@@ -651,21 +954,25 @@ async def get_terminal_connect_page(
""",
status_code=404
)
-
- # Verify token
+
if not terminal_service.verify_token(token, session.token_hash):
+ safe_title = html.escape(session.host_name or "Terminal")
return HTMLResponse(
- content="""
+ content=f"""
-
Accès Refusé
+
{safe_title}
@@ -679,28 +986,6 @@ async def get_terminal_connect_page(
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)
@@ -708,11 +993,9 @@ async def get_terminal_connect_page(
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):
+ if await _is_port_reachable(request, 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,
@@ -728,19 +1011,20 @@ async def get_terminal_connect_page(
alive = False
if not alive:
+ safe_title = html.escape(session.host_name or "Terminal")
return HTMLResponse(
content=f"""
-
{session.host_name}
+
{safe_title}
@@ -941,43 +2070,29 @@ async def get_terminal_connect_page(
@@ -987,201 +2102,75 @@ async def get_terminal_connect_page(
Connexion au terminal...
+
session={session_id[:8]}… · ttyd_port={ttyd_port} · Debug: Ctrl+Shift+D