diff --git a/sac-client.sh b/sac-client.sh index a1dd5d8..4284765 100644 --- a/sac-client.sh +++ b/sac-client.sh @@ -70,6 +70,74 @@ sac_spool_remove() { rm -f "${dir}/${event_id}.json" } +# POST готового JSON (ingest / повтор из spool). Возврат 0 при HTTP 202. +sac_post_payload() { + local payload="$1" + local event_id http_code tmp resp event_type + + if ! sac_is_configured; then + return 1 + fi + if [ "${DRY_RUN:-0}" = "1" ]; then + printf '[DRY-RUN SAC POST] %s\n' "$(printf '%s' "$payload" | head -c 120)" >&2 + return 0 + fi + + event_id="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin)["event_id"])')" || return 1 + event_type="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("type","?"))')" || event_type="?" + + tmp="$(mktemp)" + resp="$(mktemp)" + printf '%s' "$payload" >"$tmp" + http_code="$(curl -sS -o "$resp" -w '%{http_code}' \ + -X POST "$(sac_ingest_url)" \ + -H "Authorization: Bearer ${SAC_API_KEY}" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ${event_id}" \ + --connect-timeout "${SAC_TIMEOUT_SEC:-12}" \ + --max-time "${SAC_TIMEOUT_SEC:-12}" \ + -d @"$tmp" 2>/dev/null || echo 000)" + rm -f "$tmp" + + if [ "$http_code" = "202" ]; then + sac_spool_remove "$event_id" 2>/dev/null || true + write_log "SAC: принято event_id=$event_id type=$event_type" + return 0 + fi + + write_log "WARN: SAC POST HTTP $http_code: $(tr '\n' ' ' <"$resp" | head -c 200)" + sac_spool_write "$event_id" "$payload" + return 1 +} + +# Повторная отправка файлов из SAC_SPOOL_DIR (до max_files за вызов). +sac_flush_spool() { + local mode max_files="${1:-20}" + mode="$(sac_normalize_mode "${UseSAC:-off}")" + case "$mode" in + off) return 0 ;; + esac + if ! sac_is_configured; then + return 0 + fi + + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + [ -d "$dir" ] || return 0 + + local f count=0 + shopt -s nullglob + for f in "$dir"/*.json; do + [ -f "$f" ] || continue + count=$((count + 1)) + [ "$count" -gt "$max_files" ] && break + local payload + payload="$(cat "$f")" + sac_post_payload "$payload" || true + done + shopt -u nullglob + return 0 +} + # send_sac_event type severity title summary [details_json] sac_send_event() { local event_type="$1" @@ -146,31 +214,7 @@ print(json.dumps(payload, ensure_ascii=False)) PY )" || return 1 - event_id="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin)["event_id"])')" - - local tmp resp - tmp="$(mktemp)" - resp="$(mktemp)" - printf '%s' "$payload" >"$tmp" - http_code="$(curl -sS -o "$resp" -w '%{http_code}' \ - -X POST "$(sac_ingest_url)" \ - -H "Authorization: Bearer ${SAC_API_KEY}" \ - -H "Content-Type: application/json" \ - -H "Idempotency-Key: ${event_id}" \ - --connect-timeout "${SAC_TIMEOUT_SEC:-12}" \ - --max-time "${SAC_TIMEOUT_SEC:-12}" \ - -d @"$tmp" 2>/dev/null || echo 000)" - rm -f "$tmp" - - if [ "$http_code" = "202" ]; then - sac_spool_remove "$event_id" 2>/dev/null || true - write_log "SAC: принято event_id=$event_id type=$event_type" - return 0 - fi - - write_log "WARN: SAC POST HTTP $http_code: $(tr '\n' ' ' <"$resp" | head -c 200)" - sac_spool_write "$event_id" "$payload" - return 1 + sac_post_payload "$payload" } # JSON details из переменных SAC_D_* (опционально) @@ -227,6 +271,11 @@ notify_or_sac() { run_check_sac() { printf 'Проверка SAC (ssh-monitor)\n' printf 'UseSAC=%s\n' "${UseSAC:-off}" + case "$(sac_normalize_mode "${UseSAC:-off}")" in + exclusive) printf 'Режим exclusive: уведомления только в SAC (Telegram/email с агента не отправляются)\n' ;; + dual) printf 'Режим dual: SAC + локальные каналы NOTIFY_CHAIN\n' ;; + fallback) printf 'Режим fallback: SAC, при сбое — NOTIFY_CHAIN\n' ;; + esac printf 'SAC_URL=%s\n' "${SAC_URL:-}" if sac_is_configured; then printf 'SAC ingest URL=%s\n' "$(sac_ingest_url)" diff --git a/scripts/pilot-verify-exclusive.sh b/scripts/pilot-verify-exclusive.sh new file mode 100755 index 0000000..7c7f3fe --- /dev/null +++ b/scripts/pilot-verify-exclusive.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Проверка готовности хоста к пилоту UseSAC=exclusive (фаза 2.1). +set -euo pipefail + +CONFIG="${CONFIG_FILE:-/etc/ssh-monitor.conf}" +MONITOR="${SSH_MONITOR_BIN:-/usr/local/bin/ssh-monitor}" + +if [ -f "$CONFIG" ]; then + # shellcheck disable=SC1090 + . "$CONFIG" +fi + +fail=0 +ok() { printf '[OK] %s\n' "$1"; } +bad() { printf '[FAIL] %s\n' "$1"; fail=1; } + +mode="$(printf '%s' "${UseSAC:-off}" | tr '[:upper:]' '[:lower:]')" +[ "$mode" = "exclusive" ] || bad "UseSAC должен быть exclusive (сейчас: ${UseSAC:-off})" +[ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ] || bad "Задайте SAC_URL и SAC_API_KEY" +[ -x "$MONITOR" ] || bad "Не найден $MONITOR" +[ -f "${MONITOR%/*}/sac-client.sh" ] || bad "Нет sac-client.sh рядом с ssh-monitor" + +if "$MONITOR" --check-sac; then + ok "--check-sac" +else + bad "--check-sac" +fi + +spool="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" +pending=0 +if [ -d "$spool" ]; then + pending=$(find "$spool" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l) +fi +printf ' Spool %s: %s файл(ов)\n' "$spool" "$pending" + +if [ "$fail" -eq 0 ]; then + printf '\nПилот exclusive: хост готов. Сгенерируйте SSH/sudo событие и проверьте только в SAC UI.\n' + exit 0 +fi +printf '\nИсправьте ошибки выше.\n' +exit 1 diff --git a/ssh-monitor b/ssh-monitor index ac22c48..d37f971 100644 --- a/ssh-monitor +++ b/ssh-monitor @@ -1921,6 +1921,7 @@ main() { monitor_logind check_daily_report check_heartbeat + sac_flush_spool 20 2>/dev/null || true write_health_and_metrics local current_time diff --git a/update_ssh_monitor.sh b/update_ssh_monitor.sh index 71899cd..032a7c5 100755 --- a/update_ssh_monitor.sh +++ b/update_ssh_monitor.sh @@ -426,8 +426,9 @@ print_config_variables_help() { TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID — или MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO - Security Alert Center (рекомендуется dual на пилоте): - UseSAC=dual + Security Alert Center: + UseSAC=off | dual | exclusive | fallback + Пилот 2.1 (только SAC): UseSAC=exclusive SAC_URL=https://sac.kalinamall.ru SAC_API_KEY=<ключ из SAC sac-api.env / bootstrap>