Relay API
Wire reference for the dashsquad relay: the device edge, the HQ tunnel and the mux frame format.
The relay is a reverse tunnel between devices and HQs. An HQ connects out to the relay on one WebSocket and keeps it open. The relay sends each device request down that socket as a separate stream. The HQ replays each stream to its own loopback servers, so its routes and its authentication do not change. For the setup steps, see Remote access.
The relay does not authenticate app requests. It does two checks only. A device must have a valid pairing credential for the HQ. An HQ must have a valid dial-in credential. The relay sends all other headers to the HQ, with the HQ bearer token, and the HQ checks them.
Device ──HTTPS/WSS──▶ Relay ◀──WSS /gw/<gatewayId>── HQ (dials out, behind NAT)
│ │
└──── one stream per request ──┘──▶ 127.0.0.1 management / chat servers| Caller | Surface | Credential |
|---|---|---|
| Device (iOS, Android, web) | /mobile/v1 and /ws/chat on <gatewayId>.<zone> | Pairing credential. The HQ checks its own bearer token |
| HQ | /gw/<gatewayId> WebSocket | Signed dial token and proof of key (hosted mode), or a shared relay token (static mode) |
The Relay Control Plane API page tells how an HQ gets dial tokens and how a device gets a pairing credential. The HQ routes behind the relay are on HQ Mobile API and HQ Chat WebSocket.
Authentication
| Scheme | Sent as | Checked by | Failure |
|---|---|---|---|
| Pairing credential | x-dash-relay-credential header, or the dash.relay-credential.<credential> WebSocket subprotocol | The relay, against its credential store | HTTP 401, or WebSocket close 4401 |
| Dial token and proof (hosted mode) | Authorization: Bearer <dial-token> and X-Gateway-Proof: <assertion> on the HQ dial-in | The relay, offline, with the public key of the control plane | WebSocket close 4401 |
| Shared relay token (static mode) | Authorization: Bearer <relay-token> on the HQ dial-in | The relay, with a constant-time compare | WebSocket close 4401 |
| HQ bearer token | Authorization header on device requests | The HQ. The relay does not change it | The error from the HQ |
Modes
The startup settings of the relay select one of two modes.
| Mode | Selected by | HQ dial-in | Pairing credential |
|---|---|---|---|
| Hosted | A dial-token public key and an admin secret | Signed dial token and proof of key. The relay token has no effect | Checked against a durable store of SHA-256 credential hashes |
| Static | A shared relay token only | Shared relay token | Not checked. The relay accepts any value, or no value |
The dashsquad relay service runs in hosted mode. Static mode is for development only. A static
relay listens on a loopback address only, unless the operator starts it with --insecure-dev.
Route index
| Method | Path | Caller | Credential | Purpose |
|---|---|---|---|---|
| Any | /mobile/v1 and /mobile/v1/* on <gatewayId>.<zone> | Device | Pairing credential | Sent to the HQ management server |
OPTIONS | The same paths | Browser | None | CORS preflight, sent to the HQ |
GET (WebSocket) | /ws/chat on <gatewayId>.<zone> | Device | Pairing credential | Connected to the HQ chat server |
GET (WebSocket) | /gw/:gatewayId | HQ | Dial token and proof, or relay token | Opens the tunnel of the HQ |
GET | /health on all hostnames | Monitor | None | Liveness. The relay sends 200 {"status":"ok"} and does not contact an HQ |
GET | /version on all hostnames | Monitor | None | The release of the relay. Refer to Liveness |
All other paths return 404. Refer to Liveness.
The relay also has an internal admin API under /admin/. Only the control plane uses it. Refer to
Internal admin API.
Device surface
Host routing
The relay gets the gatewayId of the HQ from the first DNS label of the Host header. It
ignores the port. For example, demo.relay.example.com goes to the HQ demo. The relay does not
check the remainder of the hostname. Your DNS and your TLS terminator set the zone.
The relay matches /health, /version, /gw/* and /admin/* by path before host routing. These paths work on all
hostnames that get to the relay.
Path rules
The relay sends only two types of target to the HQ:
- HTTP:
/mobile/v1, or a path that starts with/mobile/v1/. These targets include REST and the SSE streamGET /mobile/v1/events. The relay sends the query string without change. - WebSocket:
/ws/chatexactly, with or without a query string.
All other paths return 404 and do not get to the HQ. These paths include the HQ administrative
routes without a prefix. The relay itself answers GET /health and GET /version. The relay also refuses a path that is not canonical:
- The target must start with
/and must not contain#. - URL normalization must not change the path. Thus the relay refuses literal or encoded
.and..segments. - Repeated percent-decode must not make a backslash, a new
/, or a.or..segment. The relay also refuses a path that still changes after five decode rounds.
Order of request checks
The relay does these checks in sequence on each HTTP request to a mobile path. It returns the first failure.
| Step | Check | Failure |
|---|---|---|
| 1 | The path is a canonical mobile path | 404 Not Found |
| 2 | An HQ is connected for the Host label | 502 No HQ connected |
| 3 | The pairing credential is valid (not checked for OPTIONS) | 401 Unauthorized |
| 4 | The HQ is below its rate limit and its stream limit | 429 Too Many Requests, with Retry-After: 1 |
| 5 | The relay sends the request to the HQ management server | The response from the HQ |
The relay checks the credential before the rate limit. Thus a caller without a credential cannot use the request budget of a paired device.
The relay error bodies are fixed text/plain strings. The relay sends responses from the HQ with
the status and headers from the HQ.
Pairing credential
Send the credential on each HTTP request and on each WebSocket upgrade:
curl "https://$GATEWAY_ID.$RELAY_ZONE/mobile/v1/identity" \
-H "x-dash-relay-credential: $RELAY_CREDENTIAL" \
-H "Authorization: Bearer $HQ_TOKEN"A credential is 32 random bytes in base64url. It is valid only for the HQ that the control plane made it for. The relay sends the header through the tunnel with the other request headers. The relay client in the HQ removes it before the HQ replays the request on loopback. Thus the credential does not get to the HQ HTTP servers.
In hosted mode, the relay keeps a maximum of 16 credentials for each HQ. When the control plane adds a 17th credential, the relay deletes the oldest credential.
WebSocket /ws/chat
The relay connects an upgrade to wss://<gatewayId>.<zone>/ws/chat to the HQ chat server. The
frames in the socket use the HQ chat protocol. Refer to HQ Chat WebSocket.
Native clients send the credential in the x-dash-relay-credential header. A browser cannot set
headers on a WebSocket upgrade. Thus a browser sends the credential as a subprotocol:
Sec-WebSocket-Protocol: dash.v1, dash.relay-credential.<credential>Subprotocol rules:
- The header has priority. The relay reads the subprotocol only when
x-dash-relay-credentialis absent or empty. - The offer must have exactly one
dash.relay-credential.<credential>entry, and optionallydash.v1. An unknown entry, a second credential or an empty credential causes close4401. - The relay selects only
dash.v1, and only when the client offered it. The credential does not show in a response header. - The relay removes the credential entry before it sends the upgrade to the HQ. If the client also
offered
dash.v1, the relay sendsdash.v1alone. If not, the relay removes theSec-WebSocket-Protocolheader.
Upgrade failures:
| Condition | Result |
|---|---|
The path is not a canonical /ws/chat | Raw HTTP/1.1 404 Not Found, then the socket closes |
No HQ is connected for the Host label | The relay closes the socket with no HTTP response |
| The credential is missing or not valid | The upgrade completes, then close 4401 Unauthorized |
| The HQ is at its rate limit or stream limit | The upgrade completes, then close 4429 Too Many Requests |
| The control plane revokes the credential during the connection | Close 4401 Revoked |
| The HQ tunnel closes | Close 1000 |
The relay sends the close code from the HQ to the device when the code is 1000, 1002, or in the
range 3000–4999. An example is the HQ code 4001. The relay changes all other codes to 1000.
The relay cuts close reasons to a maximum of 123 bytes. Text messages and binary messages keep
their type in the two directions.
CORS
A browser removes custom headers from a CORS preflight. Thus the relay sends an OPTIONS request
on a canonical mobile path to the HQ without a pairing credential. The HQ answers it from its
own origin allowlist. The relay does not set CORS policy for data responses. OPTIONS on other
paths returns 404, and the relay does not send it to the HQ.
When the request has an Origin header, the relay adds these headers to its own 401, 429 and
502 responses on mobile paths:
Access-Control-Allow-Origin: <request Origin>
Vary: OriginWith these headers, a browser can read the status. For example, a revoked credential shows as
401 and not as a network failure. The relay does not set Access-Control-Allow-Credentials. The
404 for an unknown path has no CORS headers.
Rate limits
The limits apply to each HQ (gatewayId). They are fixed values. You cannot change them with a
flag or an environment variable.
| Limit | Value | Applies to |
|---|---|---|
| Request rate | 50 per second, burst 100 (token bucket) | Mobile HTTP requests and /ws/chat upgrades |
| Preflight rate | 5 per second, burst 10 (a separate token bucket) | OPTIONS requests on mobile paths |
| Open streams | 256 | All open HTTP, SSE and WebSocket streams for one HQ |
The two buckets are independent. Thus a flood of preflights cannot use the request budget of paired devices. The two buckets reset when the HQ tunnel closes.
Error reference
| Status or close code | Body or reason | Meaning |
|---|---|---|
HTTP 404 | Not Found | The path is not a canonical mobile path |
HTTP 502 | No HQ connected | No HQ is connected for the Host label |
HTTP 401 | Unauthorized | The pairing credential is missing, not valid or revoked |
HTTP 429 | Too Many Requests | The HQ is at its rate limit or stream limit. Try again after 1 second |
HTTP 502 | Upstream closed | The HQ closed the stream before it sent a response |
WS 4401 | Unauthorized or Revoked | The pairing credential is not valid, or the control plane revoked it |
WS 4429 | Too Many Requests | The HQ is at its rate limit or stream limit |
If a stream closes after the HQ starts its response, the relay ends the response. The client then gets a body that is not complete.
HQ tunnel
GET /gw/:gatewayId
The HQ opens a WebSocket to /gw/<gatewayId>, with the ID URL-encoded, and keeps it open. The
relay ignores the query string. The path must have exactly one segment after /gw/. The relay does
not use the Host header. Thus the HQ can connect to any hostname that gets to the relay.
Headers
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer <dial-token> in hosted mode, Bearer <relay-token> in static mode |
X-Gateway-Proof | Hosted mode | A new proof-of-key assertion. Static mode ignores it |
GET /gw/demo HTTP/1.1
Host: demo.relay.example.com
Upgrade: websocket
Authorization: Bearer <dial-token>
X-Gateway-Proof: <assertion>Lifecycle
- The relay always completes the upgrade, and then checks the credentials. If the check fails, the
relay closes with
4401Unauthorizedbefore it adds the HQ. Thus a rejected dial cannot replace a connected HQ with the same ID. - The relay keeps one tunnel for each
gatewayId. An accepted dial with the same ID replaces the current tunnel and closes it. - When a tunnel closes, all device streams on it stop. Open HTTP requests get
502. WebSockets close with1000. - When you delete an HQ, the control plane tells the relay to close its tunnel with
4401Revoked. The relay keeps a record of the deleted HQ, also after a restart. A later dial from that HQ closes with4401Unauthorized, also with a valid dial token. The relay refuses the device credentials of that HQ, and it does not add new credentials for it.
Dial token
In hosted mode, the control plane signs a dial token for one HQ. The relay does an offline check of the token with the Ed25519 public key of the control plane. Refer to Relay Control Plane API for how an HQ gets a new token.
The token is base64url(JSON claims).base64url(signature). The signature is Ed25519 over the first
segment. The token has no alg field because the algorithm is fixed.
| Claim | Type | Meaning |
|---|---|---|
tenantId | string | The account that owns the HQ |
gatewayId | string | Must be equal to the :gatewayId in the dial path |
cnf | string | The raw 32-byte Ed25519 public key of the HQ, base64url |
exp | number | Expiry, unix seconds. The relay refuses the token at exp and after it |
Holder-of-key proof
X-Gateway-Proof has the same wire format. The HQ signs it with its own private key. The relay
does a check of the proof against the cnf key in the dial token. Thus a stolen token has no use
without the private key.
| Claim | Type | Meaning |
|---|---|---|
gatewayId | string | Must be equal to the gatewayId in the dial path |
aud | string | Must be relay-dial |
iat | number | Time of issue, unix seconds |
exp | number | Expiry, unix seconds. The HQ signs a new proof for each dial, with a life of 60 seconds |
{"gatewayId":"gw-1","aud":"relay-dial","iat":1790000000,"exp":1790000060}Keepalive and reconnect
The relay client in the HQ does these tasks:
- Heartbeat. The HQ sends a WebSocket protocol ping each 20 seconds. If no pong comes before the next ping, the HQ closes the socket and connects again. The relay answers protocol pings automatically.
- Backoff. The first reconnect waits 1 second. Each next try waits two times longer, to a maximum of 30 seconds. The backoff resets only after a connection stays open for 5 seconds.
- Token refresh. In hosted mode, a
4401close starts a dial-token refresh from the control plane, and then a new dial. The HQ does this a maximum of one time in 30 seconds. The HQ also gets a new token one hour before the current token expires. A new token applies only to new dials. The open tunnel stays open. - When the tunnel closes, the HQ stops all its loopback requests for that tunnel.
Mux frame format
Each message on the tunnel is one WebSocket text message with one JSON object. The t field gives
the frame type. The relay and the HQ ignore a message that is not valid JSON or that has an unknown
t.
The relay sets streamId, a positive integer. It starts at 1 for each tunnel connection. It
increases by one for each device request or WebSocket.
t | Direction | Fields | Meaning |
|---|---|---|---|
open | Relay to HQ | streamId, target, kind, method?, path, headers | Start a stream |
head | HQ to relay | streamId, status, headers | Response status and headers. 101 for an accepted WebSocket |
data | Both | streamId, chunk, binary? | Body bytes or one WebSocket message. chunk is base64 |
end | Both | streamId | No more body in this direction |
close | Both | streamId, code?, reason? | Stop the stream |
credit | Relay to HQ | streamId, bytes | Flow-control grant for bytes that the device got |
ping | Both | None | Application-level ping |
pong | Both | None | Reply to ping |
open fields
| Field | Values | Meaning |
|---|---|---|
target | mgmt | chat | mgmt for all mobile HTTP requests, chat for /ws/chat |
kind | http | ws | Plain HTTP (with SSE) or a WebSocket |
method | HTTP method | Present for http streams |
path | Request target | The original path and query string, for example /mobile/v1/agents?cursor=next |
headers | Record<string, string> | The device headers, with lowercase names. The relay removes host, connection and keep-alive, and joins repeated headers with , |
The HQ replays mgmt streams to its management server and chat streams to its chat server on
127.0.0.1. Before the replay, the HQ removes x-dash-relay-credential, the hop-by-hop headers and
all headers that Connection names. On WebSocket streams, it also removes all sec-websocket-*
headers.
HTTP stream
{"t":"open","streamId":1,"target":"mgmt","kind":"http","method":"GET","path":"/mobile/v1/agents?cursor=next","headers":{"authorization":"Bearer <hq-token>","x-dash-relay-credential":"<credential>"}}
{"t":"end","streamId":1}
{"t":"head","streamId":1,"status":200,"headers":{"content-type":"application/json"}}
{"t":"data","streamId":1,"chunk":"eyJvayI6dHJ1ZX0="}
{"t":"end","streamId":1}The relay sends the request body as data frames, and then end. The HQ answers with one head,
zero or more data frames, and end. If the loopback request fails, the HQ sends close with an
error reason. If this occurs before head, the device gets 502. If the device disconnects, the
relay sends close and the HQ stops the loopback request.
WebSocket stream
{"t":"open","streamId":2,"target":"chat","kind":"ws","path":"/ws/chat","headers":{"authorization":"Bearer <hq-token>"}}
{"t":"head","streamId":2,"status":101,"headers":{}}
{"t":"data","streamId":2,"chunk":"eyJ0eXBlIjoibWVzc2FnZSJ9","binary":false}
{"t":"close","streamId":2,"code":1000,"reason":""}The relay upgrades the device socket before the HQ answers. The HQ keeps device messages that come before its loopback socket opens. It then sends them in sequence.
binary keeps 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. The relay then closes the device socket as
WebSocket /ws/chat tells.
Flow control
Credit applies to HTTP response bodies, with SSE:
- The HQ counts the bytes that it sent on a stream and that have no credit yet. At 256 KiB, it stops the read from the loopback response.
- The relay sends
creditwith the number of bytes that it wrote to the device. It sends the credit immediately if the write is complete. If not, it sends the credit when the device socket drains. - The HQ starts the read again when the count without credit is less than 256 KiB.
Thus a slow device makes the HQ slower, and the relay buffer has a limit. WebSocket streams and request bodies have no credit flow control.
Heartbeat frames
Each end replies to a {"t":"ping"} frame with {"t":"pong"}. The relay and the HQ do not send
application-level pings on their own. They use WebSocket protocol pings. Refer to
Keepalive and reconnect.
Fault isolation
The relay ignores frames for unknown stream IDs. It also ignores open and credit frames from
the HQ. If the relay cannot apply a frame from the HQ, it closes only that stream. An example is a
second head on a stream. The other streams and the tunnel stay open.
Internal admin API
The relay has an admin API under /admin/. The control plane uses it to add and revoke pairing
credentials and to close the tunnel of a deleted HQ. A client does not call it. The admin API
needs a secret that only the control plane has.
The dashsquad relay service blocks /admin/* requests from the public internet with HTTP 403.
For the client operations, use the Relay Control Plane API.
Liveness
Use one of these checks:
| Check | Result | What it shows |
|---|---|---|
GET /health on any hostname | 200 with {"status":"ok"} | The relay process operates. The check needs no credential and gives no other data |
GET https://<gatewayId>.<zone>/mobile/v1/health without a credential | 502 or 401 | TLS and wildcard routing work. 502 means that no HQ is connected. 401 means that an HQ is connected |
The same request with a valid x-dash-relay-credential | The health response from the HQ | The full path from the device to the HQ works |
To see which release of the relay answers, send GET /version on any hostname. It needs no
credential and does not contact an HQ:
{ "service": "relay", "version": "0.5.0", "commit": "<git-commit>" }commit is the full Git commit of the build, or null when the build does not record it.
Logs and telemetry
The logs, traces and metrics of the relay contain only IDs, status codes, close codes, byte
counts and fixed reason names. The relay never records request bodies, response bodies,
WebSocket messages, header values such as Authorization or the pairing credential, cookies,
query strings or full URLs.
Network requirements
The relay serves plain HTTP and WebSocket behind a TLS terminator with a wildcard certificate for
the HQ subdomains. Devices and HQs connect on port 443. The terminator keeps the original Host
header and passes WebSocket upgrades through.
HQ Chat WebSocket
Wire reference for the HQ chat WebSocket at /ws/chat: the hello handshake, conversation subscriptions, Steer and Follow Up, question cards, voice mode, and browser live view.
Relay Control Plane API
HTTP API to enroll HQs on the hosted relay, pair devices and approve browser sessions.