Mentions
- bin/vgnotify.sh is shared by the alert timer and the deadman. A webhook will not ping a role
from <@&id> in the content alone: the request has to whitelist it, so every post now sends an
explicit allowed_mentions. parse is always [] as well, so an @everyone appearing inside a log
line or filename in an alert text can never ping the server.
- ALERT_MENTION is written as role:<id>/user:<id> rather than raw Discord syntax, and
ALERT_MENTION_LEVELS keeps pings to errors by default so warnings arrive quietly. The deadman
pings too, because its alarm means nothing else can get through, but its recovery is info level.
- Both scripts now read /etc/vanguard/alerts.env themselves instead of relying on systemd to
inject it. Running one by hand previously behaved differently from the timer, which is exactly
how "the mention is configured but never appears" happened.
configure.py --set
- Change one setting and apply only what it needs. --full re-prompts for every hostname and
secret, which is far too blunt for pointing the alarms at a new channel, and mistyping a
hostname there has broken TLS before.
- Every Discord credential and webhook is now one commented block at the top of .env, so there is
one place to look, and --show prints the same five together with the secrets truncated.
- Each key applies only its own side effect: the webhook rewrites its root-only file, the deploy
webhook rewrites deploy.env, the bot token recreates the bot container, log retention re-injects
the ILM delete phase. LIVE_WEBHOOK_URL deliberately stops short, because MediaMTX only reads it
at start and a recreate cuts a live recording.
Logout
- The proxy provider pointed at default-provider-invalidation-flow ("Logged out of application"),
which ends only that application's session and leaves the SSO session alive, so /logout dropped
the proxy cookie and forward-auth signed the user straight back in. It looked like a refresh.
default-invalidation-flow ("Logout") is what Authentik's own button uses. --apply now enforces
it had explicitly recommended the wrong flow.
Sweep
- The band-removal branch overwrote $reasocodec> marker, which is the
only signal telling the encode to convert audio. A Go Live stream with both the band and Opus
audio therefore got -c:a copy and shippeachable once Windows Go
Live audio started working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
9.2 KiB
Bash
Executable File
175 lines
9.2 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
|
|
|
|
# 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
|