271 lines
9.2 KiB
Bash
Executable File
271 lines
9.2 KiB
Bash
Executable File
#!/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"
|