The audit log#
The audit log is the agent's own record of every mutation it performed and every one it refused: an actor, a role, an address, an operation, whether it worked, and what the error was. It is append-only, one JSON object per line, in its own file beside the agent's state. It is not the router's log and it is not the commit history. Those are three different records that answer three different questions, and this documentation never blurs them.
Three records, three questions#
| Record | Where | Answers |
|---|---|---|
| The journal | journalctl on the router, surfaced by GET /api/log and the Logs page | What the system and its daemons said. |
| The commit history | The router's revision archive, surfaced by GET /api/history and /api/history/diff | Which commits happened, when, and what each one changed to the configuration. |
| The audit log | audit.jsonl in the agent's data directory, surfaced by GET /api/audit and the System → Audit tab | Who asked the agent to do something, from where, and what came of it. |
They are complementary rather than redundant. The commit history knows the content of a change; the audit log knows the person. Answering "who changed my NAT rule and when" today means reading both — the audit entry for the timestamp and the actor, the revision diff for the lines.
What an entry holds#
{"timestamp":"2026-09-02T14:07:11Z","actor":"kate","role":"operator",
"remote_ip":"192.0.2.40","operation":"commit","success":true}The fields are fixed (agent/store.go, AuditEntry):
| Field | Meaning |
|---|---|
timestamp | RFC 3339, UTC, stamped when the entry is recorded. |
actor | The principal's name: an account name, token:<label> for an API token, admin-token for the break-glass credential, reconcile-loop for the loop, system for the first-run bootstrap. |
role | The role that principal held. |
remote_ip | The client address as the agent saw it — see the --trust-proxy note in Accounts, sessions and tokens. |
operation | commit, commit-confirm, rollback, configure, save, login, logout, change-password, create-user, delete-token, license-set, denied POST /api/commit, and so on. |
path | Present where an operation names an object: the archive file for a rollback, the account name and role for a user change, the image for an app pull, the router id for a fleet write. |
commands | The rendered set / delete lines — see the limit below. |
success, error | Whether it worked, and the router's or the agent's own words if it did not. |
Refusals are recorded too#
A request whose role is insufficient is logged as denied <method> <path> with the
actor, the role held, the address and the role required
(agent/authhttp.go, requireRole). So are failed logins,
with the username that was tried and the reason — no such user, bad password,
bad totp — even though the response the caller gets says only invalid credentials,
because distinguishing them to the caller would be a username oracle.
Failed writes are recorded as failures, with the router's message, before the caller
is answered. That includes a write to another router in a
fleet: POST /api/fleet/{id}/configure records an entry naming
the actor, the router id and the outcome, on both the success and the failure path
(agent/main.go, handleFleetConfigure).
How it is stored#
Each entry is one line of JSON appended to audit.jsonl in the agent's data
directory — /config/wheelhouse/audit.jsonl on an appliance — mode 0600, and each
append is flushed to the disk before the call returns
(agent/store.go, auditLog.append). An audit log whose last
entries were lost to the power cut that followed the action they describe is not one
you can testify from, and the cost is a few hundred bytes.
It used to live inside state.json. That meant one audited action rewrote every
account, token and session as well — half a megabyte of flash for a single refused
request at the default retention, on the storage the router boots from, with a full
rewrite in flight on every 403. A state file written by an older agent still carries
its entries, and they are moved into the new log once, on open.
| Bound | Value | Effect |
|---|---|---|
| Rotation | 8 MiB | The file rotates to audit.jsonl.1; exactly one previous generation is kept, so the log costs at most twice that on disk. |
audit_retention | 2000 by default | How many entries the agent keeps in memory to serve GET /api/audit. |
| Hard ceiling | 10 000 | Whatever audit_retention says, the in-memory tail stops there, and the agent logs a warning saying older entries stay in the file. |
Rotation is why the tail is loaded from the rotated generation first at start-up: a restart shortly after a rotation would otherwise serve the handful of lines written since. A line torn by a power cut is skipped and the entries either side of it are still good.
Reading it#
R=https://<router>:8443
T=wh_... # an API token with the viewer role
curl -sk -H "Authorization: Bearer $T" "$R/api/audit?limit=300"Newest first; limit defaults to 300. The viewer role is enough to read it — a
deliberate choice, and the same reason the whole read plane stays open without a
licence. Losing sight of what happened to your router is not a
sensible consequence of anything.
For long-term retention, ship the file itself. It is line-delimited JSON in a fixed directory; any log shipper can tail it.
What it does not do#
See also#
- Accounts, sessions and tokens — where
actorcomes from. - Read plane, write plane, admin plane — what produces a
deniedentry. - Revisions and rollback — the other half of "what changed".
- System — audit — the screen.
- The agent's files — where
audit.jsonlsits.
Checked against#
agent/store.go ·
agent/main.go ·
agent/authhttp.go ·
agent/admin.go ·
agent/desired.go ·
agent/apps.go ·
docs/deploy.md ·
docs/security.md ·
PRICING.md ·
README.md