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.
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]
| Field | Meaning |
|---|---|
conn_id | 16 raw bytes (client-generated, hex-encoded when passed to ParseUploadFrame), identifies one logical connection across requests. |
seq | 0 on the first request for a conn_id (server should dial target fresh), non-zero on subsequent requests for the same connection. |
target | host:port to dial (only meaningful when seq == 0). |
payload | Bytes 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).
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.