Wheelhouse docs

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.

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

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, system, rescue, upnp or upnp:<device>. For a failed sign-in, the user name that was tried.
rolenoThe 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_ipnoThe 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.
operationyesWhat was done. The full list is below.
pathnoWhat the operation names: a user name and role, a token, an image, a revision file, a fleet router, a listen address.
commandsnoThe 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.
successyestrue or false.
errornoWhy it failed, usually in the router's own words.
notenoThe agent speaking about the entry itself, such as "takes effect when the agent restarts".
seqnoThe entry's sequence number: 1, 2, 3 … across rotations.
prevnoThe hash of the entry written before this one.
hashnoThis 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#

OperationThe Audit log tab showsRecorded when
loginSigned inEvery 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-onA successful single sign-on. Path is the issuer, the subject and the groups.
totp-recovery-code-usedSigned in with a recovery code
logoutSigned out
revoke-sessionSigned 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#

OperationThe Audit log tab showsRecorded when
bootstrap-adminCreated the first administratorThe first account is created on an empty state file. Actor system.
change-passwordChanged their passwordYour own password.
update-preferencesChanged their preferencesAccount → 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-disableTurned on / Turned off two-factor authentication
totp-recovery-codes-issuedIssued new recovery codes
oidc-link, oidc-unlink, oidc-provisionLinked their account to / Unlinked their account from / Created an account through single sign-onThe sign-on flow itself.
create-user, update-user, delete-userAdded / Changed / Deleted a userPath is the name; create-user also carries the role, shown as alice (operator).
oidc-link-user, oidc-unlink-userLinked a user to / Unlinked a user from single sign-onAn admin linking or unlinking an account's identity. Separate from update-user.
totp-admin-clearCleared a user’s two-factor authenticationActor is the admin, path the account it was done to.
create-token, revoke-tokenIssued / Revoked an API tokenPath is the label and role, or the token id.

Agent settings and licence#

OperationThe Audit log tab showsRecorded when
update-settingsChanged the agent settingsAdmin → Agent settings, saved.
set-listen-addressChanged the management addressAn 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-restartRestarted the agentAn 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-fallbackCould not listen on the configured management addressThe 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-removeEntered a licence key / Removed the licence key
update-policyChanged the update settingsCheck daily or Channel changed on the Updates panel.

Configuration#

OperationThe Audit log tab showsRecorded when
commitApplied changesApply 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-confirmKept changes applied with auto-revertKeep changes, pressed before the auto-revert window ran out.
discardDiscarded pending changesDiscard, when there was something to discard. commands holds what was discarded.
configureChanged the configuration directlyA direct POST /api/configure, which bypasses the pending changes.
rollbackRestored an earlier configurationRestore revision on the History tab. Path is the revision's archive file, shown as Revision 3.
saveSaved the configuration for the next startSave configuration in the System page header.
config-loadLoaded a configuration filePOST /api/config/load. Path is the file.
reconcile-stage, reconcile-commitAdded changes from / Applied changes from the desired stateThe reconcile loop, or wheelhouse-agent apply. Actor reconcile-loop; carries commands.

The router#

OperationThe Audit log tab showsRecorded when
power-reboot, power-poweroffRestarted / Shut down the routerThe Power panel.
factory-resetAsked for a factory resetThe 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_defaultInstalled / Deleted a system image, Changed the default system imageThe 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-updateInstalled an agent updateInstall or Reinstall in the Updates panel's Agent package section. Path is the version replaced and the version installed.
agent-updatedFinished an agent updateWritten by the new agent when it starts, saying whether the install took.
wake-on-lanSent a Wake-on-LAN packetDiagnostics → 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#

OperationThe Audit log tab showsRecorded when
backup-takenDownloaded a backupDownload backup. Path is the file name; commands holds one line with its size and SHA-256.
backup-restoredRestored a backupRestore backup, including a refused one, with the reason.
snapshot-takenTook a snapshotTake snapshot. The daily snapshot is not recorded.
snapshot-restoredRestored a snapshotRestore on a snapshot, including a refused one.
support-bundleDownloaded a support bundle
export-auditDownloaded the audit logGET /api/audit/export. Written after the body, so a copy never contains the record of its own export.
gitexport-config, gitexport-key, gitexport-pushChanged the git export settings / Generated the git export key / Pushed the configuration to gitThe Git export panel on the History tab, and each push.
audit-chain-startStarted the audit log’s integrity chainThe first chained entry in a log written before the chain existed.

Apps, services and diagnostics#

OperationThe Audit log tab showsRecorded when
app-pull, app-prepare, app-update, app-restart, app-image-deleteDownloaded / Prepared / Updated / Restarted an app, Deleted an app imageApp actions. app-restart stops and starts the app through two configuration changes.
container-restartRestarted an appAn app's container restarted by the router directly, with no configuration change.
companion-restartRestarted a service
feature-enable, feature-disableTurned on a feature / Turned off a feature, naming it (WireGuard, IDS, UPnP)A built-in feature module.
capture-start, capture-stop, capture-download, capture-deleteStarted / Stopped / Downloaded / Deleted a packet capturePath names the interface, with the filter for a start, or the capture file for a download.
upnp-settingsChanged the UPnP settings
upnp-map, upnp-remove, upnp-expire, upnp-forgetOpened a port for a UPnP client / Removed a UPnP mapping / Closed an expired UPnP mapping / Forgot a UPnP mappingActor upnp or upnp:<device> for what the router did by itself.

Fleet#

OperationThe Audit log tab showsRecorded when
fleet-configureChanged a fleet router’s configuration directlyA write to a remote router. Path is the router id.
fleet-commit, fleet-commit-confirm, fleet-discardApplied changes / Kept changes / Discarded pending changes on a fleet routerfleet-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.

OperationRecorded when
rescue-password-resetA web interface password reset at the console.
rescue-totp-clearAn account's second factor removed at the console.
rescue-firstbootFirst boot re-run, with a new router API key.
rescue-config-restoreA configuration restored from a file.
rescue-listen-resetThe saved management listen address forgotten.
rescue-factory-resetThe 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
FormatOne JSON object per line, appended
Durabilityfsync after every entry
RotationAt 8 MiB, to audit.jsonl.1. Exactly one previous generation, so the log costs at most 16 MiB on disk.
Served fromAn 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.

ValueEffect
Below 100, when the settings are savedRaised to 100.
Above 10 000Served as 10 000, with a warning in the agent's log naming what was asked for.
0 or below in the stored settingsTreated 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#

RouteWhat it does
Syslog exportAudit 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/exportAdmin. The whole log, both generations, byte for byte, as JSON lines. Recorded as export-audit.
GET /api/audit/verifyAdmin. 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#

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

Updated 2026-09-18 audit logging accountability