Audit entries#
The audit log records what was done through the agent and who did it. 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, and a hash chain over the
whole file. It is not the router's system log, and it is not the configuration history,
which is the router's own record of its revisions.
{"timestamp":"2026-09-18T09:14:07Z","actor":"kate","role":"operator","remote_ip":"192.0.2.44","operation":"commit","commands":["set system host-name edge-1"],"success":true,"seq":4187,"prev":"6f1c…","hash":"9ab2…"}
{"timestamp":"2026-09-18T09:15:31Z","actor":"token:terraform","role":"operator","remote_ip":"192.0.2.90","operation":"denied POST /api/admin/users","success":false,"error":"requires admin","seq":4188,"prev":"9ab2…","hash":"c410…"}
{"timestamp":"2026-09-18T09:20:02Z","actor":"admin","role":"admin","remote_ip":"192.0.2.44","operation":"agent-restart","path":["192.0.2.1:8443"],"success":true,"note":"the agent stops, and its service starts it again","seq":4189,"prev":"c410…","hash":"77e0…"}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, system, rescue, upnp or upnp:<device>. For a failed sign-in, the user name that was tried. |
role | no | The role that principal held: viewer, approver, operator or admin; system for the desired-state loop and the agent's own start-up entries; upnp for UPnP's own entries. rescue entries carry admin. |
remote_ip | no | The source address. From X-Forwarded-For's last element when --trust-proxy is set, otherwise the socket's peer. console:<tty> for wheelhouse-rescue. Absent for entries with no request behind them. |
operation | yes | What was done. The full list is below. |
path | no | What the operation names: a user name and role, a token, an image, a revision file, a fleet router, a listen address. |
commands | no | The set/delete lines the entry carried: the changes applied, discarded, reconciled or sent to a fleet router. A few other operations put one line here describing what they did, such as a backup's size and checksum. |
success | yes | true or false. |
error | no | Why it failed, usually in the router's own words. |
note | no | The agent speaking about the entry itself, such as "takes effect when the agent restarts". |
seq | no | The entry's sequence number: 1, 2, 3 … across rotations. |
prev | no | The hash of the entry written before this one. |
hash | no | This entry's own hash, and always the last field on the line. |
seq, prev and hash are absent only on entries written before the agent kept a
chain. The hash is a SHA-256 of prev, a newline, and the entry's own JSON text up to
its hash member, which is why that field must stay last and why a field a later agent
adds is covered without breaking the entries written before it.
The names admin-token and anything beginning token: are reserved. The agent
refuses to create an account with either, so an entry's actor always says which kind of
principal acted.
Every operation#
The Action column of the Audit log tab words each operation as a sentence. An operation the tab does not know is shown as its code, with hyphens as spaces and a capital first letter. In Advanced mode the tab also shows the code itself.
Signing in#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
login | Signed in | 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) | Signed in with single sign-on | A successful single sign-on. Path is the issuer, the subject and the groups. |
totp-recovery-code-used | Signed in with a recovery code | |
logout | Signed out | |
revoke-session | Signed out a session | |
denied <METHOD> <path> | Was refused access, with Needs the operator role (or the role it named) | Any request refused for insufficient role. The error names the role it needed; the method and path are shown in Advanced mode. |
Accounts#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
bootstrap-admin | Created the first administrator | The first account is created on an empty state file. Actor system. |
change-password | Changed their password | Your own password. |
update-preferences | Changed their preferences | Account → Preferences, or pinning and unpinning pages, when something changed. Path names each preference that changed: advanced with the setting that resulted, on or off, and pinned with how many pages are pinned now (pinned 3 pages, pinned none), not which. |
totp-enable, totp-disable | Turned on / Turned off two-factor authentication | |
totp-recovery-codes-issued | Issued new recovery codes | |
oidc-link, oidc-unlink, oidc-provision | Linked their account to / Unlinked their account from / Created an account through single sign-on | The sign-on flow itself. |
create-user, update-user, delete-user | Added / Changed / Deleted a user | Path is the name; create-user also carries the role, shown as alice (operator). |
oidc-link-user, oidc-unlink-user | Linked a user to / Unlinked a user from single sign-on | An admin linking or unlinking an account's identity. Separate from update-user. |
totp-admin-clear | Cleared a user’s two-factor authentication | Actor is the admin, path the account it was done to. |
create-token, revoke-token | Issued / Revoked an API token | Path is the label and role, or the token id. |
Agent settings and licence#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
update-settings | Changed the agent settings | Admin → Agent settings, saved. |
set-listen-address | Changed the management address | An admin setting the management listen address, or clearing it. Path carries both values, what was stored before and what is stored now. Recorded for a refusal too, with the reason. Takes effect when the agent restarts, which the entry's note says. |
agent-restart | Restarted the agent | An admin pressed Restart agent on Admin → Agent settings. Path is the listen address the restarted agent tries first, shown as Listening on …. Written before the agent stops, because the process that would record it afterwards is a different one. A refusal is recorded too, with its reason: changes are pending, an agent update is being installed, the agent was not started by its system service, or a restart is already under way. |
listen-fallback | Could not listen on the configured management address | The configured address would not bind at start-up, so the agent served the unit's --addr instead. actor is system, success is false, and path carries both addresses. |
license-set, license-remove | Entered a licence key / Removed the licence key | |
update-policy | Changed the update settings | Check daily or Channel changed on the Updates panel. |
Configuration#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
commit | Applied changes | Apply changes or Apply with auto-revert in the bar at the bottom of the page. commands holds the pending changes that were applied, whether the router accepted them or not. |
commit-confirm | Kept changes applied with auto-revert | Keep changes, pressed before the auto-revert window ran out. |
discard | Discarded pending changes | Discard, when there was something to discard. commands holds what was discarded. |
configure | Changed the configuration directly | A direct POST /api/configure, which bypasses the pending changes. |
rollback | Restored an earlier configuration | Restore revision on the History tab. Path is the revision's archive file, shown as Revision 3. |
save | Saved the configuration for the next start | Save configuration in the System page header. |
config-load | Loaded a configuration file | POST /api/config/load. Path is the file. |
reconcile-stage, reconcile-commit | Added changes from / Applied changes from the desired state | The reconcile loop, or wheelhouse-agent apply. Actor reconcile-loop; carries commands. |
The router#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
power-reboot, power-poweroff | Restarted / Shut down the router | The Power panel. |
factory-reset | Asked for a factory reset | The Factory reset panel, before the reset starts, whether it was refused or not. commands says what was chosen: the configuration, the apps' data, and whether the admin password was chosen or generated. |
image-add, image-delete, image-set_default | Installed / Deleted a system image, Changed the default system image | The System images panel. Path is the image name, or for an add the URL without its credentials. Listing images is a read and is not recorded. |
agent-update | Installed an agent update | Install or Reinstall in the Updates panel's Agent package section. Path is the version replaced and the version installed. |
agent-updated | Finished an agent update | Written by the new agent when it starts, saying whether the install took. |
wake-on-lan | Sent a Wake-on-LAN packet | Diagnostics → Tools. Path names the interface and the device's MAC address. Written for a refusal too, with the reason. |
Backup, support and the log itself#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
backup-taken | Downloaded a backup | Download backup. Path is the file name; commands holds one line with its size and SHA-256. |
backup-restored | Restored a backup | Restore backup, including a refused one, with the reason. |
snapshot-taken | Took a snapshot | Take snapshot. The daily snapshot is not recorded. |
snapshot-restored | Restored a snapshot | Restore on a snapshot, including a refused one. |
support-bundle | Downloaded a support bundle | |
export-audit | Downloaded the audit log | GET /api/audit/export. Written after the body, so a copy never contains the record of its own export. |
gitexport-config, gitexport-key, gitexport-push | Changed the git export settings / Generated the git export key / Pushed the configuration to git | The Git export panel on the History tab, and each push. |
audit-chain-start | Started the audit log’s integrity chain | The first chained entry in a log written before the chain existed. |
Apps, services and diagnostics#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
app-pull, app-prepare, app-update, app-restart, app-image-delete | Downloaded / Prepared / Updated / Restarted an app, Deleted an app image | App actions. app-restart stops and starts the app through two configuration changes. |
container-restart | Restarted an app | An app's container restarted by the router directly, with no configuration change. |
companion-restart | Restarted a service | |
feature-enable, feature-disable | Turned on a feature / Turned off a feature, naming it (WireGuard, IDS, UPnP) | A built-in feature module. |
capture-start, capture-stop, capture-download, capture-delete | Started / Stopped / Downloaded / Deleted a packet capture | Path names the interface, with the filter for a start, or the capture file for a download. |
upnp-settings | Changed the UPnP settings | |
upnp-map, upnp-remove, upnp-expire, upnp-forget | Opened a port for a UPnP client / Removed a UPnP mapping / Closed an expired UPnP mapping / Forgot a UPnP mapping | Actor upnp or upnp:<device> for what the router did by itself. |
Fleet#
| Operation | The Audit log tab shows | Recorded when |
|---|---|---|
fleet-configure | Changed a fleet router’s configuration directly | A write to a remote router. Path is the router id. |
fleet-commit, fleet-commit-confirm, fleet-discard | Applied changes / Kept changes / Discarded pending changes on a fleet router | fleet-commit carries the commands it sent; fleet-discard carries what it threw away. |
At the console#
wheelhouse-rescue writes its own entries into the chain, with actor rescue, role
admin and remote_ip naming the console. The Audit log tab says each in words: Reset a
password at the console, Cleared two-factor authentication at the console, Renewed the
router's API key at the console, Restored a configuration at the console, Reset the
management address at the console, Erased the Wheelhouse data at the console.
The rescue tool writes those entries itself, so the agent's blanking does not reach them:
a configuration restored from a file of commands is recorded as up to 200 of those
commands, secrets included. GET /api/audit blanks them on the way out, as it does every
older entry; the file keeps them.
| Operation | Recorded when |
|---|---|
rescue-password-reset | A web interface password reset at the console. |
rescue-totp-clear | An account's second factor removed at the console. |
rescue-firstboot | First boot re-run, with a new router API key. |
rescue-config-restore | A configuration restored from a file. |
rescue-listen-reset | The saved management listen address forgotten. |
rescue-factory-reset | The agent's data wiped: written before the wipe and again as the first entry after it. At the console its address is console:<tty>; asked for from the web interface, its actor is the admin who asked and its address web:<address>. |
Storage#
| Path | <data-dir>/audit.jsonl, mode 0600 |
| Format | One JSON object per line, appended |
| Durability | fsync after every entry |
| Rotation | At 8 MiB, to audit.jsonl.1. Exactly one previous generation, so the log costs at most 16 MiB on disk. |
| Served from | An in-memory tail, filled at start-up by reading audit.jsonl.1 and then audit.jsonl |
Appending a line costs the length of the line, so an audited action does not rewrite the
state file. A line torn by a power cut is skipped on the next read; the entries either
side of it are still good. A state file written by an older agent may still carry audit
entries inside it; they are moved into audit.jsonl once, when it is opened.
Retention#
Audit entries kept on Admin → Agent settings (audit_retention, 2000 by
default) bounds the in-memory tail, which is what the Audit log tab and
GET /api/audit serve.
| Value | Effect |
|---|---|
| Below 100, when the settings are saved | Raised to 100. |
| Above 10 000 | Served as 10 000, with a warning in the agent's log naming what was asked for. |
| 0 or below in the stored settings | Treated as the default, 2000. |
Older entries stay in the file. Nothing prunes audit.jsonl except rotation. At start-up
the tail is filled by reading back retention × 512 bytes from the end of each file.
The hash chain#
Editing an entry breaks its own hash; deleting one breaks the sequence and the link of
the entry after it; inserting one breaks the same link. GET /api/audit/verify (admin)
re-reads both generations and reports each place the record does not add up — file,
line, sequence number and kind — with the head: the last sequence number and its hash.
Entries written before the chain existed are covered by a digest in the first chained
entry (audit-chain-start) and reported as legacy, not as breaks. A final line the file
did not finish writing is reported as an interrupted write, not as an edit.
The chain proves a break; it does not prevent one. Root on the router can rewrite the file and re-chain it. The proof depends on a copy of the hashes held somewhere else.
Copies off the router#
| Route | What it does |
|---|---|
| Syslog export | Audit log export on Admin → Agent settings (audit_syslog). Each entry is sent as RFC 5424 to a collector as it is written, before the fsync, over UDP or TCP. The message is the exact line the file holds, so the collector's copy verifies the same way; over UDP an entry larger than a datagram is sent without its commands, with a note saying so. Failed and refused actions arrive at warning severity, the rest as informational. Off by default. Not a spooler: while the collector cannot be reached, entries are dropped and counted. |
GET /api/audit/export | Admin. The whole log, both generations, byte for byte, as JSON lines. Recorded as export-audit. |
GET /api/audit/verify | Admin. The chain's verdict, and whether the syslog export is connected, what it last failed with, and how many entries it has dropped. |
A backup (.whbk) does not carry the log. It carries the most recent 500 entries as
agent/audit-tail.jsonl, for reading, and a restore never writes them back.
Reading it#
# Through the API, newest first, 300 unless limit says otherwise:
curl -sk "$R/api/audit?limit=50" -H "Authorization: Bearer $T"
# On the router, oldest first, which is what the file holds:
sudo tail -n 50 /config/wheelhouse/audit.jsonl
# Every failure or refusal still in the current file:
sudo grep '"success":false' /config/wheelhouse/audit.jsonl | tail -50
# Who applied changes, and when:
sudo python3 -c '
import json
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: everyone who can see the router can see who
changed it. No entry carries a secret. The agent blanks secret values in commands,
path and error before an entry is written, by the same path rules every other read
uses, so the file, the export, the syslog copy and the chain all hold [redacted] in
their place and the chain verifies over exactly those bytes. Entries written by an agent
older than 0.9.1 kept their secrets; GET /api/audit blanks them for every role, but the
file and the export still hold them, so treat an audit.jsonl that goes back that far
with the same care as state.json.
In the web UI#
System → Audit log shows the recent entries, newest first: when, who, the action as a sentence and the result, with the changes an entry carries described the way the pending changes are. In Advanced mode it also shows the operation, the path and the commands. See System — audit log.
See also#
- Endpoint index —
GET /api/audit,/verifyand/export. - Role matrix — where
denied …entries come from. - Files and directories —
audit.jsonland its rotation. - What a read hides — the path rules the audit log's blanking uses.
- The desired-state file — the loop's entries.
- Log lines worth alerting on — the system log, which is a different thing.
- The audit log — what the log is for.