Files
Media-Server-Setup/bin/vanguard-deadman.sh
Brandyn / Techy ca01516d42 Stuck-recording triage, a diag landing page, and the fixes behind Tuesday's symptoms
Triage
- bin/vgstuck.sh classifies every unpublished recording (parked by a failure log, published
  collision, waiting, still writing), with ffmpeg's own reason for parked ones. retry clears the
  blocker; drop moves to trash/<today>/ with the same 3-day undo as a dumped night; drop --all is
  refused. A leftover .mp4.part blocks nothing (ffmpeg overwrites with -y) and is only cleaned up.
- The same tool backs a page at diag.HOST/stuck. dumpd serves /stuck/api, lets anyone past the gate
  read, and allows retry/bin only for the admin groups; the path validation is the single thing
  between a request body and a file move, so it accepts only an existing .mp4 under .live/ inside
  the streams tree.
- The sweep logs event=parked once per run so Kibana can name stuck files rather than only count
  them.

Landing
- diag.HOST opens on a "Vanguard ops" dashboard via Kibana's defaultRoute: a markdown panel of links
  to every host and the triage page, plus Discover, Dashboards, Stack Rules and Index Management.
  Kibana stays at the root; moving it under /kibana would have meant the prefix in both Beats'
  setup.kibana, two scripts and the deadman, with Metricbeat crash-looping on whichever was missed.
- /api/kibana/settings is an internal API behind server.restrictInternalApis, which Kibana 9 turns
  on by default, so the script sends x-elastic-internal-origin. kibana-setup.py takes a step name and
  verifies each step by reading it back.

Installers
- bin/install.list is the one list of what lands in /usr/local/bin, read by install.sh,
  configure.py and deploy.sh. deploy.sh's own copy had drifted to five of ten files. check.sh fails
  on drift. deploy.sh also recreates a Beat or Kibana when its bind-mounted config changes, which
  nothing did before.
- check.sh distinguishes absent from broken: the server checkout is partial, and a yaml file or node
  binary that is not there is a skip, not a failure.

Fixes
- Go Live pixelation: x-google-min-bitrate=6000 forbade Chrome from encoding below 6 Mbps, so on a
  weak uplink the excess was dropped and MediaMTX logged invalid FU-A packets. Loss was on the
  publish sessions only. Floor is 1200 and scaleResolutionDownBy is unpinned.
- HLS manifestParsingError: handle_path strips /hls, so MediaMTX's cookieCheck redirect came back
  root-relative and landed on the SPA catch-all. header_down rewrites Location.
- Sweep audio: the band-removal branch overwrote $reason and lost the probe's audio=opus marker, so a
  Go Live recording with the band shipped Opus inside an MP4. probe_reason is kept separately.
- Deadman false alarm: alerts.state was only written when something changed, so quiet runs let it go
  stale. Every run stamps last_run now, and the deadman also checks is-failed on the unit.
- Both timer scripts load /etc/vanguard/alerts.env themselves, so a run by hand matches the timer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 21:25:43 -06:00

180 lines
9.6 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
# systemd passes these in via EnvironmentFile. Load the same file when run by hand, or a manual run behaves
# differently from the timer — which cost a confusing round of "the mention is configured but never appears".
# Anything already in the environment wins, so `ALERT_SUPPRESS_S=0 vanguard-alerts.sh` still overrides.
if [[ -r /etc/vanguard/alerts.env ]]; then
while IFS='=' read -r _k _v; do
[[ -z ${_k:-} || ${_k:0:1} == '#' ]] && continue
[[ -n ${!_k:-} ]] || export "$_k=$_v"
done < /etc/vanguard/alerts.env
fi
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
# Freshness above proves the timer fires; this proves the run finished. A unit that starts every minute and
# dies immediately would otherwise look alive, because the file is written at the start of the run.
if systemctl is-failed --quiet vanguard-alerts.service 2>/dev/null; then
note "vanguard-alerts.service is in a failed state — $(systemctl show -p Result --value vanguard-alerts.service 2>/dev/null); alerts are 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
# Delivery and mention handling are shared with vanguard-alerts.sh. A missing helper falls back to a plain
# post: losing the mention is acceptable, losing the alert is not.
if [[ -r "$(dirname "$0")/vgnotify.sh" ]]; then
. "$(dirname "$0")/vgnotify.sh"
else
vg_webhook() { [[ -r $WEBHOOK_FILE ]] && tr -d '[:space:]' < "$WEBHOOK_FILE" | grep '^https://'; }
vg_post() {
local url; url=$(vg_webhook) || return 1
curl -s -o /dev/null -m 15 -X POST -H 'Content-Type: application/json' \
--data "$(VG_T="$2" python3 -c 'import json,os; print(json.dumps({"content": os.environ["VG_T"][:1900]}))')" \
"$url"
}
fi
webhook=$(vg_webhook 2>/dev/null || true)
# A deadman alarm is the one that must wake somebody: it means no other alert can get through. Recovery is
# sent at info level so good news never pings.
post() { vg_post "${2:-error}" "$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." info || 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