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"` // [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" in v1. "tcp" is reserved for later.amber-fox-42.BindOK: the full bound hostname, e.g. api.ngstone.site.BindOK: the full public URL.BindErr: subdomain_taken, invalid_subdomain, or kind_unsupported.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" v1; "tcp" later 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 future Kind: "tcp" stream has no X-Forwarded-For header to fall back on.
Versioning#
The protocol uses a single integer major version. The client offers the versions it supports in Auth.ProtoVersions; the server picks the highest mutual version and returns it in AuthOK.ProtoVersion. v1 ships as 1.
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.