Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Audit log and live viewer

Every request an rdc serve daemon handles, and every one it refuses, becomes one JSON line in its audit file. This page covers reading that file, streaming it off the machine, and watching several machines at once in a browser.

The file

Default location: rdc/audit.jsonl in the platform state directory (~/.local/state/rdc/ on Linux, ~/Library/Application Support/rdc/ on macOS, %LOCALAPPDATA%\rdc\ on Windows). It is created with owner-only permissions and rotated at max_size_mb, keeping keep old files.

rdc audit              # last 50 entries as a table
rdc audit -n 200 --json

One entry:

{"ts":"2026-09-11T18:02:12.430Z","host":"studio-mac","peer":"100.64.0.11",
 "login":"brian@example.com","node":"laptop","method":"POST","path":"/v1/act",
 "action":"input.click 640,400 Left x1","outcome":"ok","status":200,"ms":8}
FieldMeaning
tsUTC time, RFC 3339
hostthe machine whose desktop this is about (the daemon’s node name)
peersource IP of the request
login, node, tagsthe caller’s Tailscale identity, when one was established. Tagged devices have no login.
method, paththe HTTP request
actionwhat was asked for. Typed text is recorded as a character count, never as content.
outcomeok, denied (bad host, unknown peer, not allowed, missing capability) or error
statusHTTP status returned
detailwhy it was denied or failed
mshow long the request took
windowthe focused window when the request arrived, as app: title, truncated to 160 characters. Omitted when [serve.audit].window_titles = false, when nothing was focused, or when the lookup did not answer in time.
screenshot_sha256SHA-256 of the image bytes a screenshot request returned, so a saved screenshot can be matched to the line that produced it
viaadded by the viewer: the node that sent this entry over the network

Strings from the network (window titles, key chords, host names) have control characters replaced before they are written, so a hostile value cannot break the line framing or drive the terminal of whoever reads the log.

Streaming entries off the machine

A daemon can also POST its entries, as they happen, to an HTTP endpoint:

[serve.audit]
stream = "http://laptop.example-tailnet.ts.net:7771/v1/ingest"
# stream_token = "..."   # sent as `Authorization: Bearer`, for third-party collectors

or rdc serve --audit-stream http://....

Entries are batched (a few hundred lines or half a second, whichever comes first) into one request with Content-Type: application/x-ndjson: one JSON object per line, newline terminated. A failed request is retried with backoff up to 30 seconds while new entries queue behind it. If the endpoint stays down and the queue fills, new entries are dropped for the stream and a warning is logged. The local file is never affected. Treat the stream as a live feed and the file as the record.

Any collector that accepts newline-delimited JSON over HTTP works: an rdc audit-view instance, Vector’s http_server source, a small script, or a log service’s ingest URL with stream_token. Keep the endpoint on your tailnet or behind TLS; the stream carries who did what on your machines.

The live viewer

rdc audit-view is a small web server that receives streams from your daemons and shows them in one page, newest first, updating as entries arrive.

rdc audit-view --allow brian@example.com
# audit viewer at http://100.64.0.11:7771/  (daemons stream to http://100.64.0.11:7771/v1/ingest)

Then point each daemon at it with [serve.audit] stream = ".../v1/ingest" and open the URL in a browser on an allowed machine.

The viewer applies the same rules as the daemon. It listens only on a Tailscale address (or loopback with --dev-loopback), refuses requests whose Host header is not one of its own names, and identifies every caller, daemon or browser, through tailscaled whois. Both the daemons that send and the people who view must be in its allowlist. Capabilities in the allowlist are ignored here; membership is what counts. Entries the viewer itself refuses show up in the page too, with the viewer’s own host name.

The viewer sets via on every entry it receives to the sending node’s name and never trusts the sender for it. An entry that arrives without a host is attributed to its sender.

Options:

FlagConfigMeaning
--port[audit_view].portdefault 7771
--bind[audit_view].binddefault: this node’s Tailscale IPv4
--allow[audit_view].allowwho may send and view; same shapes as [serve].allow
--store PATH[audit_view].storewhere received entries are kept; default audit-view.jsonl in the state dir, rotated like the daemon’s file
--no-storekeep entries in memory only
--follow PATH[audit_view].followalso follow a local audit file, for the daemon on the same machine
--dev-loopbackbind 127.0.0.1 without authentication, for testing

The page has a text filter, an outcome filter, a per-host filter, and a pause box that holds new rows until you uncheck it. It is plain HTML and JavaScript served with a strict content security policy; log content is only ever inserted as text.

Endpoints

RouteUsed byPurpose
POST /v1/ingestdaemonsbody is application/x-ndjson, up to 8 MiB; returns {accepted, rejected} and 400 if any line was not an entry
GET /v1/recent?n=500&host=the pagethe most recent entries held in memory (up to 5000)
GET /v1/eventsthe pageserver-sent events, one entry event per line
GET /youthe page

Tailscale policy

Daemons connect to the viewer’s port, so the policy must allow the daemon machines to reach <viewer>:7771, and your own devices too for the page. With the tagging pattern from Grants and Tailscale policy:

{"action": "accept", "src": ["tag:rdc-host", "autogroup:member"], "dst": ["tag:rdc-viewer:7771"]}