Audit webhook integration
The server can push its audit log to a receiver you run, such as a SIEM ingest endpoint or a small service that archives it. This page is for whoever writes that receiver. How to switch the sink on is covered under [audit] in the server configuration.
This log records server events only. SSH sessions never pass through the server and are not recorded here.
The request
Section titled “The request”Each delivery is one HTTP request:
POST /your/path HTTP/1.1Content-Type: application/jsonX-UniSSH-Signature: sha256=<64 lowercase hex chars>X-UniSSH-Delivery: <first seq>-<last seq>
{"instance":"<instance id, base64>","entries":[ ... ]}instanceis this server’s instance id (base64). It lets one receiver tell several UniSSH servers apart.entriesholds 1 tobatch_size(default 100) entries in ascendingseqorder. Each entry object has exactly the fields of a line of the JSON Lines export:seq,server_seq,source,recorded_at,author_pubkey,vault_id,space_id,prev_hash,signature,entryandentry_blob.entryis the decoded event for aserver-observedentry, and the base64 string for aclient-signedone. The per-event fields (event,account_id,device_id, …) are listed in the entry format. Treat the event set as open.entry_blobis the base64 of the exact stored bytes. The hash chain covers these bytes and not a re-serialisedentry, so verify the chain fromentry_blobas described in Verifying an export offline.X-UniSSH-Deliveryis theseqrange of the batch, for example42-57. It is for logging and quick dedupe. The authoritative values are theseqfields in the body.
Answering
Section titled “Answering”- Any
2xxacknowledges the whole batch. The server then records the batch’s lastseqas delivered and sends what follows. - Anything else fails the batch. That includes any other status, a redirect (redirects are never followed), a connection error, or no answer within
timeout_secs(default 10 s). The server then sends the same batch again, with the same entries, after a backoff. The backoff starts at 1 s, doubles on each failure up to 5 minutes, and is shortened by a random amount of up to 25 % so that many servers do not retry in lockstep. A success resets it. - A batch the receiver can never accept is retried forever, by design. For example, a receiver that answers
413becausebatch_sizeis too large for it holds the sink at that batch. Lowerbatch_sizeor fix the receiver; the sink’s status (last_error) andunissh_audit_sink_failures_totalshow the stall. - Only one batch per sink is in flight at a time, so batches arrive in
seqorder.
Acknowledge only after the entries are stored durably on your side. If you answer 2xx first and lose the data afterwards, the server will not send it again.
At-least-once: dedupe on seq
Section titled “At-least-once: dedupe on seq”Delivery is at-least-once. A batch can arrive more than once, for example when the server crashes or loses its database write after you answered 2xx, or when your 2xx never reached it. Nothing is ever skipped: the server resumes after the last seq it recorded as delivered, including after a restart.
Receivers dedupe on seq. seq is unique per instance and never reused while the database lives (see the caution below), so (instance, seq) identifies an entry. Because batches arrive in order, the simplest rule is to keep the highest seq stored per instance and drop any entry at or below it. A unique key on (instance, seq) works as well.
Where a new receiver starts
Section titled “Where a new receiver starts”The delivered position is kept per sink type (webhook or syslog), not per URL. Pointing url at a new receiver continues from the old cursor, so the new receiver gets only entries recorded after that point, not the history. A sink type that has never delivered starts at seq 1 and sends the whole log. To give a new receiver the history, load the JSON Lines export into it, or delete that sink’s row from the audit_sink_cursor table while the server is stopped so the sink starts again from seq 1.
Verifying the signature
Section titled “Verifying the signature”X-UniSSH-Signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body:
X-UniSSH-Signature = "sha256=" + hex( HMAC-SHA256( key = secret, message = body ) )- The key is the secret’s literal string, as its UTF-8 bytes, exactly as stored in the variable or file the server reads (
secret_env/secret_file; a trailing newline in the file is ignored). Do not hex- or base64-decode it, even if it looks encoded. - The message is the body exactly as received, before any JSON parsing. Re-serialised JSON will not match.
- Compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual,hmac.Equal), and reject the request with a non-2xxstatus if the signature does not match. - The HMAC proves the batch came from a server that knows the secret and was not altered. It does not encrypt anything, so use an
https://URL.
Worked example
Section titled “Worked example”With the secret example-secret and this body (one line, no trailing newline, 502 bytes):
{"instance":"aW5zdGFuY2UtaWQ=","entries":[{"seq":42,"server_seq":null,"source":"server-observed","recorded_at":1700000000,"author_pubkey":null,"vault_id":null,"space_id":null,"prev_hash":"UNhY4JhezH9gQYqvDMWrWH9CwlcKiECVqejMrND2VFw=","signature":null,"entry":{"account_id":"Ym9iLWFjY291bnQ=","device_id":"Ym9iLWRldmljZQ==","event":"login","ts":1700000000},"entry_blob":"eyJhY2NvdW50X2lkIjoiWW05aUxXRmpZMjkxYm5RPSIsImRldmljZV9pZCI6IlltOWlMV1JsZG1salpRPT0iLCJldmVudCI6ImxvZ2luIiwidHMiOjE3MDAwMDAwMDB9"}]}the header is:
X-UniSSH-Signature: sha256=8006caf6788f2bc1e15ea2c1075275b230f3b3c28110f1ab4bad5d5d936e1f04Check it yourself, with the body saved to body.json without a trailing newline:
openssl dgst -sha256 -hmac 'example-secret' body.json# HMAC-SHA2-256(body.json)= 8006caf6788f2bc1e15ea2c1075275b230f3b3c28110f1ab4bad5d5d936e1f04A minimal receiver check in Python:
import hashlib, hmac
def verified(secret: str, body: bytes, header: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)The server’s own test suite checks this exact example, so the page and the code cannot drift apart.
Watching the sink
Section titled “Watching the sink”-
Admin panel. The audit screen shows each configured sink with its last delivered
seq, its lag behind the newest entry, its last success time and its last error, marked failing (the latest failure is newer than the latest success), lagging (entries are waiting and there has been no success for 30 s) or healthy. -
API.
GET /v1/admin/audit/sinks(instance owner only) returns{ "sinks": [ { "sink", "last_seq", "lag", "last_success_at", "last_error", "last_error_at" } ] }.last_seqis the persisted cursor, andlagis the newest auditseqminuslast_seq. The timestamps and the error are what the running process has seen since it started;last_success_atis the last acknowledged batch or the last poll that found the sink caught up.last_erroris a short code such ashttp_500,timeoutorconnect, never a URL, a secret or entry contents. The list is empty when no sink is configured. -
Prometheus. Three series, each labelled
sink(webhookorsyslog):unissh_audit_sink_delivered_seq{sink}, a gauge of the last deliveredseq;unissh_audit_sink_lag{sink}, a gauge of how many entries the log holds after thatseq. It is set at start, after each acknowledged batch, and to 0 when a poll finds the sink caught up. While every attempt fails it keeps its last value;unissh_audit_sink_failures_total{sink}, a counter of failed attempts.
All three exist from the moment the sink starts (the gauges from the saved cursor, the counter at 0), so alerts have data even after a restart. Alert on failures that keep coming, such as
increase(unissh_audit_sink_failures_total[15m]) > 0held for a while, which catches an outage. Also alert onunissh_audit_sink_lagstaying above a threshold that suits your log volume, which catches a sink that cannot keep up. For a live value between deliveries, the status endpoint’slagis computed from the database on every request.
Syslog instead
Section titled “Syslog instead”The server can also send each entry as an RFC 5424 syslog message over UDP or TCP. Those messages carry seq and the event kind in structured data, with the entry JSON as the body, but not the hash fields. Its format, the UDP and TCP delivery contracts, and the advice to use a local forwarder for TLS are in [audit] in the server configuration. Both sinks can run at once, each with its own cursor.