""" Service de récupération de l'historique shell depuis les hôtes distants via SSH. Ce service permet de récupérer le fichier ~/.bash_history des hôtes pour afficher les commandes exécutées dans le terminal. """ from __future__ import annotations import asyncio import hashlib import logging import os import shutil import subprocess import sys from datetime import datetime, timezone from typing import Dict, List, Optional, Any try: import asyncssh _asyncssh_import_error: Optional[BaseException] = None except ModuleNotFoundError as e: asyncssh = None _asyncssh_import_error = e from app.core.config import settings logger = logging.getLogger("homelab.shell_history") # Log prefix for standardized output LOG_PREFIX = "[SHELL_HISTORY]" class ShellHistoryError(Exception): """Error fetching shell history.""" pass class ShellHistoryService: """Service for fetching shell command history from remote hosts via SSH.""" def __init__(self): self.ssh_key_path = settings.ssh_key_path self.ssh_user = settings.ssh_user self.ssh_remote_user = settings.ssh_remote_user self.connect_timeout = 5 self.exec_timeout = 10 async def _run_ssh_subprocess(self, host_ip: str, username: str, remote_cmd: str) -> str: """Run a remote command via system ssh as a fallback when asyncssh isn't installed.""" ssh_bin = shutil.which("ssh") if not ssh_bin: raise ShellHistoryError("Missing dependency: ssh binary not found in PATH") key_path = self.ssh_key_path if not key_path or not os.path.exists(key_path): raise ShellHistoryError( f"SSH key not found at '{key_path}'. Configure SSH_KEY_PATH / settings.ssh_key_path." ) args = [ ssh_bin, "-i", key_path, "-o", "BatchMode=yes", "-o", f"ConnectTimeout={self.connect_timeout}", "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=/dev/null", f"{username}@{host_ip}", remote_cmd, ] try: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=self.connect_timeout + self.exec_timeout, ) except asyncio.TimeoutError as e: raise ShellHistoryError(f"Timeout reading history from {host_ip}") from e except FileNotFoundError as e: raise ShellHistoryError("ssh binary not available") from e if proc.returncode not in (0, None): err = (stderr or b"").decode(errors="ignore").strip() if not err: err = (stdout or b"").decode(errors="ignore").strip() raise ShellHistoryError(f"SSH error connecting to {host_ip}: {err or proc.returncode}") return (stdout or b"").decode(errors="ignore") async def _ssh_connect(self, host_ip: str) -> "asyncssh.SSHClientConnection": """Establish SSH connection to a host.""" if asyncssh is None: err = None try: err = str(_asyncssh_import_error) if _asyncssh_import_error else None except Exception: err = None raise ShellHistoryError( "Missing dependency: asyncssh. Install it to enable shell history retrieval. " f"(python={sys.executable}, import_error={err})" ) tried_users: List[str] = [] last_err: Optional[BaseException] = None for username in [self.ssh_remote_user, self.ssh_user]: if not username or username in tried_users: continue tried_users.append(username) try: conn = await asyncio.wait_for( asyncssh.connect( host_ip, username=username, client_keys=[self.ssh_key_path], known_hosts=None, ), timeout=self.connect_timeout ) return conn except asyncio.TimeoutError as e: last_err = e continue except Exception as e: last_err = e continue if isinstance(last_err, asyncio.TimeoutError): raise ShellHistoryError(f"SSH connection timeout to {host_ip}") raise ShellHistoryError(f"SSH error connecting to {host_ip}: {last_err}") async def fetch_bash_history( self, host_ip: str, limit: int = 100, user: str = "automation" ) -> List[Dict[str, Any]]: """ Fetch bash history from a remote host. Args: host_ip: IP address of the host limit: Maximum number of commands to return user: User whose history to fetch (default: automation) Returns: List of command dicts with 'command', 'command_hash', 'line_number' """ conn = None try: # Read .bash_history file (last N lines, reversed for recent first) # Force bash to write history by sourcing .bashrc or use history -a # The simplest approach: just read the file cmd = f"tail -n {limit * 2} ~{user}/.bash_history 2>/dev/null || tail -n {limit * 2} ~/.bash_history 2>/dev/null || echo ''" if asyncssh is None: output = "" tried_users: List[str] = [] last_err: Optional[BaseException] = None for login_user in [self.ssh_remote_user, self.ssh_user]: if not login_user or login_user in tried_users: continue tried_users.append(login_user) try: output = await self._run_ssh_subprocess(host_ip, login_user, cmd) last_err = None break except ShellHistoryError as e: last_err = e continue if last_err is not None and not output: raise last_err else: conn = await self._ssh_connect(host_ip) result = await asyncio.wait_for( conn.run(cmd, check=False), timeout=self.exec_timeout ) output = result.stdout or "" lines = output.strip().split('\n') # Filter and deduplicate seen_hashes = set() commands = [] # Process in reverse order (most recent first) for i, line in enumerate(reversed(lines)): line = line.strip() if not line: continue # Skip history metadata lines (timestamps starting with #) if line.startswith('#') and line[1:].isdigit(): continue cmd_hash = hashlib.sha256(line.encode()).hexdigest()[:16] # Deduplicate if cmd_hash in seen_hashes: continue seen_hashes.add(cmd_hash) commands.append({ "command": line, "command_hash": cmd_hash, "line_number": len(lines) - i, "last_used": datetime.now(timezone.utc).isoformat(), "execution_count": 1, # We don't have real count from bash_history }) if len(commands) >= limit: break logger.info(f"{LOG_PREFIX} Fetched {len(commands)} commands from {host_ip}") return commands except asyncio.TimeoutError: raise ShellHistoryError(f"Timeout reading history from {host_ip}") except Exception as e: raise ShellHistoryError(f"Error reading history from {host_ip}: {e}") finally: if conn: conn.close() async def fetch_zsh_history( self, host_ip: str, limit: int = 100, user: str = "automation" ) -> List[Dict[str, Any]]: """ Fetch zsh history from a remote host. Similar to bash but reads .zsh_history. """ conn = None try: cmd = f"tail -n {limit * 2} ~{user}/.zsh_history 2>/dev/null || tail -n {limit * 2} ~/.zsh_history 2>/dev/null || echo ''" if asyncssh is None: output = "" tried_users: List[str] = [] last_err: Optional[BaseException] = None for login_user in [self.ssh_remote_user, self.ssh_user]: if not login_user or login_user in tried_users: continue tried_users.append(login_user) try: output = await self._run_ssh_subprocess(host_ip, login_user, cmd) last_err = None break except ShellHistoryError as e: last_err = e continue if last_err is not None and not output: raise last_err else: conn = await self._ssh_connect(host_ip) result = await asyncio.wait_for( conn.run(cmd, check=False), timeout=self.exec_timeout ) output = result.stdout or "" lines = output.strip().split('\n') seen_hashes = set() commands = [] for i, line in enumerate(reversed(lines)): line = line.strip() if not line: continue # Zsh extended history format: : timestamp:0;command if line.startswith(': ') and ';' in line: line = line.split(';', 1)[1] cmd_hash = hashlib.sha256(line.encode()).hexdigest()[:16] if cmd_hash in seen_hashes: continue seen_hashes.add(cmd_hash) commands.append({ "command": line, "command_hash": cmd_hash, "line_number": len(lines) - i, "last_used": datetime.now(timezone.utc).isoformat(), "execution_count": 1, }) if len(commands) >= limit: break return commands except Exception as e: raise ShellHistoryError(f"Error reading zsh history from {host_ip}: {e}") finally: if conn: conn.close() async def fetch_combined_history( self, host_ip: str, limit: int = 100, user: str = "automation" ) -> List[Dict[str, Any]]: """ Fetch history from both bash and zsh, combined and deduplicated. """ bash_commands = [] zsh_commands = [] try: bash_commands = await self.fetch_bash_history(host_ip, limit, user) except ShellHistoryError: pass # Bash history not available try: zsh_commands = await self.fetch_zsh_history(host_ip, limit, user) except ShellHistoryError: pass # Zsh history not available # Combine and deduplicate seen_hashes = set() combined = [] for cmd in bash_commands + zsh_commands: if cmd["command_hash"] not in seen_hashes: seen_hashes.add(cmd["command_hash"]) combined.append(cmd) if len(combined) >= limit: break return combined # Singleton instance shell_history_service = ShellHistoryService()