tunnelcat-sdk docs
Docs / Protocols / Wire

Wire Protocol

The HTTP wire protocol Authenticator/TunnelDialer speak in tunnelcat-server, documented so a third party can implement a compatible control/exit server — or a client in a language with no SDK adapter — without reading Go source. cmd/mockserver is a minimal reference implementation of everything on this page.

You probably don't need this page

If you're consuming this SDK through one of the five language adapters, tunneld speaks this protocol for you — you only ever see the much smaller IPC protocol. This page is for implementing a compatible server, or a client that talks to a control/exit node directly.

Login

POST <server>/ with Content-Type: application/json.

Password auth

Authenticator.Login() when constructed with a username/password:

{".command": "verifyPassword", "path": "users", "user": "<username>", "password": "<password>", "key": "<apikey>"}

Optional fields (all omitted if empty): key_id, device_id, device_name.

Key auth

SetKeyAuthParams + non-empty AuthSig:

{".command": "authenticateKey", "username": "...", "node_id": "...", "auth_sig": "...", "servers": [...], "control_nodes": [...], "arbiter_pubkey": "...", "api_key": "...", "client_id": "...", "key_id": "...", "device_id": "...", "device_name": "..."}

Login tries both methods in parallel if both are configured; whichever succeeds first wins.

Response

Either method, HTTP 200:

{"session": "<opaque session token>"}

Any other status code, or a 200 with no session field, is treated as a rejected login (core.ErrAuthRejected).

Data plane

Every subsequent request carries the session token in an X-Session header and POSTs to one of two decoy paths, chosen at random per request to vary the apparent endpoint:

  • /api/media/upload
  • /api/content/submit

The request/response body is an encrypted frame:

[12B nonce][ChaCha20-Poly1305(plaintext) + 16B tag]

keyed by BLAKE2b-256(session token) — see the exported core.SessionKey / core.SealFrame / core.OpenFrame helpers.

Upload frame — polling mode (NewTunnelDialerPolling)

Plaintext request body, tag byte 0x00:

[1B 0x00][16B conn_id][4B seq_be][2B target_len_be][target][payload]
FieldMeaning
conn_id16 raw bytes (client-generated, hex-encoded when passed to ParseUploadFrame), identifies one logical connection across requests.
seq0 on the first request for a conn_id (server should dial target fresh), non-zero on subsequent requests for the same connection.
targethost:port to dial (only meaningful when seq == 0).
payloadBytes to write to the connection (may be empty, e.g. a keep-alive poll).

Response plaintext (still frame-encrypted the same way), padded to at least 512 bytes:

[4B data_len_be][data][random padding]

data is whatever bytes the target connection produced since the last poll (server-side, a short read window — core.BuildUploadResponse in the mock implementation uses ~200ms).

Stream-open frame — default streaming mode (NewTunnelDialer)

Plaintext, tag byte 0x01: [1B 0x01][16B conn_id]. The server should respond with a persistent chunked/streamed body carrying frame-encrypted data as it arrives (the client-side reader is newFramedDecryptReader).

The mock server doesn't implement this

cmd/mockserver returns 404 for stream-open frames — this is why the mock server and every SDK example pass pollingOnly: true. A production control/exit server should implement streaming for efficiency; polling is the simpler, legacy fallback, not the recommended production mode.

Manifest / discovery signing

Separately from the login/data-plane protocol above, discovery.go / bypass.go / mirror.go verify arbiter-signed manifest data using core.VerifySignedPayload(payload, pubkeyHex, sigB64) — plain Ed25519 verification. No fixed canonical payload shape is mandated by core itself; callers define their own signed structure. This is outside this SDK's connect/status/disconnect scope — see Architecture · What's out of scope.