383 lines
10 KiB
Bash
383 lines
10 KiB
Bash
# 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%/}"
|
||
}
|
||
|
||
# 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}" \
|
||
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()
|
||
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",
|
||
},
|
||
"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" ]
|
||
}
|
||
|
||
# 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}")"
|
||
|
||
# Периодический 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
|
||
}
|