Skip to content

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.

Browser / API client
│ HTTPS (1)
▼
*.xport.sh ──► xport-gateway (wildcard routing)
│
▼
xport-relay ◄══ WebSocket control (2) ══ xport CLI
▲ │
└────────── data connections (3) ────────┘
│
▼
localhost:3000
  1. Public HTTPS terminates at the gateway/relay edge.
  2. Control channel: registration, heartbeat, teardown — opened by the client, one per session.
  3. 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.
  • 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.

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

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

{
"type": "registered",
"name": "k7fz2",
"url": "https://k7fz2.xport.sh",
"expires_at": "2026-10-19T12:00:00Z"
}

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.1
Host: k7fz2.xport.sh
Content-Type: application/json
Connection: 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).

  • 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 (not 410) — we do not reveal that a name existed.
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.

  1. Client opens control WS, sends register.
  2. Relay validates token, reserves name, replies registered; the client immediately opens its pool of standing data connections.
  3. 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.
  4. Session ends on: expires timeout, client disconnect, relay shutdown, or explicit client close message. Queued data connections are closed on teardown.
  5. All state is in-memory only. Relay restart drops all sessions; clients reconnect and re-register.
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
  • Per-user API keys replacing the shared bearer token (account system).
  • Bandwidth accounting hooks for a future commercial tier.
  • Whether xport receive reuses data connections or needs its own half-reversed variant (receiver opens, sender dials).