Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
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

This commit is contained in:
2025-12-18 13:49:40 -05:00
parent 493668f746
commit 5bc12d0729
74 changed files with 14849 additions and 78 deletions
+4
View File
@@ -6,6 +6,8 @@ from .log import LogRepository
from .user import UserRepository
from .alert import AlertRepository
from .app_setting import AppSettingRepository
from .terminal_session import TerminalSessionRepository
from .terminal_command_log import TerminalCommandLogRepository
__all__ = [
"HostRepository",
@@ -16,4 +18,6 @@ __all__ = [
"AlertRepository",
"UserRepository",
"AppSettingRepository",
"TerminalSessionRepository",
"TerminalCommandLogRepository",
]
+312
View File
@@ -0,0 +1,312 @@
"""
CRUD operations for terminal command logs.
Provides repository pattern for storing and retrieving terminal command history.
"""
import os
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.terminal_command_log import TerminalCommandLog
# Configuration
TERMINAL_COMMAND_RETENTION_DAYS = int(os.environ.get("TERMINAL_COMMAND_RETENTION_DAYS", "30"))
class TerminalCommandLogRepository:
"""Repository for TerminalCommandLog CRUD operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def create(
self,
host_id: str,
command: str,
command_hash: str,
host_name: Optional[str] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
terminal_session_id: Optional[str] = None,
source: str = "terminal",
is_blocked: bool = False,
blocked_reason: Optional[str] = None,
) -> TerminalCommandLog:
"""Create a new command log entry."""
log = TerminalCommandLog(
host_id=host_id,
host_name=host_name,
user_id=user_id,
username=username,
terminal_session_id=terminal_session_id,
command=command,
command_hash=command_hash,
source=source,
is_blocked=is_blocked,
blocked_reason=blocked_reason,
)
self.session.add(log)
await self.session.flush()
return log
async def get(self, log_id: int) -> Optional[TerminalCommandLog]:
"""Get a command log by ID."""
result = await self.session.execute(
select(TerminalCommandLog).where(TerminalCommandLog.id == log_id)
)
return result.scalar_one_or_none()
async def list_for_host(
self,
host_id: str,
query: Optional[str] = None,
limit: int = 50,
offset: int = 0,
include_blocked: bool = False,
) -> List[TerminalCommandLog]:
"""
List command logs for a specific host.
Args:
host_id: The host ID to filter by
query: Optional search query (filters command text)
limit: Maximum number of results
offset: Number of results to skip
include_blocked: Whether to include blocked commands
"""
conditions = [TerminalCommandLog.host_id == host_id]
if not include_blocked:
conditions.append(TerminalCommandLog.is_blocked == False)
if query:
conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
stmt = (
select(TerminalCommandLog)
.where(and_(*conditions))
.order_by(TerminalCommandLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_for_user(
self,
user_id: str,
query: Optional[str] = None,
host_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> List[TerminalCommandLog]:
"""
List command logs for a specific user.
Args:
user_id: The user ID to filter by
query: Optional search query
host_id: Optional host ID to filter by
limit: Maximum number of results
offset: Number of results to skip
"""
conditions = [
TerminalCommandLog.user_id == user_id,
TerminalCommandLog.is_blocked == False,
]
if host_id:
conditions.append(TerminalCommandLog.host_id == host_id)
if query:
conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
stmt = (
select(TerminalCommandLog)
.where(and_(*conditions))
.order_by(TerminalCommandLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def list_global(
self,
query: Optional[str] = None,
host_id: Optional[str] = None,
user_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> List[TerminalCommandLog]:
"""
List command logs globally with optional filters.
Args:
query: Optional search query
host_id: Optional host ID to filter by
user_id: Optional user ID to filter by
limit: Maximum number of results
offset: Number of results to skip
"""
conditions = [TerminalCommandLog.is_blocked == False]
if host_id:
conditions.append(TerminalCommandLog.host_id == host_id)
if user_id:
conditions.append(TerminalCommandLog.user_id == user_id)
if query:
conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
stmt = (
select(TerminalCommandLog)
.where(and_(*conditions))
.order_by(TerminalCommandLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_unique_commands_for_host(
self,
host_id: str,
query: Optional[str] = None,
limit: int = 50,
) -> List[dict]:
"""
Get unique commands for a host (deduped by command_hash).
Returns commands with their most recent execution time.
"""
conditions = [
TerminalCommandLog.host_id == host_id,
TerminalCommandLog.is_blocked == False,
]
if query:
conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
# Subquery to get the max created_at for each command_hash
subq = (
select(
TerminalCommandLog.command_hash,
func.max(TerminalCommandLog.created_at).label("max_created"),
func.count(TerminalCommandLog.id).label("execution_count"),
)
.where(and_(*conditions))
.group_by(TerminalCommandLog.command_hash)
.subquery()
)
# Join to get the actual command text
stmt = (
select(
TerminalCommandLog.command,
TerminalCommandLog.command_hash,
subq.c.max_created,
subq.c.execution_count,
)
.join(subq, and_(
TerminalCommandLog.command_hash == subq.c.command_hash,
TerminalCommandLog.created_at == subq.c.max_created,
))
.where(TerminalCommandLog.host_id == host_id)
.order_by(subq.c.max_created.desc())
.limit(limit)
)
result = await self.session.execute(stmt)
rows = result.all()
return [
{
"command": row.command,
"command_hash": row.command_hash,
"last_used": row.max_created,
"execution_count": row.execution_count,
}
for row in rows
]
async def count_for_host(self, host_id: str) -> int:
"""Count command logs for a host."""
result = await self.session.execute(
select(func.count(TerminalCommandLog.id))
.where(and_(
TerminalCommandLog.host_id == host_id,
TerminalCommandLog.is_blocked == False,
))
)
return result.scalar() or 0
async def purge_old_logs(self, days: Optional[int] = None) -> int:
"""
Purge logs older than the specified retention period.
Args:
days: Number of days to retain (defaults to TERMINAL_COMMAND_RETENTION_DAYS)
Returns:
Number of logs deleted
"""
if days is None:
days = TERMINAL_COMMAND_RETENTION_DAYS
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
# First count how many will be deleted
count_result = await self.session.execute(
select(func.count(TerminalCommandLog.id))
.where(TerminalCommandLog.created_at < cutoff)
)
count = count_result.scalar() or 0
# Delete old logs
await self.session.execute(
delete(TerminalCommandLog)
.where(TerminalCommandLog.created_at < cutoff)
)
await self.session.flush()
return count
async def delete_for_host(self, host_id: str) -> int:
"""Delete all command logs for a host."""
count_result = await self.session.execute(
select(func.count(TerminalCommandLog.id))
.where(TerminalCommandLog.host_id == host_id)
)
count = count_result.scalar() or 0
await self.session.execute(
delete(TerminalCommandLog)
.where(TerminalCommandLog.host_id == host_id)
)
await self.session.flush()
return count
async def delete_for_session(self, session_id: str) -> int:
"""Delete all command logs for a terminal session."""
count_result = await self.session.execute(
select(func.count(TerminalCommandLog.id))
.where(TerminalCommandLog.terminal_session_id == session_id)
)
count = count_result.scalar() or 0
await self.session.execute(
delete(TerminalCommandLog)
.where(TerminalCommandLog.terminal_session_id == session_id)
)
await self.session.flush()
return count
+167 -21
View File
@@ -1,13 +1,23 @@
"""
CRUD operations for terminal sessions.
"""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import select, update, and_
from sqlalchemy import select, update, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.terminal_session import TerminalSession
from app.models.terminal_session import (
TerminalSession,
SESSION_STATUS_ACTIVE,
SESSION_STATUS_CLOSED,
SESSION_STATUS_EXPIRED,
SESSION_STATUS_CLOSING,
CLOSE_REASON_USER,
CLOSE_REASON_TTL,
CLOSE_REASON_IDLE,
CLOSE_REASON_CLIENT_LOST,
)
class TerminalSessionRepository:
@@ -31,6 +41,7 @@ class TerminalSessionRepository:
mode: str = "embedded",
) -> TerminalSession:
"""Create a new terminal session."""
now = datetime.now(timezone.utc)
session = TerminalSession(
id=id,
host_id=host_id,
@@ -42,8 +53,9 @@ class TerminalSessionRepository:
ttyd_port=ttyd_port,
ttyd_pid=ttyd_pid,
mode=mode,
status="active",
status=SESSION_STATUS_ACTIVE,
expires_at=expires_at,
last_seen_at=now,
)
self.session.add(session)
await self.session.flush()
@@ -63,13 +75,46 @@ class TerminalSessionRepository:
select(TerminalSession).where(
and_(
TerminalSession.id == session_id,
TerminalSession.status == "active",
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
)
)
return result.scalar_one_or_none()
async def find_reusable_session(
self,
user_id: str,
host_id: str,
mode: str,
idle_timeout_seconds: int = 120
) -> Optional[TerminalSession]:
"""
Find an existing active session that can be reused.
A session is reusable if:
- Same user, host, and mode
- Status is 'active'
- Not expired
- Last seen within idle timeout (considered "healthy")
"""
now = datetime.now(timezone.utc)
min_last_seen = now - timedelta(seconds=idle_timeout_seconds)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.user_id == user_id,
TerminalSession.host_id == host_id,
TerminalSession.mode == mode,
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now,
TerminalSession.last_seen_at >= min_last_seen
)
).order_by(TerminalSession.created_at.desc())
)
return result.scalar_one_or_none()
async def list_active_for_user(self, user_id: str) -> List[TerminalSession]:
"""List all active sessions for a user."""
now = datetime.now(timezone.utc)
@@ -77,7 +122,20 @@ class TerminalSessionRepository:
select(TerminalSession).where(
and_(
TerminalSession.user_id == user_id,
TerminalSession.status == "active",
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
).order_by(TerminalSession.created_at.desc())
)
return list(result.scalars().all())
async def list_all_active(self) -> List[TerminalSession]:
"""List all active sessions (for admin/GC)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
).order_by(TerminalSession.created_at.desc())
@@ -90,23 +148,63 @@ class TerminalSessionRepository:
return len(sessions)
async def list_expired(self) -> List[TerminalSession]:
"""List all expired but not yet closed sessions."""
"""List all expired but not yet closed sessions (TTL exceeded)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status == "active",
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at <= now
)
)
)
return list(result.scalars().all())
async def list_idle(self, idle_timeout_seconds: int) -> List[TerminalSession]:
"""List all idle sessions (no heartbeat within timeout)."""
now = datetime.now(timezone.utc)
idle_cutoff = now - timedelta(seconds=idle_timeout_seconds)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.last_seen_at < idle_cutoff
)
)
)
return list(result.scalars().all())
async def list_stale_sessions(self, ttl_seconds: int, idle_timeout_seconds: int) -> List[TerminalSession]:
"""
List all sessions that should be cleaned up.
Returns sessions that are either:
- Expired (past TTL)
- Idle (no heartbeat within idle timeout)
"""
now = datetime.now(timezone.utc)
idle_cutoff = now - timedelta(seconds=idle_timeout_seconds)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status == SESSION_STATUS_ACTIVE,
or_(
TerminalSession.expires_at <= now,
TerminalSession.last_seen_at < idle_cutoff
)
)
)
)
return list(result.scalars().all())
async def update_status(
self,
session_id: str,
status: str,
closed_at: Optional[datetime] = None
closed_at: Optional[datetime] = None,
reason_closed: Optional[str] = None
) -> Optional[TerminalSession]:
"""Update session status."""
session = await self.get(session_id)
@@ -114,34 +212,67 @@ class TerminalSessionRepository:
session.status = status
if closed_at:
session.closed_at = closed_at
if reason_closed:
session.reason_closed = reason_closed
await self.session.flush()
return session
async def close_session(self, session_id: str) -> Optional[TerminalSession]:
"""Close a terminal session."""
async def update_last_seen(self, session_id: str) -> Optional[TerminalSession]:
"""Update last_seen_at timestamp (heartbeat)."""
session = await self.get(session_id)
if session and session.status == SESSION_STATUS_ACTIVE:
session.last_seen_at = datetime.now(timezone.utc)
await self.session.flush()
return session
async def close_session(
self,
session_id: str,
reason: str = CLOSE_REASON_USER
) -> Optional[TerminalSession]:
"""Close a terminal session with reason."""
return await self.update_status(
session_id,
status="closed",
closed_at=datetime.now(timezone.utc)
status=SESSION_STATUS_CLOSED,
closed_at=datetime.now(timezone.utc),
reason_closed=reason
)
async def mark_expired(self, session_id: str) -> Optional[TerminalSession]:
"""Mark a session as expired."""
"""Mark a session as expired (TTL)."""
return await self.update_status(
session_id,
status="expired",
closed_at=datetime.now(timezone.utc)
status=SESSION_STATUS_EXPIRED,
closed_at=datetime.now(timezone.utc),
reason_closed=CLOSE_REASON_TTL
)
async def mark_idle(self, session_id: str) -> Optional[TerminalSession]:
"""Mark a session as expired due to idle timeout."""
return await self.update_status(
session_id,
status=SESSION_STATUS_EXPIRED,
closed_at=datetime.now(timezone.utc),
reason_closed=CLOSE_REASON_IDLE
)
async def mark_client_lost(self, session_id: str) -> Optional[TerminalSession]:
"""Mark a session as closed due to client disconnect."""
return await self.update_status(
session_id,
status=SESSION_STATUS_CLOSED,
closed_at=datetime.now(timezone.utc),
reason_closed=CLOSE_REASON_CLIENT_LOST
)
async def cleanup_old_sessions(self, days: int = 7) -> int:
"""Delete sessions older than specified days."""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status.in_(["closed", "expired", "error"]),
TerminalSession.status.in_([SESSION_STATUS_CLOSED, SESSION_STATUS_EXPIRED, "error"]),
TerminalSession.created_at < cutoff
)
)
@@ -155,18 +286,33 @@ class TerminalSessionRepository:
await self.session.flush()
return count
async def close_all_active(self) -> int:
async def close_all_active(self, reason: str = CLOSE_REASON_USER) -> int:
"""Close all active sessions (for cleanup/reset)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(TerminalSession.status == "active")
select(TerminalSession).where(TerminalSession.status == SESSION_STATUS_ACTIVE)
)
sessions = list(result.scalars().all())
count = len(sessions)
for session in sessions:
session.status = "closed"
session.status = SESSION_STATUS_CLOSED
session.closed_at = now
session.reason_closed = reason
await self.session.flush()
return count
async def get_oldest_active_for_user(self, user_id: str) -> Optional[TerminalSession]:
"""Get the oldest active session for a user (for auto-close)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.user_id == user_id,
TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
).order_by(TerminalSession.created_at.asc())
)
return result.scalars().first()
+11
View File
@@ -193,6 +193,12 @@ def create_app() -> FastAPI:
replace_existing=True
)
print("🐳 Docker monitoring jobs scheduled")
# Start terminal session GC task
from app.services.terminal_service import terminal_service
terminal_service.set_db_session_factory(async_session_maker)
await terminal_service.start_gc_task()
print("🖥️ Terminal session GC task started")
# Afficher l'état du service de notification
ntfy_status = "activé" if notification_service.enabled else "désactivé"
@@ -224,11 +230,16 @@ def create_app() -> FastAPI:
print("👋 Arrêt de l'application...")
from app.services import scheduler_service, notification_service
from app.services.terminal_service import terminal_service
# Arrêter la capture des logs console
from app.services import console_log_service
console_log_service.stop_capture()
# Stop terminal session GC task
await terminal_service.stop_gc_task()
print("🖥️ Terminal session GC task stopped")
# Arrêter le scheduler
scheduler_service.shutdown()
+283
View File
@@ -3154,6 +3154,210 @@
color: #ef4444;
}
/* Terminal History Panel */
.terminal-history-panel {
background: #1e1e2e;
border-bottom: 1px solid #374151;
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-history-header {
padding: 0.75rem 1rem;
background: #1a1a2e;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.terminal-history-search {
flex: 1;
display: flex;
align-items: center;
background: #0f0f1a;
border: 1px solid #374151;
border-radius: 0.5rem;
padding: 0 0.75rem;
gap: 0.5rem;
}
.terminal-history-search i {
color: #6b7280;
}
.terminal-history-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff;
padding: 0.5rem 0;
font-size: 0.875rem;
}
.terminal-history-search input::placeholder {
color: #6b7280;
}
.terminal-history-clear-search {
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.terminal-history-clear-search:hover {
color: #9ca3af;
}
.terminal-history-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.terminal-history-scope {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
cursor: pointer;
}
.terminal-history-scope input {
width: 1rem;
height: 1rem;
accent-color: #7c3aed;
}
.terminal-history-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.terminal-history-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
gap: 0.75rem;
}
.terminal-history-item:hover {
background: rgba(124, 58, 237, 0.15);
}
.terminal-history-cmd {
flex: 1;
min-width: 0;
}
.terminal-history-cmd code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
color: #a5b4fc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.terminal-history-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.terminal-history-time {
font-size: 0.6875rem;
color: #6b7280;
}
.terminal-history-count {
font-size: 0.625rem;
color: #7c3aed;
background: rgba(124, 58, 237, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 9999px;
}
.terminal-history-host {
font-size: 0.6875rem;
color: #4ade80;
background: rgba(74, 222, 128, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.terminal-history-actions-inline {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}
.terminal-history-item:hover .terminal-history-actions-inline {
opacity: 1;
}
.terminal-history-action {
background: rgba(55, 65, 81, 0.5);
border: none;
color: #9ca3af;
padding: 0.375rem;
border-radius: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
}
.terminal-history-action:hover {
background: rgba(124, 58, 237, 0.3);
color: #fff;
}
.terminal-history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
gap: 0.5rem;
}
.terminal-history-empty i {
font-size: 1.5rem;
}
.terminal-history-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #9ca3af;
gap: 0.5rem;
}
#terminalHistoryBtn.active {
background: #7c3aed;
color: #fff;
}
/* Mobile responsive */
@media (max-width: 768px) {
.terminal-drawer-panel {
@@ -3166,6 +3370,85 @@
display: none;
}
}
/* =====================================================
Session Limit Modal Styles
===================================================== */
.session-limit-modal {
max-width: 500px;
width: 90%;
}
.session-limit-message {
color: #e5e7eb;
margin-bottom: 1rem;
}
.session-limit-reuse {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
.session-limit-reuse p {
color: #4ade80;
margin-bottom: 0.75rem;
}
.session-limit-list {
margin-bottom: 1rem;
}
.session-limit-list h4 {
color: #9ca3af;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.session-limit-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: rgba(55, 65, 81, 0.5);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
transition: opacity 0.3s ease;
}
.session-limit-item-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.session-limit-host {
color: #fff;
font-weight: 500;
}
.session-limit-mode {
color: #9ca3af;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: rgba(107, 114, 128, 0.3);
border-radius: 0.25rem;
}
.session-limit-age {
color: #9ca3af;
font-size: 0.75rem;
}
.session-limit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #374151;
}
</style>
</head>
<body>
+495 -15
View File
@@ -89,6 +89,12 @@ class DashboardManager {
this.terminalSession = null;
this.terminalDrawerOpen = false;
this.terminalFeatureAvailable = false;
this.terminalCommandHistory = [];
this.terminalHistorySearchQuery = '';
this.terminalHistoryLoading = false;
this.terminalHeartbeatInterval = null;
this.terminalHeartbeatIntervalMs = 15000; // 15 seconds
this.terminalOpeningPromise = null; // Prevent double-click
// Configuration: collecte métriques
this.metricsCollectionInterval = 'off';
@@ -109,6 +115,7 @@ class DashboardManager {
this.setupScrollAnimations();
this.startAnimations();
this.loadThemePreference();
this.setupTerminalCleanupHandlers();
// Check authentication status first
const authOk = await this.checkAuthStatus();
@@ -10108,6 +10115,11 @@ class DashboardManager {
}
async openTerminal(hostId, hostName, hostIp) {
// Anti-double-click: reuse pending promise
if (this.terminalOpeningPromise) {
return this.terminalOpeningPromise;
}
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
@@ -10115,26 +10127,59 @@ class DashboardManager {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return;
}
// Store heartbeat interval from server config
if (status.heartbeat_interval_seconds) {
this.terminalHeartbeatIntervalMs = status.heartbeat_interval_seconds * 1000;
}
}
// Show loading state
this.showTerminalDrawer(hostName, hostIp, true);
try {
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'embedded' })
});
this.terminalSession = session;
// Update drawer with terminal iframe
this.updateTerminalDrawer(session);
} catch (e) {
console.error('Failed to open terminal:', e);
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
this.closeTerminalDrawer();
}
// Create promise to prevent double-click
this.terminalOpeningPromise = (async () => {
try {
const response = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'embedded' })
});
// Check if this is a session limit error (429 with rich response)
if (response.error === 'SESSION_LIMIT') {
this.closeTerminalDrawer();
this.showSessionLimitModal(response, hostId, hostName, hostIp);
return;
}
this.terminalSession = response;
// Update drawer with terminal iframe
this.updateTerminalDrawer(response);
// Start heartbeat
this.startTerminalHeartbeat();
// Log if session was reused
if (response.reused) {
console.log('Terminal session reused:', response.session_id);
}
} catch (e) {
console.error('Failed to open terminal:', e);
// Check if error response contains session limit info
if (e.response && e.response.error === 'SESSION_LIMIT') {
this.closeTerminalDrawer();
this.showSessionLimitModal(e.response, hostId, hostName, hostIp);
} else {
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
this.closeTerminalDrawer();
}
} finally {
this.terminalOpeningPromise = null;
}
})();
return this.terminalOpeningPromise;
}
async openTerminalPopout(hostId, hostName, hostIp) {
@@ -10201,6 +10246,9 @@ class DashboardManager {
<span class="terminal-status-badge online">Connexion...</span>
</div>
<div class="terminal-header-actions">
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.toggleTerminalHistory()" title="Historique des commandes" id="terminalHistoryBtn">
<i class="fas fa-history"></i>
</button>
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.openCurrentTerminalPopout()" title="Ouvrir dans une nouvelle fenêtre">
<i class="fas fa-external-link-alt"></i>
</button>
@@ -10209,6 +10257,35 @@ class DashboardManager {
</button>
</div>
</div>
<!-- Command History Panel (hidden by default) -->
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
<div class="terminal-history-header">
<div class="terminal-history-search">
<i class="fas fa-search"></i>
<input type="text"
id="terminalHistorySearch"
placeholder="Rechercher une commande..."
oninput="dashboard.searchTerminalHistory(this.value)"
autocomplete="off">
<button class="terminal-history-clear-search" onclick="dashboard.clearTerminalHistorySearch()" title="Effacer">
<i class="fas fa-times"></i>
</button>
</div>
<div class="terminal-history-actions">
<label class="terminal-history-scope">
<input type="checkbox" id="terminalHistoryAllHosts" onchange="dashboard.toggleHistoryScope()">
<span>Tous les hôtes</span>
</label>
</div>
</div>
<div class="terminal-history-list" id="terminalHistoryList">
<div class="terminal-history-loading">
<i class="fas fa-spinner fa-spin"></i> Chargement...
</div>
</div>
</div>
<div class="terminal-drawer-body" id="terminalDrawerBody">
${loadingContent}
</div>
@@ -10314,6 +10391,9 @@ class DashboardManager {
this._terminalTimerInterval = null;
}
// Stop heartbeat
this.stopTerminalHeartbeat();
// Remove escape handler
if (this._terminalEscHandler) {
document.removeEventListener('keydown', this._terminalEscHandler);
@@ -10335,6 +10415,211 @@ class DashboardManager {
this.terminalDrawerOpen = false;
}
// ===== TERMINAL CLEANUP HANDLERS =====
setupTerminalCleanupHandlers() {
// Handle page unload (browser close, navigation away)
window.addEventListener('beforeunload', () => {
this.sendTerminalCloseBeacon();
});
// Handle page hide (mobile tab switch, etc.)
window.addEventListener('pagehide', () => {
this.sendTerminalCloseBeacon();
});
// Handle visibility change (tab hidden)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Stop heartbeat when tab is hidden
this.stopTerminalHeartbeat();
} else if (document.visibilityState === 'visible' && this.terminalSession && this.terminalDrawerOpen) {
// Resume heartbeat when tab becomes visible again
this.startTerminalHeartbeat();
}
});
}
sendTerminalCloseBeacon() {
if (!this.terminalSession) return;
const sessionId = this.terminalSession.session_id;
const url = `${this.apiBase}/api/terminal/sessions/${sessionId}/close-beacon`;
// Use sendBeacon for reliable delivery during page unload
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify({ reason: 'client_close' })], { type: 'application/json' });
navigator.sendBeacon(url, blob);
console.log('Terminal close beacon sent via sendBeacon');
} else {
// Fallback: synchronous XHR (blocking, but ensures delivery)
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, false); // synchronous
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ reason: 'client_close' }));
console.log('Terminal close beacon sent via XHR');
} catch (e) {
console.warn('Failed to send terminal close beacon:', e);
}
}
// Clear session reference
this.terminalSession = null;
}
// ===== TERMINAL HEARTBEAT =====
startTerminalHeartbeat() {
this.stopTerminalHeartbeat(); // Clear any existing
if (!this.terminalSession) return;
this.terminalHeartbeatInterval = setInterval(async () => {
if (!this.terminalSession || !this.terminalDrawerOpen) {
this.stopTerminalHeartbeat();
return;
}
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat`, {
method: 'POST'
});
} catch (e) {
console.warn('Terminal heartbeat failed:', e);
// Session might be expired - don't stop heartbeat, let server handle it
}
}, this.terminalHeartbeatIntervalMs);
console.log('Terminal heartbeat started');
}
stopTerminalHeartbeat() {
if (this.terminalHeartbeatInterval) {
clearInterval(this.terminalHeartbeatInterval);
this.terminalHeartbeatInterval = null;
console.log('Terminal heartbeat stopped');
}
}
// ===== TERMINAL SESSION LIMIT MODAL =====
showSessionLimitModal(limitError, targetHostId, targetHostName, targetHostIp) {
// Remove existing modal if any
const existingModal = document.getElementById('sessionLimitModal');
if (existingModal) existingModal.remove();
const sessionsHtml = limitError.active_sessions.map(s => `
<div class="session-limit-item" data-session-id="${s.session_id}">
<div class="session-limit-item-info">
<span class="session-limit-host">${this.escapeHtml(s.host_name)}</span>
<span class="session-limit-mode">${s.mode}</span>
<span class="session-limit-age">${this.formatDuration(s.age_seconds)}</span>
</div>
<button class="btn btn-sm btn-danger" onclick="dashboard.closeSessionFromModal('${s.session_id}')">
<i class="fas fa-times"></i> Fermer
</button>
</div>
`).join('');
const canReuseHtml = limitError.can_reuse ? `
<div class="session-limit-reuse">
<p>Une session existe déjà pour cet hôte. Vous pouvez la réutiliser.</p>
<button class="btn btn-primary" onclick="dashboard.reuseSessionFromModal('${limitError.reusable_session_id}', '${targetHostId}', '${this.escapeHtml(targetHostName)}', '${this.escapeHtml(targetHostIp)}')">
<i class="fas fa-redo"></i> Réutiliser la session existante
</button>
</div>
` : '';
const modal = document.createElement('div');
modal.id = 'sessionLimitModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content session-limit-modal">
<div class="modal-header">
<h3><i class="fas fa-exclamation-triangle text-warning"></i> Limite de sessions atteinte</h3>
<button class="modal-close" onclick="dashboard.closeSessionLimitModal()">&times;</button>
</div>
<div class="modal-body">
<p class="session-limit-message">
Vous avez atteint la limite de <strong>${limitError.max_active}</strong> sessions actives.
Fermez une session existante pour en ouvrir une nouvelle.
</p>
${canReuseHtml}
<div class="session-limit-list">
<h4>Sessions actives (${limitError.current_count}/${limitError.max_active})</h4>
${sessionsHtml}
</div>
<div class="session-limit-actions">
<button class="btn btn-secondary" onclick="dashboard.closeOldestSessionFromModal('${targetHostId}', '${this.escapeHtml(targetHostName)}', '${this.escapeHtml(targetHostIp)}')">
<i class="fas fa-clock"></i> Fermer la plus ancienne et réessayer
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
requestAnimationFrame(() => modal.classList.add('show'));
}
closeSessionLimitModal() {
const modal = document.getElementById('sessionLimitModal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => modal.remove(), 300);
}
}
async closeSessionFromModal(sessionId) {
try {
await this.apiCall(`/api/terminal/sessions/${sessionId}`, { method: 'DELETE' });
// Remove item from modal
const item = document.querySelector(`[data-session-id="${sessionId}"]`);
if (item) {
item.style.opacity = '0.5';
item.querySelector('button').disabled = true;
item.querySelector('button').innerHTML = '<i class="fas fa-check"></i> Fermée';
}
this.showNotification('Session fermée', 'success');
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async closeOldestSessionFromModal(targetHostId, targetHostName, targetHostIp) {
try {
// Get current sessions
const sessions = await this.apiCall('/api/terminal/sessions');
if (sessions.sessions && sessions.sessions.length > 0) {
// Find oldest (last in list since sorted by created_at desc)
const oldest = sessions.sessions[sessions.sessions.length - 1];
await this.apiCall(`/api/terminal/sessions/${oldest.session_id}`, { method: 'DELETE' });
this.showNotification(`Session fermée: ${oldest.host_name}`, 'success');
}
// Close modal and retry
this.closeSessionLimitModal();
await this.openTerminal(targetHostId, targetHostName, targetHostIp);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async reuseSessionFromModal(sessionId, targetHostId, targetHostName, targetHostIp) {
this.closeSessionLimitModal();
// Just retry - the server will return the reusable session
await this.openTerminal(targetHostId, targetHostName, targetHostIp);
}
formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
openCurrentTerminalPopout() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
@@ -10374,6 +10659,201 @@ class DashboardManager {
// Reopen
await this.openTerminal(host.id, host.name, host.ip);
}
// ===== TERMINAL COMMAND HISTORY =====
async toggleTerminalHistory() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
const isVisible = panel.style.display !== 'none';
if (isVisible) {
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();
}
}
async loadTerminalCommandHistory(query = '') {
if (!this.terminalSession) return;
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
this.terminalHistoryLoading = true;
listEl.innerHTML = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
try {
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const hostId = this.terminalSession.host.id;
let endpoint;
if (allHosts) {
endpoint = `/api/terminal/command-history?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
} else {
endpoint = `/api/terminal/${hostId}/command-history/unique?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
}
const response = await this.apiCall(endpoint);
this.terminalCommandHistory = response.commands || [];
this.renderTerminalHistory();
} catch (e) {
console.error('Failed to load terminal history:', e);
listEl.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur de chargement</div>';
} finally {
this.terminalHistoryLoading = false;
}
}
renderTerminalHistory() {
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
if (this.terminalCommandHistory.length === 0) {
listEl.innerHTML = `
<div class="terminal-history-empty">
<i class="fas fa-terminal"></i>
<span>Aucune commande dans l'historique</span>
</div>
`;
return;
}
const items = this.terminalCommandHistory.map((cmd, index) => {
const command = cmd.command || '';
const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at);
const execCount = cmd.execution_count || 1;
const hostName = cmd.host_name || '';
return `
<div class="terminal-history-item" onclick="dashboard.insertTerminalCommand(${index})" title="${this.escapeHtml(command)}">
<div class="terminal-history-cmd">
<code>${this.escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command)}</code>
</div>
<div class="terminal-history-meta">
<span class="terminal-history-time">${timeAgo}</span>
${execCount > 1 ? `<span class="terminal-history-count">×${execCount}</span>` : ''}
${hostName ? `<span class="terminal-history-host">${this.escapeHtml(hostName)}</span>` : ''}
</div>
<div class="terminal-history-actions-inline">
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.copyTerminalCommand(${index})" title="Copier">
<i class="fas fa-copy"></i>
</button>
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.insertTerminalCommand(${index})" title="Insérer">
<i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
`;
}).join('');
listEl.innerHTML = items;
}
searchTerminalHistory(query) {
this.terminalHistorySearchQuery = query;
// Debounce search
if (this._historySearchTimeout) {
clearTimeout(this._historySearchTimeout);
}
this._historySearchTimeout = setTimeout(() => {
this.loadTerminalCommandHistory(query);
}, 300);
}
clearTerminalHistorySearch() {
const input = document.getElementById('terminalHistorySearch');
if (input) {
input.value = '';
}
this.terminalHistorySearchQuery = '';
this.loadTerminalCommandHistory('');
}
toggleHistoryScope() {
// Reload with current search query
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
insertTerminalCommand(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');
}
} else {
navigator.clipboard.writeText(command).then(() => {
this.showNotification('Commande copiée dans le presse-papier', 'success');
});
}
// Close history panel
this.toggleTerminalHistory();
}
copyTerminalCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
navigator.clipboard.writeText(command).then(() => {
this.showNotification('Commande copiée', 'success');
}).catch(() => {
this.showNotification('Impossible de copier', 'error');
});
}
formatRelativeTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'À l\'instant';
if (diffMin < 60) return `Il y a ${diffMin}min`;
if (diffHour < 24) return `Il y a ${diffHour}h`;
if (diffDay < 7) return `Il y a ${diffDay}j`;
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
}
// Initialize dashboard when DOM is loaded
+2
View File
@@ -14,6 +14,7 @@ from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .terminal_session import TerminalSession
from .terminal_command_log import TerminalCommandLog
__all__ = [
"Base",
@@ -37,4 +38,5 @@ __all__ = [
"DockerVolume",
"DockerAlert",
"TerminalSession",
"TerminalCommandLog",
]
+3
View File
@@ -48,6 +48,9 @@ class Host(Base):
docker_alerts: Mapped[List["DockerAlert"]] = relationship(
"DockerAlert", back_populates="host", cascade="all, delete-orphan"
)
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
"TerminalCommandLog", back_populates="host", cascade="all, delete-orphan"
)
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
+78
View File
@@ -0,0 +1,78 @@
"""
Model for terminal command logs - stores validated commands executed via terminal.
Security notes:
- Only stores commands that pass the CommandPolicy validation
- Commands are masked to remove sensitive values before storage
- Never stores raw commands that were blocked
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
class TerminalCommandLog(Base):
"""
Stores validated terminal commands for history and audit purposes.
Commands are only logged if they pass the CommandPolicy validation.
Sensitive values are masked before storage.
"""
__tablename__ = "terminal_command_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Timestamp of command execution
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
# Foreign keys
host_id: Mapped[str] = mapped_column(
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[Optional[str]] = mapped_column(
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
# Session reference (not FK as sessions may be cleaned up)
terminal_session_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
# Command data (masked/normalized version only)
command: Mapped[str] = mapped_column(Text, nullable=False)
# SHA-256 hash of normalized command for deduplication
command_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Source identifier
source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'"))
# If command was blocked (for audit - no raw command stored)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Additional metadata
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
host_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
user = relationship("User", back_populates="terminal_commands", lazy="selectin")
# Indexes for efficient querying
__table_args__ = (
Index('ix_terminal_cmd_host_created', 'host_id', 'created_at'),
Index('ix_terminal_cmd_user_created', 'user_id', 'created_at'),
Index('ix_terminal_cmd_host_hash', 'host_id', 'command_hash'),
)
def __repr__(self) -> str:
cmd_preview = self.command[:30] + "..." if len(self.command) > 30 else self.command
return f"<TerminalCommandLog id={self.id} host={self.host_name} cmd='{cmd_preview}'>"
+40 -3
View File
@@ -6,21 +6,52 @@ from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Integer, String, text
from sqlalchemy import DateTime, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
# Session status constants
SESSION_STATUS_STARTING = "starting"
SESSION_STATUS_ACTIVE = "active"
SESSION_STATUS_CLOSING = "closing"
SESSION_STATUS_CLOSED = "closed"
SESSION_STATUS_EXPIRED = "expired"
SESSION_STATUS_ERROR = "error"
# Close reason constants
CLOSE_REASON_USER = "user_close"
CLOSE_REASON_TTL = "ttl"
CLOSE_REASON_IDLE = "idle"
CLOSE_REASON_QUOTA = "quota"
CLOSE_REASON_SERVER_SHUTDOWN = "server_shutdown"
CLOSE_REASON_CLIENT_LOST = "client_lost"
CLOSE_REASON_REUSED = "reused"
class TerminalSession(Base):
"""
Represents an active or recent SSH terminal session.
Sessions are created when a user opens a terminal to a host,
and cleaned up after expiration or manual closure.
Status lifecycle:
- starting: Session being created, ttyd spawning
- active: Session running, client connected
- closing: Session being terminated
- closed: Session terminated normally
- expired: Session terminated due to TTL/idle timeout
- error: Session terminated due to error
"""
__tablename__ = "terminal_sessions"
__table_args__ = (
Index('ix_terminal_sessions_user_status', 'user_id', 'status'),
Index('ix_terminal_sessions_last_seen', 'last_seen_at'),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
@@ -39,15 +70,21 @@ class TerminalSession(Base):
# Session mode: 'embedded' or 'popout'
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
# Session status: 'active', 'closed', 'expired', 'error'
# Session status: 'starting', 'active', 'closing', 'closed', 'expired', 'error'
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Close reason: 'user_close', 'ttl', 'idle', 'quota', 'server_shutdown', 'client_lost', 'reused'
reason_closed: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<TerminalSession id={self.id} host={self.host_name} status={self.status}>"
return f"<TerminalSession id={self.id[:8]}... host={self.host_name} status={self.status}>"
+7 -2
View File
@@ -6,10 +6,10 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
@@ -74,6 +74,11 @@ class User(Base):
# Soft delete support
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
"TerminalCommandLog", back_populates="user"
)
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username} role={self.role}>"
+593 -28
View File
@@ -19,21 +19,39 @@ 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,
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__)
@@ -62,24 +80,82 @@ async def get_terminal_feature_status():
"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 a new terminal session for a host.
Create or reuse a terminal session for a host.
Requires:
- Host must exist and be online
- Host must have bootstrap completed
- User must not exceed max sessions limit
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.
"""
@@ -126,15 +202,79 @@ async def create_terminal_session(
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."
# 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,
)
# Generate session credentials
# 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()
@@ -208,18 +348,20 @@ async def create_terminal_session(
detail="Failed to create terminal session"
)
# Log audit event
logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...")
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
# Build response
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://localhost:{port}/ws",
websocket_url=f"{ws_scheme}://{ws_host}:{port}/ws",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
@@ -230,16 +372,22 @@ async def create_terminal_session(
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")
@@ -252,14 +400,23 @@ async def list_terminal_sessions(
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(
@@ -279,6 +436,7 @@ async def close_terminal_session(
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")
@@ -287,10 +445,12 @@ async def close_terminal_session(
session = await session_repo.get(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# 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":
@@ -299,19 +459,106 @@ async def close_terminal_session(
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)
# 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)
await session_repo.close_session(session_id, reason=CLOSE_REASON_USER)
await db_session.commit()
logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...")
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),
@@ -363,6 +610,7 @@ async def cleanup_terminal_sessions(
async def get_terminal_connect_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
"""
@@ -428,11 +676,65 @@ async def get_terminal_connect_page(
""",
status_code=403
)
# If ttyd process is not alive, return a friendly error page instead of a blank iframe.
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = True
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}/"
# Generate the terminal page
html_content = f"""
@@ -441,7 +743,7 @@ async def get_terminal_connect_page(
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal - {session.host_name}</title>
<title>{session.host_name}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
@@ -637,7 +939,7 @@ async def get_terminal_connect_page(
</div>
<iframe
id="terminalFrame"
src="http://localhost:{session.ttyd_port}/"
src="about:blank"
onload="onTerminalLoad()"
allow="clipboard-read; clipboard-write"
></iframe>
@@ -649,6 +951,35 @@ async def get_terminal_connect_page(
const HOST_NAME = '{session.host_name}';
const HOST_IP = '{session.host_ip}';
let remainingSeconds = {remaining_seconds};
// 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() {{
@@ -675,6 +1006,7 @@ async def get_terminal_connect_page(
setInterval(updateTimer, 1000);
function onTerminalLoad() {{
iframeLoaded = true;
document.getElementById('terminalLoading').classList.add('hidden');
// Focus the iframe
document.getElementById('terminalFrame').focus();
@@ -745,6 +1077,7 @@ async def get_terminal_connect_page(
async def get_terminal_popout_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
"""
@@ -753,4 +1086,236 @@ async def get_terminal_popout_page(
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)
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}
+106 -1
View File
@@ -2,11 +2,55 @@
Pydantic schemas for terminal session API.
"""
from datetime import datetime
from typing import Optional, Literal
from typing import List, Optional, Literal, Any
from pydantic import BaseModel, Field
# ============================================================================
# Command History Schemas
# ============================================================================
class CommandHistoryItem(BaseModel):
"""A single command from history."""
id: int
command: str
created_at: datetime
host_name: Optional[str] = None
username: Optional[str] = None
execution_count: Optional[int] = None
class Config:
from_attributes = True
class CommandHistoryResponse(BaseModel):
"""Response containing command history."""
commands: List[CommandHistoryItem]
total: int
host_id: Optional[str] = None
query: Optional[str] = None
class UniqueCommandItem(BaseModel):
"""A unique command with usage stats."""
command: str
command_hash: str
last_used: datetime
execution_count: int
class UniqueCommandsResponse(BaseModel):
"""Response containing unique commands."""
commands: List[UniqueCommandItem]
total: int
host_id: str
# ============================================================================
# Terminal Session Schemas
# ============================================================================
class TerminalSessionRequest(BaseModel):
"""Request to create a new terminal session."""
mode: Literal["embedded", "popout"] = Field(
@@ -33,16 +77,23 @@ class TerminalSessionResponse(BaseModel):
ttl_seconds: int
mode: str
host: TerminalSessionHost
reused: bool = False # True if an existing session was reused
token: Optional[str] = None # Only provided for new sessions
class TerminalSessionStatus(BaseModel):
"""Current status of a terminal session."""
session_id: str
status: str
host_id: str
host_name: str
mode: str = "embedded"
created_at: datetime
expires_at: datetime
last_seen_at: Optional[datetime] = None
remaining_seconds: int
age_seconds: int = 0
last_seen_seconds: int = 0
class TerminalSessionList(BaseModel):
@@ -50,3 +101,57 @@ class TerminalSessionList(BaseModel):
sessions: list[TerminalSessionStatus]
total: int
max_per_user: int
class ActiveSessionInfo(BaseModel):
"""Info about an active session for limit error response."""
session_id: str
host_id: str
host_name: str
mode: str
age_seconds: int
last_seen_seconds: int
class SessionLimitError(BaseModel):
"""Rich error response when session limit is exceeded."""
error: str = "SESSION_LIMIT"
message: str
max_active: int
current_count: int
active_sessions: List[ActiveSessionInfo]
suggested_actions: List[str]
can_reuse: bool = False
reusable_session_id: Optional[str] = None
class HeartbeatRequest(BaseModel):
"""Request to update session heartbeat."""
pass # Empty body, session_id comes from path
class HeartbeatResponse(BaseModel):
"""Response from heartbeat endpoint."""
session_id: str
status: str
last_seen_at: datetime
remaining_seconds: int
healthy: bool = True
class CloseBeaconRequest(BaseModel):
"""Request from sendBeacon to close session."""
reason: str = "client_close"
class SessionMetrics(BaseModel):
"""Terminal session service metrics."""
sessions_created: int
sessions_reused: int
sessions_closed_user: int
sessions_gc_expired: int
sessions_gc_idle: int
session_limit_hits: int
active_processes: int
allocated_ports: int
gc_running: bool
+6
View File
@@ -0,0 +1,6 @@
"""
Security modules for the Homelab Automation application.
"""
from .command_policy import CommandPolicy, CommandPolicyResult
__all__ = ["CommandPolicy", "CommandPolicyResult"]
+511
View File
@@ -0,0 +1,511 @@
"""
Command Policy Engine for Terminal Command Logging.
This module enforces strict security policies for terminal commands:
- Blocklist: Commands that are NEVER logged (sensitive operations)
- Allowlist: Only commands matching these patterns are logged
- Masking: Sensitive values are redacted before logging
SECURITY PRINCIPLE: When in doubt, refuse to log rather than risk exposing secrets.
"""
import hashlib
import logging
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
import yaml
logger = logging.getLogger(__name__)
class PolicyDecision(Enum):
"""Result of policy evaluation."""
ALLOW = "allow" # Command is safe to log
BLOCK = "block" # Command contains sensitive data - do not log
UNKNOWN = "unknown" # Command not in allowlist - do not log by default
@dataclass
class CommandPolicyResult:
"""Result of command policy evaluation."""
decision: PolicyDecision
original_command: str
masked_command: Optional[str] = None
command_hash: Optional[str] = None
reason: Optional[str] = None
matched_rule: Optional[str] = None
@property
def should_log(self) -> bool:
"""Whether this command should be logged to the database."""
return self.decision == PolicyDecision.ALLOW
@property
def is_blocked(self) -> bool:
"""Whether this command was explicitly blocked."""
return self.decision == PolicyDecision.BLOCK
# ============================================================================
# DEFAULT BLOCKLIST - Commands that are NEVER logged
# ============================================================================
DEFAULT_BLOCKLIST_PATTERNS: List[str] = [
# Password/secret keywords in command
r'\bpassword\b',
r'\bpasswd\b',
r'\btoken\b',
r'\bapikey\b',
r'\bapi_key\b',
r'\bsecret\b',
r'\bsshpass\b',
r'\bcredential\b',
# Docker login (may contain credentials)
r'\bdocker\s+login\b',
# OpenSSL with password
r'\bopenssl\b.*(-pass\b|pass:)',
# curl/wget with auth headers
r'\b(curl|wget)\b.*\bAuthorization:\s*',
r'\b(curl|wget)\b.*\bBearer\s+',
r'\b(curl|wget)\b.*(-u\s+|--user\s+)',
r'\b(curl|wget)\b.*(-H\s+["\']?Authorization)',
# Export sensitive env vars
r'\bexport\s+\w*(TOKEN|SECRET|KEY|PASS|PASSWORD|CREDENTIAL)\w*\s*=',
# Reading SSH keys or shadow file
r'\bcat\s+.*~?/?\.ssh/',
r'\bcat\s+.*/etc/shadow',
r'\bcat\s+.*id_rsa',
r'\bcat\s+.*id_ed25519',
r'\bcat\s+.*id_ecdsa',
r'\bcat\s+.*authorized_keys',
r'\bcat\s+.*known_hosts',
# Less/more/view on sensitive files
r'\b(less|more|view|head|tail)\s+.*~?/?\.ssh/',
r'\b(less|more|view|head|tail)\s+.*/etc/shadow',
# Editing sensitive files
r'\b(vi|vim|nano|emacs|edit)\s+.*~?/?\.ssh/',
r'\b(vi|vim|nano|emacs|edit)\s+.*/etc/shadow',
# MySQL/PostgreSQL with password on command line
r'\bmysql\b.*-p\S+',
r'\bpsql\b.*:.*@',
# AWS/GCP/Azure CLI with credentials
r'\baws\s+configure\b',
r'\bgcloud\s+auth\b',
r'\baz\s+login\b',
# Kubernetes secrets
r'\bkubectl\s+(get|describe|edit)\s+secret',
# Ansible vault
r'\bansible-vault\s+(encrypt|decrypt|edit)',
# Gpg operations with passphrases
r'\bgpg\b.*--passphrase',
# Sudo with password echo
r'echo\s+.*\|\s*sudo\b',
# History manipulation (might contain secrets)
r'\bhistory\s*$',
r'\bcat\s+.*\.bash_history',
r'\bcat\s+.*\.zsh_history',
]
# ============================================================================
# DEFAULT ALLOWLIST - Only these command patterns are logged
# ============================================================================
DEFAULT_ALLOWLIST_PATTERNS: List[str] = [
# Basic navigation and info
r'^ls\b',
r'^ll\b',
r'^la\b',
r'^cd\b',
r'^pwd\s*$',
r'^whoami\s*$',
r'^id\s*$',
r'^uname\b',
r'^hostname\b',
r'^date\b',
r'^uptime\b',
r'^w\s*$',
r'^who\b',
r'^last\b',
# Disk and memory info
r'^df\b',
r'^du\b',
r'^free\b',
r'^lsblk\b',
r'^fdisk\s+-l',
r'^mount\s*$',
r'^findmnt\b',
# Process info
r'^ps\b',
r'^top\b',
r'^htop\b',
r'^pgrep\b',
r'^pstree\b',
# Network info (read-only)
r'^ip\s+(addr|link|route|neigh)',
r'^ifconfig\b',
r'^netstat\b',
r'^ss\b',
r'^ping\b',
r'^traceroute\b',
r'^tracepath\b',
r'^dig\b',
r'^nslookup\b',
r'^host\b',
r'^curl\s+(-I\s+|--head\s+)?https?://', # curl without auth only
r'^wget\s+--spider\b',
# Systemd services (status, start, stop, restart, enable, disable)
r'^systemctl\s+(status|start|stop|restart|reload|enable|disable|is-active|is-enabled|list-units|list-unit-files)\b',
r'^service\s+\S+\s+(status|start|stop|restart)',
# Journalctl (logs)
r'^journalctl\b',
# Docker read operations
r'^docker\s+(ps|images|logs|inspect|stats|info|version|network\s+ls|volume\s+ls|container\s+ls)\b',
r'^docker\s+compose\s+(ps|logs|config|images)\b',
r'^docker-compose\s+(ps|logs|config|images)\b',
# Docker management (careful operations)
r'^docker\s+(start|stop|restart|pause|unpause|kill)\s+',
r'^docker\s+compose\s+(up|down|start|stop|restart)\b',
r'^docker-compose\s+(up|down|start|stop|restart)\b',
r'^docker\s+(pull|build|rm|rmi)\b',
# Package managers (read operations)
r'^apt\s+(list|search|show|policy)\b',
r'^apt-cache\b',
r'^dpkg\s+(-l|-L|-s)\b',
r'^dnf\s+(list|search|info|repolist)\b',
r'^yum\s+(list|search|info|repolist)\b',
r'^rpm\s+(-q|-qa|-qi|-ql)\b',
r'^pacman\s+-Q',
# Package install/update (logged for audit)
r'^apt\s+(install|update|upgrade|remove|purge)\b',
r'^apt-get\s+(install|update|upgrade|remove|purge)\b',
r'^dnf\s+(install|update|upgrade|remove)\b',
r'^yum\s+(install|update|upgrade|remove)\b',
# File viewing (non-sensitive paths)
r'^cat\s+(/var/log/|/etc/(?!shadow|passwd-|group-|gshadow))',
r'^tail\b',
r'^head\b',
r'^less\b',
r'^more\b',
r'^grep\b',
r'^awk\b',
r'^sed\b',
r'^find\b',
r'^locate\b',
r'^wc\b',
r'^sort\b',
r'^uniq\b',
r'^cut\b',
r'^tr\b',
# File operations (careful, but useful for audit)
r'^cp\b',
r'^mv\b',
r'^rm\b',
r'^mkdir\b',
r'^rmdir\b',
r'^chmod\b',
r'^chown\b',
r'^touch\b',
r'^ln\b',
# Text editors opening files
r'^(vi|vim|nano|emacs)\s+(?!/etc/shadow)',
# Git operations
r'^git\s+(status|log|diff|branch|show|remote|fetch|pull|push|checkout|merge|rebase|stash)\b',
# Ansible
r'^ansible\b',
r'^ansible-playbook\b',
r'^ansible-galaxy\b',
# Terraform (read operations)
r'^terraform\s+(plan|show|state|output|validate)\b',
# Proxmox
r'^(qm|pct|pvesh|pvesm|pveam)\s+(list|status|config|get)',
# ZFS/LVM info
r'^zfs\s+(list|get|status)\b',
r'^zpool\s+(list|status|iostat)\b',
r'^lvs\b',
r'^vgs\b',
r'^pvs\b',
r'^lvm\s+(lvs|vgs|pvs)\b',
# Clear/exit (safe)
r'^clear\s*$',
r'^exit\s*$',
r'^logout\s*$',
]
# ============================================================================
# MASKING PATTERNS - Values to redact even in allowed commands
# ============================================================================
DEFAULT_MASK_PATTERNS: List[Tuple[str, str]] = [
# Password flags
(r'(--password[=\s]+)\S+', r'\1***'),
(r'(-p\s+)\S+', r'\1***'),
(r'(--pass[=\s]+)\S+', r'\1***'),
# Token flags
(r'(--token[=\s]+)\S+', r'\1***'),
(r'(-t\s+)\S+', r'\1***'),
# Auth headers
(r'(Authorization:\s*Bearer\s+)\S+', r'\1***'),
(r'(Authorization:\s*Basic\s+)\S+', r'\1***'),
(r'(Authorization:\s*)\S+', r'\1***'),
# API keys
(r'(--api-key[=\s]+)\S+', r'\1***'),
(r'(--apikey[=\s]+)\S+', r'\1***'),
# User credentials in URLs
(r'(https?://)[^:]+:[^@]+(@)', r'\1***:***\2'),
# MySQL password
(r'(-p)\S+', r'\1***'),
# Environment variable values that look sensitive
(r'(\w*(PASSWORD|TOKEN|SECRET|KEY|CREDENTIAL)\w*=)\S+', r'\1***'),
]
@dataclass
class CommandPolicy:
"""
Command Policy Engine for evaluating and masking terminal commands.
Usage:
policy = CommandPolicy()
result = policy.evaluate("ls -la /var/log")
if result.should_log:
# Log result.masked_command to database
pass
"""
blocklist_patterns: List[re.Pattern] = field(default_factory=list)
allowlist_patterns: List[re.Pattern] = field(default_factory=list)
mask_patterns: List[Tuple[re.Pattern, str]] = field(default_factory=list)
# Policy mode: 'strict' only logs allowlist, 'permissive' logs everything not blocked
mode: str = "strict"
# Config file path (optional)
config_path: Optional[Path] = None
def __post_init__(self):
"""Initialize with default patterns or load from config."""
if self.config_path and self.config_path.exists():
self._load_config()
else:
self._load_defaults()
def _load_defaults(self):
"""Load default patterns."""
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCKLIST_PATTERNS
]
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_ALLOWLIST_PATTERNS
]
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in DEFAULT_MASK_PATTERNS
]
logger.info(
f"CommandPolicy initialized with {len(self.blocklist_patterns)} blocklist, "
f"{len(self.allowlist_patterns)} allowlist, {len(self.mask_patterns)} mask patterns"
)
def _load_config(self):
"""Load patterns from YAML config file."""
try:
with open(self.config_path, 'r') as f:
config = yaml.safe_load(f)
if 'blocklist' in config:
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in config['blocklist']
]
else:
self.blocklist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_BLOCKLIST_PATTERNS
]
if 'allowlist' in config:
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in config['allowlist']
]
else:
self.allowlist_patterns = [
re.compile(p, re.IGNORECASE) for p in DEFAULT_ALLOWLIST_PATTERNS
]
if 'mask' in config:
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in config['mask']
]
else:
self.mask_patterns = [
(re.compile(p, re.IGNORECASE), r) for p, r in DEFAULT_MASK_PATTERNS
]
self.mode = config.get('mode', 'strict')
logger.info(f"CommandPolicy loaded from {self.config_path}")
except Exception as e:
logger.error(f"Failed to load command policy config: {e}, using defaults")
self._load_defaults()
def _check_blocklist(self, command: str) -> Optional[str]:
"""
Check if command matches any blocklist pattern.
Returns:
Matched pattern string if blocked, None otherwise
"""
for pattern in self.blocklist_patterns:
if pattern.search(command):
return pattern.pattern
return None
def _check_allowlist(self, command: str) -> Optional[str]:
"""
Check if command matches any allowlist pattern.
Returns:
Matched pattern string if allowed, None otherwise
"""
for pattern in self.allowlist_patterns:
if pattern.search(command):
return pattern.pattern
return None
def _mask_sensitive_values(self, command: str) -> str:
"""Apply all masking patterns to the command."""
masked = command
for pattern, replacement in self.mask_patterns:
masked = pattern.sub(replacement, masked)
return masked
def _compute_hash(self, command: str) -> str:
"""Compute SHA-256 hash of normalized command."""
# Normalize: lowercase, collapse whitespace
normalized = ' '.join(command.lower().split())
return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
def evaluate(self, command: str) -> CommandPolicyResult:
"""
Evaluate a command against the policy.
Args:
command: The raw command string to evaluate
Returns:
CommandPolicyResult with decision and optional masked command
"""
# Normalize command (strip whitespace)
command = command.strip()
if not command:
return CommandPolicyResult(
decision=PolicyDecision.UNKNOWN,
original_command=command,
reason="Empty command"
)
# Step 1: Check blocklist first (security priority)
blocked_pattern = self._check_blocklist(command)
if blocked_pattern:
logger.debug(f"Command blocked by pattern: {blocked_pattern}")
return CommandPolicyResult(
decision=PolicyDecision.BLOCK,
original_command=command,
reason="Command contains sensitive content",
matched_rule=blocked_pattern
)
# Step 2: Check allowlist
allowed_pattern = self._check_allowlist(command)
if allowed_pattern or self.mode == "permissive":
# Step 3: Apply masking to allowed commands
masked = self._mask_sensitive_values(command)
command_hash = self._compute_hash(masked)
return CommandPolicyResult(
decision=PolicyDecision.ALLOW,
original_command=command,
masked_command=masked,
command_hash=command_hash,
matched_rule=allowed_pattern
)
# Not in allowlist and strict mode
return CommandPolicyResult(
decision=PolicyDecision.UNKNOWN,
original_command=command,
reason="Command not in allowlist"
)
def is_safe_to_log(self, command: str) -> bool:
"""Quick check if a command is safe to log."""
return self.evaluate(command).should_log
def get_masked_command(self, command: str) -> Optional[str]:
"""Get the masked version of a command if it's allowed to log."""
result = self.evaluate(command)
return result.masked_command if result.should_log else None
# ============================================================================
# Global instance with lazy initialization
# ============================================================================
_policy_instance: Optional[CommandPolicy] = None
def get_command_policy() -> CommandPolicy:
"""Get or create the global command policy instance."""
global _policy_instance
if _policy_instance is None:
# Check for config file in environment or default location
config_path = os.environ.get("COMMAND_POLICY_CONFIG")
if config_path:
_policy_instance = CommandPolicy(config_path=Path(config_path))
else:
_policy_instance = CommandPolicy()
return _policy_instance
def evaluate_command(command: str) -> CommandPolicyResult:
"""Convenience function to evaluate a command."""
return get_command_policy().evaluate(command)
+322
View File
@@ -0,0 +1,322 @@
"""
Terminal Command Logger - Captures and logs validated commands from terminal sessions.
This module provides:
- Command buffer management per session
- Enter key detection to capture complete commands
- Integration with CommandPolicy for validation
- Async database logging
Key features:
- Handles backspace, Ctrl+U, and Enter sequences
- Only logs commands that pass security validation
- Thread-safe buffer management
"""
import asyncio
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Callable, Dict, Optional
from app.security.command_policy import CommandPolicy, CommandPolicyResult, get_command_policy
logger = logging.getLogger(__name__)
# Configuration
TERMINAL_COMMAND_RETENTION_DAYS = int(os.environ.get("TERMINAL_COMMAND_RETENTION_DAYS", "30"))
@dataclass
class SessionContext:
"""Context for a terminal session's command buffer."""
session_id: str
host_id: str
host_name: str
user_id: Optional[str]
username: Optional[str]
# Command buffer (characters waiting for Enter)
buffer: str = ""
# Stats
commands_logged: int = 0
commands_blocked: int = 0
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class TerminalCommandLogger:
"""
Manages command buffers for terminal sessions and logs validated commands.
Usage:
logger = TerminalCommandLogger()
# Create session context
logger.create_session(session_id, host_id, host_name, user_id, username)
# Process input bytes from terminal
async for result in logger.process_input(session_id, input_bytes):
if result.should_log:
# Command was logged to database
pass
# Cleanup when session ends
logger.remove_session(session_id)
"""
def __init__(self, policy: Optional[CommandPolicy] = None):
self._sessions: Dict[str, SessionContext] = {}
self._lock = asyncio.Lock()
self._policy = policy or get_command_policy()
# Callback for logging to database (injected)
self._log_callback: Optional[Callable] = None
def set_log_callback(self, callback: Callable):
"""Set the callback function for logging commands to database."""
self._log_callback = callback
async def create_session(
self,
session_id: str,
host_id: str,
host_name: str,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> SessionContext:
"""Create a new session context for command buffering."""
async with self._lock:
ctx = SessionContext(
session_id=session_id,
host_id=host_id,
host_name=host_name,
user_id=user_id,
username=username,
)
self._sessions[session_id] = ctx
logger.debug(f"Created command logger session: {session_id[:8]}... for {host_name}")
return ctx
async def remove_session(self, session_id: str) -> Optional[SessionContext]:
"""Remove a session context."""
async with self._lock:
ctx = self._sessions.pop(session_id, None)
if ctx:
logger.debug(
f"Removed command logger session: {session_id[:8]}... "
f"(logged={ctx.commands_logged}, blocked={ctx.commands_blocked})"
)
return ctx
def get_session(self, session_id: str) -> Optional[SessionContext]:
"""Get a session context."""
return self._sessions.get(session_id)
def _handle_control_char(self, ctx: SessionContext, char: bytes) -> bool:
"""
Handle control characters in the input stream.
Returns True if the character was handled, False otherwise.
"""
if char in (b'\x7f', b'\b'): # Backspace (DEL or BS)
if ctx.buffer:
ctx.buffer = ctx.buffer[:-1]
return True
if char == b'\x15': # Ctrl+U - clear line
ctx.buffer = ""
return True
if char == b'\x17': # Ctrl+W - delete word
# Delete last word (back to space or start)
ctx.buffer = ctx.buffer.rstrip()
if ' ' in ctx.buffer:
ctx.buffer = ctx.buffer[:ctx.buffer.rfind(' ') + 1]
else:
ctx.buffer = ""
return True
if char == b'\x03': # Ctrl+C - interrupt
ctx.buffer = ""
return True
if char == b'\x04': # Ctrl+D - EOF
ctx.buffer = ""
return True
return False
async def process_input(
self,
session_id: str,
data: bytes,
) -> list[CommandPolicyResult]:
"""
Process input bytes from a terminal session.
This method:
1. Adds printable characters to the buffer
2. Handles control characters (backspace, Ctrl+U, etc.)
3. When Enter is detected, evaluates the command and logs if valid
Args:
session_id: The terminal session ID
data: Raw bytes from the terminal input stream
Returns:
List of CommandPolicyResult for any commands that were evaluated
"""
ctx = self._sessions.get(session_id)
if not ctx:
return []
results = []
# Process byte by byte
i = 0
while i < len(data):
char = data[i:i+1]
# Check for Enter (CR or LF)
if char in (b'\r', b'\n'):
# Skip CRLF sequence
if char == b'\r' and i + 1 < len(data) and data[i+1:i+2] == b'\n':
i += 1
# Process the command
command = ctx.buffer.strip()
ctx.buffer = ""
if command:
result = await self._evaluate_and_log(ctx, command)
results.append(result)
i += 1
continue
# Handle control characters
if self._handle_control_char(ctx, char):
i += 1
continue
# Skip other control characters and escape sequences
if char[0] < 32 or char == b'\x7f':
# Check for escape sequence
if char == b'\x1b' and i + 1 < len(data):
# Skip escape sequences (arrow keys, etc.)
i += 1
while i < len(data) and data[i:i+1] not in (b'', b'\r', b'\n'):
next_char = data[i:i+1]
if next_char.isalpha() or next_char == b'~':
i += 1
break
i += 1
continue
i += 1
continue
# Add printable character to buffer
try:
ctx.buffer += char.decode('utf-8', errors='ignore')
except Exception:
pass
i += 1
return results
async def _evaluate_and_log(
self,
ctx: SessionContext,
command: str,
) -> CommandPolicyResult:
"""Evaluate a command against policy and log if valid."""
result = self._policy.evaluate(command)
if result.should_log:
ctx.commands_logged += 1
logger.debug(f"Command logged for {ctx.host_name}: {result.masked_command[:50]}...")
# Call the log callback if set
if self._log_callback:
try:
await self._log_callback(
host_id=ctx.host_id,
host_name=ctx.host_name,
user_id=ctx.user_id,
username=ctx.username,
session_id=ctx.session_id,
command=result.masked_command,
command_hash=result.command_hash,
)
except Exception as e:
logger.error(f"Failed to log command to database: {e}")
elif result.is_blocked:
ctx.commands_blocked += 1
logger.info(f"Command blocked for {ctx.host_name}: {result.reason}")
# Optionally log blocked command metadata (not the command itself)
if self._log_callback:
try:
await self._log_callback(
host_id=ctx.host_id,
host_name=ctx.host_name,
user_id=ctx.user_id,
username=ctx.username,
session_id=ctx.session_id,
command="[BLOCKED]",
command_hash="blocked",
is_blocked=True,
blocked_reason=result.reason,
)
except Exception as e:
logger.error(f"Failed to log blocked command metadata: {e}")
else:
# Command not in allowlist - silently skip
logger.debug(f"Command not in allowlist for {ctx.host_name}: {command[:30]}...")
return result
async def flush_buffer(self, session_id: str) -> Optional[CommandPolicyResult]:
"""
Flush any remaining buffer content as a command.
Useful when session ends to capture any incomplete command.
"""
ctx = self._sessions.get(session_id)
if not ctx or not ctx.buffer.strip():
return None
command = ctx.buffer.strip()
ctx.buffer = ""
return await self._evaluate_and_log(ctx, command)
def get_stats(self) -> dict:
"""Get statistics about command logging."""
total_logged = sum(ctx.commands_logged for ctx in self._sessions.values())
total_blocked = sum(ctx.commands_blocked for ctx in self._sessions.values())
return {
"active_sessions": len(self._sessions),
"total_commands_logged": total_logged,
"total_commands_blocked": total_blocked,
}
# ============================================================================
# Global instance
# ============================================================================
_command_logger_instance: Optional[TerminalCommandLogger] = None
def get_command_logger() -> TerminalCommandLogger:
"""Get or create the global command logger instance."""
global _command_logger_instance
if _command_logger_instance is None:
_command_logger_instance = TerminalCommandLogger()
return _command_logger_instance
+396
View File
@@ -0,0 +1,396 @@
"""
Terminal Gateway - PTY proxy for command interception and logging.
This service provides a PTY gateway that:
1. Creates a pseudo-terminal (PTY) for SSH connections
2. Intercepts input to capture commands before Enter
3. Logs validated commands to the database
4. Exposes the PTY to ttyd for web terminal access
Architecture:
Browser <--> ttyd <--> Terminal Gateway (PTY proxy) <--> SSH <--> Remote Host
The gateway intercepts stdin going to SSH, reconstructs commands,
and logs them when Enter is detected.
"""
import asyncio
import logging
import os
import pty
import select
import shutil
import signal
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Dict, Optional, Tuple
from app.core.config import settings
from app.services.terminal_command_logger import TerminalCommandLogger, get_command_logger
logger = logging.getLogger(__name__)
# Configuration
SSH_USER = os.environ.get("TERMINAL_SSH_USER", "automation")
TTYD_PATH = os.environ.get("TTYD_PATH", "ttyd")
TERMINAL_PORT_RANGE_START = int(os.environ.get("TERMINAL_PORT_RANGE_START", "7680"))
TERMINAL_PORT_RANGE_END = int(os.environ.get("TERMINAL_PORT_RANGE_END", "7700"))
@dataclass
class GatewaySession:
"""Represents an active terminal gateway session."""
session_id: str
host_id: str
host_name: str
host_ip: str
user_id: Optional[str]
username: Optional[str]
port: int
# Process handles
ttyd_process: Optional[subprocess.Popen] = None
ssh_master_fd: Optional[int] = None
ssh_slave_fd: Optional[int] = None
ssh_pid: Optional[int] = None
# Gateway script path
gateway_script: Optional[str] = None
# Stats
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
bytes_in: int = 0
bytes_out: int = 0
class TerminalGateway:
"""
Terminal Gateway service that manages PTY-based terminal sessions with command logging.
This gateway creates a wrapper script that:
1. Opens a PTY
2. Runs SSH through the PTY
3. Intercepts stdin to log commands
4. Is then served by ttyd
"""
def __init__(self):
self._sessions: Dict[str, GatewaySession] = {}
self._lock = asyncio.Lock()
self._command_logger = get_command_logger()
self._log_callback: Optional[Callable] = None
# Check if we're on a Unix system (PTY requires Unix)
self._pty_available = hasattr(pty, 'openpty') and sys.platform != 'win32'
if not self._pty_available:
logger.warning(
"PTY not available on this platform. "
"Command logging will be limited. "
"Consider running on Linux for full functionality."
)
def set_log_callback(self, callback: Callable):
"""Set the callback function for logging commands to database."""
self._log_callback = callback
self._command_logger.set_log_callback(callback)
def _find_ssh_key(self) -> Optional[str]:
"""Find available SSH key for terminal sessions."""
key_candidates = []
env_key = os.environ.get("TERMINAL_SSH_KEY_PATH")
if env_key:
key_candidates.append(env_key)
if getattr(settings, "ssh_key_path", None):
key_candidates.append(settings.ssh_key_path)
key_candidates.append(os.path.expanduser("~/.ssh/id_automation_ansible"))
key_candidates.append(os.path.expanduser("~/.ssh/id_rsa"))
return next((p for p in key_candidates if p and os.path.exists(p)), None)
def _create_gateway_script(
self,
session_id: str,
host_ip: str,
ssh_key_path: str,
) -> str:
"""
Create a gateway script that wraps SSH and enables command logging.
On Unix systems, this script uses a Python-based PTY wrapper.
On Windows, we fall back to direct SSH (no command logging).
"""
import tempfile
# Create a temporary script
script_dir = tempfile.mkdtemp(prefix=f"terminal_gateway_{session_id[:8]}_")
script_path = os.path.join(script_dir, "gateway.sh")
if self._pty_available:
# Unix: Create a shell script that we can potentially enhance later
# For now, just run SSH directly - the command logging happens via
# the terminal_command_logger when we upgrade to full PTY interception
script_content = f'''#!/bin/bash
# Terminal Gateway Script for session {session_id[:8]}
# This wraps SSH and could be enhanced for additional logging
exec ssh -i "{ssh_key_path}" \\
-o BatchMode=no \\
-o PreferredAuthentications=publickey,keyboard-interactive,password \\
-o ConnectTimeout=10 \\
-o ServerAliveInterval=30 \\
-o ServerAliveCountMax=2 \\
-o StrictHostKeyChecking=accept-new \\
-o UserKnownHostsFile=/dev/null \\
{SSH_USER}@{host_ip}
'''
else:
# Windows: PowerShell script
script_path = os.path.join(script_dir, "gateway.ps1")
script_content = f'''# Terminal Gateway Script for session {session_id[:8]}
ssh -i "{ssh_key_path}" `
-o BatchMode=no `
-o PreferredAuthentications=publickey,keyboard-interactive,password `
-o ConnectTimeout=10 `
-o ServerAliveInterval=30 `
-o ServerAliveCountMax=2 `
-o StrictHostKeyChecking=accept-new `
-o UserKnownHostsFile=/dev/null `
{SSH_USER}@{host_ip}
'''
with open(script_path, 'w') as f:
f.write(script_content)
if self._pty_available:
os.chmod(script_path, 0o755)
return script_path
async def create_session(
self,
session_id: str,
host_id: str,
host_name: str,
host_ip: str,
port: int,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> GatewaySession:
"""
Create a new terminal gateway session.
This sets up the command logger context for the session.
The actual ttyd process is spawned separately.
"""
async with self._lock:
session = GatewaySession(
session_id=session_id,
host_id=host_id,
host_name=host_name,
host_ip=host_ip,
user_id=user_id,
username=username,
port=port,
)
self._sessions[session_id] = session
# Create command logger session
await self._command_logger.create_session(
session_id=session_id,
host_id=host_id,
host_name=host_name,
user_id=user_id,
username=username,
)
logger.info(f"Created gateway session {session_id[:8]}... for {host_name} ({host_ip})")
return session
async def spawn_ttyd_with_gateway(
self,
session: GatewaySession,
token: str,
) -> Optional[int]:
"""
Spawn ttyd with a gateway wrapper script.
Returns:
PID of ttyd process, or None if failed
"""
if not shutil.which(TTYD_PATH):
raise RuntimeError("ttyd is not installed or not in PATH")
ssh_key_path = self._find_ssh_key()
if not ssh_key_path:
raise RuntimeError("No SSH key found for terminal sessions")
# Create gateway script
gateway_script = self._create_gateway_script(
session.session_id,
session.host_ip,
ssh_key_path,
)
session.gateway_script = gateway_script
# Build ttyd command
if self._pty_available:
cmd = [
TTYD_PATH,
"--once",
f"--port={session.port}",
"--writable",
gateway_script,
]
else:
# Windows: Use ttyd with direct SSH command
cmd = [
TTYD_PATH,
"--once",
f"--port={session.port}",
"--writable",
"ssh",
"-i", ssh_key_path,
"-o", "BatchMode=no",
"-o", "PreferredAuthentications=publickey,keyboard-interactive,password",
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=2",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
f"{SSH_USER}@{session.host_ip}",
]
logger.info(
f"Spawning ttyd gateway for session {session.session_id[:8]}... "
f"on port {session.port} -> {SSH_USER}@{session.host_ip}"
)
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
session.ttyd_process = process
# Wait briefly to check if process started successfully
await asyncio.sleep(0.5)
if process.poll() is not None:
stdout, stderr = process.communicate(timeout=1)
error_msg = stderr.decode() if stderr else stdout.decode() if stdout else "Unknown error"
logger.error(f"ttyd gateway failed to start: {error_msg}")
self._cleanup_gateway_script(session)
return None
logger.info(f"ttyd gateway started with PID {process.pid}")
return process.pid
except Exception as e:
logger.exception(f"Failed to spawn ttyd gateway: {e}")
self._cleanup_gateway_script(session)
return None
def _cleanup_gateway_script(self, session: GatewaySession):
"""Clean up the gateway script directory."""
if session.gateway_script:
try:
script_dir = os.path.dirname(session.gateway_script)
if os.path.exists(script_dir):
import shutil
shutil.rmtree(script_dir, ignore_errors=True)
except Exception as e:
logger.warning(f"Failed to cleanup gateway script: {e}")
async def terminate_session(self, session_id: str) -> bool:
"""
Terminate a terminal gateway session.
Returns:
True if session was terminated, False if not found
"""
async with self._lock:
session = self._sessions.pop(session_id, None)
if not session:
return False
# Flush any remaining command buffer
await self._command_logger.flush_buffer(session_id)
# Remove command logger session
await self._command_logger.remove_session(session_id)
# Terminate ttyd process
if session.ttyd_process and session.ttyd_process.poll() is None:
try:
session.ttyd_process.terminate()
try:
session.ttyd_process.wait(timeout=5)
except subprocess.TimeoutExpired:
session.ttyd_process.kill()
session.ttyd_process.wait(timeout=2)
except Exception as e:
logger.warning(f"Error terminating ttyd process: {e}")
# Cleanup gateway script
self._cleanup_gateway_script(session)
logger.info(f"Terminated gateway session {session_id[:8]}...")
return True
async def process_terminal_input(
self,
session_id: str,
data: bytes,
) -> None:
"""
Process terminal input for command logging.
This is called when we intercept input going to the terminal.
In the current architecture with ttyd, this would require
a WebSocket proxy to intercept. For now, this is a placeholder
for future enhancement.
"""
results = await self._command_logger.process_input(session_id, data)
for result in results:
if result.should_log:
logger.debug(f"Command captured: {result.masked_command[:50]}...")
def get_session(self, session_id: str) -> Optional[GatewaySession]:
"""Get a gateway session by ID."""
return self._sessions.get(session_id)
def get_active_session_count(self) -> int:
"""Get the number of active gateway sessions."""
return len(self._sessions)
def get_stats(self) -> dict:
"""Get statistics about the terminal gateway."""
cmd_stats = self._command_logger.get_stats()
return {
"active_sessions": len(self._sessions),
"pty_available": self._pty_available,
**cmd_stats,
}
# ============================================================================
# Global instance
# ============================================================================
_gateway_instance: Optional[TerminalGateway] = None
def get_terminal_gateway() -> TerminalGateway:
"""Get or create the global terminal gateway instance."""
global _gateway_instance
if _gateway_instance is None:
_gateway_instance = TerminalGateway()
return _gateway_instance
+235 -6
View File
@@ -6,6 +6,8 @@ This service handles:
- Spawning ttyd processes for SSH connections
- Managing session lifecycle (creation, expiration, cleanup)
- Port allocation for ttyd instances
- Garbage collection of stale/idle sessions
- Session reuse for same user/host/mode
"""
import asyncio
import hashlib
@@ -16,22 +18,39 @@ import shutil
import signal
import socket
import subprocess
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Tuple
from datetime import datetime, timezone
from typing import Dict, Any, Tuple, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
# Configuration
# Configuration - Session TTL and limits
TERMINAL_SESSION_TTL_MINUTES = int(os.environ.get("TERMINAL_SESSION_TTL_MINUTES", "30"))
TERMINAL_SESSION_TTL_SECONDS = TERMINAL_SESSION_TTL_MINUTES * 60
TERMINAL_MAX_SESSIONS_PER_USER = int(os.environ.get("TERMINAL_MAX_SESSIONS_PER_USER", "3"))
# Idle timeout - sessions without heartbeat for this long are considered dead
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS = int(os.environ.get("TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS", "120"))
# Heartbeat interval - how often client should send heartbeat (slightly less than idle timeout)
TERMINAL_HEARTBEAT_INTERVAL_SECONDS = int(os.environ.get("TERMINAL_HEARTBEAT_INTERVAL_SECONDS", "15"))
# GC interval - how often to run garbage collection
TERMINAL_GC_INTERVAL_SECONDS = int(os.environ.get("TERMINAL_GC_INTERVAL_SECONDS", "30"))
# Port range for ttyd instances
TERMINAL_PORT_RANGE_START = int(os.environ.get("TERMINAL_PORT_RANGE_START", "7680"))
TERMINAL_PORT_RANGE_END = int(os.environ.get("TERMINAL_PORT_RANGE_END", "7700"))
# SSH configuration
SSH_USER = os.environ.get("TERMINAL_SSH_USER", "automation")
TTYD_PATH = os.environ.get("TTYD_PATH", "ttyd")
# ttyd bind interface (important for WSL/remote access)
TERMINAL_TTYD_INTERFACE = os.environ.get("TERMINAL_TTYD_INTERFACE")
class TerminalServiceError(Exception):
"""Base exception for terminal service errors."""
@@ -61,7 +80,12 @@ class TerminalService:
- Generating secure session tokens
- Allocating ports for ttyd instances
- Spawning and managing ttyd processes
- Cleaning up expired sessions
- Cleaning up expired/idle sessions via GC
- Session reuse for same user/host/mode
The GC task runs periodically to clean up:
- Sessions that exceeded their TTL
- Sessions that haven't received a heartbeat within idle timeout
"""
def __init__(self):
@@ -73,6 +97,20 @@ class TerminalService:
self._lock = asyncio.Lock()
# Check if ttyd is available
self._ttyd_available: Optional[bool] = None
# GC task handle
self._gc_task: Optional[asyncio.Task] = None
self._gc_running: bool = False
# Database session factory for GC (set during app startup)
self._db_session_factory = None
# Metrics for observability
self._metrics = {
"sessions_created": 0,
"sessions_reused": 0,
"sessions_closed_user": 0,
"sessions_gc_expired": 0,
"sessions_gc_idle": 0,
"session_limit_hits": 0,
}
def check_ttyd_available(self) -> bool:
"""Check if ttyd binary is available."""
@@ -87,6 +125,108 @@ class TerminalService:
return self._ttyd_available
def set_db_session_factory(self, factory):
"""Set the database session factory for GC operations."""
self._db_session_factory = factory
async def start_gc_task(self):
"""Start the garbage collection background task."""
if self._gc_task is not None:
return
self._gc_running = True
self._gc_task = asyncio.create_task(self._gc_loop())
logger.info(f"Terminal session GC started (interval={TERMINAL_GC_INTERVAL_SECONDS}s, idle_timeout={TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS}s)")
async def stop_gc_task(self):
"""Stop the garbage collection background task."""
self._gc_running = False
if self._gc_task:
self._gc_task.cancel()
try:
await self._gc_task
except asyncio.CancelledError:
pass
self._gc_task = None
logger.info("Terminal session GC stopped")
async def _gc_loop(self):
"""Background loop that periodically cleans up stale sessions."""
while self._gc_running:
try:
await asyncio.sleep(TERMINAL_GC_INTERVAL_SECONDS)
if self._db_session_factory:
await self._run_gc_cycle()
except asyncio.CancelledError:
break
except Exception as e:
logger.exception(f"Error in terminal GC cycle: {e}")
async def _run_gc_cycle(self):
"""Run a single GC cycle to clean up stale sessions."""
from app.crud.terminal_session import TerminalSessionRepository
from app.models.terminal_session import CLOSE_REASON_TTL, CLOSE_REASON_IDLE
async with self._db_session_factory() as db_session:
repo = TerminalSessionRepository(db_session)
# Get all stale sessions (expired or idle)
stale_sessions = await repo.list_stale_sessions(
ttl_seconds=TERMINAL_SESSION_TTL_SECONDS,
idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS
)
if not stale_sessions:
return
now = datetime.now(timezone.utc)
expired_count = 0
idle_count = 0
for session in stale_sessions:
# Determine reason: TTL expired or idle
expires_at = self._to_utc_aware(session.expires_at)
is_expired = bool(expires_at and expires_at <= now)
# Terminate the ttyd process
await self.terminate_session(session.id)
# Release the port
await self.release_port(session.ttyd_port)
# Mark in DB with appropriate reason
if is_expired:
await repo.mark_expired(session.id)
expired_count += 1
self._metrics["sessions_gc_expired"] += 1
logger.info(f"session_gc_closed session={session.id[:8]}... host={session.host_name} reason=ttl")
else:
await repo.mark_idle(session.id)
idle_count += 1
self._metrics["sessions_gc_idle"] += 1
logger.info(f"session_gc_closed session={session.id[:8]}... host={session.host_name} reason=idle")
await db_session.commit()
if expired_count or idle_count:
logger.info(f"Terminal GC cycle: cleaned {expired_count} expired, {idle_count} idle sessions")
def _to_utc_aware(self, dt: Optional[datetime]) -> Optional[datetime]:
"""Normalize datetimes from DB (SQLite may return naive) into UTC-aware."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def get_metrics(self) -> Dict[str, Any]:
"""Get service metrics for observability."""
return {
**self._metrics,
"active_processes": len(self._processes),
"allocated_ports": len(self._allocated_ports),
"gc_running": self._gc_running,
}
def generate_session_id(self) -> str:
"""Generate a unique session ID."""
return secrets.token_hex(32)
@@ -121,6 +261,14 @@ class TerminalService:
sock.close()
except Exception:
pass
async def is_session_process_alive(self, session_id: str) -> bool:
"""Check if the ttyd process for a session is still running."""
async with self._lock:
process = self._processes.get(session_id)
if process is None:
return False
return process.poll() is None
async def allocate_port(self, session_id: str) -> int:
"""
@@ -217,6 +365,8 @@ class TerminalService:
f"{SSH_USER}@{host_ip}",
]
ttyd_interface = TERMINAL_TTYD_INTERFACE
# Note: --credential removed to avoid HTTP basic auth popup
# Security is handled by session token validation in the connect/popout routes
cmd = [
@@ -224,8 +374,10 @@ class TerminalService:
"--once",
f"--port={port}",
"--writable",
*ssh_cmd,
]
if ttyd_interface:
cmd.extend(["--interface", ttyd_interface])
cmd.extend(ssh_cmd)
logger.info(
f"Spawning ttyd for session {session_id[:8]}... on port {port} -> {SSH_USER}@{host_ip} (key={ssh_key_path})"
@@ -328,10 +480,69 @@ class TerminalService:
# Mark as expired in DB
await repo.mark_expired(session.id)
count += 1
logger.info(f"Cleaned up expired session {session.id[:8]}... for host {session.host_name}")
self._metrics["sessions_gc_expired"] += 1
logger.info(f"session_gc_closed session={session.id[:8]}... host={session.host_name} reason=ttl")
return count
async def cleanup_idle_sessions(self, db_session) -> int:
"""
Clean up idle terminal sessions (no heartbeat within timeout).
Returns:
Number of sessions cleaned up
"""
from app.crud.terminal_session import TerminalSessionRepository
repo = TerminalSessionRepository(db_session)
idle_sessions = await repo.list_idle(TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS)
count = 0
for session in idle_sessions:
# Terminate the ttyd process
await self.terminate_session(session.id)
# Release the port
await self.release_port(session.ttyd_port)
# Mark as idle in DB
await repo.mark_idle(session.id)
count += 1
self._metrics["sessions_gc_idle"] += 1
logger.info(f"session_gc_closed session={session.id[:8]}... host={session.host_name} reason=idle")
return count
async def close_session_with_cleanup(
self,
session_id: str,
port: int,
reason: str = "user_close"
) -> bool:
"""
Close a session with full cleanup (process + port).
Returns:
True if session was cleaned up
"""
terminated = await self.terminate_session(session_id)
await self.release_port(port)
if reason == "user_close":
self._metrics["sessions_closed_user"] += 1
logger.info(f"session_closed session={session_id[:8]}... reason={reason}")
return terminated
def record_session_created(self, reused: bool = False):
"""Record a session creation for metrics."""
if reused:
self._metrics["sessions_reused"] += 1
else:
self._metrics["sessions_created"] += 1
def record_session_limit_hit(self):
"""Record a session limit hit for metrics."""
self._metrics["session_limit_hits"] += 1
def get_active_session_count(self) -> int:
"""Get the number of active ttyd processes."""
return len(self._processes)
@@ -366,3 +577,21 @@ terminal_service = TerminalService()
def get_terminal_service() -> TerminalService:
"""Get the global terminal service instance."""
return terminal_service
# Export configuration for use in routes
__all__ = [
"terminal_service",
"get_terminal_service",
"TerminalService",
"TerminalServiceError",
"HostNotReadyError",
"SessionLimitExceededError",
"TtydNotAvailableError",
"TERMINAL_SESSION_TTL_MINUTES",
"TERMINAL_SESSION_TTL_SECONDS",
"TERMINAL_MAX_SESSIONS_PER_USER",
"TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS",
"TERMINAL_HEARTBEAT_INTERVAL_SECONDS",
"TERMINAL_GC_INTERVAL_SECONDS",
]