# SAC client for ssh-monitor (source from ssh-monitor) # Версия для SAC = SSH_MONITOR_VERSION в 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%/}" } # URL ingest (POST): всегда .../api/v1/events, даже если в конфиге указан только хост sac_ingest_url() { local base base="$(sac_base_url)" [ -n "$base" ] || return 1 printf '%s/api/v1/events' "$base" } 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" } sac_fail_count_file() { printf '%s' "${SAC_FAIL_COUNT_FILE:-/var/lib/ssh-monitor/sac-fail.count}" } sac_read_fail_count() { local f f="$(sac_fail_count_file)" if [ -f "$f" ]; then cat "$f" 2>/dev/null | tr -dc '0-9' else printf '0' fi } sac_write_fail_count() { local n="$1" local f f="$(sac_fail_count_file)" mkdir -p "$(dirname "$f")" 2>/dev/null || true printf '%s' "$n" >"$f" } sac_reset_fail_count() { sac_write_fail_count 0 } sac_increment_fail_count() { local n max max="${SAC_FALLBACK_FAILURES:-5}" n="$(sac_read_fail_count)" n=$((n + 1)) sac_write_fail_count "$n" [ "$n" -ge "$max" ] } # В fallback: после N подряд ошибок POST не дергать SAC, пока /health снова не OK. sac_should_attempt_send() { local mode mode="$(sac_normalize_mode "${UseSAC:-off}")" [ "$mode" = "fallback" ] || return 0 local max n max="${SAC_FALLBACK_FAILURES:-5}" n="$(sac_read_fail_count)" [ "$n" -lt "$max" ] && return 0 if sac_check_health; then sac_reset_fail_count write_log "SAC: /health восстановлен, снова отправляем в SAC (fallback)" return 0 fi write_log "WARN: SAC fallback: пропуск POST ($n>=$max сбоев подряд), только локальные каналы" return 1 } # POST готового JSON (ingest / повтор из spool). Возврат 0 при HTTP 201, 409 или 202 (legacy). 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 if ! sac_should_attempt_send; then return 1 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" = "201" ] || [ "$http_code" = "409" ] || [ "$http_code" = "202" ]; then sac_spool_remove "$event_id" 2>/dev/null || true sac_reset_fail_count 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" sac_increment_fail_count || write_log "WARN: SAC fallback: достигнут порог SAC_FALLBACK_FAILURES" 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" 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 if ! sac_should_attempt_send; then return 1 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}" \ SAC_SERVER_DISPLAY_NAME="${SERVER_DISPLAY_NAME:-}" \ SAC_HOST_IPV4="${SERVER_IPV4:-}" \ 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) def valid_ipv4(value: str) -> bool: try: parts = value.split(".") return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts) except Exception: return False def detect_host_ipv4() -> str: manual = os.environ.get("SAC_HOST_IPV4", "").strip() if manual and valid_ipv4(manual): return manual try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("1.1.1.1", 53)) ip = s.getsockname()[0] s.close() if valid_ipv4(ip) and not ip.startswith(("127.", "169.254.")): return ip except Exception: pass try: infos = socket.getaddrinfo(socket.gethostname(), None, family=socket.AF_INET) for info in infos: ip = info[4][0] if valid_ipv4(ip) and not ip.startswith(("127.", "169.254.")): return ip except Exception: pass return "" host = socket.gethostname() display = os.environ.get("SAC_SERVER_DISPLAY_NAME", "").strip() host_ipv4 = detect_host_ipv4() etype = os.environ["SAC_EVENT_TYPE"] if etype.startswith(("ssh.", "auth.", "rdp.")): category = "auth" elif etype.startswith("privilege."): category = "privilege" elif etype.startswith("session."): category = "session" elif etype.startswith("report."): category = "report" elif etype.startswith("rdg."): category = "network" else: category = "agent" 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", **({"display_name": display} if display else {}), **({"ipv4": host_ipv4} if host_ipv4 else {}), }, "category": category, "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 sac_post_payload "$payload" } # JSON details из переменных SAC_D_* (опционально) sac_build_details_json() { python3 <<'PY' import json, os out = {} for k, v in os.environ.items(): if k.startswith("SAC_D_") and v: out[k[6:].lower()] = v print(json.dumps(out) if out else "") PY } sac_is_heartbeat_only_event() { [ "${1:-}" = "agent.heartbeat" ] } # Добавляет generated_by / telegram_via в JSON details для ingest. sac_merge_notify_details() { local details_json="${1:-}" local telegram_via="${2:-}" DETAILS_IN="$details_json" TG_VIA="$telegram_via" python3 <<'PY' import json, os d = {} raw = os.environ.get("DETAILS_IN", "").strip() if raw: try: d = json.loads(raw) except Exception: d = {} d.setdefault("generated_by", "agent") via = os.environ.get("TG_VIA", "").strip() if via: d.setdefault("telegram_via", via) print(json.dumps(d, ensure_ascii=False)) PY } # notify_or_sac type severity title summary telegram_message [details_json] notify_or_sac() { local event_type="$1" local severity="$2" local title="$3" local summary="$4" local telegram_message="${5:-$summary}" local details_json="${6:-}" if [ -z "$details_json" ] && [ -n "${SAC_D_USER:-}${SAC_D_IP:-}${SAC_D_COMMAND:-}" ]; then details_json="$(sac_build_details_json)" fi unset SAC_D_USER SAC_D_IP SAC_D_COMMAND SAC_D_ATTEMPT SAC_D_MAX SAC_D_RUN_AS SAC_D_PWD 2>/dev/null || true local mode mode="$(sac_normalize_mode "${UseSAC:-off}")" local tg_via="" case "$mode" in exclusive|fallback) tg_via="sac" ;; dual) tg_via="agent" ;; esac if [ -n "$tg_via" ]; then details_json="$(sac_merge_notify_details "$details_json" "$tg_via")" fi # Периодический heartbeat — только SAC (браузер), без Telegram/email в любом режиме. if sac_is_heartbeat_only_event "$event_type"; then case "$mode" in off) return 0 ;; exclusive|dual|fallback) sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" return $? ;; *) return 0 ;; esac fi case "$mode" in off) notify_send "$telegram_message" ;; exclusive) sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" ;; dual) sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" || true NOTIFY_SKIP_SAC_MIRROR=1 notify_send "$telegram_message" ;; fallback) if sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"; then return 0 fi NOTIFY_SKIP_SAC_MIRROR=1 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}" 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)" fi 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 201)\n' return 0 fi printf 'SAC ingest agent.test: FAIL\n' >&2 return 1 }