Tunnel Protocol (Design Spec)
This document is the design specification for the tunnel data plane. It is a decision record, not a description of shipped behaviour. Status of each decision is marked; open questions are listed at the end.
Overview
Section titled “Overview”Browser / API client │ HTTPS (1) ▼*.xport.sh ──► xport-gateway (wildcard routing) │ ▼xport-relay ◄══ WebSocket control (2) ══ xport CLI ▲ │ └────────── data connections (3) ────────┘ │ ▼ localhost:3000- Public HTTPS terminates at the gateway/relay edge.
- Control channel: registration, heartbeat, teardown — opened by the client, one per session.
- Data channels: standing WebSocket connections the client opens to the relay (clients sit behind NAT, so the relay can never dial them). Each data connection carries exactly one request/response pair, then is replaced.
Transport: WebSocket (decided)
Section titled “Transport: WebSocket (decided)”- One control connection per client session: registration, address assignment, heartbeats, teardown.
- Data connections are opened by the client (outbound, so NAT is no issue) and queued on the session. Each data connection carries exactly one request/response pair: the relay serializes the public request as an HTTP/1.1 byte preamble, the client replays it to the local target and pipes the raw response back. The connection is then replaced.
- Binary frames carry raw bytes; no message framing is imposed on the payload — the tunnel is byte-transparent.
Rationale: WebSocket traverses proxies and CDN/edge layers on a single port, has mature Go libraries, and keeps the relay stateless between connections. Alternatives considered: HTTP/2 streams (harder through some edge networks, more complex flow control to reimplement) and raw TCP (blocked by restrictive egress firewalls on the client side).
Direction of initiation: the client opens both control and data connections outbound — the relay never dials the client. The relay keeps a small pool of standing data connections per session (buffer of 32) and consumes one per incoming request.
Future optimisation: multiplex many streams over one connection (yamux-style). Not needed for MVP.
Control messages (JSON, text frames)
Section titled “Control messages (JSON, text frames)”| Direction | Type | Purpose |
|---|---|---|
| client → relay | register |
Start session: auth token, requested name (optional), expires, password |
| relay → client | registered |
Assigned public name, e.g. k7fz2, full URL |
| relay → client | error |
Registration denied (auth, name taken, limits) |
| both | ping / pong |
Heartbeat; missing pong after 30 s closes the session |
register
Section titled “register”Authentication is enforced at connection time (bearer token on the WebSocket upgrade request), so the register message itself carries no credentials:
{ "type": "register", "name": "demo", "target": "http://localhost:3000", "expires": "30m", "password": "optional-shared-secret"}name is optional; without it the relay assigns a random 5-character ID
(Crockford base32, unambiguous, e.g. k7fz2).
registered
Section titled “registered”{ "type": "registered", "name": "k7fz2", "url": "https://k7fz2.xport.sh", "expires_at": "2026-10-19T12:00:00Z"}Data connection handshake
Section titled “Data connection handshake”The client dials /v1/data (authenticated like the control endpoint)
with an X-Session-Name header naming its session, then waits. When a
public request arrives, the relay writes the request as an HTTP/1.1 byte
preamble over that connection — the client replays it verbatim to the
local target, which needs no knowledge of the tunnel:
POST /api/items HTTP/1.1Host: k7fz2.xport.shContent-Type: application/jsonConnection: close
{"name":"example"}Connection: close is always set by the relay so the target’s response
is self-terminating; the client copies the response bytes back and the
data connection is retired. The relay forwards the original visitor
address as X-Forwarded-For / X-Real-IP (spoofed client copies are
stripped at the edge).
Addressing & routing
Section titled “Addressing & routing”- Public URLs live under
*.xport.sh(paid tiers) or*.xport.free(anonymous/free tier) — the relay picks the base per session at register time (server.free_base, empty = one domain for everyone). - xport-gateway owns wildcard DNS + TLS and routes
Host-header / SNI to the correct relay region, which looks up the session by name. - Expired or unknown names get
404(not410) — we do not reveal that a name existed.
Encryption model (decided, honest)
Section titled “Encryption model (decided, honest)”| Hop | Protection | Who sees plaintext |
|---|---|---|
| browser → gateway/relay | TLS (public internet) | relay operator |
| relay → client (WS) | TLS + bearer token (upgrade to mTLS later) | relay operator |
| client → localhost | plain HTTP on loopback | local machine only |
The relay is therefore not yet content-blind: TLS terminates at the edge by design (browsers require it), so edge and relay operators can, in principle, observe tunneled traffic. What we guarantee at this layer: encryption in transit end-to-end against third parties on the path, and zero-storage (no persistence).
Content-blind file shares (Phase 2): for xport file.zip, the client
encrypts the payload with a key derived from the share password (or an
ephemeral key encoded in the URL fragment #key=…). Browser receivers
decrypt via WebCrypto; the relay sees only ciphertext. URL fragments
never reach the server, which makes the relay genuinely blind for shares.
Session lifecycle
Section titled “Session lifecycle”- Client opens control WS, sends
register. - Relay validates token, reserves name, replies
registered; the client immediately opens its pool of standing data connections. - Public request arrives at gateway → relay finds session, takes one queued data connection → writes the request preamble → client replays to the target and pipes the response back → relay copies it to the public client. The client replaces the consumed connection.
- Session ends on:
expirestimeout, client disconnect, relay shutdown, or explicit clientclosemessage. Queued data connections are closed on teardown. - All state is in-memory only. Relay restart drops all sessions; clients reconnect and re-register.
Limits (defaults, configurable)
Section titled “Limits (defaults, configurable)”| Limit | Default |
|---|---|
| Session lifetime | 24 h |
| Idle data connection | 5 min |
| Max concurrent connections per session | 32 |
| Max request body (buffered preamble) | 1 MB; larger bodies stream |
Open questions
Section titled “Open questions”- Per-user API keys replacing the shared bearer token (account system).
- Bandwidth accounting hooks for a future commercial tier.
- Whether
xport receivereuses data connections or needs its own half-reversed variant (receiver opens, sender dials).

