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:
@@ -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}>"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user