diff --git a/alembic/versions/0014_add_terminal_command_logs_table.py b/alembic/versions/0014_add_terminal_command_logs_table.py new file mode 100644 index 0000000..bbf7a17 --- /dev/null +++ b/alembic/versions/0014_add_terminal_command_logs_table.py @@ -0,0 +1,94 @@ +"""Add terminal_command_logs table for command history + +Revision ID: 0014 +Revises: 0013_add_terminal_sessions_table +Create Date: 2024-12-18 + +This migration creates the terminal_command_logs table to store +validated terminal commands for history and audit purposes. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = '0014' +down_revision = '0013' +branch_labels = None +depends_on = None + + +def table_exists(table_name: str) -> bool: + """Check if a table exists in the database.""" + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() + + +def index_exists(index_name: str, table_name: str) -> bool: + """Check if an index exists on a table.""" + bind = op.get_bind() + inspector = inspect(bind) + indexes = inspector.get_indexes(table_name) + return any(idx['name'] == index_name for idx in indexes) + + +def upgrade() -> None: + # Check if table already exists (idempotent migration) + if table_exists('terminal_command_logs'): + print("Table 'terminal_command_logs' already exists, skipping creation.") + else: + op.create_table( + 'terminal_command_logs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('host_id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('terminal_session_id', sa.String(64), nullable=True), + sa.Column('command', sa.Text(), nullable=False), + sa.Column('command_hash', sa.String(64), nullable=False), + sa.Column('source', sa.String(20), server_default='terminal', nullable=False), + sa.Column('is_blocked', sa.Boolean(), server_default='0', nullable=False), + sa.Column('blocked_reason', sa.String(255), nullable=True), + sa.Column('username', sa.String(100), nullable=True), + sa.Column('host_name', sa.String(100), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['host_id'], ['hosts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'), + ) + + # Create indexes for efficient querying (only if they don't exist) + if not index_exists('ix_terminal_command_logs_created_at', 'terminal_command_logs'): + op.create_index('ix_terminal_command_logs_created_at', 'terminal_command_logs', ['created_at']) + if not index_exists('ix_terminal_command_logs_host_id', 'terminal_command_logs'): + op.create_index('ix_terminal_command_logs_host_id', 'terminal_command_logs', ['host_id']) + if not index_exists('ix_terminal_command_logs_user_id', 'terminal_command_logs'): + op.create_index('ix_terminal_command_logs_user_id', 'terminal_command_logs', ['user_id']) + if not index_exists('ix_terminal_command_logs_terminal_session_id', 'terminal_command_logs'): + op.create_index('ix_terminal_command_logs_terminal_session_id', 'terminal_command_logs', ['terminal_session_id']) + if not index_exists('ix_terminal_command_logs_command_hash', 'terminal_command_logs'): + op.create_index('ix_terminal_command_logs_command_hash', 'terminal_command_logs', ['command_hash']) + + # Composite indexes for common query patterns + if not index_exists('ix_terminal_cmd_host_created', 'terminal_command_logs'): + op.create_index('ix_terminal_cmd_host_created', 'terminal_command_logs', ['host_id', 'created_at']) + if not index_exists('ix_terminal_cmd_user_created', 'terminal_command_logs'): + op.create_index('ix_terminal_cmd_user_created', 'terminal_command_logs', ['user_id', 'created_at']) + if not index_exists('ix_terminal_cmd_host_hash', 'terminal_command_logs'): + op.create_index('ix_terminal_cmd_host_hash', 'terminal_command_logs', ['host_id', 'command_hash']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_terminal_cmd_host_hash', table_name='terminal_command_logs') + op.drop_index('ix_terminal_cmd_user_created', table_name='terminal_command_logs') + op.drop_index('ix_terminal_cmd_host_created', table_name='terminal_command_logs') + op.drop_index('ix_terminal_command_logs_command_hash', table_name='terminal_command_logs') + op.drop_index('ix_terminal_command_logs_terminal_session_id', table_name='terminal_command_logs') + op.drop_index('ix_terminal_command_logs_user_id', table_name='terminal_command_logs') + op.drop_index('ix_terminal_command_logs_host_id', table_name='terminal_command_logs') + op.drop_index('ix_terminal_command_logs_created_at', table_name='terminal_command_logs') + + # Drop table + op.drop_table('terminal_command_logs') diff --git a/alembic/versions/0015_add_terminal_session_fields.py b/alembic/versions/0015_add_terminal_session_fields.py new file mode 100644 index 0000000..560edd3 --- /dev/null +++ b/alembic/versions/0015_add_terminal_session_fields.py @@ -0,0 +1,49 @@ +"""Add last_seen_at and reason_closed to terminal_sessions + +Revision ID: 0015 +Revises: 0014 +Create Date: 2024-12-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0015' +down_revision: Union[str, None] = '0014' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # NOTE: SQLite cannot do `ALTER TABLE ... ADD COLUMN ... DEFAULT CURRENT_TIMESTAMP`. + # We use batch mode to recreate the table safely. + + with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op: + batch_op.add_column(sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('reason_closed', sa.String(30), nullable=True)) + + # Backfill last_seen_at from created_at for existing rows + op.execute("UPDATE terminal_sessions SET last_seen_at = created_at WHERE last_seen_at IS NULL") + + # Enforce NOT NULL + default for future inserts (recreate table again for SQLite) + with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op: + batch_op.alter_column('last_seen_at', nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')) + + # Create indexes for efficient queries + op.create_index('ix_terminal_sessions_user_status', 'terminal_sessions', ['user_id', 'status']) + op.create_index('ix_terminal_sessions_last_seen', 'terminal_sessions', ['last_seen_at']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_terminal_sessions_last_seen', table_name='terminal_sessions') + op.drop_index('ix_terminal_sessions_user_status', table_name='terminal_sessions') + + # Drop columns (batch mode for SQLite) + with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op: + batch_op.drop_column('reason_closed') + batch_op.drop_column('last_seen_at') diff --git a/app/crud/__init__.py b/app/crud/__init__.py index e182dda..242fd16 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -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", ] diff --git a/app/crud/terminal_command_log.py b/app/crud/terminal_command_log.py new file mode 100644 index 0000000..4663488 --- /dev/null +++ b/app/crud/terminal_command_log.py @@ -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 diff --git a/app/crud/terminal_session.py b/app/crud/terminal_session.py index 6ebac7e..7f57674 100644 --- a/app/crud/terminal_session.py +++ b/app/crud/terminal_session.py @@ -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() diff --git a/app/factory.py b/app/factory.py index df7ae87..07b16d3 100644 --- a/app/factory.py +++ b/app/factory.py @@ -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() diff --git a/app/index.html b/app/index.html index bf528c0..69a0987 100644 --- a/app/index.html +++ b/app/index.html @@ -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; + } diff --git a/app/main.js b/app/main.js index df19b1e..8ff4c45 100644 --- a/app/main.js +++ b/app/main.js @@ -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 { Connexion...
+ @@ -10209,6 +10257,35 @@ class DashboardManager {
+ + + +
${loadingContent}
@@ -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 => ` +
+
+ ${this.escapeHtml(s.host_name)} + ${s.mode} + ${this.formatDuration(s.age_seconds)} +
+ +
+ `).join(''); + + const canReuseHtml = limitError.can_reuse ? ` +
+

Une session existe dΓ©jΓ  pour cet hΓ΄te. Vous pouvez la rΓ©utiliser.

+ +
+ ` : ''; + + const modal = document.createElement('div'); + modal.id = 'sessionLimitModal'; + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + + 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 = ' 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 = '
Chargement...
'; + + 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 = '
Erreur de chargement
'; + } finally { + this.terminalHistoryLoading = false; + } + } + + renderTerminalHistory() { + const listEl = document.getElementById('terminalHistoryList'); + if (!listEl) return; + + if (this.terminalCommandHistory.length === 0) { + listEl.innerHTML = ` +
+ + Aucune commande dans l'historique +
+ `; + 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 ` +
+
+ ${this.escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command)} +
+
+ ${timeAgo} + ${execCount > 1 ? `Γ—${execCount}` : ''} + ${hostName ? `${this.escapeHtml(hostName)}` : ''} +
+
+ + +
+
+ `; + }).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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 95f22b4..13fa2d5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/host.py b/app/models/host.py index 80a497e..15d123e 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -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"" diff --git a/app/models/terminal_command_log.py b/app/models/terminal_command_log.py new file mode 100644 index 0000000..6e178a1 --- /dev/null +++ b/app/models/terminal_command_log.py @@ -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"" diff --git a/app/models/terminal_session.py b/app/models/terminal_session.py index c854e2a..0f00392 100644 --- a/app/models/terminal_session.py +++ b/app/models/terminal_session.py @@ -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"" + return f"" diff --git a/app/models/user.py b/app/models/user.py index b53f6b7..fd70cd2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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"" diff --git a/app/routes/terminal.py b/app/routes/terminal.py index 4ac1455..edee172 100644 --- a/app/routes/terminal.py +++ b/app/routes/terminal.py @@ -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""" + + + + {session.host_name} + + + +
+

Terminal indisponible

+

Le service terminal (ttyd) ne rΓ©pond pas pour cette session.

+

Essayez de reconnecter ou de recrΓ©er une session depuis le dashboard.

+ Retour au Dashboard +
+ + + """, + 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( - Terminal - {session.host_name} + {session.host_name}