tun-el protocol
v1Version 1 — WebSocket + yamux + typed JSON.
TL;DR
- Transport
- WebSocket over TLS (WSS) on the control plane
- Multiplexing
- yamux — one stream per proxied request
- Auth
- Bearer token in the Hello frame
- Messages
- Line-delimited, typed JSON (LDJSON)
- Keepalive
- Ping / Pong every 15s, 45s idle timeout
Handshake sequence
Solid arrows are synchronous control frames; dashed arrows are async per-request streams multiplexed over the single yamux session.
client server
│ │
│ ── WSS connect ─────────────▶ │
│ ── Hello {token, subdomain} ▶ │
│ │ validate token
│ ◀──────── HelloAck {url,sid} ─│ (success)
│ │
│ ═══ yamux session open ══════ │
│ │
│ ◀╌╌╌ Stream(open) req#1 ╌╌╌╌╌ │ public request in
│ ──── Stream(data) resp#1 ───▶ │
│ │
│ ── Ping ───────────────────▶ │
│ ◀──────────────────── Pong ── │
│ │
│ ── Close {reason} ─────────▶ │
│ ◀──────────── Close {ack} ─── │Phases
Handshake
Client dials WSS, sends Hello with its token and requested subdomain. Server replies HelloAck with the assigned public URL and session id.
Active tunnel
Each inbound public request opens a new yamux stream. The client proxies it to localhost and streams the response back over the same stream.
Teardown
Either side sends a close frame. In-flight streams drain within the grace window, then the session and its subdomain are released.
Messages
All control messages are JSON objects with a type discriminator, one per line.
Hello
First frame the client sends after the socket opens. Authenticates the session and requests a subdomain.
| Field | Type | Req | Description |
|---|---|---|---|
| protocol_version | int | yes | Protocol revision the client speaks. Currently 1. |
| token | string | yes | Bearer auth token. |
| subdomain | string | no | Requested subdomain; server assigns a random one if omitted or taken. |
| client | string | yes | Client name and version, e.g. tunel/0.1.0. |
{
"type": "Hello",
"protocol_version": 1,
"token": "tok_8f3a21…",
"subdomain": "acme-staging",
"client": "tunel/0.1.0"
}HelloAck
Server response on a successful handshake. After this frame the yamux session is considered open.
| Field | Type | Req | Description |
|---|---|---|---|
| session_id | string | yes | Opaque id for this tunnel session. |
| url | string | yes | Public URL now routing to the client. |
| region | string | yes | Edge node that terminated the tunnel. |
| expires_at | string | no | RFC 3339 timestamp if the token has a TTL. |
{
"type": "HelloAck",
"session_id": "sess_4d9c0b",
"url": "https://acme-staging.tunnel.example.dev",
"region": "par",
"expires_at": null
}Error
Sent before the socket closes when the handshake or an operation fails. Always terminal.
| Field | Type | Req | Description |
|---|---|---|---|
| code | string | yes | Machine-readable code, e.g. invalid_token, subdomain_taken. |
| message | string | yes | Human-readable explanation. |
| retryable | bool | yes | Whether the client should back off and retry. |
{
"type": "Error",
"code": "subdomain_taken",
"message": "subdomain 'acme-staging' is reserved by another account",
"retryable": false
}Ping / Pong
Application-level keepalive multiplexed alongside data streams. Independent from WebSocket control pings.
| Field | Type | Req | Description |
|---|---|---|---|
| nonce | int | yes | Echoed back in the matching Pong to measure RTT. |
| ts | int | yes | Unix millisecond timestamp at send time. |
{ "type": "Ping", "nonce": 42, "ts": 1748534400123 }
{ "type": "Pong", "nonce": 42, "ts": 1748534400131 }Versioning
The client advertises protocol_version in the Hello frame. The server rejects versions it cannot speak with an Error of code unsupported_version. New fields are added in a backward-compatible way; removing or renaming a field is a breaking change that bumps the major protocol version.
Security
Hardening lives in the security docs