Some checks failed
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
397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""
|
|
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
|