#!/bin/bash # The deadman: the one check that does not depend on the Elastic stack being alive. # # Kibana tells you when something on the box is wrong. Nothing inside Elasticsearch, Kibana or the Beats can # tell you that they are the thing that broke, and silence looks exactly like health. So this runs on the HOST # from a systemd timer, uses nothing but curl, and posts straight to a Discord webhook. # # It checks six things, in the order they go wrong in practice: # 1. Elasticsearch answers and the cluster is not red # 2. no index has been flipped to read-only by the disk watermark — the classic Elastic failure, because it # does NOT clear itself when space is freed, so ingestion stays silently dead until someone resets it # 3. Kibana reports itself available (it is the only UI; if it is down you are blind) # 4. Filebeat has actually written a document recently — "running" is not the same as "shipping" # 5. Metricbeat likewise # 6. the heartbeat a Kibana rule posts to dumpd is fresh, which proves rule evaluation AND delivery work # …and the disks, because that is the failure this box is most likely to die of, and it must be reported # even when the thing that would normally report it is what broke. # # State in /var/lib/vanguard/deadman.state: it announces a change, not the same thing every five minutes, # and says so once when everything recovers. # # No webhook configured? The checks still run and still print to the journal, and the unit exits non-zero # when it found something it could not report — so `systemctl is-failed vanguard-deadman.service` and # `journalctl -u vanguard-deadman` remain a signal. It never fails because the webhook is absent alone. set -uo pipefail ES="${ES_URL:-http://127.0.0.1:9200}" KIBANA="${KIBANA_URL:-http://127.0.0.1:5601}" WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-/etc/vanguard/alert-webhook}" HEARTBEAT="${ALERT_HEARTBEAT_FILE:-/srv/media/.markers/alert-heartbeat}" HEARTBEAT_MAX_S="${HEARTBEAT_MAX_S:-900}" STALE_MAX_S="${BEAT_STALE_MAX_S:-900}" STATE="${DEADMAN_STATE:-/var/lib/vanguard/deadman.state}" DISK_PCT="${DEADMAN_DISK_PCT:-90}" problems=() note() { problems+=("$1"); } jq_get() { python3 -c 'import json,sys try: d=json.load(sys.stdin) except Exception: sys.exit(1) for k in sys.argv[1].split("."): d = d.get(k) if isinstance(d, dict) else None if d is None: sys.exit(1) print(d)' "$1" 2>/dev/null; } # 1. Elasticsearch health=$(curl -s -m 8 "$ES/_cluster/health" || true) if [[ -z $health ]]; then note "Elasticsearch is not answering on ${ES#http://} — nothing is being stored" else status=$(printf '%s' "$health" | jq_get status) case "$status" in green|yellow) ;; red) note "Elasticsearch cluster health is RED: some index has no usable shard" ;; *) note "Elasticsearch health is unreadable (got: ${status:-nothing})" ;; esac # 2. read-only indices. Elastic sets this when the disk passes the flood watermark and never unsets it. blocked=$(curl -s -m 8 "$ES/_all/_settings/index.blocks.read_only_allow_delete" \ | python3 -c 'import json,sys try: d=json.load(sys.stdin) except Exception: sys.exit(0) bad=[n for n,v in d.items() if str(v.get("settings",{}).get("index",{}).get("blocks",{}).get("read_only_allow_delete","")).lower()=="true"] print(len(bad))' 2>/dev/null || echo 0) (( ${blocked:-0} > 0 )) && note "$blocked Elasticsearch index/indices are READ-ONLY from the disk watermark; ingestion is dead until this is reset (see README 5h)" # 4 and 5. are the Beats actually writing? for beat in filebeat metricbeat; do count=$(curl -s -m 8 -H 'Content-Type: application/json' \ "$ES/${beat}-*/_count" \ -d "{\"query\":{\"range\":{\"@timestamp\":{\"gte\":\"now-${STALE_MAX_S}s\"}}}}" | jq_get count) if [[ -z ${count:-} ]]; then note "cannot tell whether $beat is shipping (no index yet, or the query failed)" elif (( count == 0 )); then note "$beat has written nothing in the last $((STALE_MAX_S/60)) minutes — it may be up but not shipping" fi done fi # 3. Kibana level=$(curl -s -m 8 "$KIBANA/api/status" | jq_get status.overall.level) [[ ${level:-} == available ]] || note "Kibana is not available (${level:-no answer}) — the only UI is down" # 6. the heartbeat a Kibana rule posts here. Missing right after a restart is not yet a fault; age is. if [[ -f $HEARTBEAT ]]; then age=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT") )) (( age <= HEARTBEAT_MAX_S )) || note "no alert heartbeat for ${age}s (the Kibana rule → webhook → dumpd path is broken)" else note "no alert heartbeat has ever arrived (is the Kibana heartbeat rule created? README 5h)" fi # the disks, independently of any agent while read -r pct mount; do (( pct >= DISK_PCT )) && note "$mount is ${pct}% full" done < <(df --output=pcent,target /srv / 2>/dev/null | tail -n +2 | tr -d '%' | awk '{print $1, $2}') # ---- report only on change --------------------------------------------------------------------------- now="$(IFS='|'; echo "${problems[*]-}")" mkdir -p "$(dirname "$STATE")" was="$(cat "$STATE" 2>/dev/null || true)" printf '%s' "$now" > "$STATE" [[ $now == "$was" ]] && exit 0 webhook="" if [[ -r $WEBHOOK_FILE ]]; then webhook="$(tr -d '[:space:]' < "$WEBHOOK_FILE" 2>/dev/null || true)"; fi case "$webhook" in https://*) ;; "") webhook="" ;; *) echo "deadman: ALERT_WEBHOOK_URL is not an https URL; ignoring it" >&2; webhook="" ;; esac post() { [[ -n $webhook ]] || return 1 curl -s -m 15 -H 'Content-Type: application/json' \ --data "$(python3 -c 'import json,sys; print(json.dumps({"content": sys.argv[1][:1900]}))' "$1")" \ "$webhook" >/dev/null && return 0 echo "deadman: the webhook post failed" >&2 return 1 } if [[ -z $now ]]; then echo "deadman: recovered — Elastic, Kibana, both Beats and the disks are healthy" post "✅ **Vanguard monitoring recovered** — Elasticsearch, Kibana and both Beats are healthy again." || true exit 0 fi printf 'deadman: %s\n' "${problems[@]}" >&2 body="⚠️ **Vanguard monitoring is not healthy** (sent from the host, outside Elastic):" for item in "${problems[@]}"; do body+=$'\n'"• $item"; done body+=$'\n'"Until this clears, assume no other alert can reach you." if post "$body"; then exit 0; fi [[ -z $webhook ]] && echo "deadman: no Discord webhook configured (ALERT_WEBHOOK_URL in .env -> $WEBHOOK_FILE), so the above was not sent" >&2 exit 1