The control plane’s own routes, including dial-token minting and refresh, are documented in
Relay Control Plane API. The forwarded HQ routes are documented in
HQ Mobile API and HQ Chat WebSocket.
Authentication
Modes
The relay’s startup configuration selects one of three modes. Each mode decides how HQs are admitted and whether pairing credentials are enforced.
The self-hosted store is in memory, so a relay restart drops every pairing and each device needs a
newly provisioned credential. The hosted store survives restarts and keeps only SHA-256 hashes.
Route index
Every other path returns
404. The relay has no health route. See Liveness.
Device-facing surface
Host routing
The relay takes the HQ’sgatewayId from the first DNS label of the Host header, ignoring any
port. demo.relay.example.com routes to demo. The rest of the hostname is not checked, so the
zone is set by your DNS and TLS terminator, not by the relay.
/admin/* and /gw/* are matched by path before host routing and work on any hostname that
reaches the relay.
Path rules
Only two targets are forwarded:- HTTP:
/mobile/v1, or any path beginning with/mobile/v1/. This covers REST and the SSEGET /mobile/v1/eventsstream. The query string is forwarded unchanged. - WebSocket: exactly
/ws/chat, with any query string.
/mobile/v2, the HQ’s unprefixed administrative routes, and /health,
return 404 without contacting the HQ. The relay also rejects a path that is not canonical:
- The target must start with
/and contain no#. - URL normalization must leave the path unchanged, so literal or encoded
.and..segments are refused. - Repeated percent-decoding must never produce a backslash or a
./..segment. A path that still changes after five rounds of decoding is also refused.
Request processing order
Each HTTP request to/mobile/v1 passes these checks in order. The first failure is returned.
The credential is checked before the rate limit, so unauthenticated callers cannot spend a paired
device’s request budget.
Relay-generated error bodies are
text/plain constants. Responses produced by the HQ are passed
through with the HQ’s status and headers.
Pairing credential
Send the credential on every HTTP request and WebSocket upgrade:WebSocket /ws/chat
Upgrades towss://<gatewayId>.<zone>/ws/chat are bridged to the HQ’s chat server. The frames
inside are the HQ chat protocol. See HQ Chat WebSocket.
Native clients send the credential in the x-dash-relay-credential header. Browsers can’t set
headers on a WebSocket upgrade, so they offer the credential as a subprotocol instead:
- The header wins. The subprotocol is read only when
x-dash-relay-credentialis absent or empty. - The offer must contain exactly one
dash.relay-credential.<credential>entry, plus an optionaldash.v1. An unknown extra entry, a second credential, or an empty credential closes with4401. - The relay selects only
dash.v1, and only when the client offered it. The credential never appears in a response header. - The relay removes the credential entry before forwarding. If
dash.v1was also offered, it is forwarded alone. Otherwise theSec-WebSocket-Protocolheader is dropped.
The relay passes the HQ’s close code to the device when it is
1000 or in the range 3000–4999,
for example the HQ’s 4001. Any other code becomes 1000. Close reasons are truncated to 123
bytes. Text and binary messages keep their type in both directions.
CORS
A browser strips custom headers from a CORS preflight, so anOPTIONS request to a canonical
/mobile/v1 path is forwarded without a pairing credential. The HQ answers it from its own
origin allowlist. The relay never sets CORS policy for data responses. OPTIONS on any other path
returns 404 and is not forwarded.
When the request carries an Origin header, the relay adds these headers to its own 401, 429,
and 502 responses on /mobile/v1:
401 instead of a network failure. Access-Control-Allow-Credentials is never set. The 404 for
an unknown path gets no CORS headers, and neither does a 502 caused by the tunnel closing
mid-request.
Rate limits
Limits apply per HQ (gatewayId). They are fixed defaults and cannot be set from the relay’s
command line or environment.
The two buckets are independent, so preflight floods cannot use up the authenticated budget.
Both buckets reset when the HQ’s tunnel closes.
Error reference
If a stream closes after the HQ has already sent a response, the relay ends the response early
and the client sees a truncated body.
HQ tunnel
GET /gw/:gatewayId
The HQ opens a WebSocket to/gw/<gatewayId>, with the ID URL-encoded, and keeps it open. The
query string is ignored. The path must have exactly one segment after /gw/. The Host header
isn’t used, so the HQ may dial any hostname that reaches the relay.
Headers
- The relay always completes the upgrade, then checks the credentials. On failure it closes with
4401Unauthorizedbefore registering the HQ, so a rejected dial cannot replace a connected HQ with the same ID. - Only one tunnel per
gatewayId. An accepted dial with the same ID replaces the existing tunnel and closes it. - When a tunnel closes, every device stream on it ends. Pending HTTP requests get
502and WebSockets close with1000. POST /admin/gateways/revokecloses the tunnel with4401Revoked.
Dial token
In hosted mode the control plane signs a dial token bound to one HQ. The relay verifies it offline with the Ed25519 public key inRELAY_DIAL_TOKEN_PUBLIC_KEY. See
Relay Control Plane API for how HQs get and refresh tokens.
The token is base64url(JSON claims).base64url(signature): an Ed25519 signature over the first
segment. It has no alg field because the algorithm is fixed.
Holder-of-key proof
X-Gateway-Proof uses the same wire format, signed with the HQ’s own private key. The relay
verifies it against the cnf key from the dial token, so a stolen token is useless without the
private key.
Keepalive and reconnect
These behaviors belong to the HQ’s relay client:- Heartbeat. The HQ sends a WebSocket protocol ping every 20 seconds. If no pong arrives before the next interval, it terminates the socket and reconnects. The relay answers protocol pings automatically.
- Backoff. Reconnects wait 1 second, doubling each attempt up to 30 seconds. The backoff
resets only after a connection stays up for 5 seconds, so a relay that accepts and immediately
closes with
4401is not retried in a tight loop. - Token refresh. In hosted mode a
4401close triggers a dial-token refresh from the control plane, at most once every 30 seconds, then a redial. The HQ also refreshes one hour before the token expires. A refresh affects only new dials; the open tunnel stays up. - On any disconnect the HQ drops all in-flight loopback requests for that tunnel.
Mux frame format
Every message on the tunnel is one WebSocket text message containing one JSON object. Thet field
gives the frame type. The relay and the HQ ignore messages that are not valid JSON or have an
unknown t.
streamId is a positive integer assigned by the relay. It starts at 1 for each tunnel connection
and increases by one for each device request or WebSocket.
open fields
The HQ replays
mgmt streams to its management server and chat streams to its chat server on
127.0.0.1. Before replaying, it removes x-dash-relay-credential, hop-by-hop headers, and any
header named in Connection. On WebSocket streams it also removes every sec-websocket-* header.
HTTP stream
data frames, then end. The HQ answers with one head,
any number of data frames, and end. If the HQ’s loopback request fails, the HQ sends close
with an error reason. Before head, the device then gets 502. If the device disconnects, the
relay sends close and the HQ aborts the loopback request.
WebSocket stream
binary preserves the
message type: the HQ chat server accepts only text messages. When the loopback socket closes, the
HQ sends close with its code and reason, and the relay closes the device socket as described in
WebSocket /ws/chat.
Flow control
Credit applies to HTTP response bodies, including SSE:- The HQ counts bytes it has sent on a stream but not yet had credited. At 256 KiB it pauses reading from the loopback response.
- The relay sends
creditwith the number of bytes written to the device. It sends the credit immediately if the write was flushed, otherwise after the device socket drains. - The HQ resumes reading once the uncredited count drops below 256 KiB.
Heartbeat frames
Both ends reply to a{"t":"ping"} frame with {"t":"pong"}. Neither the relay nor the HQ sends
application-level pings on its own. Liveness uses WebSocket protocol pings, described in
Keepalive and reconnect.
Fault isolation
The relay ignores frames for unknown stream IDs andopen or credit frames sent by the HQ. If a
frame from the HQ can’t be applied, for example a second head on a stream, the relay closes only
that stream. Other streams and the tunnel stay up.
Admin API
The admin API manages pairing credentials. The control plane is its caller in hosted deployments. On a self-hosted relay you call it yourself. It runs on the relay’s listener under/admin/ and works on any hostname.
Common behavior
- Every route is
POSTwith a JSON body. Responses areapplication/json. - Authenticate with
Authorization: Bearer <admin-secret>. - The body is limited to 64 KiB. An empty body is treated as
{}. tenantIdandgatewayIdare required non-empty strings on every route.- Both revoke routes return
200even when nothing matched, so retries are safe.
POST /admin/pairings
Provisions a new pairing credential for an HQ. Each paired device gets its own credential. The raw value is returned only in this response. Body200
POST /admin/pairings/revoke
Revokes one device, or every device, for an HQ. Revocation is immediate. The relay also closes that device’s open connections:- WebSockets close with
4401Revoked. - An HTTP request that has not responded yet gets
401. - A stream that has already sent headers, such as SSE, is ended.
If
credentialHash is present it is used. Otherwise credential is used. With neither, every
credential for the HQ is revoked and every credentialed connection to it is closed. The control
plane revokes by credentialHash because it stores only hashes.
200
POST /admin/gateways/revoke
Closes the HQ’s live tunnel immediately with4401 Revoked, ending every device stream on it.
Pairing credentials are not changed. Whether the HQ can dial back in depends on its dial-in
credential. In hosted mode that is the dial token from the control plane.
Body
200
Liveness
The relay has no health route. Use one of these probes:
In development mode, which has no admin secret, the second probe returns the HQ’s health response
instead of
401.
Configuration
Each setting can be passed as a command-line flag or an environment variable. A flag overrides the environment variable, which overrides the default.
The relay exits at startup if neither a relay token nor a dial-token public key is configured.
The relay serves plain HTTP and WebSocket. Put a TLS terminator in front of it, such as Caddy or
Traefik, with a wildcard certificate for the HQ subdomains. Devices and HQs then connect on port
443. The terminator must keep the original Host header and pass WebSocket upgrades through.
Settings such as the DNS zone and the DNS provider token are read by the terminator, not by the
relay.
Rate limits and the stream cap use the defaults in Rate limits and are not
configurable from the command line or environment.