Wheelhouse docs

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.

json
{"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#

FieldAlwaysMeaning
timestampyesRFC 3339, UTC, second resolution. Stamped by the agent when the entry is recorded.
actoryesWho. An account name, token:<label>, admin-token, reconcile-loop, or system.
rolenoThe role that principal held: viewer, operator, admin, or system for the loop.
remote_ipnoThe 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.
operationyesWhat was attempted. The full list is below.
pathnoThe object the operation names: a user name, a token id, a container name, an image reference, a fleet router id, a revision file.
commandsnoThe rendered set/delete lines, for reconcile entries.
successyestrue or false.
errornoWhy 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#

OperationRecorded when
bootstrap-adminThe first account is created on an empty state file. Actor system.
loginEvery 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-passwordYour own password.
totp-enable, totp-disable
revoke-session
create-user, update-user, delete-userPath is the name; create-user also carries the role.
oidc-link-user, oidc-unlink-userAn 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-unlinkThe same events driven by the sign-on flow itself.
create-token, revoke-tokenPath is the label and role, or the token id.
update-settings
license-set, license-remove
configureA direct POST /api/configure.
commit, commit-confirm
rollbackPath is the archived revision file that was loaded.
save, config-loadPath is the file.
reconcile-stage, reconcile-commitThe loop, or wheelhouse-agent apply. Carries commands.
app-pull, app-prepare, app-update, app-restart, app-image-delete
feature-enable, feature-disableA built-in feature module.
power-reboot, power-poweroff
image-add, image-delete, image-set_defaultBoot images. image-show is a read and is not recorded.
fleet-configureA 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
FormatOne JSON object per line, appended
Durabilityfsync after every entry
RotationAt 8 MB, to audit.jsonl.1. Exactly one previous generation, so the log costs at most 16 MB on disk.
Served fromAn 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.

ValueEffect
0 or belowFalls back to the default, 2000.
Below 100Raised to 100 by PUT /api/admin/settings.
Above 10000Clamped 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#

bash
# 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#

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.

Updated 2026-09-02 audit logging accountability