The wire protocol
A dedicated control stream carries a small, fully-typed set of JSON messages between the agent and the edge. Every other stream — one per proxied request — carries a single JSON preamble followed by raw bytes.
Framing & encoding#
Every control message and every stream preamble uses the same trivial framing: a big-endian length prefix followed by a JSON payload.
uint32 big-endian payload length | JSON payload (max frame: 64 KiB)JSON was chosen over protobuf or gob — it's greppable in debug logs, control-plane volume is a few messages a minute so performance is irrelevant, and it avoids codegen for a handful of message types owned by one repo. Data streams carry raw HTTP/1.1 bytes after the preamble — JSON never touches the hot path.
The message envelope#
Every control-stream message is wrapped in the same envelope:
type Msg struct { Type string `json:"type"` // discriminator ID uint64 `json:"id,omitempty"` // correlates request→reply; // per-direction ID spaces Data json.RawMessage `json:"data,omitempty"` // type-specific payload}ID spaces are per-direction — the agent mints odd IDs, the edge mints even ones — so both peers can initiate a message (both send Ping) without colliding. Replies don't get a fresh ID: a reply carries the same Msg.ID as the request it answers — AuthOK echoes Auth's ID, BindOK echoes Bind's, Pong echoes Ping's.
Handshake#
The first exchange on every control stream. Anything else as the first message closes the connection.
type Auth struct { Token string `json:"token"` InstanceID string `json:"instance_id"` // stable per agent-process start ClientVersion string `json:"client_version"` // e.g. "v0.1.0 (commit 3f1a9c2, built 2026-06-01T12:00:00Z)" ProtoVersions []int `json:"proto_versions"` // [2, 1] — supported protocol majors}type AuthOK struct { ProtoVersion int `json:"proto_version"` // server picks highest mutual SessionID string `json:"session_id"` // minted per session ServerVersion string `json:"server_version"`}type AuthErr struct { Code string `json:"code"` // bad_token, no_common_version Message string `json:"message"`}The first message must be Auth within 5 seconds of TLS establishment, or the edge closes the connection. The token check is a constant-time comparison against the SHA-256 of the configured token.
Tunnel lifecycle#
A bound session can bind, unbind, and be evicted by a takeover — six message types cover the whole lifecycle.
Bind / BindOK / BindErr
"http" or "tcp". A "tcp" bind skips the subdomain/hostname machinery entirely and gets back an allocated port instead — see TCP tunnels.amber-fox-42. Ignored for kind: "tcp".kind: "tcp" only. Requested remote port; 0 means server-assigned.user:pass credentials — the edge never sees the plaintext. HTTP only; a TCP bind carrying this field is rejected. See Access control.WWW-Authenticate realm string. Ignored unless basic_auth_sha256 is also set; falls back to a default realm if empty.kind: "http" and kind: "tcp".BindOK: the full bound hostname, e.g. api.ngstone.site.BindOK: the full public URL.BindOK for kind: "tcp": the allocated remote port.BindErr: subdomain_taken, invalid_subdomain, kind_unsupported, port_unavailable, too_many_binds, or invalid_access.invalid_access
tcp bind), and also when a session negotiated protocol v1 but the Bind carries any access-control field — see Versioning.Unbind / UnbindOK / Evicted
Evicted: always "takeover" in v1 — this binding was reclaimed, stop advertising the URL.Liveness & shutdown#
Both peers send Ping on the control stream every 15 seconds; Pongechoes the ping's timestamp so the sender can log RTT. Three consecutive misses (≈45 seconds) declares the peer dead.
type Ping struct { TS int64 `json:"ts_unix_ms"` }type Pong struct { TS int64 `json:"ts_unix_ms"` } // echoes Ping.TSGraceful shutdown is fire-and-forget by design — there is no GoodbyeOK. The sender starts its own drain timer the moment it sends and behaves the same whether or not the peer honored the message.
type Goodbye struct { Reason string `json:"reason"` // "shutdown", "reload", "idle" DrainMS int `json:"drain_ms"` // sender stops opening new streams}Stream preamble#
Every data stream opens with one JSON frame — the only JSON on that stream — before raw bytes take over.
type StreamOpen struct { Kind string `json:"kind"` // "http" or "tcp" Host string `json:"host"` // which binding this request targets ReqID string `json:"req_id"` // edge-minted ULID; ties edge logs ↔ // inspector entries ClientAddr string `json:"client_addr"` // public client ip:port}ClientAddr is carried here rather than derived from the stream, because a Kind: "tcp" stream has no X-Forwarded-For header to fall back on.
Versioning#
The protocol uses a single integer major version, currently 2. The agent offers every version it supports in Auth.ProtoVersions — [2, 1]— and the edge picks the highest version present in both its own supported list and the agent's offer, returning it in AuthOK.ProtoVersion. An edge that only understands v1 still negotiates fine with a v2-capable agent; it just settles on 1.
v2 added the three Bind access-control fields above (basic_auth_sha256, basic_auth_realm, allow_cidrs) and the invalid_access error code. Access control is a hard v2 requirement, not a best-effort one: if a session negotiates v1, the edge rejects a Bind carrying any access-control field with invalid_access rather than silently binding without protection, and the agent checks this client-side too — it refuses to send the Bind at all when --basic-auth/--allow-cidr was requested but the negotiated version is below 2. Either way the tunnel fails closed instead of coming up unprotected.
Additive JSON fields never bump the version — unknown fields are simply ignored. Semantic changes do. Within a negotiated version, an unknown Msg.Type or a malformed frame gets a generic Error reply and then the session closes — never a silent ignore.
Error is also a catch-all reply
Error{Code, Message}is sent as the reply to any request the receiver can't satisfy, and also right before a connection is closed for a protocol violation. Codes include unknown_type, malformed, not_bound, and internal.