Alert on drift#
You will end up being told when the router stops matching the desired-state file it should match. Two mechanisms: a Prometheus metric, and an endpoint you poll.
Before you start#
- A desired-state file, and
the reconcile loop turned on. Both mechanisms report on the loop; an
agent with no
--reconcile-filehas nothing to report. - An API token with the viewer role.
The endpoint#
R=https://<router>:8443
T=wh_...
curl -sk -H "Authorization: Bearer $T" "$R/api/drift"{"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage","drifted":true,
"ops":2,"checked_at":"2026-08-30T22:27:07Z"}| Field | Means |
|---|---|
managed | Whether a file is set at all. false on an agent without --reconcile-file, which is the default. |
file | Which file |
mode | stage, commit, or off |
drifted | Whether the last pass found a difference |
ops | How many operations would close it |
checked_at | When the loop last looked |
checked_at is the field to watch as closely as drifted. A loop that has stopped running
reports drifted: false for ever, and a stale timestamp is the only thing that tells you.
The metrics#
wheelhouse_desired_managed
wheelhouse_desired_drift
wheelhouse_desired_pending_ops- alert: WheelhouseConfigDrift
expr: wheelhouse_desired_drift == 1
for: 15m
annotations:
summary: "a router has drifted from its desired-state file"
- alert: WheelhouseReconcileStopped
expr: wheelhouse_desired_managed == 1 and absent_over_time(wheelhouse_desired_drift[10m])
for: 10mfor: 15m matters in stage mode: drift is normal for as long as it takes somebody to look
at the Commit Bar and commit. Alerting instantly makes the alert meaningless.
Scrape the agent has the scrape configuration.
Without Prometheus#
A scheduled job somewhere else, using the endpoint:
#!/bin/sh
set -eu
R=${R:?}; T=${T:?}
curl -sfk -H "Authorization: Bearer $T" "$R/api/drift" |
python3 - <<'PY'
import json, sys, datetime
d = json.load(sys.stdin)
if not d.get("managed"):
sys.exit(0) # not managing a file; nothing to say
stale = False
if d.get("checked_at"):
seen = datetime.datetime.fromisoformat(d["checked_at"].replace("Z", "+00:00"))
stale = (datetime.datetime.now(datetime.timezone.utc) - seen).total_seconds() > 600
if d.get("drifted") or stale:
print(f"drift={d.get('drifted')} ops={d.get('ops')} checked_at={d.get('checked_at')}")
sys.exit(1)
PYRun it from cron on the monitoring host and let the non-zero exit be the alert.
What drift is, and what it is not#
Drift here means one thing: the configuration differs from the desired-state file. It does not mean the router is broken, and it does not cover the other kind of drift this product reports — what the kernel is doing differing from what the configuration declares. That second kind is on the pages themselves: Interfaces flags anything the configuration does not declare, Static routes flags a route declared but not installed, and QoS shows the attached policy beside the qdisc the kernel installed.
Neither of those is alertable through this endpoint. There is no metric for them.
A different check, for a migrated router#
If what you want is "does this router still match the OPNsense configuration it was built from", that is a different tool and it exits non-zero too — Check a running router against the configuration it should have.
Check it worked#
Change something on the router that the file declares, wait an interval, and confirm the alert fires. Then put it back and confirm it clears. An alert nobody has seen fire is a belief.
Undoing it#
Remove the rule or the cron entry, and revoke the token if it was issued only for this.
See also#
- Keep the router aligned on a timer
- Config as code with
agent.yaml - Scrape the agent
- State, intent and drift
Checked against agent/desired.go ·
agent/metrics.go ·
docs/deploy.md