#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: collect_ssh_evidence.sh --credentials /path/to/credentials.env [--output /path/to/output] Collecte en lecture seule via SSH : - informations système (CPU, mémoire, swap, disques) - indices sur la version Home Assistant - extraits de logs si accessibles - fichiers de configuration principaux si accessibles Le script n'effectue aucune modification distante. USAGE } credentials_file="" output_dir="" while [[ $# -gt 0 ]]; do case "$1" in --credentials) credentials_file="${2:-}" shift 2 ;; --output) output_dir="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Argument inconnu: $1" >&2 usage >&2 exit 2 ;; esac done if [[ -z "$credentials_file" ]]; then echo "--credentials est obligatoire" >&2 exit 2 fi if [[ ! -f "$credentials_file" ]]; then echo "Fichier d'identifiants introuvable: $credentials_file" >&2 exit 2 fi # shellcheck disable=SC1090 source "$credentials_file" : "${HA_SSH_HOST:?HA_SSH_HOST manquant}" : "${HA_SSH_USER:?HA_SSH_USER manquant}" HA_SSH_PORT="${HA_SSH_PORT:-22}" HA_SSH_PASSWORD="${HA_SSH_PASSWORD:-}" HA_SSH_KEY_PATH="${HA_SSH_KEY_PATH:-}" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" if [[ -z "$output_dir" ]]; then output_dir="./ha-ssh-evidence-$timestamp" fi mkdir -p "$output_dir"/{system,home-assistant,config} chmod 700 "$output_dir" ssh_base=(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -p "$HA_SSH_PORT") scp_base=(scp -P "$HA_SSH_PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new) if [[ -n "$HA_SSH_KEY_PATH" ]]; then ssh_base+=(-i "$HA_SSH_KEY_PATH") scp_base+=(-i "$HA_SSH_KEY_PATH") fi run_ssh() { local remote_cmd="$1" if [[ -n "$HA_SSH_PASSWORD" && -z "$HA_SSH_KEY_PATH" ]]; then if ! command -v sshpass >/dev/null 2>&1; then echo "sshpass est requis pour l'authentification par mot de passe, mais il est absent." >&2 return 127 fi sshpass -p "$HA_SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "$remote_cmd" else "${ssh_base[@]}" "$HA_SSH_USER@$HA_SSH_HOST" "$remote_cmd" fi } copy_remote_file() { local remote_path="$1" local local_path="$2" run_ssh "if [ -r '$remote_path' ]; then cat '$remote_path'; fi" > "$local_path" 2>/dev/null || true if [[ ! -s "$local_path" ]]; then rm -f "$local_path" fi } collect_interactive_core_logs() { local lines="$1" local raw_path="$2" local clean_path="$3" local start_marker="__HA_LOG_START__" local end_marker="__HA_LOG_END__" if [[ -n "$HA_SSH_PASSWORD" && -z "$HA_SSH_KEY_PATH" ]]; then if ! command -v sshpass >/dev/null 2>&1; then return 127 fi printf 'echo %s\nha core logs --lines %s\necho %s\nexit\n' "$start_marker" "$lines" "$end_marker" \ | sshpass -p "$HA_SSH_PASSWORD" ssh -tt -o StrictHostKeyChecking=accept-new -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" \ > "$raw_path" 2>&1 || true else printf 'echo %s\nha core logs --lines %s\necho %s\nexit\n' "$start_marker" "$lines" "$end_marker" \ | "${ssh_base[@]}" -tt "$HA_SSH_USER@$HA_SSH_HOST" \ > "$raw_path" 2>&1 || true fi python3 - "$raw_path" "$clean_path" "$start_marker" "$end_marker" <<'PY2' import re, sys from pathlib import Path raw_path, clean_path, start_marker, end_marker = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4] text = raw_path.read_text(errors='ignore') text = re.sub(r'\x1B\][^\x07]*(?:\x07|\x1b\\)', '', text) text = re.sub(r'\x1B\[[0-9;?]*[ -/]*[@-~]', '', text) text = text.replace('\r', '') lines = text.splitlines() # The command script itself is echoed before execution. Keep the *last* start marker, # which is the marker printed by the shell, then the last matching end marker after it. start_idx = max((i for i,l in enumerate(lines) if start_marker in l), default=-1) end_idx = max((i for i,l in enumerate(lines) if end_marker in l and i > start_idx), default=-1) if start_idx != -1 and end_idx != -1: body = lines[start_idx + 1:end_idx] clean = [] for line in body: stripped = line.strip() if not stripped: clean.append('') continue if 'ha core logs --lines' in stripped: continue if stripped == start_marker or stripped == end_marker or stripped == 'exit': continue if stripped.startswith('➜') or stripped == '#': continue clean.append(line) clean_path.write_text('\n'.join(clean).strip() + '\n') PY2 } { echo "collected_at_utc=$timestamp" echo "ssh_host=$HA_SSH_HOST" echo "ssh_port=$HA_SSH_PORT" echo "ssh_user=$HA_SSH_USER" echo "install_type_hint_source=ssh_shell_only" } > "$output_dir/collection.meta" run_ssh 'uname -a || true' > "$output_dir/system/uname.txt" || true run_ssh 'cat /etc/os-release 2>/dev/null || true' > "$output_dir/system/os-release.txt" || true run_ssh 'if [ -d /usr/share/hassio ] || [ -e /data/supervisor ]; then echo home_assistant_os_or_supervised; else echo unknown; fi' > "$output_dir/home-assistant/install-type-hint.txt" || true run_ssh 'nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || true' > "$output_dir/system/cpu-count.txt" || true run_ssh 'lscpu 2>/dev/null || cat /proc/cpuinfo 2>/dev/null || true' > "$output_dir/system/cpu.txt" || true run_ssh 'free -h 2>/dev/null || cat /proc/meminfo 2>/dev/null || true' > "$output_dir/system/memory.txt" || true run_ssh 'df -hT 2>/dev/null || df -h 2>/dev/null || true' > "$output_dir/system/disks.txt" || true run_ssh 'swapon --show 2>/dev/null || true' > "$output_dir/system/swap.txt" || true run_ssh ' if command -v ha >/dev/null 2>&1; then ha core info 2>/dev/null || true fi ' > "$output_dir/home-assistant/ha-core-info.txt" || true run_ssh ' if command -v docker >/dev/null 2>&1; then docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null || true fi ' > "$output_dir/home-assistant/docker-ps.txt" || true run_ssh ' for f in \ /config/.HA_VERSION \ /usr/share/hassio/homeassistant/.HA_VERSION \ /home/homeassistant/.homeassistant/.HA_VERSION; do if [ -r "$f" ]; then echo "=== $f ===" cat "$f" fi done ' > "$output_dir/home-assistant/version-files.txt" || true run_ssh ' for f in \ /config/home-assistant.log \ /usr/share/hassio/homeassistant/home-assistant.log \ /home/homeassistant/.homeassistant/home-assistant.log; do if [ -r "$f" ] && [ -s "$f" ]; then echo "=== $f ===" tail -n 400 "$f" exit 0 fi done ' > "$output_dir/home-assistant/home-assistant-log-tail.txt" || true run_ssh ' echo "current_log_exists=$( [ -r /config/home-assistant.log ] && echo yes || echo no )" echo "haos_default_log_file_expected=no" for f in /config/home-assistant.log /config/home-assistant.log.1 /config/home-assistant.log.fault; do if [ -e "$f" ]; then size=$(wc -c < "$f" 2>/dev/null || echo unknown) echo "$f bytes=$size" fi done ' > "$output_dir/home-assistant/log-status.txt" || true run_ssh 'ha core logs --lines 40 2>&1 || true' > "$output_dir/home-assistant/ha-core-logs-attempt.txt" || true if grep -q '401: Unauthorized' "$output_dir/home-assistant/ha-core-logs-attempt.txt" 2>/dev/null; then collect_interactive_core_logs 120 "$output_dir/home-assistant/ha-core-logs-interactive.raw.txt" "$output_dir/home-assistant/ha-core-logs-interactive.txt" || true fi if [[ -s "$output_dir/home-assistant/ha-core-logs-interactive.txt" ]] && command -v rtk >/dev/null 2>&1; then rtk log "$output_dir/home-assistant/ha-core-logs-interactive.txt" > "$output_dir/home-assistant/ha-core-logs-interactive.compact.txt" || true fi if [[ -s "$output_dir/home-assistant/home-assistant-log-tail.txt" ]] && command -v rtk >/dev/null 2>&1; then rtk log "$output_dir/home-assistant/home-assistant-log-tail.txt" > "$output_dir/home-assistant/home-assistant-log-compact.txt" || true fi run_ssh ' if command -v journalctl >/dev/null 2>&1; then journalctl -u home-assistant@homeassistant.service -n 300 --no-pager 2>/dev/null || true fi ' > "$output_dir/home-assistant/journal-home-assistant.txt" || true for remote in \ /config/configuration.yaml \ /config/automations.yaml \ /config/scripts.yaml \ /config/scenes.yaml \ /usr/share/hassio/homeassistant/configuration.yaml \ /home/homeassistant/.homeassistant/configuration.yaml; do name="$(echo "$remote" | sed 's#^/##; s#/#__#g')" copy_remote_file "$remote" "$output_dir/config/$name" || true done run_ssh ' for d in /config/.storage /usr/share/hassio/homeassistant/.storage /home/homeassistant/.homeassistant/.storage; do if [ -d "$d" ]; then echo "=== $d ===" for f in "$d"/*; do [ -f "$f" ] && basename "$f" done | sort fi done ' > "$output_dir/config/storage-index.txt" || true for remote in \ /config/.storage/lovelace.lovelace \ /config/.storage/lovelace_dashboards \ /config/.storage/lovelace_resources \ /config/.storage/repairs.issue_registry \ /config/.storage/core.entity_registry \ /config/.storage/core.config_entries; do name="$(echo "$remote" | sed 's#^/##; s#/#__#g')" copy_remote_file "$remote" "$output_dir/config/$name" || true done echo "Collecte terminée: $output_dir"