Skip to main content
The relay is a reverse tunnel between devices and HQs. An HQ dials out to the relay over one persistent WebSocket, and the relay forwards each device request down that socket as a multiplexed stream. The HQ replays every stream against its own loopback servers, so its authentication and routes are unchanged. See Remote Access for the user-facing setup. The relay routes traffic and never authenticates app requests itself. It checks that a device may reach an HQ (the pairing credential) and that an HQ may register (the dial-in credential). Everything else, including the HQ’s own Mobile bearer, is forwarded to the HQ.
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’s gatewayId 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 SSE GET /mobile/v1/events stream. The query string is forwarded unchanged.
  • WebSocket: exactly /ws/chat, with any query string.
Other paths, including /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:
A credential is 32 random bytes encoded as base64url. It is valid only for the HQ it was provisioned for. The relay passes the header through the tunnel with the other request headers. The HQ’s relay client removes it before replaying the request on loopback, so it never reaches the HQ’s HTTP servers.

WebSocket /ws/chat

Upgrades to wss://<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:
Subprotocol rules:
  • The header wins. The subprotocol is read only when x-dash-relay-credential is absent or empty.
  • The offer must contain exactly one dash.relay-credential.<credential> entry, plus an optional dash.v1. An unknown extra entry, a second credential, or an empty credential closes with 4401.
  • 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.v1 was also offered, it is forwarded alone. Otherwise the Sec-WebSocket-Protocol header is dropped.
Upgrade failures: The relay passes the HQ’s close code to the device when it is 1000 or in the range 30004999, 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 an OPTIONS 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:
These headers let a browser read the status. For example, a revoked credential then shows as 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
Lifecycle
  • The relay always completes the upgrade, then checks the credentials. On failure it closes with 4401 Unauthorized before 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 502 and WebSockets close with 1000.
  • POST /admin/gateways/revoke closes the tunnel with 4401 Revoked.

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 in RELAY_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 4401 is not retried in a tight loop.
  • Token refresh. In hosted mode a 4401 close 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. The t 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

The relay sends request body chunks as 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

The relay upgrades the device before the HQ answers. Device messages that arrive before the HQ’s loopback socket opens are queued by the HQ and delivered in order. 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 credit with 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.
A slow device therefore slows down the HQ, and the relay does not buffer without limit. WebSocket streams and request bodies have no credit flow control.

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 and open 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 POST with a JSON body. Responses are application/json.
  • Authenticate with Authorization: Bearer <admin-secret>.
  • The body is limited to 64 KiB. An empty body is treated as {}.
  • tenantId and gatewayId are required non-empty strings on every route.
  • Both revoke routes return 200 even when nothing matched, so retries are safe.
Checks run in this order:

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. Body
Response 200
The relay keeps at most 16 credentials per HQ. Provisioning a 17th evicts one. The in-memory store evicts the credential that has gone longest without being used. The durable store evicts the oldest by creation time.

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 4401 Revoked.
  • An HTTP request that has not responded yet gets 401.
  • A stream that has already sent headers, such as SSE, is ended.
Body 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.
Response 200

POST /admin/gateways/revoke

Closes the HQ’s live tunnel immediately with 4401 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
Response 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.