dashsquad

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
CallerSurfaceCredential
Device (iOS, Android, web)/mobile/v1 and /ws/chat on <gatewayId>.<zone>Pairing credential. The HQ checks its own bearer token
HQ/gw/<gatewayId> WebSocketSigned 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

SchemeSent asChecked byFailure
Pairing credentialx-dash-relay-credential header, or the dash.relay-credential.<credential> WebSocket subprotocolThe relay, against its credential storeHTTP 401, or WebSocket close 4401
Dial token and proof (hosted mode)Authorization: Bearer <dial-token> and X-Gateway-Proof: <assertion> on the HQ dial-inThe relay, offline, with the public key of the control planeWebSocket close 4401
Shared relay token (static mode)Authorization: Bearer <relay-token> on the HQ dial-inThe relay, with a constant-time compareWebSocket close 4401
HQ bearer tokenAuthorization header on device requestsThe HQ. The relay does not change itThe error from the HQ

Modes

The startup settings of the relay select one of two modes.

ModeSelected byHQ dial-inPairing credential
HostedA dial-token public key and an admin secretSigned dial token and proof of key. The relay token has no effectChecked against a durable store of SHA-256 credential hashes
StaticA shared relay token onlyShared relay tokenNot 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

MethodPathCallerCredentialPurpose
Any/mobile/v1 and /mobile/v1/* on <gatewayId>.<zone>DevicePairing credentialSent to the HQ management server
OPTIONSThe same pathsBrowserNoneCORS preflight, sent to the HQ
GET (WebSocket)/ws/chat on <gatewayId>.<zone>DevicePairing credentialConnected to the HQ chat server
GET (WebSocket)/gw/:gatewayIdHQDial token and proof, or relay tokenOpens the tunnel of the HQ
GET/health on all hostnamesMonitorNoneLiveness. The relay sends 200 {"status":"ok"} and does not contact an HQ
GET/version on all hostnamesMonitorNoneThe 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 stream GET /mobile/v1/events. The relay sends the query string without change.
  • WebSocket: /ws/chat exactly, 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.

StepCheckFailure
1The path is a canonical mobile path404 Not Found
2An HQ is connected for the Host label502 No HQ connected
3The pairing credential is valid (not checked for OPTIONS)401 Unauthorized
4The HQ is below its rate limit and its stream limit429 Too Many Requests, with Retry-After: 1
5The relay sends the request to the HQ management serverThe 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-credential is absent or empty.
  • The offer must have exactly one dash.relay-credential.<credential> entry, and optionally dash.v1. An unknown entry, a second credential or an empty credential causes close 4401.
  • 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 sends dash.v1 alone. If not, the relay removes the Sec-WebSocket-Protocol header.

Upgrade failures:

ConditionResult
The path is not a canonical /ws/chatRaw HTTP/1.1 404 Not Found, then the socket closes
No HQ is connected for the Host labelThe relay closes the socket with no HTTP response
The credential is missing or not validThe upgrade completes, then close 4401 Unauthorized
The HQ is at its rate limit or stream limitThe upgrade completes, then close 4429 Too Many Requests
The control plane revokes the credential during the connectionClose 4401 Revoked
The HQ tunnel closesClose 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: Origin

With 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.

LimitValueApplies to
Request rate50 per second, burst 100 (token bucket)Mobile HTTP requests and /ws/chat upgrades
Preflight rate5 per second, burst 10 (a separate token bucket)OPTIONS requests on mobile paths
Open streams256All 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 codeBody or reasonMeaning
HTTP 404Not FoundThe path is not a canonical mobile path
HTTP 502No HQ connectedNo HQ is connected for the Host label
HTTP 401UnauthorizedThe pairing credential is missing, not valid or revoked
HTTP 429Too Many RequestsThe HQ is at its rate limit or stream limit. Try again after 1 second
HTTP 502Upstream closedThe HQ closed the stream before it sent a response
WS 4401Unauthorized or RevokedThe pairing credential is not valid, or the control plane revoked it
WS 4429Too Many RequestsThe 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

HeaderRequiredValue
AuthorizationYesBearer <dial-token> in hosted mode, Bearer <relay-token> in static mode
X-Gateway-ProofHosted modeA 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 4401 Unauthorized before 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 with 1000.
  • When you delete an HQ, the control plane tells the relay to close its tunnel with 4401 Revoked. The relay keeps a record of the deleted HQ, also after a restart. A later dial from that HQ closes with 4401 Unauthorized, 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.

ClaimTypeMeaning
tenantIdstringThe account that owns the HQ
gatewayIdstringMust be equal to the :gatewayId in the dial path
cnfstringThe raw 32-byte Ed25519 public key of the HQ, base64url
expnumberExpiry, 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.

ClaimTypeMeaning
gatewayIdstringMust be equal to the gatewayId in the dial path
audstringMust be relay-dial
iatnumberTime of issue, unix seconds
expnumberExpiry, 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 4401 close 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.

tDirectionFieldsMeaning
openRelay to HQstreamId, target, kind, method?, path, headersStart a stream
headHQ to relaystreamId, status, headersResponse status and headers. 101 for an accepted WebSocket
dataBothstreamId, chunk, binary?Body bytes or one WebSocket message. chunk is base64
endBothstreamIdNo more body in this direction
closeBothstreamId, code?, reason?Stop the stream
creditRelay to HQstreamId, bytesFlow-control grant for bytes that the device got
pingBothNoneApplication-level ping
pongBothNoneReply to ping

open fields

FieldValuesMeaning
targetmgmt | chatmgmt for all mobile HTTP requests, chat for /ws/chat
kindhttp | wsPlain HTTP (with SSE) or a WebSocket
methodHTTP methodPresent for http streams
pathRequest targetThe original path and query string, for example /mobile/v1/agents?cursor=next
headersRecord<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 credit with 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:

CheckResultWhat it shows
GET /health on any hostname200 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 credential502 or 401TLS 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-credentialThe health response from the HQThe 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.

On this page