#!/bin/bash # server-scout.sh - Минимальный инструмент для быстрой разведки Linux сервера # Быстрая разведка за 10 минут без разрастания функционала # Usage: sudo bash server-scout.sh [--json] [--preset=minimal|basic|full] [--outdir /path] [--quiet] [--selftest] set -Eeuo pipefail # Версия скрипта VERSION="1.2" # Проверяем поддержку nanoseconds один раз при старте HAS_NS=false if date +%s%N 2>/dev/null | grep -qE '^[0-9]{15,}$'; then HAS_NS=true fi # Проверка версии Bash (требуется 4+ для ассоциативных массивов) if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then echo "Error: Bash 4+ required (current: $BASH_VERSION)" >&2 exit 1 fi # Флаги JSON_MODE=false QUIET_MODE=false PRESET="minimal" REPORT_DIR="" DEBUG_MODE=false TRACE_MODE=false DEBUG_FILE="" STDERR_MODE=false TRACE_LINES=30 TIMEOUT_CMD=8 OUTPUT_FILE="" # Единый ассоциативный массив для всех данных (вместо 41 массивов) declare -A DATA=() # Hostname для анонимизации (опционально) HOSTNAME=$(hostname 2>/dev/null || echo "") # Формат временной метки TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) # Вывод справки show_help() { cat << EOF server-scout.sh v$VERSION - Quick Server Scout Tool Usage: sudo bash $0 [OPTIONS] Options: --json Output in JSON format (no console output) --preset=NAME Use preset: minimal (default), basic, full --outdir PATH Custom output directory (default: /root/server-scout for root, ./server-scout for user) --debug Debug mode (log commands, exit codes, duration to debug file) --trace Trace mode (debug + first N lines of output) --debug-file PATH Custom path to debug log (default: next to report file) --stderr Duplicate debug messages to stderr --trace-lines N Number of lines to show in trace mode (default: 30) --quiet, -q Suppress console output --selftest Run self-diagnostics and exit --version, -V Show version and exit --help, -h Show this help and exit Presets: minimal - Quick scan: OS, disk, ports, docker (if present), proxy detect basic - minimal + systemd filtered, compose files, DB presence, journal errors full - All checks (optional, not default) Examples: sudo bash $0 # Minimal preset, text output sudo bash $0 --json # JSON output sudo bash $0 --preset=basic # Basic preset sudo bash $0 --preset=basic --debug # Basic preset with debug logging sudo bash $0 --preset=basic --trace --trace-lines 50 # Trace mode sudo bash $0 --selftest # Run self-test Report location: /server-scout_YYYY-MM-DD_HH-MM-SS.{txt|json} Debug log: /server-scout_YYYY-MM-DD_HH-MM-SS.debug.log (if --debug/--trace) EOF exit 0 } # Обработка аргументов parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --help|-h) show_help ;; --version|-V) echo "server-scout.sh v$VERSION" exit 0 ;; --json) JSON_MODE=true shift ;; --preset=*) PRESET="${1#*=}" case "$PRESET" in minimal|basic|full) ;; *) echo "Error: Unknown preset '$PRESET'" >&2 echo "Available presets: minimal, basic, full" >&2 exit 1 ;; esac shift ;; --outdir) if [[ -z "${2:-}" ]]; then echo "Error: --outdir requires a path argument" >&2 exit 1 fi REPORT_DIR="$2" case "$REPORT_DIR" in /|/etc|/bin|/sbin|/usr|/lib|/boot|/proc|/sys|/dev) echo "Error: --outdir '$REPORT_DIR' is a system directory, refusing" >&2 exit 1 ;; esac shift 2 ;; --debug) DEBUG_MODE=true shift ;; --trace) TRACE_MODE=true DEBUG_MODE=true # Trace включает debug автоматически shift ;; --debug-file) if [[ -z "${2:-}" ]]; then echo "Error: --debug-file requires a path argument" >&2 exit 1 fi DEBUG_FILE="$2" shift 2 ;; --stderr) STDERR_MODE=true shift ;; --trace-lines) if [[ -z "${2:-}" ]]; then echo "Error: --trace-lines requires a number argument" >&2 exit 1 fi TRACE_LINES="$2" shift 2 ;; --quiet|-q) QUIET_MODE=true shift ;; --selftest) run_selftest exit $? ;; *) echo "Unknown option: $1" >&2 echo "Try '$0 --help' for more information." >&2 exit 1 ;; esac done } # Функция для логирования (не выводит в JSON режиме и в quiet режиме) log() { [[ "$JSON_MODE" == true ]] && return 0 [[ "$QUIET_MODE" == true ]] && return 0 echo "$@" } # Санитизация секретов (скрытие паролей, токенов, ключей) sanitize_secrets() { local input="${1:-}" # Защита от пустого ввода [[ -z "$input" ]] && { echo ""; return 0; } # Включаем case-insensitive matching local old_nocasematch old_nocasematch=$(shopt -p nocasematch 2>/dev/null || echo "shopt -u nocasematch") shopt -s nocasematch local result="" local line # Обрабатываем построчно while IFS= read -r line || [[ -n "$line" ]]; do # Паттерн для key=value или key: value (маскируем только значение) # Ключи: password, passwd, secret, token, api_key, private_key, access_key, client_secret if [[ "$line" =~ ^(.*)((password|passwd|secret|token|api_key|private_key|access_key|client_secret)[[:space:]]*[=:][[:space:]]*)([^[:space:]\"]+|\"[^\"]*\")(.*)$ ]]; then line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}***HIDDEN***${BASH_REMATCH[5]}" fi result+="$line"$'\n' done <<< "$input" # Восстанавливаем nocasematch eval "$old_nocasematch" 2>/dev/null || true # Убираем trailing newline printf '%s' "${result%$'\n'}" } # Функция для анонимизации данных (опционально, пока не используется) anonymize_text() { local text="$1" # Пока не используется, но функция сохранена для будущего использования echo "$text" } # JSON escape для строк json_escape() { local str="$1" # Используем jq если доступен - более надёжное экранирование if command -v jq &>/dev/null; then # jq -Rs выводит строку в JSON формате с кавычками, убираем их printf '%s' "$str" | jq -Rs '.' | sed 's/^"//;s/"$//' else # Fallback: ручное экранирование # Порядок важен: сначала backslash, потом остальные str=$(printf '%s' "$str" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed 's/\t/\\t/g' | sed 's/\r/\\r/g') # Заменяем переводы строк str=$(printf '%s' "$str" | sed ':a;N;$!ba;s/\n/\\n/g') printf '%s' "$str" fi } # Единый раннер команд с поддержкой debug/trace run() { local key="$1" # Ключ для DATA[key] local label="$2" # Описание для лога local cmd="$3" # Команда (строка) local timeout="${4:-$TIMEOUT_CMD}" # Timeout local start_time start_time=$(date +%s%N 2>/dev/null || date +%s) local output="" local ret=0 local cmd_str="$cmd" # Выполнение с timeout set +e if command -v timeout &>/dev/null; then output=$(timeout "$timeout" bash -c "$cmd_str" 2>&1) ret=$? else output=$(bash -c "$cmd_str" 2>&1) ret=$? fi set -e local end_time end_time=$(date +%s%N 2>/dev/null || date +%s) local duration=0 if [[ -n "$start_time" ]] && [[ -n "$end_time" ]]; then # Используем глобальный флаг HAS_NS (определён при старте скрипта) if [[ "$HAS_NS" == true ]]; then duration=$(( (end_time - start_time) / 1000000 )) # ms из nanoseconds else duration=$(( (end_time - start_time) * 1000 )) # ms из seconds fi fi # Обработка timeout if [[ $ret -eq 124 ]]; then output="[TIMEOUT] Command exceeded ${timeout}s timeout"$'\n'"$output" elif [[ $ret -ne 0 ]]; then output="[ERROR] Exit code: $ret"$'\n'"$output" fi # Санитизация секретов if [[ -n "$output" ]]; then local sanitized_output="" while IFS= read -r line || [[ -n "$line" ]]; do local sanitized_line=$(sanitize_secrets "$line") sanitized_output+="$sanitized_line"$'\n' done <<< "$output" sanitized_output="${sanitized_output%$'\n'}" output="$sanitized_output" fi # Сохранение в DATA DATA["$key"]="$output" DATA["${key}__rc"]=$ret DATA["${key}__duration"]=$duration DATA["${key}__bytes"]=${#output} # Debug/Trace логирование if [[ "$DEBUG_MODE" == true ]] || [[ "$TRACE_MODE" == true ]]; then local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date) local log_line="[$timestamp] $label | cmd: $cmd_str | rc: $ret | duration: ${duration}ms | bytes: ${#output}" # В trace режиме добавляем первые N строк if [[ "$TRACE_MODE" == true ]] && [[ -n "$output" ]]; then local trace_output trace_output=$(echo "$output" | head -n "$TRACE_LINES" 2>/dev/null || echo "$output") log_line+=$'\n'"--- stdout/stderr (first $TRACE_LINES lines) ---"$'\n'"$trace_output" fi echo "$log_line" >> "$DEBUG_FILE" # Дублирование в stderr если включено if [[ "$STDERR_MODE" == true ]]; then echo "$log_line" >&2 fi fi return $ret } # Функция для установки прав на директорию и файлы ensure_outdir_permissions() { local mode="${1:-dir}" # "dir" для директории, "files" для файлов # Определяем TARGET_USER local target_user="" if [[ "$(id -u)" -eq 0 ]] && [[ -n "${SUDO_USER:-}" ]] && [[ "${SUDO_USER}" != "root" ]]; then target_user="$SUDO_USER" fi if [[ "$mode" == "dir" ]]; then # Создаём директорию если нужно mkdir -p "$REPORT_DIR" || { echo "Error: Cannot create directory $REPORT_DIR" >&2 exit 1 } # Устанавливаем права на директорию if [[ "$(id -u)" -eq 0 ]] && [[ -n "$target_user" ]]; then if chown -R "$target_user:$target_user" "$REPORT_DIR" 2>/dev/null; then if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Set ownership of $REPORT_DIR to $target_user" >> "$DEBUG_FILE" 2>/dev/null || true fi else if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Warning: Failed to chown $REPORT_DIR to $target_user" >> "$DEBUG_FILE" 2>/dev/null || true fi fi fi # Устанавливаем права 700 на директорию if chmod 700 "$REPORT_DIR" 2>/dev/null; then if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Set permissions 700 on $REPORT_DIR" >> "$DEBUG_FILE" 2>/dev/null || true fi else if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Warning: Failed to chmod 700 on $REPORT_DIR" >> "$DEBUG_FILE" 2>/dev/null || true fi fi elif [[ "$mode" == "files" ]]; then # Устанавливаем права на файлы if [[ "$(id -u)" -eq 0 ]] && [[ -n "$target_user" ]]; then local files_to_chown=() [[ -f "$OUTPUT_FILE" ]] && files_to_chown+=("$OUTPUT_FILE") [[ -f "$DEBUG_FILE" ]] && [[ ("$DEBUG_MODE" == true || "$TRACE_MODE" == true) ]] && files_to_chown+=("$DEBUG_FILE") if [[ ${#files_to_chown[@]} -gt 0 ]]; then if chown "$target_user:$target_user" "${files_to_chown[@]}" 2>/dev/null; then if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Set ownership of files to $target_user" >> "$DEBUG_FILE" 2>/dev/null || true fi else if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Warning: Failed to chown files to $target_user" >> "$DEBUG_FILE" 2>/dev/null || true fi fi # Устанавливаем права 600 на файлы if chmod 600 "${files_to_chown[@]}" 2>/dev/null; then if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Set permissions 600 on report files" >> "$DEBUG_FILE" 2>/dev/null || true fi else if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] Warning: Failed to chmod 600 on report files" >> "$DEBUG_FILE" 2>/dev/null || true fi fi fi fi fi } # Инициализация директории и файлов init_output() { # Определяем TARGET_USER TARGET_USER="" if [[ "$(id -u)" -eq 0 ]] && [[ -n "${SUDO_USER:-}" ]] && [[ "${SUDO_USER}" != "root" ]]; then TARGET_USER="$SUDO_USER" fi # Умный default для REPORT_DIR if [[ -z "$REPORT_DIR" ]]; then if [[ $EUID -eq 0 ]]; then # Если запущено через sudo (есть $SUDO_USER и он не root) if [[ -n "${SUDO_USER:-}" ]] && [[ "$SUDO_USER" != "root" ]]; then REPORT_DIR="/home/$SUDO_USER/server-scout" else REPORT_DIR="/root/server-scout" fi else REPORT_DIR="./server-scout" fi fi # Устанавливаем права на директорию (создаёт директорию если нужно) ensure_outdir_permissions "dir" if [[ "$JSON_MODE" == true ]]; then OUTPUT_FILE="$REPORT_DIR/server-scout_${TIMESTAMP}.json" else OUTPUT_FILE="$REPORT_DIR/server-scout_${TIMESTAMP}.txt" fi # Инициализация DEBUG_FILE if [[ -z "$DEBUG_FILE" ]]; then DEBUG_FILE="${OUTPUT_FILE%.*}.debug.log" fi # Создаём файлы сразу с правами 600 (umask 177 = 666-177 = 600) (umask 177; > "$OUTPUT_FILE") if [[ "$DEBUG_MODE" == true ]] || [[ "$TRACE_MODE" == true ]]; then (umask 177; > "$DEBUG_FILE") fi } # Секция: OS Information scout_os() { log "[INFO] Collecting OS information..." run "os_release" "OS Release" "cat /etc/os-release" || true run "uptime" "Uptime" "uptime" || true run "kernel" "Kernel" "uname -a" || true if [[ "$JSON_MODE" != true ]]; then echo "=== OS Information ===" >> "$OUTPUT_FILE" echo "${DATA["os_release"]}" >> "$OUTPUT_FILE" echo "${DATA["uptime"]}" >> "$OUTPUT_FILE" echo "${DATA["kernel"]}" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Disk Usage scout_disk() { log "[INFO] Collecting disk information..." run "disk_usage" "Disk Usage" "df -h" || true if [[ "$JSON_MODE" != true ]]; then echo "=== Disk Usage ===" >> "$OUTPUT_FILE" echo "${DATA["disk_usage"]}" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Listening Ports scout_ports() { log "[INFO] Collecting listening ports..." # Получаем порты через run() для debug (используем -H для удаления заголовка) run "listening_ports_raw" "Listening Ports" "ss -H -lntup 2>/dev/null" || true local ports_output="${DATA["listening_ports_raw"]}" # Подсчёт count строго из RAW (количество непустых строк) local ports_count=0 if [[ -n "$ports_output" ]]; then # Считаем только непустые строки ports_count=$(echo "$ports_output" | awk 'NF > 0 {count++} END {print count+0}') fi [[ -z "$ports_count" ]] && ports_count=0 # Подсчёт public ports через awk (Local Address обычно 5-я колонка) local public_count=0 if [[ -n "$ports_output" ]]; then public_count=$(echo "$ports_output" | awk '{ # Local Address обычно в 5-й колонке, но может быть и в 4-й local_addr = $5 if (local_addr == "" || local_addr ~ /^[0-9]+$/) { local_addr = $4 } # Проверяем публичные адреса: 0.0.0.0:, [::]:, *:, ::: if (local_addr ~ /^0\.0\.0\.0:/ || local_addr ~ /^\[::\]:/ || local_addr ~ /^\*:/ || local_addr ~ /^:::/) { # НЕ считаем если начинается с 127. или ::1 или localhost if (local_addr !~ /^127\./ && local_addr !~ /^::1/ && local_addr !~ /localhost/) { count++ } } } END {print count+0}') fi [[ -z "$public_count" ]] && public_count=0 # Извлечение всех public ports (уникальные порты из public listeners) local public_ports=() local flagged_ports=() local flagged_ports_list="22 2375 5432 3306 6379 9200 27017 15672 5672 8080 8443 9000 9090 5678" # 80/443 не включаем в flagged для risks, но можем показать в списке if [[ -n "$ports_output" ]] && [[ "$public_count" -gt 0 ]]; then # Извлекаем все порты из public listeners local public_ports_list=$(echo "$ports_output" | awk '{ local_addr = $5 if (local_addr == "" || local_addr ~ /^[0-9]+$/) { local_addr = $4 } if (local_addr ~ /^0\.0\.0\.0:/ || local_addr ~ /^\[::\]:/ || local_addr ~ /^\*:/ || local_addr ~ /^:::/) { if (local_addr !~ /^127\./ && local_addr !~ /^::1/ && local_addr !~ /localhost/) { # Извлекаем порт после последнего ":" split(local_addr, parts, ":") port = parts[length(parts)] print port } } }' | sort -u) # Собираем все public ports while IFS= read -r port || [[ -n "$port" ]]; do [[ -z "$port" ]] && continue local found=false for p in "${public_ports[@]}"; do if [[ "$p" == "$port" ]]; then found=true break fi done [[ "$found" == false ]] && public_ports+=("$port") done <<< "$public_ports_list" # Собираем flagged ports (пересечение с опасным списком) for port in "${public_ports[@]}"; do if echo "$flagged_ports_list" | grep -qw "$port"; then local found=false for p in "${flagged_ports[@]}"; do if [[ "$p" == "$port" ]]; then found=true break fi done [[ "$found" == false ]] && flagged_ports+=("$port") fi done fi DATA["listening_ports"]="$ports_output" DATA["ports_count"]=$ports_count DATA["public_ports_count"]=$public_count DATA["public_ports"]=$(IFS=','; echo "${public_ports[*]}") DATA["flagged_ports"]=$(IFS=','; echo "${flagged_ports[*]}") # Сохраняем для обратной совместимости DATA["exposed_ports"]=$(IFS=','; echo "${flagged_ports[*]}") # Debug логирование if [[ "$DEBUG_MODE" == true ]] && [[ -n "${DEBUG_FILE:-}" ]] && [[ -f "$DEBUG_FILE" ]]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ports_public_count: $public_count" >> "$DEBUG_FILE" 2>/dev/null || true echo "[$(date '+%Y-%m-%d %H:%M:%S')] public_ports_list: ${DATA["public_ports"]}" >> "$DEBUG_FILE" 2>/dev/null || true echo "[$(date '+%Y-%m-%d %H:%M:%S')] flagged_ports_list: ${DATA["flagged_ports"]}" >> "$DEBUG_FILE" 2>/dev/null || true fi if [[ "$JSON_MODE" != true ]]; then echo "=== Listening Ports (count: $ports_count, public: $public_count) ===" >> "$OUTPUT_FILE" echo "${DATA["listening_ports"]}" >> "$OUTPUT_FILE" if [[ ${#public_ports[@]} -gt 0 ]]; then echo "" >> "$OUTPUT_FILE" echo "Public ports: $(IFS=','; echo "${public_ports[*]}")" >> "$OUTPUT_FILE" else echo "" >> "$OUTPUT_FILE" echo "Public ports: none" >> "$OUTPUT_FILE" fi if [[ ${#flagged_ports[@]} -gt 0 ]]; then echo "Flagged ports detected: $(IFS=','; echo "${flagged_ports[*]}")" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" fi } # Секция: Top Processes (CPU и Memory) scout_top_procs() { log "[INFO] Collecting top processes (CPU and Memory)..." # Top CPU processes (raw) run "top_cpu_raw" "Top CPU Processes" "ps -eo pid,user,comm,%cpu,%mem,etime --sort=-%cpu 2>/dev/null | head -n 11" || true # Top Memory processes (raw) run "top_mem_raw" "Top Memory Processes" "ps -eo pid,user,comm,%mem,%cpu,etime --sort=-%mem 2>/dev/null | head -n 11" || true # Фильтруем self-noise (ps, head, bash с текущим PID, текущий PID и PID родителя) local pid_self="$$" local pid_parent="${PPID:-}" local top_cpu_filtered="" local top_mem_filtered="" if [[ -n "${DATA["top_cpu_raw"]}" ]] && [[ "${DATA["top_cpu_raw__rc"]}" != "127" ]]; then top_cpu_filtered=$(echo "${DATA["top_cpu_raw"]}" | awk -v s="$pid_self" -v p="$pid_parent" ' NR==1 {print; next} $1==s || $1==p {next} $3=="ps" || $3=="head" || $3=="bash" {next} {print} ' | head -n 11) DATA["top_cpu"]="$top_cpu_filtered" fi if [[ -n "${DATA["top_mem_raw"]}" ]] && [[ "${DATA["top_mem_raw__rc"]}" != "127" ]]; then top_mem_filtered=$(echo "${DATA["top_mem_raw"]}" | awk -v s="$pid_self" -v p="$pid_parent" ' NR==1 {print; next} $1==s || $1==p {next} $3=="ps" || $3=="head" || $3=="bash" {next} {print} ' | head -n 11) DATA["top_mem"]="$top_mem_filtered" fi if [[ "$JSON_MODE" != true ]]; then echo "=== Top CPU Processes (top 10) ===" >> "$OUTPUT_FILE" if [[ -n "${DATA["top_cpu"]}" ]] && [[ "${DATA["top_cpu_raw__rc"]}" != "127" ]]; then echo "${DATA["top_cpu"]}" >> "$OUTPUT_FILE" else echo "ps not available" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" echo "=== Top Memory Processes (top 10) ===" >> "$OUTPUT_FILE" if [[ -n "${DATA["top_mem"]}" ]] && [[ "${DATA["top_mem_raw__rc"]}" != "127" ]]; then echo "${DATA["top_mem"]}" >> "$OUTPUT_FILE" else echo "ps not available" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" fi } # Секция: Docker scout_docker() { log "[INFO] Checking Docker..." if ! command -v docker &>/dev/null; then DATA["docker_found"]=false DATA["docker_ps"]="" if [[ "$JSON_MODE" != true ]]; then echo "=== Docker ===" >> "$OUTPUT_FILE" echo "Docker not found" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi return 0 fi DATA["docker_found"]=true run "docker_ps" "Docker Containers" "docker ps -a 2>/dev/null" || true if [[ "$JSON_MODE" != true ]]; then echo "=== Docker Containers ===" >> "$OUTPUT_FILE" echo "${DATA["docker_ps"]}" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Proxy Detection scout_proxy() { log "[INFO] Detecting web proxies..." DATA["nginx_found"]=false DATA["apache_found"]=false DATA["caddy_found"]=false DATA["traefik_found"]=false # Nginx if command -v nginx &>/dev/null; then DATA["nginx_found"]=true run "nginx_version" "Nginx Version" "nginx -v 2>&1" || true fi # Проверка через systemctl if command -v systemctl &>/dev/null; then run "nginx_systemd" "Nginx Systemd Status" "systemctl is-active nginx 2>/dev/null || echo 'inactive'" || true if [[ "${DATA["nginx_systemd"]}" == "active" ]]; then DATA["nginx_found"]=true if [[ -z "${DATA["nginx_version"]}" ]]; then run "nginx_version" "Nginx Version" "nginx -v 2>&1" || true fi fi fi # Apache if command -v apache2 &>/dev/null || command -v httpd &>/dev/null; then DATA["apache_found"]=true local apache_cmd="apache2" command -v httpd &>/dev/null && apache_cmd="httpd" run "apache_version" "Apache Version" "$apache_cmd -v 2>&1" || true fi # Проверка через systemctl if command -v systemctl &>/dev/null; then run "apache_systemd" "Apache Systemd Status" "systemctl is-active apache2 2>/dev/null || systemctl is-active httpd 2>/dev/null || echo 'inactive'" || true if [[ "${DATA["apache_systemd"]}" == "active" ]]; then DATA["apache_found"]=true if [[ -z "${DATA["apache_version"]}" ]]; then local apache_cmd="apache2" command -v httpd &>/dev/null && apache_cmd="httpd" run "apache_version" "Apache Version" "$apache_cmd -v 2>&1" || true fi fi fi # Caddy if command -v caddy &>/dev/null; then DATA["caddy_found"]=true run "caddy_version" "Caddy Version" "caddy version 2>/dev/null" || true fi # Проверка через systemctl if command -v systemctl &>/dev/null; then run "caddy_systemd" "Caddy Systemd Status" "systemctl is-active caddy 2>/dev/null || echo 'inactive'" || true if [[ "${DATA["caddy_systemd"]}" == "active" ]]; then DATA["caddy_found"]=true if [[ -z "${DATA["caddy_version"]}" ]]; then run "caddy_version" "Caddy Version" "caddy version 2>/dev/null" || true fi fi fi # Traefik if command -v traefik &>/dev/null; then DATA["traefik_found"]=true run "traefik_version" "Traefik Version" "traefik version 2>/dev/null" || true fi # Проверка через systemctl if command -v systemctl &>/dev/null; then run "traefik_systemd" "Traefik Systemd Status" "systemctl is-active traefik 2>/dev/null || echo 'inactive'" || true if [[ "${DATA["traefik_systemd"]}" == "active" ]]; then DATA["traefik_found"]=true if [[ -z "${DATA["traefik_version"]}" ]]; then run "traefik_version" "Traefik Version" "traefik version 2>/dev/null" || true fi fi fi # Traefik in Docker if [[ "${DATA["docker_found"]}" == true ]] && command -v docker &>/dev/null; then run "docker_traefik_check" "Docker Traefik Check" "docker ps --filter 'name=traefik' -q 2>/dev/null" || true local traefik_check="${DATA["docker_traefik_check"]}" if [[ -n "$traefik_check" ]]; then DATA["traefik_found"]=true run "traefik_container" "Traefik Container" "docker ps --filter 'name=traefik' --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}' 2>/dev/null" || true fi fi # Docker containers with nginx/traefik/caddy if [[ "${DATA["docker_found"]}" == true ]] && command -v docker &>/dev/null; then run "proxy_containers" "Proxy Containers in Docker" "docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE '(nginx|traefik|caddy)' || true" || true fi if [[ "$JSON_MODE" != true ]]; then echo "=== Web Proxies ===" >> "$OUTPUT_FILE" local found_any=false [[ "${DATA["nginx_found"]}" == true ]] && { echo "Nginx: ${DATA["nginx_version"]}" >> "$OUTPUT_FILE"; found_any=true; } [[ "${DATA["apache_found"]}" == true ]] && { echo "Apache: ${DATA["apache_version"]}" >> "$OUTPUT_FILE"; found_any=true; } [[ "${DATA["caddy_found"]}" == true ]] && { echo "Caddy: ${DATA["caddy_version"]}" >> "$OUTPUT_FILE"; found_any=true; } [[ "${DATA["traefik_found"]}" == true ]] && { echo "Traefik: ${DATA["traefik_version"]:-${DATA["traefik_container"]:-found}}" >> "$OUTPUT_FILE"; found_any=true; } [[ "$found_any" == false ]] && echo "No web proxies detected" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Systemd (filtered - running + enabled, top 50) scout_systemd() { log "[INFO] Collecting systemd services (running + enabled, top 50)..." # Running services run "systemd_running" "Systemd Running Services" "systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | head -50" || true # Enabled services (используем list-unit-files для корректного списка) run "systemd_enabled" "Systemd Enabled Services" "systemctl list-unit-files --type=service --state=enabled --no-pager --no-legend 2>/dev/null | head -50" || true if [[ "$JSON_MODE" != true ]]; then echo "=== Systemd Services (Running, top 50) ===" >> "$OUTPUT_FILE" if [[ -n "${DATA["systemd_running"]}" ]]; then echo "${DATA["systemd_running"]}" >> "$OUTPUT_FILE" else echo "No running services found" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" echo "=== Systemd Services (Enabled, top 50) ===" >> "$OUTPUT_FILE" if [[ -n "${DATA["systemd_enabled"]}" ]]; then echo "${DATA["systemd_enabled"]}" >> "$OUTPUT_FILE" else echo "No enabled services found" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" fi } # Секция: Docker Compose Files scout_compose() { log "[INFO] Scanning for docker-compose files..." local compose_files="" local compose_count=0 set +e local compose_list compose_list=$(find /etc /var/www /srv /home -maxdepth 4 -type f \( -name "docker-compose.yml" -o -name "docker-compose.yaml" -o -name "compose.yml" -o -name "compose.yaml" \) ! -path "*/node_modules/*" ! -path "*/vendor/*" ! -path "*/.git/*" 2>/dev/null) || true compose_count=$(echo "$compose_list" | grep -c . 2>/dev/null || echo 0) compose_files=$(echo "$compose_list" | head -20) set -e [[ -z "$compose_count" ]] && compose_count=0 DATA["compose_files_count"]=$compose_count DATA["compose_files_list"]="$compose_files" if [[ "$JSON_MODE" != true ]]; then echo "=== Docker Compose Files (found: $compose_count) ===" >> "$OUTPUT_FILE" echo "${DATA["compose_files_list"]}" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Database Presence + Version scout_db() { log "[INFO] Checking database presence..." DATA["postgres_found"]=false DATA["mysql_found"]=false DATA["redis_found"]=false # PostgreSQL if command -v psql &>/dev/null; then DATA["postgres_found"]=true run "postgres_version" "PostgreSQL Version" "psql --version 2>/dev/null" || true fi # MySQL/MariaDB if command -v mysql &>/dev/null || command -v mariadb &>/dev/null; then DATA["mysql_found"]=true local mysql_cmd="mysql" command -v mariadb &>/dev/null && mysql_cmd="mariadb" run "mysql_version" "MySQL/MariaDB Version" "$mysql_cmd --version 2>/dev/null" || true fi # Redis if command -v redis-cli &>/dev/null; then DATA["redis_found"]=true run "redis_version" "Redis Version" "redis-cli --version 2>/dev/null" || true fi # DB in Docker (поиск по IMAGE, а не только по name) if [[ "${DATA["docker_found"]}" == true ]] && command -v docker &>/dev/null; then # PostgreSQL/PostGIS run "docker_postgres_containers" "Docker PostgreSQL Containers" "docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'postgres|postgis' | head -1" || true local postgres_line="${DATA["docker_postgres_containers"]}" if [[ -n "$postgres_line" ]]; then local postgres_container=$(echo "$postgres_line" | awk '{print $1}') local postgres_image=$(echo "$postgres_line" | awk '{print $2}') DATA["postgres_found"]=true DATA["postgres_container"]="$postgres_container" DATA["postgres_image"]="$postgres_image" run "postgres_version" "PostgreSQL Version (container)" "docker exec $postgres_container psql --version 2>/dev/null || echo 'found (version exec failed)'" || true if [[ -z "${DATA["postgres_version"]}" ]] || [[ "${DATA["postgres_version"]}" == "found (version exec failed)" ]]; then DATA["postgres_version"]="found (version exec failed)" fi fi # MySQL/MariaDB run "docker_mysql_containers" "Docker MySQL/MariaDB Containers" "docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'mysql|mariadb' | head -1" || true local mysql_line="${DATA["docker_mysql_containers"]}" if [[ -n "$mysql_line" ]]; then local mysql_container=$(echo "$mysql_line" | awk '{print $1}') local mysql_image=$(echo "$mysql_line" | awk '{print $2}') DATA["mysql_found"]=true DATA["mysql_container"]="$mysql_container" DATA["mysql_image"]="$mysql_image" run "mysql_version" "MySQL/MariaDB Version (container)" "docker exec $mysql_container mysql --version 2>/dev/null || echo 'found (version exec failed)'" || true if [[ -z "${DATA["mysql_version"]}" ]] || [[ "${DATA["mysql_version"]}" == "found (version exec failed)" ]]; then DATA["mysql_version"]="found (version exec failed)" fi fi # Redis run "docker_redis_containers" "Docker Redis Containers" "docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'redis' | head -1" || true local redis_line="${DATA["docker_redis_containers"]}" if [[ -n "$redis_line" ]]; then local redis_container=$(echo "$redis_line" | awk '{print $1}') local redis_image=$(echo "$redis_line" | awk '{print $2}') DATA["redis_found"]=true DATA["redis_container"]="$redis_container" DATA["redis_image"]="$redis_image" run "redis_version" "Redis Version (container)" "docker exec $redis_container redis-server --version 2>/dev/null || echo 'found (version exec failed)'" || true if [[ -z "${DATA["redis_version"]}" ]] || [[ "${DATA["redis_version"]}" == "found (version exec failed)" ]]; then DATA["redis_version"]="found (version exec failed)" fi fi fi if [[ "$JSON_MODE" != true ]]; then echo "=== Databases ===" >> "$OUTPUT_FILE" local db_found=false [[ "${DATA["postgres_found"]}" == true ]] && { echo "PostgreSQL: ${DATA["postgres_version"]:-found in container}" >> "$OUTPUT_FILE"; db_found=true; } [[ "${DATA["mysql_found"]}" == true ]] && { echo "MySQL/MariaDB: ${DATA["mysql_version"]:-found in container}" >> "$OUTPUT_FILE"; db_found=true; } [[ "${DATA["redis_found"]}" == true ]] && { echo "Redis: ${DATA["redis_version"]:-found in container}" >> "$OUTPUT_FILE"; db_found=true; } [[ "$db_found" == false ]] && echo "No databases detected" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Секция: Journal Errors (за сегодня) scout_journal() { log "[INFO] Checking systemd journal errors (today)..." # Получаем ошибки через run() для debug run "journal_errors" "Systemd Journal Errors" "journalctl -p err --since today --no-pager 2>/dev/null | head -100" || true if [[ "$JSON_MODE" != true ]]; then echo "=== Systemd Journal Errors (today) ===" >> "$OUTPUT_FILE" if [[ -n "${DATA["journal_errors"]}" ]]; then echo "${DATA["journal_errors"]}" >> "$OUTPUT_FILE" else echo "No errors found" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" fi } # Секция: Security Risks scout_risks() { log "[INFO] Checking security risks..." local risks=() local ssh_exposed=false local firewall_detected=false local fail2ban_detected=false # Проверяем SSH exposed (порт 22 публичный) - используем flagged_ports local flagged_ports_str="${DATA["flagged_ports"]:-}" if [[ -n "$flagged_ports_str" ]] && echo "$flagged_ports_str" | grep -qw "22"; then ssh_exposed=true risks+=("SSH port 22 is exposed to public (warning)") fi DATA["ssh_exposed"]=$ssh_exposed # Проверяем другие flagged порты (80/443 не ругаем как risk) if [[ -n "$flagged_ports_str" ]]; then # Docker API (2375) - critical if echo "$flagged_ports_str" | grep -qw "2375"; then risks+=("Docker API port 2375 is exposed to public (CRITICAL)") fi # Database ports - critical local db_ports="" for port in 5432 3306 6379; do if echo "$flagged_ports_str" | grep -qw "$port"; then [[ -n "$db_ports" ]] && db_ports+="," db_ports+="$port" fi done if [[ -n "$db_ports" ]]; then risks+=("Database ports ($db_ports) are exposed to public (CRITICAL)") fi # Other important ports - warning (исключаем 80/443) local other_ports="" for port in 9200 27017 15672 5672 8080 8443 9000 9090 5678; do if echo "$flagged_ports_str" | grep -qw "$port"; then [[ -n "$other_ports" ]] && other_ports+="," other_ports+="$port" fi done if [[ -n "$other_ports" ]]; then risks+=("Important ports ($other_ports) are exposed to public (warning)") fi fi # Firewall local has_fw=false run "firewall_check_ufw" "Firewall Check (UFW)" "ufw status 2>/dev/null | grep -q 'Status: active' && echo 'active' || echo 'inactive'" || true run "firewall_check_firewalld" "Firewall Check (firewalld)" "firewall-cmd --state 2>/dev/null | grep -q 'running' && echo 'running' || echo 'not running'" || true run "firewall_check_iptables" "Firewall Check (iptables)" "iptables -L INPUT -n 2>/dev/null | grep -cE '^(ACCEPT|DROP|REJECT|RETURN|f2b)' || echo 0" || true run "firewall_check_nftables" "Firewall Check (nftables)" "nft list ruleset 2>/dev/null | grep -q 'chain input' && echo 'active' || echo 'inactive'" || true run "firewall_check_netfilter" "Firewall Check (netfilter-persistent)" "systemctl is-active netfilter-persistent 2>/dev/null || echo 'inactive'" || true local iptables_rules="${DATA["firewall_check_iptables"]:-0}" if [[ "${DATA["firewall_check_ufw"]}" == "active" ]] \ || [[ "${DATA["firewall_check_firewalld"]}" == "running" ]] \ || [[ "${DATA["firewall_check_nftables"]}" == "active" ]] \ || [[ "${DATA["firewall_check_netfilter"]}" == "active" ]] \ || [[ "$iptables_rules" =~ ^[0-9]+$ ]] && [[ "$iptables_rules" -gt 0 ]]; then has_fw=true firewall_detected=true fi if [[ "$has_fw" == false ]]; then risks+=("No active firewall detected") fi DATA["firewall_active"]=$has_fw DATA["firewall_detected"]=$firewall_detected # Fail2ban run "fail2ban_check" "Fail2ban Check" "command -v fail2ban-client &>/dev/null && echo 'installed' || echo 'not installed'" || true if [[ "${DATA["fail2ban_check"]}" == "installed" ]]; then fail2ban_detected=true DATA["fail2ban_installed"]=true run "fail2ban_status" "Fail2ban Status" "systemctl is-active fail2ban 2>/dev/null || echo 'inactive'" || true if [[ "${DATA["fail2ban_status"]}" != "active" ]]; then risks+=("fail2ban is installed but not running") fi else DATA["fail2ban_installed"]=false risks+=("fail2ban is not installed") fi DATA["fail2ban_detected"]=$fail2ban_detected # Unattended-upgrades (Debian/Ubuntu) run "unattended_upgrades_check" "Unattended-upgrades Check" "dpkg -l 2>/dev/null | grep -q unattended-upgrades && echo 'installed' || echo 'not installed'" || true if [[ "${DATA["unattended_upgrades_check"]}" != "installed" ]]; then risks+=("unattended-upgrades not installed (auto security updates)") fi # SSH config if [[ -f /etc/ssh/sshd_config ]]; then run "ssh_config_permit_root" "SSH PermitRootLogin" "grep -E '^PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null | awk '{print \$2}' || echo 'yes'" || true run "ssh_config_password_auth" "SSH PasswordAuthentication" "grep -E '^PasswordAuthentication' /etc/ssh/sshd_config 2>/dev/null | awk '{print \$2}' || echo 'yes'" || true run "ssh_config_permit_empty" "SSH PermitEmptyPasswords" "grep -E '^PermitEmptyPasswords' /etc/ssh/sshd_config 2>/dev/null | awk '{print \$2}' || echo 'no'" || true local permit_root="${DATA["ssh_config_permit_root"]}" local password_auth="${DATA["ssh_config_password_auth"]}" local permit_empty="${DATA["ssh_config_permit_empty"]}" DATA["ssh_permit_root"]="$permit_root" DATA["ssh_password_auth"]="$password_auth" DATA["ssh_permit_empty"]="$permit_empty" if [[ "$permit_root" != "no" ]]; then risks+=("SSH PermitRootLogin enabled") fi if [[ "$password_auth" == "yes" ]]; then risks+=("SSH PasswordAuthentication enabled (consider using keys only)") fi if [[ "$permit_empty" == "yes" ]]; then risks+=("SSH PermitEmptyPasswords enabled (CRITICAL)") fi fi DATA["risks_list"]=$(IFS='|'; echo "${risks[*]}") DATA["risks_count"]=${#risks[@]} if [[ "$JSON_MODE" != true ]]; then echo "=== Security Risks ===" >> "$OUTPUT_FILE" if [[ ${#risks[@]} -gt 0 ]]; then for risk in "${risks[@]}"; do echo " [!] $risk" >> "$OUTPUT_FILE" done else echo "No critical security risks detected" >> "$OUTPUT_FILE" fi echo "" >> "$OUTPUT_FILE" echo "Security facts:" >> "$OUTPUT_FILE" echo " - SSH exposed: $([ "$ssh_exposed" == true ] && echo "yes" || echo "no")" >> "$OUTPUT_FILE" echo " - Firewall detected: $([ "$firewall_detected" == true ] && echo "yes" || echo "no")" >> "$OUTPUT_FILE" echo " - Fail2ban detected: $([ "$fail2ban_detected" == true ] && echo "yes" || echo "no")" >> "$OUTPUT_FILE" echo "" >> "$OUTPUT_FILE" fi } # Рендеринг JSON render_json() { { echo "{" echo " \"generated\": \"$(date -Iseconds 2>/dev/null || date)\"," echo " \"preset\": \"$PRESET\"," echo " \"system\": {" echo " \"os_release\": \"$(json_escape "${DATA["os_release"]:-}")\"," echo " \"uptime\": \"$(json_escape "${DATA["uptime"]:-}")\"," echo " \"kernel\": \"$(json_escape "${DATA["kernel"]:-}")\"," echo " \"disk_usage\": \"$(json_escape "${DATA["disk_usage"]:-}")\"" echo " }," echo " \"network\": {" echo " \"listening_ports\": \"$(json_escape "${DATA["listening_ports"]:-}")\"," echo " \"ports_count\": ${DATA["ports_count"]:-0}" echo " }," echo " \"containers\": {" echo " \"docker_found\": ${DATA["docker_found"]:-false}," echo " \"docker_ps\": \"$(json_escape "${DATA["docker_ps"]:-}")\"" echo " }," echo " \"web\": {" echo " \"nginx_found\": ${DATA["nginx_found"]:-false}," echo " \"nginx_version\": \"$(json_escape "${DATA["nginx_version"]:-}")\"," echo " \"apache_found\": ${DATA["apache_found"]:-false}," echo " \"apache_version\": \"$(json_escape "${DATA["apache_version"]:-}")\"," echo " \"caddy_found\": ${DATA["caddy_found"]:-false}," echo " \"caddy_version\": \"$(json_escape "${DATA["caddy_version"]:-}")\"," echo " \"traefik_found\": ${DATA["traefik_found"]:-false}," echo " \"traefik_version\": \"$(json_escape "${DATA["traefik_version"]:-}")\"," echo " \"traefik_container\": \"$(json_escape "${DATA["traefik_container"]:-}")\"" echo " }," echo " \"data\": {" echo " \"postgres_found\": ${DATA["postgres_found"]:-false}," echo " \"postgres_version\": \"$(json_escape "${DATA["postgres_version"]:-}")\"," echo " \"mysql_found\": ${DATA["mysql_found"]:-false}," echo " \"mysql_version\": \"$(json_escape "${DATA["mysql_version"]:-}")\"," echo " \"redis_found\": ${DATA["redis_found"]:-false}," echo " \"redis_version\": \"$(json_escape "${DATA["redis_version"]:-}")\"," echo " \"compose_files_count\": ${DATA["compose_files_count"]:-0}," echo " \"compose_files_list\": \"$(json_escape "${DATA["compose_files_list"]:-}")\"," echo " \"systemd_running\": \"$(json_escape "${DATA["systemd_running"]:-}")\"," echo " \"systemd_enabled\": \"$(json_escape "${DATA["systemd_enabled"]:-}")\"," echo " \"journal_errors\": \"$(json_escape "${DATA["journal_errors"]:-}")\"" echo " }," echo " \"risks\": {" echo " \"firewall_active\": ${DATA["firewall_active"]:-false}," echo " \"fail2ban_installed\": ${DATA["fail2ban_installed"]:-false}," echo " \"ssh_permit_root\": \"${DATA["ssh_permit_root"]:-}\"," echo " \"ssh_password_auth\": \"${DATA["ssh_password_auth"]:-}\"," echo " \"ssh_permit_empty\": \"${DATA["ssh_permit_empty"]:-}\"," echo " \"risks_count\": ${DATA["risks_count"]:-0}," echo " \"risks_list\": \"$(json_escape "${DATA["risks_list"]:-}")\"" echo " }," echo " \"summary\": {" echo " \"preset\": \"$PRESET\"," echo " \"docker_found\": ${DATA["docker_found"]:-false}," echo " \"nginx_found\": ${DATA["nginx_found"]:-false}," echo " \"apache_found\": ${DATA["apache_found"]:-false}," echo " \"ports_count\": ${DATA["ports_count"]:-0}," echo " \"risks_count\": ${DATA["risks_count"]:-0}" echo " }" echo "}" } > "$OUTPUT_FILE" } # Self-test функция run_selftest() { echo "=== server-scout.sh v$VERSION Self-Test ===" echo "" local errors=0 # Отключаем set -e для selftest (чтобы не падать на ((errors++))) set +e # Получаем абсолютный путь к скрипту local script_path script_path="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}")" # Test 1: Syntax check echo -n "[TEST 1] Syntax check (bash -n)... " if bash -n "$script_path" 2>/dev/null; then echo "PASS" else echo "FAIL" errors=$((errors + 1)) fi # Test 2: JSON validation (если есть python3) if command -v python3 &>/dev/null; then echo -n "[TEST 2] JSON validation... " local test_dir="/tmp/server-scout-selftest-$$" mkdir -p "$test_dir" if bash "$script_path" --json --quiet --preset=minimal --outdir "$test_dir" 2>/dev/null; then local json_file json_file=$(ls -t "$test_dir"/*.json 2>/dev/null | head -1) if [[ -n "$json_file" ]] && [[ -f "$json_file" ]]; then if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$json_file" 2>/dev/null; then echo "PASS" else echo "FAIL (invalid JSON)" errors=$((errors + 1)) fi else echo "FAIL (no JSON file created)" errors=$((errors + 1)) fi else echo "FAIL (script error)" errors=$((errors + 1)) fi rm -rf "$test_dir" 2>/dev/null else echo "[TEST 2] JSON validation... SKIP (python3 not found)" fi # Test 3: Minimal preset не пустой (проверяем файл, а не stdout) echo -n "[TEST 3] Minimal preset produces data... " local test_dir="/tmp/server-scout-selftest-$$" mkdir -p "$test_dir" # Запускаем скрипт в quiet режиме (без вывода в консоль) if bash "$script_path" --preset=minimal --quiet --outdir "$test_dir" 2>/dev/null; then # Ищем созданный файл local test_file test_file=$(ls -t "$test_dir"/*.txt 2>/dev/null | head -1) if [[ -n "$test_file" ]] && [[ -f "$test_file" ]]; then # Проверяем что файл содержит данные if grep -qE "(OS Information|Disk Usage|Listening Ports|===)" "$test_file" 2>/dev/null; then # Дополнительно проверяем наличие конкретных данных if grep -qiE "(PRETTY_NAME|NAME=|VERSION=|uptime|kernel|Filesystem|df -h)" "$test_file" 2>/dev/null; then echo "PASS" else echo "FAIL (file exists but seems empty)" errors=$((errors + 1)) fi else echo "FAIL (file exists but no expected sections found)" errors=$((errors + 1)) fi else echo "FAIL (no output file created)" errors=$((errors + 1)) fi else echo "FAIL (script execution failed)" errors=$((errors + 1)) fi rm -rf "$test_dir" 2>/dev/null # Test 4: Проверка правильности вычисления путей для SUDO_USER echo -n "[TEST 4] Outdir path calculation for SUDO_USER... " set +e local euid_test=$(id -u 2>/dev/null || echo "0") local sudo_user_test="${SUDO_USER:-}" if [[ "$euid_test" -eq 0 ]] && [[ -n "$sudo_user_test" ]] && [[ "$sudo_user_test" != "root" ]]; then # Симулируем вычисление пути (без создания файлов) local expected_dir="/home/$sudo_user_test/server-scout" local default_dir="/root/server-scout" if [[ "$expected_dir" != "$default_dir" ]]; then echo "PASS (would use $expected_dir for SUDO_USER=$sudo_user_test)" else echo "FAIL (path calculation incorrect)" errors=$((errors + 1)) fi else echo "SKIP (not running as root with SUDO_USER)" fi set -e # Test 5: Проверка что basic preset содержит top_procs echo -n "[TEST 5] Basic preset includes top_procs... " local test_dir="/tmp/server-scout-selftest-top-$$" mkdir -p "$test_dir" set +e if bash "$script_path" --preset=basic --quiet --outdir "$test_dir" 2>/dev/null; then local test_file test_file=$(ls -t "$test_dir"/*.txt 2>/dev/null | head -1) if [[ -n "$test_file" ]] && [[ -f "$test_file" ]]; then if grep -qE "(Top CPU Processes|Top Memory Processes)" "$test_file" 2>/dev/null; then echo "PASS" else echo "FAIL (top_procs section not found in basic preset)" errors=$((errors + 1)) fi else echo "FAIL (no output file created)" errors=$((errors + 1)) fi else echo "FAIL (script execution failed)" errors=$((errors + 1)) fi set -e rm -rf "$test_dir" 2>/dev/null # Включаем обратно set -e set -e echo "" if [[ $errors -eq 0 ]]; then echo "=== All tests passed ===" return 0 else echo "=== $errors test(s) failed ===" return 1 fi } # Главная функция main() { # Парсим аргументы до проверки root (для --help, --version, --selftest) parse_args "$@" # Проверка root (критическая ошибка - выводим в stderr) if [[ $EUID -ne 0 ]]; then echo "Error: This script must be run as root (use sudo)" >&2 exit 1 fi init_output log "Starting server scout v$VERSION..." log "Preset: $PRESET" log "JSON: $JSON_MODE" log "Output: $OUTPUT_FILE" log "" # Выполнение секций в зависимости от пресета scout_os || true scout_disk || true scout_ports || true scout_docker || true scout_proxy || true if [[ "$PRESET" == "basic" ]] || [[ "$PRESET" == "full" ]]; then scout_systemd || true scout_compose || true scout_db || true scout_journal || true scout_top_procs || true fi # Risks всегда проверяем scout_risks || true # Summary ошибок (только txt режим, если включён debug) if [[ "$DEBUG_MODE" == true ]] && [[ "$JSON_MODE" != true ]]; then echo "" >> "$OUTPUT_FILE" echo "=== COMMAND EXECUTION SUMMARY ===" >> "$OUTPUT_FILE" local errors=() local not_found=() local timeouts=() local empty_output=() for key in "${!DATA[@]}"; do if [[ "$key" =~ __rc$ ]]; then local base_key="${key%__rc}" local rc="${DATA[$key]}" local bytes_key="${base_key}__bytes" local bytes="${DATA[$bytes_key]:-0}" if [[ "$rc" == "127" ]]; then not_found+=("$base_key") elif [[ "$rc" == "124" ]]; then timeouts+=("$base_key") elif [[ "$rc" != "0" ]]; then errors+=("$base_key (rc=$rc)") fi # Empty output (bytes == 0 и rc == 0) if [[ "$rc" == "0" ]] && [[ "$bytes" == "0" ]]; then empty_output+=("$base_key") fi fi done if [[ ${#errors[@]} -gt 0 ]]; then echo "Errors (rc != 0 && rc != 127): ${errors[*]}" >> "$OUTPUT_FILE" fi if [[ ${#not_found[@]} -gt 0 ]]; then echo "Not installed (rc=127): ${not_found[*]}" >> "$OUTPUT_FILE" fi if [[ ${#timeouts[@]} -gt 0 ]]; then echo "Timeouts (rc=124): ${timeouts[*]}" >> "$OUTPUT_FILE" fi if [[ ${#empty_output[@]} -gt 0 ]]; then echo "Empty output (bytes=0): ${empty_output[*]}" >> "$OUTPUT_FILE" fi if [[ ${#errors[@]} -eq 0 ]] && [[ ${#not_found[@]} -eq 0 ]] && [[ ${#timeouts[@]} -eq 0 ]] && [[ ${#empty_output[@]} -eq 0 ]]; then echo "No issues detected" >> "$OUTPUT_FILE" fi fi # Рендеринг JSON в конце if [[ "$JSON_MODE" == true ]]; then render_json || true fi # Устанавливаем права на файлы в конце ensure_outdir_permissions "files" log "" log "========================================" log "Scout completed!" log "Report saved to: $OUTPUT_FILE" log "========================================" } # Обработка ошибок trap 'echo "Error at line $LINENO: $BASH_COMMAND" >&2' ERR # Запуск main "$@"