feat(alerts): three notification channels, and security routes on its own - #118
Merged
Conversation
… own `urgent` and `default` both read the same url_file, so the two receiver names described a difference that did not exist. The routing tree differentiated timing — 0s/30m for critical power against 30s/12h for the default route — but not destination, so a UPS on battery and a warning about scrape latency arrived in the same place. Timing is not the part you notice. Per-topic sound and do-not-disturb settings live on the receiving end, so only a separate destination can make "this one is urgent" audible. Three receivers now read three files: webhook_url default warnings urgent_url urgent criticals, power first security_url security anything category=security And nothing routed on `category` except power. Ten rules carry `category: security` across security.rules.yaml and network.rules.yaml — SSH brute force, a terminal segment reaching the internal network, IoT lateral movement, priority-1 Suricata — and routed on `severity` alone, the warning-severity half of that list landed in the default channel on a 12-hour repeat, indistinguishable from a disk filling up. `category: power` was already the precedent for fixing that. The tree is first-match-wins with no `continue` anywhere, so the order of the routes IS the design — and `amtool check-config` cannot see it. Demoting the security warning route below the bare `severity = "warning"` route was tried against the pinned image: check-config reports SUCCESS and every warning-severity security alert silently goes to the wrong channel. So scripts/validate.sh and CI now assert the table itself, one `amtool config routes test --verify.receivers` per row. Both places in one commit, because that pair has drifted before (#68). render-config.sh renders the three from a list rather than three copies of the same four lines, and cross-checks every `url_file:` in alertmanager.yaml against that list — a url_file that does not exist is not a config error, since Alertmanager reads it at notify time, so the stack starts, check-config passes, and the first real alert is what discovers the missing file. Adding a receiver and forgetting the list now fails at render time instead. The two new keys have to be filled in with `make secrets-edit` before `make up` will run; render-config.sh dies naming them. Closes #66 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 tasks
Owner
Author
|
Merged, not deployed — the two new SOPS keys have to be filled in first. Tracked with the exact commands and the current host state in #120. |
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
urgent,defaultandsecuritybecome three real destinations instead ofthree names for one URL, and
category: securitygets its own routes at bothseverities.
critical+category=powerurgentcritical+category=securitysecuritywarning+category=securitysecuritycritical(anything else)urgentwarning(anything else)defaultinfonullTwo new SOPS keys are required before
make upwill run —ALERTMANAGER_URGENT_WEBHOOK_URLandALERTMANAGER_SECURITY_WEBHOOK_URL. See"Before deploying" at the bottom.
Why
Closes #66.
urgentanddefaultboth readurl_file: .../webhook_url, so the routing treedifferentiated timing and not destination. Timing is not the part you
notice: per-topic sound and do-not-disturb settings live on the receiving end, so
only a separate destination can make "this one is urgent" audible. A UPS on
battery and a warning about scrape latency arrived in the same place, differing
only in how often they repeated.
And nothing routed on
categoryexcept power, while ten rules carrycategory: security—SshBruteForce,TerminalSegmentReachedInternalNetwork,IoTAttemptedLateralMovement,SuricataHighPriorityAlertand the rest. Routed onseverityalone, the warning-severity half of that list went to the defaultchannel on a 12-hour repeat, indistinguishable from a disk filling up.
category: powerhaving its own route was already the precedent.Blast radius
secrets/*.sops.yamlAlertmanager only. Two new keys go in the encrypted secrets file; nothing else
in the stack is touched. Worth stating explicitly: until the new keys exist,
render-config.shrefuses to render anything at all, so a half-configureddeploy is not reachable.
Verification
make validate— all pass, one skip (gitleaksnot installed locally).The routing assertions are the point of this PR
amtool check-configproves the tree parses and that every route names areceiver that exists. It cannot see route order — and the tree is
first-match-wins with no
continueanywhere, so order is the entire design.I mutated the config to confirm that gap is real, taking the security warning
route and demoting it below the bare
severity = "warning"route:check-config passes; every warning-severity security alert silently goes to the
wrong channel. So
scripts/validate.shand.github/workflows/ci.ymlnow asserteach row of the table above with
amtool config routes test --verify.receivers:Both places in the same commit —
make validateand CI have drifted before(#68).
render-config.sh
The three URLs render from a list rather than three copies of the same four
lines, and every
url_file:inalertmanager.yamlis cross-checked against thatlist. That check matters because a missing
url_fileis not a config error:Alertmanager reads it at notify time, so the stack starts,
check-configpasses,and the first real alert is what discovers it.
Exercised end to end on the host with throwaway URLs supplied via the
environment, so nothing went near the encrypted file:
Three files,
0600, no trailing newline (a newline inside a URL is a deliveryerror, not a warning). And with a deliberate typo in one
url_file:The cross-check also caught a bug in its own first draft — a bare
secrets/[a-z_]+grep matchedsecrets/observability.sops.yamlin the file'sown header comment and reported the comment as a missing channel. It is anchored
on
url_file:now, and the comment says why.Not deployed
make validatepassesdocs/observability.mdgains a### Routingsection withthe table;
secrets/README.md, thecompose.yamlmount comment and thesecrets example all catch up
Before deploying
make secrets-edit # add ALERTMANAGER_URGENT_WEBHOOK_URL and ALERTMANAGER_SECURITY_WEBHOOK_URL make upWith ntfy, three topics on one server is the whole setup — the topic is the last
path segment and needs no creation step. Then confirm all three arrive
separately:
docker exec alertmanager ls /etc/alertmanager/secrets/ amtool alert add --alertmanager.url=http://localhost:9093 \ alertname=RouteTest severity=warning category=securityPointing all three at the same topic works and breaks nothing — but then the
receiver names are lying again, which is what this PR fixed, so the secrets
example says to record that decision in
docs/observability.mdif you make it.🤖 Generated with Claude Code