homelab_automation/app/services/shell_history_service.py
Bruno Charest 6d8432169b
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
Add enhanced terminal history panel UI with animations, keyboard navigation, advanced filtering, search highlighting, and improved storage metrics display with detailed filesystem tables and ZFS/LVM support
2025-12-21 12:31:08 -05:00

350 lines
12 KiB
Python

"""
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()