Files
Media-Server-Setup/bin/vanguard-deadman.sh
Brandyn / Techy b38d40ab0c Elastic security, our own metrics, and an alert pipeline that can actually reach Discord
Observability was running but could not report anything. This closes that end to end and
makes every piece of it reproducible from the installers instead of from a shell history.

Logs
- Filebeat 9 REMOVED the container input; it refuses the config per container and then runs
  with zero harvesters and no index. Container logs now use filestream + the container parser
  with a stable id each, and add_docker_metadata is told where the id is.
- Every line gets event.dataset, or Kibana's Logs rate chart is one "unknown" bucket.
- Beats pin replicas to 0: a single node cannot place one, so the cluster sat yellow forever.

Security
- xpack.security.enabled=true, because Kibana's Observability apps ask Elasticsearch whether
  the user holds a privilege and that API does not exist when security is off (the
  Infrastructure page failed on every view). Nobody sees a login: Kibana's anonymous provider
  signs every visitor in as one native user, and Caddy + Authentik + DIAG_REQUIRE_GROUP stays
  the only gate. configure.py generates the service passwords, creates the accounts and writes
  a root-only netrc for the deadman; --es-security retries just that step.

Alerting
- A basic licence has no webhook connector (403, Gold+), so Kibana's rules write documents into
  a vanguard-alerts index through the free .index connector and vanguard-alerts.timer delivers
  them to Discord. Alerts are therefore searchable beside the logs that caused them.
- Six rules in observability/kibana-alarms.py: disk and memory below 15% free, bot not running,
  bot unhealthy, recordings stuck over an hour, normalise failures parked. Each repeats every
  12 h while unresolved.
- The deadman watches both halves of that pipeline, since nothing inside the stack can report
  that the stack's own alerting died.

Our own metrics
- bin/vgmetrics.sh counters from the sweep and vgdeband, plus live gauges on dumpd /metrics
  (loopback only, unrouted by Caddy), scraped by Metricbeat. vanguard_media_pending is the
  measured answer to whether normalising keeps up, which was previously only a benchmark guess.
- The sweep now captures ffmpeg's stderr: rc= alone was not diagnosable.
- Its file loop reads a process substitution, not a pipe, or the subshell discards every counter.

Housekeeping
- configure.py --apply installs the media scripts (it never did, so applies left stale binaries),
  creates the alert index, injects an ILM delete phase and runs Kibana's first-run setup.
- The discord-bot container asks the bot about itself via /health rather than probing a port.
- Removed the dead diag landing page: Kibana occupies the root of that host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 19:58:06 -06:00

163 lines
8.4 KiB
Bash
Executable File

#!/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}"
# Elasticsearch requires credentials (security is on; observability/kibana.yml says why). configure.py writes
# this netrc, root-only: netrc rather than -u so the password is never visible in a process list. If the file
# is missing, the calls below get 401 and the deadman reports Elasticsearch as unreachable — which is correct,
# because from its point of view it is. Kibana needs nothing: its anonymous provider signs callers in.
ES_NETRC="${ES_NETRC_FILE:-/etc/vanguard/es-netrc}"
es_curl() {
if [[ -r $ES_NETRC ]]; then curl -s --netrc-file "$ES_NETRC" "$@"; else curl -s "$@"; fi
}
KIBANA="${KIBANA_URL:-http://127.0.0.1:5601}"
WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-/etc/vanguard/alert-webhook}"
ALERT_INDEX="${ALERT_INDEX:-vanguard-alerts}" # the alert bus Kibana's rules write into
ALERT_STATE="${ALERT_STATE:-/var/lib/vanguard/alerts.state}" # touched by every vanguard-alerts.sh run
ALERT_STATE_MAX_S="${ALERT_STATE_MAX_S:-900}"
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=$(es_curl -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=$(es_curl -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=$(es_curl -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 writes a kind=heartbeat document into the alert index every minute (see
# observability/kibana-setup.py). Its absence is the ONLY thing that catches the alerting pipeline itself
# dying: if Kibana, its rule engine or the index connector stops, no ordinary alert can report it, because
# the thing that would report it is what broke. Checked in Elasticsearch, not as a file: a basic licence has
# no webhook connector, so nothing posts to dumpd any more.
if [[ -n $health ]]; then
beats=$(es_curl -m 8 -H 'Content-Type: application/json' "$ES/$ALERT_INDEX/_count" \
-d "{\"query\":{\"bool\":{\"filter\":[{\"term\":{\"kind\":\"heartbeat\"}},{\"range\":{\"@timestamp\":{\"gte\":\"now-${HEARTBEAT_MAX_S}s\"}}}]}}}")
if grep -q 'index_not_found_exception' <<<"${beats:-}"; then
note "the alert index $ALERT_INDEX does not exist — the Kibana connector and heartbeat rule have not been created (observability/kibana-setup.py)"
else
hb=$(printf '%s' "${beats:-}" | jq_get count)
if [[ -z ${hb:-} ]]; then
note "cannot read the alert heartbeat (query against $ALERT_INDEX failed)"
elif (( hb == 0 )); then
note "no alert heartbeat in the last $((HEARTBEAT_MAX_S/60)) minutes — Kibana's rule engine or the index connector has stopped, so NO alert can reach Discord"
fi
fi
fi
# 7. is the delivery side running? The heartbeat proves Kibana still writes alerts; this proves something
# still takes them to Discord. Both halves have to be alive for an alarm to arrive.
if [[ -f $ALERT_STATE ]]; then
age=$(( $(date +%s) - $(stat -c %Y "$ALERT_STATE") ))
(( age <= ALERT_STATE_MAX_S )) || note "vanguard-alerts.timer has not run for ${age}s — alerts are being written but not delivered"
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