Skip to content

feat(alerts): three notification channels, and security routes on its own - #118

Merged
Gerrrt merged 1 commit into
mainfrom
feat/three-notification-channels
Aug 24, 2026
Merged

feat(alerts): three notification channels, and security routes on its own#118
Gerrrt merged 1 commit into
mainfrom
feat/three-notification-channels

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 24, 2026

Copy link
Copy Markdown
Owner

What changed

urgent, default and security become three real destinations instead of
three names for one URL, and category: security gets its own routes at both
severities.

Matches Receiver First notification Repeats
critical + category=power urgent immediately 30m
critical + category=security security immediately 1h
warning + category=security security 30s 4h
critical (anything else) urgent 10s 4h
warning (anything else) default 30s 12h
info null never

Two new SOPS keys are required before make up will run
ALERTMANAGER_URGENT_WEBHOOK_URL and ALERTMANAGER_SECURITY_WEBHOOK_URL. See
"Before deploying" at the bottom.

Why

Closes #66.

urgent and default both read url_file: .../webhook_url, so the routing tree
differentiated 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 category except power, while ten rules carry
category: securitySshBruteForce, TerminalSegmentReachedInternalNetwork,
IoTAttemptedLateralMovement, SuricataHighPriorityAlert and the rest. Routed on
severity alone, the warning-severity half of that list went to the default
channel on a 12-hour repeat, indistinguishable from a disk filling up.
category: power having its own route was already the precedent.

Blast radius

  • No change to network segmentation or firewall rules
  • No new port published to a VLAN that could not already reach the service
  • No credential added outside secrets/*.sops.yaml

Alertmanager 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.sh refuses to render anything at all
, so a half-configured
deploy is not reachable.

Verification

make validate — all pass, one skip (gitleaks not installed locally).

The routing assertions are the point of this PR

amtool check-config proves the tree parses and that every route names a
receiver that exists. It cannot see route order — and the tree is
first-match-wins with no continue anywhere, 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:

$ amtool check-config am-broken.yaml
Checking 'am-broken.yaml'  SUCCESS

$ amtool config routes test --config.file=am-broken.yaml \
    --verify.receivers=security severity=warning category=security
default
WARNING: Expected receivers did not match resolved receivers.
exit=1

check-config passes; every warning-severity security alert silently goes to the
wrong channel. So scripts/validate.sh and .github/workflows/ci.yml now assert
each row of the table above with amtool config routes test --verify.receivers:

  PASS  amtool check-config
  PASS  amtool config routes test (6 assertions)

Both places in the same commit — make validate and 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: in alertmanager.yaml is cross-checked against that
list. That check matters because a missing url_file is not a config error:
Alertmanager reads it at notify time, so the stack starts, check-config passes,
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:

-- rendering 3 alertmanager receiver URL(s)
-- writing observability/.env
-- done — observability is ready to start

Three files, 0600, no trailing newline (a newline inside a URL is a delivery
error, not a warning). And with a deliberate typo in one url_file:

error: alertmanager.yaml reads typo_url, which nothing in AM_CHANNELS renders
exit=1

The cross-check also caught a bug in its own first draft — a bare
secrets/[a-z_]+ grep matched secrets/observability.sops.yaml in the file's
own 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 validate passes
  • Deployed to the lab and confirmed working — deliberately not, see below
  • Docs updated — docs/observability.md gains a ### Routing section with
    the table; secrets/README.md, the compose.yaml mount comment and the
    secrets example all catch up

Before deploying

make secrets-edit     # add ALERTMANAGER_URGENT_WEBHOOK_URL and ALERTMANAGER_SECURITY_WEBHOOK_URL
make up

With 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=security

Pointing 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.md if you make it.

🤖 Generated with Claude Code

… 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>
@Gerrrt
Gerrrt merged commit 53a7e05 into main Aug 24, 2026
3 checks passed
@Gerrrt

Gerrrt commented Aug 24, 2026

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

urgent and default alert to the same webhook, and nothing routes on category=security

1 participant