Audit entries#
The audit log answers "what changed my NAT rule, and who". It is one JSON object per
line in <data-dir>/audit.jsonl, appended and fsynced for every entry, with the actor,
their role and their source address on each one. It is not the journal — that is the
router's system log — and it is not the commit history, which is the router's own record
of configuration revisions. Three different things, three different places.
{"timestamp":"2026-09-02T21:14:07Z","actor":"kate","role":"operator","remote_ip":"192.0.2.44","operation":"commit","success":true}
{"timestamp":"2026-09-02T21:15:31Z","actor":"token:terraform","role":"operator","remote_ip":"192.0.2.90","operation":"denied POST /api/admin/users","success":false,"error":"requires admin"}
{"timestamp":"2026-09-02T21:20:02Z","actor":"reconcile-loop","role":"system","operation":"reconcile-stage","commands":["set service dns forwarding cache-size 10000"],"success":true}Fields#
| Field | Always | Meaning |
|---|---|---|
timestamp | yes | RFC 3339, UTC, second resolution. Stamped by the agent when the entry is recorded. |
actor | yes | Who. An account name, token:<label>, admin-token, reconcile-loop, or system. |
role | no | The role that principal held: viewer, operator, admin, or system for the loop. |
remote_ip | no | The source address. From X-Forwarded-For's last element when --trust-proxy is set, otherwise the socket's peer. Absent for entries with no request behind them. |
operation | yes | What was attempted. The full list is below. |
path | no | The object the operation names: a user name, a token id, a container name, an image reference, a fleet router id, a revision file. |
commands | no | The rendered set/delete lines, for reconcile entries. |
success | yes | true or false. |
error | no | Why it failed — usually the router's own words. |
The names admin-token and anything starting token: are reserved: the agent
refuses to create an account with one, so an entry's actor is never ambiguous about which
kind of principal acted.
Every operation name#
| Operation | Recorded when |
|---|---|
bootstrap-admin | The first account is created on an empty state file. Actor system. |
login | Every sign-in attempt, successful or not. A failure carries no such user, bad password or bad totp as its error — the API never distinguishes them, but the log does. |
login (oidc) | A successful single sign-on. |
logout | |
change-password | Your own password. |
totp-enable, totp-disable | |
revoke-session | |
create-user, update-user, delete-user | Path is the name; create-user also carries the role. |
oidc-link-user, oidc-unlink-user | An admin linking or unlinking an account's identity. A separate operation from update-user, because it changes how an account is entered. |
oidc-link, oidc-provision, oidc-unlink | The same events driven by the sign-on flow itself. |
create-token, revoke-token | Path is the label and role, or the token id. |
update-settings | |
license-set, license-remove | |
configure | A direct POST /api/configure. |
commit, commit-confirm | |
rollback | Path is the archived revision file that was loaded. |
save, config-load | Path is the file. |
reconcile-stage, reconcile-commit | The loop, or wheelhouse-agent apply. Carries commands. |
app-pull, app-prepare, app-update, app-restart, app-image-delete | |
feature-enable, feature-disable | A built-in feature module. |
power-reboot, power-poweroff | |
image-add, image-delete, image-set_default | Boot images. image-show is a read and is not recorded. |
fleet-configure | A write to a remote router. Path is the router id. |
denied <METHOD> <path> | Any request refused for insufficient role. The error names the role it required. |
Storage#
| Path | <data-dir>/audit.jsonl, mode 0600 |
| Format | One JSON object per line, appended |
| Durability | fsync after every entry |
| Rotation | At 8 MB, to audit.jsonl.1. Exactly one previous generation, so the log costs at most 16 MB on disk. |
| Served from | An in-memory tail, filled at startup by reading audit.jsonl.1 and then audit.jsonl |
Appending a line costs the length of the line. The log used to be a field inside
state.json, which meant one audited action rewrote every account, token and session
too — half a megabyte of flash for a single refused request, on the storage the router
boots from, with a full rewrite in flight on every 403, which is exactly when a power
cut is most likely to find one.
A line torn by a power cut is skipped on the next read; the entries either side of it are still good.
Retention#
audit_retention in the agent's settings, 2000 by default, bounds the in-memory
tail — which is what GET /api/audit serves.
| Value | Effect |
|---|---|
| 0 or below | Falls back to the default, 2000. |
| Below 100 | Raised to 100 by PUT /api/admin/settings. |
| Above 10000 | Clamped to 10000, with a warning naming what you asked for and what you got, because a setting the page echoes back and the agent does not honour is worse than a refusal. |
Older entries stay in the file. Nothing prunes audit.jsonl except rotation.
At startup the tail is filled by reading back retention × 512 bytes from the end of each
file — the rest stays on disk and is not served.
Reading it#
# Through the API, newest first, default 300:
curl -sk "$R/api/audit?limit=50" -H "Authorization: Bearer $T"
# On the box, oldest first, which is what the file holds:
sudo tail -n 50 /config/wheelhouse/audit.jsonl
# Every refusal in the last day:
sudo grep '"success":false' /config/wheelhouse/audit.jsonl | tail -50
# Who committed, and when:
sudo python3 -c '
import json,sys
for line in open("/config/wheelhouse/audit.jsonl"):
e = json.loads(line)
if e["operation"] == "commit":
print(e["timestamp"], e["actor"], e.get("remote_ip",""), e["success"])
'GET /api/audit is a viewer route with no redaction wrapper. If that is not what you
want, do not issue viewer accounts to people who should not see who changed what.
What it is not#
Secret redaction does not apply to this log. Reconcile entries carry the rendered
commands, and a reconcile that sets a key renders that key. Treat audit.jsonl with the
same care as state.json.
In the web UI#
The System page shows the recent entries. It is the same data as GET /api/audit,
newest first.
See also#
- Endpoint index —
GET /api/audit. - Role matrix — where
denied …entries come from. - Files and directories —
audit.jsonland its rotation. - What a read hides — and why it does not cover this.
- The desired-state file — the loop's entries.
- Log lines worth alerting on — the journal, which is a different thing.
- The audit log — what it is for, and what it is not.
Checked against#
agent/store.go (AuditEntry, auditLog, openAuditLog,
append, rotateLocked, loadTail, clampKeep, Record, RecentAudit,
auditFileMax, auditTailMax, auditTailScan),
agent/main.go (recordAudit, recordAuditPath,
recordAuditFull, handleAudit, bootstrapAdmin),
agent/authhttp.go (requireRole, handleLogin),
agent/admin.go,
agent/apps.go,
agent/parity.go,
agent/oidc.go,
agent/desired.go (recordLoopAudit, recordCLIAudit),
agent/auth.go (clientIP),
agent/store_test.go,
ui/src/pages/System.tsx.