Secure transport: end-to-end configuration
This page is the full configuration reference for connecting a client to a daemon securely, in three topologies:
- Client direct to daemon - mutual-TLS WSS, no relay.
- Daemon to relay - the daemon keeps an outbound bridge to a nominated relay so it is reachable from behind NAT/CGNAT.
- Client to relay to daemon - the client reaches the daemon through that relay, while the real client<->daemon mTLS still terminates at the daemon.
For the 60-second quickstart see Remote setup (WSS); this page is the deeper, knob-by-knob guide.
Mental model: two envelopes, one trust boundary
There are two TLS layers, and only one of them is the security boundary:
- Inner mTLS (the real boundary). TLS 1.3 only, mutually authenticated. The
client presents a daemon-issued certificate; the daemon presents its server
leaf. This is the RPC plane. There is no server-only / unauthenticated path
on it - a client certificate is always required. The certificate admits the
device; it is not an RPC credential. Every remote
initializemust also carry a bearerauth_tokenthat identifies the principal, or the daemon refuses it withAUTH_REQUIRED(-32010). See Bearer token. - Outer TLS (a metadata boundary). When a relay is in the path, the relay terminates an outer TLS + WebSocket session and forwards opaque ciphertext. It never holds a key that can read the inner RPC. On the direct topology there is no outer layer.
Default ports (all configurable):
| Plane | Default | Config |
|---|---|---|
| Daemon WSS (inner mTLS RPC) | 9781 | [wss].port |
| Daemon enrollment endpoint | 9782 | [enroll].port |
| Relay (outer TLS + WS) | 8443 | relay --bind / [bind] |
Throughout, <data_dir> is the daemon’s data directory (typically ~/.zeroclaw)
and <config-dir> is the client’s zerocode config directory (--config-dir,
typically ~/.zeroclaw). Config files do not expand ~; use absolute paths.
Topology 1: client direct to daemon
zerocode ===== mutual-TLS WSS (TLS 1.3) =====> daemon [wss] :9781
1a. Daemon side
Enable the WSS listener. The secure default is to let the daemon auto-generate its own CA and server certificate on first boot, so you do not hand-manage any TLS material:
[wss]
enabled = true
# bind = "0.0.0.0" # default
# port = 9781 # default
# Leave cert_path/key_path empty to auto-generate a server cert under
# <data_dir>/tls/ on first boot. Set them only to bring your own server cert.
On first start with [wss].enabled = true the daemon writes, under
<data_dir>/tls/ (directory mode 0700):
| File | Purpose | Mode |
|---|---|---|
ca.crt | Per-daemon CA certificate (public) | default umask |
ca.key | CA private key (signs client certs) | 0600 |
server.crt | WSS server leaf (SANs localhost, 127.0.0.1) | default umask |
server.key | WSS server private key | 0600 |
Private keys are written 0600; the public certificates use the process umask.
The tls/ directory itself is 0700.
The CA is never silently rotated: if ca.crt and ca.key exist they are
reused. Auto-generated CA lifetime is 10 years; the server leaf is ~27 months;
issued client certs are 30 days.
Open the port (sudo ufw allow 9781/tcp) and start the daemon. You should see a
log line that the WSS listener is up on 0.0.0.0:9781.
The client now needs a certificate. There are two ways to get one.
OIDC first contact (no pairing)
For a configured OIDC deployment, start with
zerocode login <alias> wss://<host>:9781 --gateway-url https://<gateway>.
The client can have an empty config directory, and the daemon need not know
its user or device or have any pairing code pending. After OIDC approval,
the gateway verifies the bearer and session-creation authority (or
system:execute) and signs the
client’s locally generated CSR. The returned CA bootstraps WSS trust, while
WSS remains mandatory mTLS plus per-connection bearer authentication. This
path does not use [enroll], port 9782, or a SAS prompt. See
OIDC remote login.
1b. Client side - option A: pairing-based enrollment
Enrollment gives a certless client its first certificate over a pairing-gated, server-authenticated endpoint, with no manual cert handling. Turn it on:
[enroll]
enabled = true
# bind = "0.0.0.0" # default
# port = 9782 # default
# Requires [wss] enabled and a daemon CA key (auto-generated above, or BYO+key).
# If the CA key is absent the endpoint fails closed and certs must be provisioned
# out of band.
The daemon prints a one-time pairing code and a short-auth-string (SAS) to
its console/log on start. The code is single-use and expires 10 minutes after
it is minted - it is the only bearer credential for certificate issuance, and
it lands in consoles and logs, so a copied code must stop working shortly after
the operator has used it. An expired code is refused and cleared; mint a
replacement with zeroclaw security enroll-paircode --new.
[gateway.pairing_dashboard] can shorten the lifetime (code_ttl_secs, 30 to
600 seconds) and sets how many wrong codes a client may try before it is locked
out (max_failed_attempts) and for how long (lockout_secs). No setting makes
a code live longer than 10 minutes.
This enrollment code issues a certificate and nothing else: POST /pair
refuses it, so a party that sees it cannot turn it into a bearer token. It is
separate from the gateway’s pairing code, and minting either one leaves the
other pending. Enrollment always requires a code, even when
[gateway].require_pairing is off. A gateway pairing code is also accepted by
POST /enroll, once: enrolling with it spends it, so it can no longer pair.
Then, on the workstation:
# Interactive: a certless client auto-enrolls on first connect.
zerocode --connect wss://<remote-host>:9781
# Or explicitly / non-interactively:
zerocode --enroll --connect wss://<remote-host>:9781
zerocode prompts for the pairing code, generates a P-256 key and CSR locally
(the private key never leaves the device), and shows the SAS. Confirm the SAS
matches the one the daemon printed (this catches a man-in-the-middle CA), and it
caches, under <config-dir>/tls/:
| File | Purpose | Mode |
|---|---|---|
client.crt | Issued client certificate | default umask |
client.key | Client private key (generated locally) | 0600 |
ca.crt | Daemon CA chain, pinned for the RPC plane | default umask |
profile.json | Cached device_id, not_after, relay profile | default umask |
Later runs reuse the cached certificate without enrolling again. The cert auto-renews at ~50% of its lifetime (~15 days) over the live mTLS session; a revoked cert cannot self-renew.
Enrollment issues a certificate only, not a bearer token. Before the daemon
accepts a session, pair with the gateway for a zc_... token using a gateway
pairing code (zeroclaw gateway get-paircode --new), not the enrollment code,
and give it to zerocode via ZEROCLAW_AUTH_TOKEN, auth_token_file, or auth_token (see
Bearer token). With the
token in place, zerocode --connect wss://<remote-host>:9781 (or just zerocode
if uri is in config) needs no more flags.
Enrollment endpoint defaults: --enroll-host defaults to --connect’s host;
--enroll-port defaults to 9782.
The dedicated enrollment listener requires a pairing code for every request.
OIDC first contact instead uses the authenticated gateway route above.
The reserved allow_unpaired_enrollment knob is rejected at daemon startup until
the client has an explicit no-code trust anchor such as a pinned daemon CA
fingerprint. This keeps the provisional enrollment TLS path from turning into
blind trust-on-first-use.
1c. Client side - option B: operator-issued certificate
If you would rather mint a cert on the daemon and copy it out:
# On the daemon host. --out-dir also writes a drop-in ca.crt/client.crt/client.key.
zeroclaw security issue-client-cert --name my-laptop --out-dir /tmp/my-laptop-tls
# add --force to overwrite an existing cert for this name
Copy the three files to the client’s <config-dir>/tls/ as ca.crt,
client.crt, client.key (then zerocode --connect wss://host:9781 finds them
automatically), or point at them explicitly:
zerocode --connect wss://<remote-host>:9781 \
--tls-ca-cert /path/ca.crt \
--tls-client-cert /path/client.crt \
--tls-client-key /path/client.key
Equivalent config (so plain zerocode works):
[connection.wss]
uri = "wss://<remote-host>:9781"
[connection.wss.tls]
ca_cert_path = "/abs/path/ca.crt"
client_cert_path = "/abs/path/client.crt"
client_key_path = "/abs/path/client.key"
Plain zerocode still needs a bearer token, from ZEROCLAW_AUTH_TOKEN or from
auth_token_file / auth_token under [connection.wss].
A certless client that reaches the WSS plane without enrolling gets an actionable “enroll first” message (and the daemon logs the rejected un-migrated client) - never a silent hang.
--tls-skip-verifyonly relaxes server verification for a self-signed dev daemon; the client certificate is still required.
Topology 2: daemon to relay
daemon ====== outbound: register + bridge ======> relay :8443
[relay] (blind forwarder)
The daemon dials the relay, proves a stable Ed25519 identity, and registers a node-id. Clients later dial that node-id (Topology 3). The relay only ever forwards ciphertext.
2a. Run the relay (zerorelay)
Configure with relay.toml (see apps/zerorelay/relay.example.toml); every CLI
flag overrides the matching file value. The [admission] section hot-reloads on
SIGHUP; a reload that would turn a public relay into open, tokenless admission
without the explicit opt-in is refused, and the previous policy stays live.
# relay.toml
bind = "0.0.0.0:8443"
[tls]
# Omit cert/key to SELF-PROVISION an outer TLS cert into dir on first run (no
# openssl). Set sans to the relay's public hostname(s)/IP(s).
dir = "/data/tls"
sans = ["relay.example.com"]
# Or bring your own (e.g. a public-CA cert):
# cert = "/etc/zerorelay/fullchain.pem"
# key = "/etc/zerorelay/privkey.pem"
[admission]
# "open" admits any signed daemon (subject to the deny list); "allowlist" admits
# only listed daemon pubkey fingerprints. Deny always wins.
mode = "open"
allow = []
deny = []
# A public (non-loopback) relay MUST gate registration: set a shared secret here
# (each daemon presents it via [relay] token) or use mode = "allowlist".
# A relay that serves daemons enrolled with `zeroclaw relay claim` must use
# mode = "allowlist" and leave relay_token unset (see 2d).
# Otherwise an OPEN, tokenless relay on a public bind refuses to start, because
# any daemon on the internet could register and squat unclaimed node-ids. (A
# loopback bind for local development is exempt; a deliberate open public relay
# can override with allow_public_open = true.)
relay_token = "change-me-to-a-long-random-secret"
[limits]
max_conns_per_node = 256
idle_timeout_secs = 300
lease_ttl_secs = 300
accept_burst_per_ip = 30
accept_rate_per_ip = 10.0
connect_burst_per_node = 60
connect_rate_per_node = 20.0
Run it:
zerorelay --config /etc/zerorelay/relay.toml
# equivalently, flags only (a public bind needs a token or an allowlist, else the
# relay refuses to start):
zerorelay --bind 0.0.0.0:8443 --tls-san relay.example.com \
--relay-token change-me-to-a-long-random-secret
When --tls-cert/--tls-key are omitted the relay self-provisions a CA + server
cert under the TLS dir (resolution order: $ZERORELAY_DATA_DIR/tls, else
$HOME/.zerorelay/tls, else ./zerorelay/tls); localhost and 127.0.0.1 are
always in the SANs. The self-provisioned ca.crt is what a daemon/client trusts
for the relay’s outer TLS.
Admission. open mode plus an optional relay_token is the simplest access
control. allowlist mode keys on the daemon’s registration pubkey fingerprint
(SHA-256 hex of the Ed25519 key at the daemon’s <data_dir>/relay/registration.key);
add fingerprints to allow (and reload with kill -HUP <pid>). A node-id is
bound to its first registrant’s pubkey, so a different key cannot hijack a live
node-id (it gets node_taken).
The relay checks relay_token before the allow list, in every mode. A relay that
serves self-serve claims (2d) must therefore use allowlist with relay_token
unset: a claim never delivers the shared token, so a claimed daemon would be
refused with forbidden: bad relay token. The allow list already gates a public
relay on its own, so the token adds nothing there.
Docker. apps/zerorelay/Dockerfile runs distroless with
CMD ["--config", "/etc/zerorelay/relay.toml"] and a shell-less
zerorelay healthcheck --addr HEALTHCHECK; compose.yaml mounts a volume at
/data so the self-provisioned TLS persists. Expose 8443.
2b. Point the daemon at the relay
[wss]
enabled = true # REQUIRED: the relay forwards to the local WSS listener
[relay]
enabled = true
url = "relay.example.com:8443"
# node_id: leave EMPTY (recommended) to auto-mint + persist a random 128-bit
# capability at <data_dir>/relay/node_id. Set it only to pin a specific id.
# token = "change-me" # must match the relay's [admission].relay_token, if set
# Trust for the relay's OUTER certificate - pick ONE:
relay_ca_path = "/path/to/relay/ca.crt" # trust the relay's (self-signed) CA
# tofu = true # OR pin the relay leaf on first use
# relay_insecure = true # OR skip outer verification (dev only)
# (leave all three unset to use built-in public roots, for a public-CA relay)
[relay] requires [wss] enabled (the relay bridges to 127.0.0.1:<wss.port>),
and fails closed if url is empty. On start the daemon logs the node-id with the
hint “give clients this as –relay-node”; you can also read it from
<data_dir>/relay/node_id. The daemon’s stable registration key is created at
<data_dir>/relay/registration.key (0600).
Outer-cert trust precedence (highest first): relay_insecure >
relay_ca_path > a stored pin at <data_dir>/relay/relay_pin (explicit or
TOFU) > tofu > public roots. Configuring a CA therefore replaces a stale pin
without deleting it. With tofu = true the observed relay leaf fingerprint is
pinned to <data_dir>/relay/relay_pin, and enrollment hands that same pin to
clients so they pin the identical leaf.
2c. (optional) node-id rotation and outer mTLS
[relay]
node_id_rotation_days = 30 # auto-rotate the auto-minted id every N days (0 = never)
Rotation mints a fresh id, runs it alongside the old one for a 10-minute grace
window so in-flight clients are not cut off, then retires the old id; the new id
reaches clients in-band on their next certificate renewal. Force one now with
zeroclaw security relay-rotate-node-id (auto-mint mode only; a pinned node_id
is never rotated).
For a relay that authenticates daemons on the outer layer too, set the
relay’s [admission].outer_client_auth = "required" + outer_client_ca, and on
the daemon [relay].outer_client_cert / outer_client_key. This is additive on
the outer TLS and never touches the inner mTLS.
2d. Self-serve enrollment with zeroclaw relay claim
When the relay is run alongside a ZeroRelay control plane, an operator can enroll a daemon with a one-time claim token instead of editing the relay’s allow list by hand. The control plane issues the token and prints the command to run on the daemon host:
zeroclaw relay claim <TOKEN> --control https://control.example.com
The daemon signs the claim with its existing registration key, so the fingerprint
the control plane allow-lists is exactly the key the daemon registers with. On a
verified success the control plane adds that fingerprint to the relay’s allow
list and reloads the relay, and the daemon writes [relay] enabled, url,
node_id, and relay_host. Start or restart the daemon to register.
Before claiming:
- The relay must admit by fingerprint alone. Use
mode = "allowlist"and leaverelay_tokenunset (see 2a). A claim does not deliver the shared token. - Enable
[wss]. The relay refuses registration until the WSS listener is enabled. A claim made without it is still saved, and takes effect on the next start after[wss]is enabled. - Configure trust for the relay’s outer certificate first, as in 2b
(
relay_ca_path,tofu, or public roots). The claim writes only the four fields above and leaves the rest of[relay]alone. - Do not set the claim-managed fields through environment overrides. If
relay.enabled,relay.url,relay.node_id, orrelay.relay_hostis overridden, the claim refuses before sending anything, because the override would discard the written values.
--control must be https, or http on a loopback address. Redirects and
proxies are disabled so the token cannot reach another origin. Every refusal says
no config was written. A token is single-use: once the control plane accepts
it, it is spent, even if the daemon then refuses the response (for example an
unusable relay address). In that case, fix the cause and mint a new token.
Topology 3: client to relay to daemon
zerocode ==outer TLS+WS==> relay ==forwards ciphertext==> daemon
\________________ inner mutual-TLS (TLS 1.3) terminates here _______________/
This composes Topologies 1 and 2: the client needs an inner client certificate (enroll, as in 1b) and the relay coordinates (address, node-id, and trust for the relay’s outer cert).
3a. The easy path: enrollment carries the relay profile
When the daemon has [relay] configured, its enrollment response includes a
relay profile (relay_url, node_id, and the relay’s leaf relay_cert_pin).
So a single enrollment provisions everything:
zerocode --enroll --connect wss://<daemon-host>:9781
zerocode caches the inner cert and the relay profile in
<config-dir>/tls/profile.json. Later, plain zerocode reaches the daemon
through the relay with no flags - it already knows the relay address, the
node-id, and the pin.
3b. The manual path
Give the client the relay coordinates explicitly. The inner cert still comes from
enrollment or --tls-* (Topology 1):
zerocode \
--relay relay.example.com:8443 \
--relay-node <node-id-from-daemon-log> \
--relay-ca /path/to/relay/ca.crt
# inner mTLS material: from <config-dir>/tls (after enrolling), or pass --tls-* flags
Choose exactly one trust mode for the relay’s outer cert, mirroring the daemon:
| Flag | Meaning |
|---|---|
--relay-ca <pem> | Trust the relay’s (self-signed) CA |
--relay-pin <sha256> | Pin the relay’s outer leaf (usually delivered at enrollment) |
--relay-tofu | Trust on first use; persist the pin to <config-dir>/relay/relay_pin |
--relay-insecure | Skip outer verification (dev/self-signed only) |
| (none) | Use built-in public roots (public-CA relay) |
--relay-host <name> | Override the expected outer-cert SAN (defaults to the --relay host) |
Config equivalent (so plain zerocode works):
[connection.wss]
relay_url = "relay.example.com:8443"
relay_node = "<node-id>"
The relay’s outer trust (
--relay-ca/--relay-pin/--relay-tofu/--relay-insecure) is supplied by flags or the cached enrollment pin, not by[connection.wss]keys.
3c. Direct-first with relay fallback
If you give the client both a direct address and a relay, it prefers the direct path and falls back to the relay, then re-probes and migrates back:
zerocode --connect wss://<daemon-host>:9781 \
--relay relay.example.com:8443 --relay-node <node-id>
Tuning (in [connection.wss]):
| Key | Default | Meaning |
|---|---|---|
direct_attempts | 2 | Direct attempts before falling back to the relay |
direct_timeout_secs | 3 | Per-attempt direct-connect timeout |
reprobe_secs | 30 | Re-probe cadence to migrate back to direct (0 disables) |
In relay-only mode (no --connect/uri), the inner WSS URL defaults to
wss://127.0.0.1:9781 because the inner mTLS terminates at the daemon’s loopback
listener; the relay address is only the TCP dial target.
3d. Browser enrollment through the relay frontdoor (opt-in, enrollment only)
The relay can serve a browser enrollment page (the “frontdoor”): open the relay URL, enter the node id and a pairing code, compare the short-auth-string against the daemon console, and the browser is issued a client certificate.
It enrolls only. Once enrolled, the page hands you the certificate and
private key and points you at zerocode. The daemon’s RPC plane requires mTLS,
which a browser cannot present through the native WebSocket API, so the page
opens no session itself. To use the web dashboard through a relay, see 3e,
which is relay-terminated in the same disclosed way.
It is off by default, because it narrows the blind-forwarder guarantee for the browsers that use it, in two distinct ways:
- The relay is a trusted code origin. A browser that enrolls here runs enrollment JavaScript served by the relay. A compromised relay could substitute code that leaks what the page handles.
- The relay is a principal in the enrollment. The daemon’s enrollment
endpoint speaks TLS, and the relay forwards those bytes without decrypting
them, so a browser cannot perform the exchange itself. The relay therefore
performs it on the browser’s behalf: it fetches the daemon CA, then makes the
CA-pinned
POST /enrollcarrying the pairing code. The relay sees the pairing code and the issued certificate, and could use an observed pairing code to enrol a client of its own. Give the frontdoor an enrollment code (zeroclaw security enroll-paircode --new): the gateway’sPOST /pairrefuses it. A gateway pairing code also enrolls here, but a relay that saw one could pair a bearer token with it instead, if it can reach the gateway.
What the relay does not get is the private key: the browser generates the keypair with WebCrypto and transmits only a CSR.
The short-auth-string still works as a detector - a relay that substitutes a different daemon CA produces a SAS that does not match the daemon console, so stop if it differs. It cannot protect you from a relay you have chosen to trust: browser enrollment through the frontdoor is relay-terminated, not end-to-end.
Earlier iterations of this frontdoor shipped a hand-written TLS 1.3 and X.509 client in relay-served JavaScript so the browser could speak to the daemon itself. That is deleted, not ported. An unaudited TLS stack in served JavaScript is a worse trust story than an honestly disclosed relay-terminated one, and it did not remove the trusted-code-origin problem anyway.
The narrowing is bounded: the RPC plane stays end-to-end mTLS past the relay
in every mode, and zerocode/native enrollment never executes relay-served
code and is never relay-terminated - use those (sections 1b/1c/3a) when the
relay must stay fully untrusted.
# relay.toml
[frontdoor]
enabled = true # default false; read the trust note above first
When disabled, plain HTTP requests to the relay get a 404 and browsers cannot
enroll through it; daemon registration, zerocode clients, and the tunneled RPC
plane are unaffected. The enrollment page itself carries the same trust note,
and zerorelay logs a warning at startup while the frontdoor is on.
Prefilled enrollment links
To spare a phone user from typing a long node id, a link can carry the node id and the pairing code, and the page fills both fields when it loads:
https://relay.example.com/#node=<node-id>&code=<pairing-code>
https://relay.example.com/?node=<node-id>&code=<pairing-code>
Prefer the # form. A URL fragment is never sent to any server, so the pairing
code stays out of reverse-proxy and CDN access logs; the ? form is accepted
too. The relay itself drops the query string before routing and never logs or
reflects it, and serves the page with cache-control: no-store and
referrer-policy: no-referrer.
The page only fills the fields. It does not fetch the agent CA or submit anything: the user still presses Fetch the agent CA and confirms the short-auth-string. Values that do not match the node-id or pairing-code shape are ignored, and both parameters are removed from the address bar and the current history entry as soon as the page reads them, so a copied or bookmarked URL does not carry the code.
The browser can still record the link as it was first opened in its own history
database (Chrome does, for both the ? and # forms); no page can rewrite that.
What makes that copy harmless is the code itself: it is consumed by the first
successful enrollment and expires at most ten minutes after it is minted, so the stored
link is spent or dead. Treat a link that carries an unused code like the code
itself, and prefer enrolling promptly after minting.
3e. The web dashboard through the relay (opt-in, relay-terminated)
A relay can serve a daemon’s web dashboard at a host of its own,
https://<label>.<domain>/, so a browser anywhere reaches it with no VPN
and no port opened on the daemon’s side. People then sign in to the
dashboard as themselves (dashboard sign-in, see
Authentication), each with their own grants.
Sign-in is the only way in: pairing and paired tokens are refused through the
relay (see below).
This route is not end-to-end. The relay ends the browser’s TLS, with
its operator’s wildcard certificate, and forwards the dashboard’s HTTP to
the daemon over the daemon’s own relay link. Anyone who can modify the
relay can read and change that traffic for everyone who uses the address:
pages, session cookies and CSRF tokens, and so act as a signed-in user for
that session.
Both ends say so at startup, and the dashboard’s sign-in screen shows a
notice while it is reached this way. zerocode and other native clients
through the same relay stay end-to-end, and a direct HTTPS connection to the
gateway (or a VPN such as Tailscale) keeps the relay out entirely. Use those
when the relay must not be trusted.
An administrator’s session gives the relay administrator power. Acting
for someone signed in as an administrator, the relay can make any change an
administrator can, including changes that outlast the session: adding
scheduled jobs, writing credentials into the config, or adding a sign-in
provider or identity mapping that it controls and then signing in with it
later. Sign in as an administrator through the relay only if you trust the
relay that far. Otherwise administer the daemon over a direct connection or
with zerocode.
What the relay does not get, even so:
- Identity-provider passwords. The browser talks to the identity provider directly, so the relay sees the authorization code in the callback, which is useless without the PKCE verifier that stays in the gateway. A hostile relay could still put a fake sign-in form on the dashboard’s page, so check the provider’s address before typing a password.
- Anything but the dashboard. The daemon hands these connections to its gateway in process. It never dials a local port with them, so the relay cannot reach the RPC plane, enrollment, or any other listener.
- This machine’s standing. The gateway serves each connection as the
browser at the address the relay reports. That address is never a loopback
one, so the admin routes, the pairing limits’ local exemption and every
other “on this machine” rule treat it as remote. Forwarded headers
(
X-Forwarded-*,X-Real-IP,Forwarded) are dropped from these requests whatevertrust_forwarded_headerssays. - A pairing code or a paired token. The relay sees everything a browser
sends through it. A pairing code it saw could be redeemed first for a
lasting operator token, and a token sent through it could be kept and used
after the browser leaves. So on these connections the gateway refuses
pairing (
POST /pair,POST /api/pair, and minting a code) before it reads the code or counts the attempt, and refuses any request that presents a token or secret: inAuthorization, inX-Webhook-Secret, in abearer.WebSocket subprotocol, or in atokenquery parameter. OIDC enrollment (/oidc/login/{alias}and the device grant) is refused too, because it hands the browser an access token. Pair or enroll on a direct connection to the gateway instead. A token sent through the relay anyway is refused, but the relay has still seen it, so revoke or rotate it on a direct connection. This covers what the gateway hands out or accepts. An administrator’s session can still write a credential into the config, as described above. - More than the signed-in user may do. Authorization stays at the gateway.
- Another daemon’s dashboard. Each dashboard is its own origin. The label comes from the daemon’s relay registration key, which the relay verifies when the daemon registers, so only that daemon is served under it.
The address
The label is the first 26 characters of the base32 of a SHA-256 of the daemon’s relay registration public key. The daemon logs the whole address, and the redirect URI to register, when the relay bridge starts:
relay dashboard: serving the dashboard through the relay ... dashboard=https://<label>.relay.example.com/ redirect_uri=https://<label>.relay.example.com/oidc/callback
It does not change when the node-id rotates, so bookmarks and the identity provider’s registration keep working. It changes only if the daemon’s registration key does. It is an address, not a secret: it appears in DNS lookups and in the TLS server name. Access is controlled by dashboard sign-in, and the node-id never appears in it.
On the relay
The relay needs:
- Wildcard DNS:
*.relay.example.comresolving to the relay. Use a name of its own, not your main domain’s wildcard. - A publicly trusted wildcard certificate for
*.relay.example.com. Certificate authorities issue wildcards through the DNS challenge only, so this needs DNS API access or a manual renewal. One certificate per host would publish every label in the public Certificate Transparency logs. The relay presents it for names under the domain, and its own certificate for everything else, so daemons and zerocode clients are unaffected. - Port 443 is recommended. Otherwise the port is in every dashboard address and redirect URI.
# relay.toml
[dashboard]
enabled = true
domain = "relay.example.com"
cert = "/etc/zerorelay/wildcard-fullchain.pem"
key = "/etc/zerorelay/wildcard-privkey.pem"
# The daemons whose dashboards this relay serves: their registration-key
# fingerprints, the same values `[admission] allow` takes. Required.
allow = ["<daemon fingerprint>"]
The allowlist is required in every admission mode, and an empty one refuses to start. On an open relay, without it, any daemon that registers would get a trusted HTTPS host under the relay’s domain. A daemon that is listed but has not enabled the dashboard, or that is enabled but not listed, gets no host.
The relay routes by the TLS server name alone and never parses the
forwarded HTTP. It offers browsers HTTP/1.1 only, so a browser never reuses
one connection for two dashboard hosts. Each daemon gets its own cap on
dashboard connections, separate from its native clients’ slots, and each
browser address gets a cap as well. Every browser connection also counts
against the relay’s existing per-address handshake limit
([limits] accept_burst_per_ip, accept_rate_per_ip): a browser opens
several connections per page, so raise it if many people reach the relay
from one address (one office NAT, for example), or their new connections are
dropped before TLS. A browser the relay sees at a loopback
address is refused by the daemon. That includes a proxy on the relay’s own
host forwarding to it: the daemon would see the proxy’s loopback address,
not the browser’s.
On the daemon
[relay]
enabled = true
url = "relay.example.com:443"
[relay.dashboard]
enabled = true
domain = "relay.example.com" # the relay's [dashboard] domain
# port = 443 # if the relay listens elsewhere
The daemon refuses [relay.dashboard]:
- without
[relay]enabled; - with
gateway.require_pairing = false, which would leave the dashboard open to anyone who reaches the address; - without
[gateway.dashboard_sign_in]offering at least one provider, because sign-in is the only way in through the relay; - with a
gateway.path_prefix, because the dashboard is served at the root of its own host so its cookies can use the__Host-prefix; - with
relay.relay_insecure = true, because the daemon’s link to the relay is the only encryption past the relay for this traffic. For native clients, the inner mTLS covers it.
The gateway answers these connections only for the dashboard’s own host
(anything else gets 421), treats them as HTTPS, and builds the sign-in
redirect URI and its own origin from the dashboard’s address, never from the
request’s Host.
The identity provider
The relay carries only the dashboard. Browsers reach the identity provider directly, so it needs a public HTTPS address of its own, and the daemon uses the same issuer URL for discovery, keys and the token exchange. Register the exact redirect URI the daemon logs, with no wildcard.
Configuration reference
Daemon [wss]
| Key | Default | Description |
|---|---|---|
enabled | false | Enable the mutual-TLS WSS listener |
bind | 0.0.0.0 | Bind address |
port | 9781 | Listen port |
cert_path | (empty) | BYO server cert; empty auto-generates under <data_dir>/tls/ |
key_path | (empty) | BYO server key; empty auto-generates |
Daemon [wss.client_auth] (optional; mTLS is always on either way)
| Key | Default | Description |
|---|---|---|
enabled | false | Use a BYO CA; when false the daemon uses its auto-generated CA |
ca_cert_path | (empty) | PEM CA used to verify client certs (BYO mode) |
pinned_certs | [] | If non-empty, only client certs matching these SHA-256 fingerprints are accepted |
crl_path | (empty) | Revoked-fingerprint file; empty uses the ledger-materialized <data_dir>/tls/revoked |
Daemon [enroll]
| Key | Default | Description |
|---|---|---|
enabled | false | Enable the enrollment endpoint (requires [wss] + a CA key) |
bind | 0.0.0.0 | Bind address |
port | 9782 | Listen port |
allow_unpaired_enrollment | (empty) | Reserved; non-empty values are rejected until a no-code client trust anchor exists |
Daemon [relay]
| Key | Default | Description |
|---|---|---|
enabled | false | Enable the relay bridge (requires [wss]) |
url | (empty) | Relay address host:port; required when enabled |
node_id | (empty) | Empty auto-mints + persists a 128-bit id; set to pin one |
token | (empty, secret) | Shared-secret presented at registration |
relay_ca_path | (empty) | PEM CA for the relay’s outer cert; empty uses public roots |
relay_host | (empty) | Expected outer-cert SAN; empty derives from url |
relay_insecure | false | Skip outer-cert verification (dev only) |
tofu | false | Pin the relay leaf on first use to <data_dir>/relay/relay_pin |
outer_client_cert | (empty) | Daemon’s outer-mTLS client cert for relay admission |
outer_client_key | (empty) | Key for outer_client_cert |
node_id_rotation_days | 0 | Auto-rotate the auto-minted node-id every N days (0 = never) |
Daemon [relay.dashboard] (see 3e: relay-terminated)
| Key | Default | Description |
|---|---|---|
enabled | false | Serve the web dashboard through the relay at https://<label>.<domain>/ |
domain | (empty) | The relay’s [dashboard] domain; required when enabled |
port | 443 | The HTTPS port browsers reach the relay on |
Relay relay.toml
| Section.key | Default | Description |
|---|---|---|
bind | 0.0.0.0:8443 | Listen address (daemon + client) |
[tls].cert / .key | (self-provision) | Outer TLS identity; omit both to self-provision |
[tls].dir | data dir /tls | Where the self-provisioned cert is written |
[tls].sans | [] | Extra SANs (localhost, 127.0.0.1 always included) |
[admission].mode | open | open or allowlist |
[admission].allow / .deny | [] | Daemon pubkey fingerprints (deny wins) |
[admission].relay_token | (none) | Optional shared-secret gate |
[admission].outer_client_auth | off | off / optional / required (outer mTLS) |
[admission].outer_client_ca | (none) | PEM CA for outer client certs |
[admission].route_by_client_cert | false | Route by the outer cert CN’s node-id |
[limits].max_conns_per_node | 256 | Simultaneous client conns per node-id |
[limits].idle_timeout_secs | 300 | Drop idle client conns after N seconds |
[limits].lease_ttl_secs | 300 | Lease TTL advertised at registration (advisory in v1: WebSocket liveness is the real cleanup rule) |
[limits].accept_burst_per_ip / accept_rate_per_ip | 30 / 10.0 | Per-IP handshake token bucket |
[limits].connect_burst_per_node / connect_rate_per_node | 60 / 20.0 | Per-node connect token bucket |
[frontdoor].enabled | false | Serve the browser enrollment page (see 3d: makes the relay a trusted code origin AND a principal in browser enrollment) |
[limits].max_pending_handshakes | 256 | Sockets past accept but not yet classified |
[limits].handshake_timeout_secs | 10 | One deadline over TLS, WS upgrade and signed registration |
[limits].max_registered_nodes | 1024 | Simultaneously registered daemons (N+1 gets registry_full) |
[dashboard].enabled | false | Serve listed daemons’ web dashboards at https://<label>.<domain>/ (see 3e: relay-terminated) |
[dashboard].domain | (none) | Dashboard domain; *.<domain> must resolve to the relay |
[dashboard].cert / .key | (none) | Publicly trusted certificate for *.<domain>; required when enabled |
[dashboard].allow | [] | Daemon registration-key fingerprints whose dashboards are served; required when enabled |
[dashboard].max_conns_per_node | 64 | Concurrent dashboard connections to one daemon |
[dashboard].max_conns_per_ip | 16 | Concurrent dashboard connections from one browser address |
zerorelay CLI (overrides relay.toml)
--config --bind --tls-cert --tls-key --tls-dir --tls-san (repeatable)
--registration-mode --allow (repeatable) --deny (repeatable) --relay-token
--max-conns-per-node --idle-timeout-secs --lease-ttl-secs --status-file
--frontdoor (opt-in; see 3d for the trust implications).
Subcommands: healthcheck [--addr 127.0.0.1:8443], status --file <path>.
zerocode [connection.wss] and CLI
[connection.wss] key | Default | CLI override |
|---|---|---|
uri | (none) | --connect |
relay_url | (none) | --relay (requires --relay-node) |
relay_node | (none) | --relay-node (requires --relay) |
direct_attempts | 2 | - |
direct_timeout_secs | 3 | - |
reprobe_secs | 30 | - |
auth_token | (none) | ZEROCLAW_AUTH_TOKEN env (wins over both token keys) |
auth_token_file | (none) | - (wins over auth_token; must be owner-only) |
auth_provider | native (pairing token) | - (e.g. oidc.<alias>) |
[connection.wss.tls] key | Default | CLI override |
|---|---|---|
ca_cert_path | <config-dir>/tls/ca.crt | --tls-ca-cert |
client_cert_path | <config-dir>/tls/client.crt | --tls-client-cert (requires key) |
client_key_path | <config-dir>/tls/client.key | --tls-client-key (requires cert) |
skip_verify | false | --tls-skip-verify |
Relay outer-trust and enrollment are CLI/cache only: --relay-ca --relay-host
--relay-insecure --relay-pin --relay-tofu --relay-client-cert
--relay-client-key --enroll --enroll-host --enroll-port.
File layout
Daemon <data_dir>/:
tls/ca.crt tls/ca.key per-daemon CA (key 0600)
tls/server.crt tls/server.key WSS server leaf (key 0600)
tls/ledger.db issued-cert ledger (SQLite)
tls/revoked revoked fingerprints (handshake-checked)
relay/registration.key Ed25519 relay identity (0600)
relay/node_id auto-minted node-id
relay/relay_pin pinned relay outer-leaf fingerprint (TOFU)
Client <config-dir>/:
tls/client.crt tls/client.key client identity (key 0600)
tls/ca.crt pinned daemon CA
tls/profile.json device_id, not_after, cached relay profile
relay/relay_pin relay outer-leaf pin (--relay-tofu)
Relay <tls-dir>/: ca.crt, server.crt, server.key (self-provisioned).
Upgrading an existing issued-cert ledger
tls/ledger.db carries a schema version. A ledger written by an earlier
revision is rebuilt in place the first time the new daemon opens it, preserving
every issued and revoked certificate along with its device id, validity and
audit actor. The rebuild runs in a single transaction: if it cannot complete,
the daemon refuses to start and leaves the original ledger untouched rather than
half-migrated, and the error names the file. No operator action is required, and
enrolled devices do not need to re-enroll.
Undelivered certificates
The daemon records an issuance in the ledger before it hands the certificate
over - to the enrolling client, to a renewing client, or to the operator’s
issue-client-cert files. That order is deliberate: the alternative can leave a
live CA-signed certificate in someone’s hands with no ledger row, and a
certificate the ledger has never heard of cannot be listed or revoked.
The cost is that a failure in between - a client that disconnects mid-response,
a failed rename - leaves an active row for a certificate nobody received. Such
a row is tracked separately as undelivered and is revoked once it is older
than one hour, at the first of:
- any new certificate issuance or renewal,
- any enrollment connection,
- any daemon restart or other ledger open.
It is not a background timer. A daemon doing no certificate work at all defers
the sweep until its next such activity; until then the certificate stays out of
tls/revoked and would still pass a WSS handshake, though it cannot renew. Any
enrollment or renewal traffic - including a connection that fails to
authenticate - is enough to reconcile it.
Reconciled certificates are revoked, never deleted: the row stays in the
ledger and the fingerprint lands in tls/revoked exactly like an operator
revocation, with the audit actor reconcile:undelivered so the trail
distinguishes them. security list-client-certs shows ACTIVE certificates
only, so a reconciled certificate disappears from that listing - during
incident response, read tls/revoked or the audit log for revocation history. If a device reports that a certificate it did receive
stopped working, look for that actor - it means the delivery record was lost,
and the fix is to re-enroll or re-issue.
Verifying and troubleshooting
- Daemon up: look for the WSS listener log on
0.0.0.0:9781and, with a relay, thenode_idlog line. - Certless connect refused: expected on the mTLS plane - enroll first (Topology 1b). The message is actionable, not a hang.
- Relay reachable:
zerorelay healthcheck --addr <host>:8443exits 0. - Relay metrics: run with
--status-file <path>, thenzerorelay status --file <path>(counts only, never payloads). - Revoke a lost device: revoking in the daemon ledger materializes
<data_dir>/tls/revoked; the cert is refused at its next handshake and cannot self-renew. - SAS mismatch at enrollment: the client refuses to persist the cert and aborts. A mismatch means the CA you received is not the daemon’s - investigate a possible man-in-the-middle before retrying.
There is a self-contained end-to-end harness at scripts/dev/mtls-relay-testbed.sh
that boots a daemon + relay, issues a cert, enrolls over the wire, and exercises
all three topologies; read it as a worked example.