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>
163 lines
7.4 KiB
Bash
Executable File
163 lines
7.4 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}"
|
|
|
|
# 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
|