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
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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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()">×</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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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}'>"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Security modules for the Homelab Automation application.
|
||||
"""
|
||||
from .command_policy import CommandPolicy, CommandPolicyResult
|
||||
|
||||
__all__ = ["CommandPolicy", "CommandPolicyResult"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user