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
350 lines
12 KiB
Python
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()
|