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>
180 lines
9.6 KiB
Bash
Executable File
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
|