From dd971d80bc470e701c0132760ef95aeffc0accd1 Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 26 May 2026 21:46:35 +1000 Subject: [PATCH] feat: UseSAC sac-client.sh and --check-sac --- sac-client.sh | 209 +++++++++++++++++++++++++++++++++++++++ ssh-monitor | 64 +++++++++++- ssh-monitor.conf.example | 8 ++ 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 sac-client.sh diff --git a/sac-client.sh b/sac-client.sh new file mode 100644 index 0000000..90b0fe8 --- /dev/null +++ b/sac-client.sh @@ -0,0 +1,209 @@ +# SAC client for ssh-monitor (source from ssh-monitor) +# shellcheck shell=bash + +sac_normalize_mode() { + printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]' +} + +sac_is_configured() { + [ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ] +} + +sac_base_url() { + local url="${SAC_URL%/}" + case "$url" in + */api/v1/events) url="${url%/api/v1/events}" ;; + esac + printf '%s' "${url%/}" +} + +sac_agent_instance_id() { + local id_file="${SAC_AGENT_ID_FILE:-/var/lib/ssh-monitor/agent_instance_id}" + local dir + dir="$(dirname "$id_file")" + if [ ! -d "$dir" ]; then + mkdir -p "$dir" 2>/dev/null || id_file="/tmp/ssh-monitor-agent_instance_id" + fi + if [ -f "$id_file" ]; then + cat "$id_file" + return 0 + fi + local new_id + new_id="$(python3 -c 'import uuid; print(uuid.uuid4())')" + printf '%s\n' "$new_id" >"$id_file" 2>/dev/null || true + printf '%s' "$new_id" +} + +sac_check_health() { + local base code + base="$(sac_base_url)" + [ -n "$base" ] || return 1 + if [ "${DRY_RUN:-0}" = "1" ]; then + return 0 + fi + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + --connect-timeout "${SAC_TIMEOUT_SEC:-12}" \ + --max-time "${SAC_TIMEOUT_SEC:-12}" \ + "${base}/health" || echo 000)" + [ "$code" = "200" ] +} + +sac_spool_write() { + local event_id="$1" + local body="$2" + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + mkdir -p "$dir" 2>/dev/null || return 1 + printf '%s' "$body" >"${dir}/${event_id}.json" +} + +sac_spool_remove() { + local event_id="$1" + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + rm -f "${dir}/${event_id}.json" +} + +# send_sac_event type severity title summary [details_json] +sac_send_event() { + local event_type="$1" + local severity="$2" + local title="$3" + local summary="$4" + local details_json="${5:-}" + + if ! sac_is_configured; then + write_log "WARN: SAC не настроен (SAC_URL / SAC_API_KEY)" + return 1 + fi + + if [ "${DRY_RUN:-0}" = "1" ]; then + printf '[DRY-RUN SAC] %s | %s | %s\n' "$event_type" "$severity" "$title" >&2 + return 0 + fi + + local payload http_code event_id + payload="$(SAC_EVENT_TYPE="$event_type" \ + SAC_SEVERITY="$severity" \ + SAC_TITLE="$title" \ + SAC_SUMMARY="$summary" \ + SAC_DETAILS_JSON="$details_json" \ + SAC_AGENT_ID="$(sac_agent_instance_id)" \ + SAC_PRODUCT_VERSION="${SSH_MONITOR_VERSION:-unknown}" \ + python3 <<'PY' +import json, os, socket, uuid +from datetime import datetime, timezone + +def details(): + raw = os.environ.get("SAC_DETAILS_JSON", "").strip() + if not raw: + return None + return json.loads(raw) + +host = socket.gethostname() +payload = { + "schema_version": "1.0", + "event_id": str(uuid.uuid4()), + "occurred_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "source": { + "product": "ssh-monitor", + "product_version": os.environ.get("SAC_PRODUCT_VERSION", "unknown"), + "agent_instance_id": os.environ["SAC_AGENT_ID"], + }, + "host": { + "hostname": host, + "os_family": "linux", + }, + "category": "agent", + "type": os.environ["SAC_EVENT_TYPE"], + "severity": os.environ["SAC_SEVERITY"], + "title": os.environ["SAC_TITLE"], + "summary": os.environ["SAC_SUMMARY"], +} +d = details() +if d is not None: + payload["details"] = d +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_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 +} + +# notify_or_sac type severity title summary telegram_message +notify_or_sac() { + local event_type="$1" + local severity="$2" + local title="$3" + local summary="$4" + local telegram_message="${5:-$summary}" + local mode + mode="$(sac_normalize_mode "${UseSAC:-off}")" + + case "$mode" in + off) + notify_send "$telegram_message" + ;; + exclusive) + sac_send_event "$event_type" "$severity" "$title" "$summary" + ;; + dual) + sac_send_event "$event_type" "$severity" "$title" "$summary" || true + notify_send "$telegram_message" + ;; + fallback) + if sac_send_event "$event_type" "$severity" "$title" "$summary"; then + return 0 + fi + notify_send "$telegram_message" + ;; + *) + write_log "WARN: неизвестный UseSAC=$mode, только Telegram" + notify_send "$telegram_message" + ;; + esac +} + +run_check_sac() { + printf 'Проверка SAC (ssh-monitor)\n' + printf 'UseSAC=%s\n' "${UseSAC:-off}" + printf 'SAC_URL=%s\n' "${SAC_URL:-}" + if ! sac_is_configured; then + printf 'SAC: SAC_URL или SAC_API_KEY не заданы\n' >&2 + return 1 + fi + if sac_check_health; then + printf 'SAC health: OK\n' + else + printf 'SAC health: FAIL\n' >&2 + return 1 + fi + if sac_send_event "agent.test" "info" "SAC test" "ssh-monitor --check-sac"; then + printf 'SAC ingest agent.test: OK (ожидается HTTP 202)\n' + return 0 + fi + printf 'SAC ingest agent.test: FAIL\n' >&2 + return 1 +} diff --git a/ssh-monitor b/ssh-monitor index ea2f2c0..fd7432a 100644 --- a/ssh-monitor +++ b/ssh-monitor @@ -9,6 +9,18 @@ IFS=$'\n\t' CONFIG_FILE="/etc/ssh-monitor.conf" SSH_MONITOR_VERSION="1.1.3-server-label" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/sac-client.sh" ]; then + # shellcheck source=sac-client.sh + source "$SCRIPT_DIR/sac-client.sh" +else + sac_normalize_mode() { printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]'; } + sac_is_configured() { return 1; } + sac_check_health() { return 1; } + notify_or_sac() { notify_send "${5:-$4}"; } + run_check_sac() { printf 'sac-client.sh не найден\n' >&2; return 1; } +fi + TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" BACKUP_WEBHOOK_URL="" @@ -17,6 +29,15 @@ BACKUP_WEBHOOK_URL="" # Пусто = авто: только настроенные каналы, порядок telegram → email. NOTIFY_ORDER="" +# Security Alert Center: off | exclusive | dual | fallback +UseSAC="off" +SAC_URL="" +SAC_API_KEY="" +SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool" +SAC_TIMEOUT_SEC="12" +SAC_SEND_HEARTBEAT="1" +SAC_FALLBACK_FAILURES="5" + # Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. MAIL_SMTP_HOST="" MAIL_SMTP_PORT="587" @@ -192,6 +213,11 @@ load_config() { local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}" local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}" local cfg_notify_order="${NOTIFY_ORDER:-}" + local cfg_use_sac="${UseSAC:-off}" + local cfg_sac_url="${SAC_URL:-}" + local cfg_sac_api_key="${SAC_API_KEY:-}" + local cfg_sac_spool_dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + local cfg_sac_timeout_sec="${SAC_TIMEOUT_SEC:-12}" local cfg_mail_smtp_host="${MAIL_SMTP_HOST:-}" local cfg_mail_smtp_port="${MAIL_SMTP_PORT:-587}" local cfg_mail_smtp_user="${MAIL_SMTP_USER:-}" @@ -254,6 +280,11 @@ load_config() { csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")" + UseSAC="$(trim_value "${UseSAC:-$cfg_use_sac}")" + SAC_URL="$(trim_value "${SAC_URL:-$cfg_sac_url}")" + SAC_API_KEY="$(trim_value "${SAC_API_KEY:-$cfg_sac_api_key}")" + SAC_SPOOL_DIR="$(trim_value "${SAC_SPOOL_DIR:-$cfg_sac_spool_dir}")" + SAC_TIMEOUT_SEC="$(trim_value "${SAC_TIMEOUT_SEC:-$cfg_sac_timeout_sec}")" MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")" MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}" MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")" @@ -357,8 +388,31 @@ validate_config() { exit 1 fi fi + local sac_mode + sac_mode="$(sac_normalize_mode "${UseSAC:-off}")" + case "$sac_mode" in + off) ;; + exclusive | dual | fallback) + if ! sac_is_configured; then + printf '%s\n' "UseSAC=${sac_mode}: задайте SAC_URL и SAC_API_KEY" >&2 + exit 1 + fi + ;; + *) + printf '%s\n' "Неверный UseSAC=\"${UseSAC:-}\"" >&2 + exit 1 + ;; + esac + refresh_notify_chain - if [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then + if [ "$sac_mode" = "exclusive" ]; then + if [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${CHECK_SAC_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then + if ! sac_check_health; then + printf '%s\n' 'UseSAC=exclusive: SAC /health недоступен' >&2 + exit 1 + fi + fi + elif [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2 exit 1 fi @@ -1868,6 +1922,8 @@ run_check_config() { "${MAIL_SMTP_STARTTLS:-}" "${MAIL_SMTP_SSL:-}" printf 'MAIL_SMTP_USER: %s\n' "$([ -n "${MAIL_SMTP_USER:-}" ] && echo задан || echo не задан)" printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)" + printf 'UseSAC=%s SAC_URL=%s SAC_API_KEY=%s\n' "${UseSAC:-off}" "${SAC_URL:-}" \ + "$([ -n "${SAC_API_KEY:-}" ] && echo задан || echo не задан)" if bash -n "$0" 2>/dev/null; then printf 'bash -n: OK\n' else @@ -1881,6 +1937,7 @@ parse_args() { for a in "$@"; do case "$a" in --check-config) CHECK_CONFIG_ONLY=1 ;; + --check-sac) CHECK_SAC_ONLY=1 ;; --dry-run) DRY_RUN=1 ;; esac done @@ -1900,6 +1957,11 @@ if [ "$CHECK_CONFIG_ONLY" = "1" ]; then exit $? fi +if [ "${CHECK_SAC_ONLY:-0}" = "1" ]; then + run_check_sac + exit $? +fi + trap send_shutdown_notification EXIT INT TERM mkdir -p "$(dirname "$LOG_FILE")" diff --git a/ssh-monitor.conf.example b/ssh-monitor.conf.example index d227372..0bd69bf 100644 --- a/ssh-monitor.conf.example +++ b/ssh-monitor.conf.example @@ -25,6 +25,14 @@ BACKUP_WEBHOOK_URL="" # NOTIFY_ORDER="email" NOTIFY_ORDER="" +# --- Security Alert Center (SAC) --- +# off | exclusive | dual | fallback — см. docs в security-alert-center (agent-integration.md) +UseSAC="off" +SAC_URL="https://sac.kalinamall.ru/api/v1/events" +SAC_API_KEY="" +SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool" +SAC_TIMEOUT_SEC="12" + # SMTP (python3). Для авто-режима почты нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. MAIL_SMTP_HOST="" MAIL_SMTP_PORT="587"