Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-18 13:49:40 -05:00
parent 493668f746
commit 5bc12d0729
74 changed files with 14849 additions and 78 deletions
+2
View File
@@ -14,6 +14,7 @@ from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .terminal_session import TerminalSession
from .terminal_command_log import TerminalCommandLog
__all__ = [
"Base",
@@ -37,4 +38,5 @@ __all__ = [
"DockerVolume",
"DockerAlert",
"TerminalSession",
"TerminalCommandLog",
]
+3
View File
@@ -48,6 +48,9 @@ class Host(Base):
docker_alerts: Mapped[List["DockerAlert"]] = relationship(
"DockerAlert", back_populates="host", cascade="all, delete-orphan"
)
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
"TerminalCommandLog", back_populates="host", cascade="all, delete-orphan"
)
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
+78
View File
@@ -0,0 +1,78 @@
"""
Model for terminal command logs - stores validated commands executed via terminal.
Security notes:
- Only stores commands that pass the CommandPolicy validation
- Commands are masked to remove sensitive values before storage
- Never stores raw commands that were blocked
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
class TerminalCommandLog(Base):
"""
Stores validated terminal commands for history and audit purposes.
Commands are only logged if they pass the CommandPolicy validation.
Sensitive values are masked before storage.
"""
__tablename__ = "terminal_command_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Timestamp of command execution
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
# Foreign keys
host_id: Mapped[str] = mapped_column(
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[Optional[str]] = mapped_column(
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
# Session reference (not FK as sessions may be cleaned up)
terminal_session_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
# Command data (masked/normalized version only)
command: Mapped[str] = mapped_column(Text, nullable=False)
# SHA-256 hash of normalized command for deduplication
command_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Source identifier
source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'"))
# If command was blocked (for audit - no raw command stored)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Additional metadata
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
host_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
user = relationship("User", back_populates="terminal_commands", lazy="selectin")
# Indexes for efficient querying
__table_args__ = (
Index('ix_terminal_cmd_host_created', 'host_id', 'created_at'),
Index('ix_terminal_cmd_user_created', 'user_id', 'created_at'),
Index('ix_terminal_cmd_host_hash', 'host_id', 'command_hash'),
)
def __repr__(self) -> str:
cmd_preview = self.command[:30] + "..." if len(self.command) > 30 else self.command
return f"<TerminalCommandLog id={self.id} host={self.host_name} cmd='{cmd_preview}'>"
+40 -3
View File
@@ -6,21 +6,52 @@ from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Integer, String, text
from sqlalchemy import DateTime, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
# Session status constants
SESSION_STATUS_STARTING = "starting"
SESSION_STATUS_ACTIVE = "active"
SESSION_STATUS_CLOSING = "closing"
SESSION_STATUS_CLOSED = "closed"
SESSION_STATUS_EXPIRED = "expired"
SESSION_STATUS_ERROR = "error"
# Close reason constants
CLOSE_REASON_USER = "user_close"
CLOSE_REASON_TTL = "ttl"
CLOSE_REASON_IDLE = "idle"
CLOSE_REASON_QUOTA = "quota"
CLOSE_REASON_SERVER_SHUTDOWN = "server_shutdown"
CLOSE_REASON_CLIENT_LOST = "client_lost"
CLOSE_REASON_REUSED = "reused"
class TerminalSession(Base):
"""
Represents an active or recent SSH terminal session.
Sessions are created when a user opens a terminal to a host,
and cleaned up after expiration or manual closure.
Status lifecycle:
- starting: Session being created, ttyd spawning
- active: Session running, client connected
- closing: Session being terminated
- closed: Session terminated normally
- expired: Session terminated due to TTL/idle timeout
- error: Session terminated due to error
"""
__tablename__ = "terminal_sessions"
__table_args__ = (
Index('ix_terminal_sessions_user_status', 'user_id', 'status'),
Index('ix_terminal_sessions_last_seen', 'last_seen_at'),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
@@ -39,15 +70,21 @@ class TerminalSession(Base):
# Session mode: 'embedded' or 'popout'
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
# Session status: 'active', 'closed', 'expired', 'error'
# Session status: 'starting', 'active', 'closing', 'closed', 'expired', 'error'
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Close reason: 'user_close', 'ttl', 'idle', 'quota', 'server_shutdown', 'client_lost', 'reused'
reason_closed: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<TerminalSession id={self.id} host={self.host_name} status={self.status}>"
return f"<TerminalSession id={self.id[:8]}... host={self.host_name} status={self.status}>"
+7 -2
View File
@@ -6,10 +6,10 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
@@ -74,6 +74,11 @@ class User(Base):
# Soft delete support
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
"TerminalCommandLog", back_populates="user"
)
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username} role={self.role}>"