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>
167 lines
7.7 KiB
Bash
Executable File
167 lines
7.7 KiB
Bash
Executable File
#!/bin/bash
|
|
# Deliver Kibana's alerts to Discord. Runs from a systemd timer every minute; curl and python only.
|
|
#
|
|
# WHY THIS EXISTS
|
|
# On a basic license Kibana's only connector types are .index and .server-log — every connector that leaves
|
|
# the stack is Gold or above. So Kibana rules write their alerts into ONE Elasticsearch index (the alert bus,
|
|
# created by observability/kibana-setup.py) and this script is the delivery side. Alerts are therefore
|
|
# ordinary documents: queryable, replayable, and visible in Discover alongside the logs that caused them.
|
|
#
|
|
# Heartbeat documents (kind=heartbeat) are deliberately NOT delivered. They exist for bin/vanguard-deadman.sh
|
|
# to notice their absence, which is the only way to catch the alerting pipeline itself dying.
|
|
#
|
|
# Repeat suppression is a safety net, not the primary throttle: a rule should use "On status change" or its
|
|
# own throttle in Kibana. This stops a misconfigured rule turning into a message every minute, and it is per
|
|
# rule name, so one noisy rule never silences another.
|
|
#
|
|
# Exit status: 0 when there was nothing to send or everything was sent; 1 when alerts existed and could not be
|
|
# delivered, so `systemctl status vanguard-alerts` and the journal both carry the failure.
|
|
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}"
|
|
ES_NETRC="${ES_NETRC_FILE:-/etc/vanguard/es-netrc}"
|
|
INDEX="${ALERT_INDEX:-vanguard-alerts}"
|
|
STATE="${ALERT_STATE:-/var/lib/vanguard/alerts.state}"
|
|
WEBHOOK_FILE="${ALERT_WEBHOOK_FILE:-/etc/vanguard/alert-webhook}"
|
|
SUPPRESS_S="${ALERT_SUPPRESS_S:-900}"
|
|
BATCH="${ALERT_BATCH:-50}"
|
|
|
|
vglog() { local lvl="$1"; shift; printf 'ts=%s level=%s component=alerts %s\n' "$(date -Is)" "$lvl" "$*"; }
|
|
es_curl() { if [[ -r $ES_NETRC ]]; then curl -s --netrc-file "$ES_NETRC" "$@"; else curl -s "$@"; fi; }
|
|
|
|
state_get() { [[ -r $STATE ]] || return 0; grep -m1 "^$1=" "$STATE" 2>/dev/null | cut -d= -f2-; }
|
|
state_set() {
|
|
local key="$1" value="$2" tmp
|
|
install -d "$(dirname "$STATE")" 2>/dev/null || return 0
|
|
tmp=$(mktemp "${STATE}.XXXXXX" 2>/dev/null) || return 0
|
|
{ [[ -r $STATE ]] && grep -v "^${key}=" "$STATE"; printf '%s=%s\n' "$key" "$value"; } > "$tmp" 2>/dev/null
|
|
mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
|
|
}
|
|
|
|
# Delivery (and the mention handling) is shared with the deadman. If the helper is missing — a half-finished
|
|
# deploy — fall back to posting without mentions rather than losing alerts entirely.
|
|
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)
|
|
|
|
now=$(date +%s)
|
|
cursor=$(state_get cursor); cursor="${cursor:-0}"
|
|
# Record every run, not just runs that did something. The deadman watches this file's age to tell whether
|
|
# delivery is still happening at all, and almost every run is quiet: without this the file went stale within
|
|
# minutes and the deadman reported the timer as dead, then recovered the moment an alert happened to flow.
|
|
state_set last_run "$now"
|
|
|
|
# Everything newer than the cursor that is not a heartbeat, oldest first so the cursor advances safely.
|
|
query=$(printf '{"size":%d,"sort":[{"@timestamp":"asc"}],"query":{"bool":{"filter":[{"range":{"@timestamp":{"gt":%s,"format":"epoch_millis"}}}],"must_not":[{"term":{"kind":"heartbeat"}}]}}}' "$BATCH" "$cursor")
|
|
response=$(es_curl -m 15 -H 'Content-Type: application/json' "$ES/$INDEX/_search" -d "$query")
|
|
|
|
if [[ -z $response ]]; then
|
|
vglog error 'event=unreachable msg="Elasticsearch did not answer; cannot read the alert bus"' >&2
|
|
exit 1
|
|
fi
|
|
# A missing index just means no rule has fired yet. Not a fault, and not worth a line every minute.
|
|
if grep -q 'index_not_found_exception' <<<"$response"; then
|
|
exit 0
|
|
fi
|
|
|
|
# Retention. A heartbeat document arrives every minute, so without this the alert bus grows without bound.
|
|
# At most once every 6 h: a delete_by_query is cheap, but not worth doing sixty times an hour. Deliberately
|
|
# before the "nothing new" exit below, because most runs have nothing to deliver and would skip it.
|
|
retention_days="${ALERT_RETENTION_DAYS:-90}"
|
|
last_purge=$(state_get purged); last_purge="${last_purge:-0}"
|
|
if (( now - last_purge > 21600 )); then
|
|
purge=$(es_curl -m 120 -H 'Content-Type: application/json' \
|
|
-XPOST "$ES/$INDEX/_delete_by_query?conflicts=proceed" \
|
|
-d "{\"query\":{\"range\":{\"@timestamp\":{\"lt\":\"now-${retention_days}d\"}}}}")
|
|
gone=$(printf '%s' "${purge:-}" | python3 -c 'import json,sys
|
|
try: print(json.load(sys.stdin).get("deleted", 0))
|
|
except Exception: print("?")')
|
|
state_set purged "$now"
|
|
vglog info "event=purged older_than=${retention_days}d deleted=${gone:-?}"
|
|
fi
|
|
|
|
rows=$(printf '%s' "$response" | python3 -c '
|
|
import json, sys
|
|
from datetime import datetime
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
except Exception:
|
|
sys.exit(0)
|
|
for hit in data.get("hits", {}).get("hits", []):
|
|
source = hit.get("_source", {})
|
|
stamp = str(source.get("@timestamp", ""))
|
|
try:
|
|
when = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
millis = int(when.timestamp() * 1000)
|
|
except Exception:
|
|
continue
|
|
def clean(value, limit):
|
|
return " ".join(str(value).split())[:limit] or "-"
|
|
print("\t".join((str(millis),
|
|
clean(source.get("rule", "unnamed rule"), 120),
|
|
clean(source.get("level", "warn"), 12),
|
|
clean(source.get("message", json.dumps(source)[:400]), 1200))))
|
|
')
|
|
|
|
[[ -z $rows ]] && exit 0
|
|
|
|
# One per line: "a=0 b=0 now" would make bash run `now` as a COMMAND with a and b as temporary
|
|
# assignments, leaving every counter unset for the rest of the script.
|
|
sent=0
|
|
suppressed=0
|
|
failed=0
|
|
while IFS=$'\t' read -r millis rule level message; do
|
|
[[ -z ${millis:-} ]] && continue
|
|
last=$(state_get "sent:$rule"); last="${last:-0}"
|
|
if (( now - last < SUPPRESS_S )); then
|
|
suppressed=$((suppressed + 1))
|
|
vglog info "event=suppressed rule=\"$rule\" within=${SUPPRESS_S}s"
|
|
state_set cursor "$millis"
|
|
continue
|
|
fi
|
|
case "$level" in
|
|
error|critical) mark="[ERROR]" ;;
|
|
warn|warning) mark="[WARN]" ;;
|
|
*) mark="[$level]" ;;
|
|
esac
|
|
text="$mark $rule — $message"
|
|
if [[ -z $webhook ]]; then
|
|
vglog warn "event=undeliverable rule=\"$rule\" msg=\"no Discord webhook configured; alert only in the journal\"" >&2
|
|
vglog warn "event=alert rule=\"$rule\" level=$level msg=\"$message\"" >&2
|
|
failed=$((failed + 1))
|
|
state_set cursor "$millis"
|
|
continue
|
|
fi
|
|
if vg_post "$level" "$text"; then
|
|
sent=$((sent + 1)); state_set "sent:$rule" "$now"
|
|
vglog info "event=delivered rule=\"$rule\" level=$level mention=$([[ -n ${ALERT_MENTION:-} && ,${ALERT_MENTION_LEVELS:-error}, == *,$level,* ]] && echo yes || echo no)"
|
|
else
|
|
failed=$((failed + 1))
|
|
vglog error "event=delivery_failed rule=\"$rule\" level=$level" >&2
|
|
fi
|
|
state_set cursor "$millis"
|
|
done <<<"$rows"
|
|
|
|
vglog info "event=run sent=$sent suppressed=$suppressed failed=$failed"
|
|
(( failed > 0 )) && exit 1
|
|
exit 0
|