MTE RELAY V5 / FOR DEVELOPERS
A binary protocol.
Verified frame by frame.
What Relay V5 puts on the wire and why: one binary frame for every transport, integrity checked before release, a server that advertises its limits, and a control plane for the whole fleet.
Released October 1, 2026 · Engineering guide to 5.0.0
One foundation. Three products.
Released on their own schedules.
Three products on one protocol
Every protected request and response in Relay 5.0.0 travels as binary frames, and the receiving side verifies each request, response, and nonempty data frame in full before it releases a single plaintext byte. The same frame carries HTTP, WebSocket, and TCP, so the HTTP Relay, SocketX V2, and TCPQ share one specification, one error registry, one set of byte exact test vectors, and one discovery schema.
01 / VERIFIED
Verify, then release.
Each request, response, and nonempty data frame is checked before any of its plaintext is released. Method and status are bound to the encrypted metadata.
02 / BINARY
Raw bytes on the wire.
Ciphertext travels as bytes in the body, and the metadata of each message takes one encrypted operation.
03 / CONTROL PLANE
One panel for the fleet.
An optional console and API for fleet health, live configuration, scoped service tokens, and versioned rollback.
04 / THREE TRANSPORTS
HTTP, WebSocket, and TCP.
SocketX V2 and TCPQ run on the same frame, each as its own product.
| Item | 5.0.0 |
|---|---|
| Versions | MTE Relay Server (MRS) and MTE API Relay (MAR) 5.0.0; SocketX Server 2.0.0; TCPQ 1 |
| Wire protocol | Frame version 2: a 12 byte envelope and a kind specific payload, one layout for HTTP, WebSocket, and TCP |
| Client SDKs | mte-relay-browser 5.0.0, public client 5.0.0; the Go, SocketX TypeScript, iOS, and Android clients speak the same frame |
| Relay errors | HTTP 470 to 490 with reason tokens; 500, 502, and 503 for true faults |
| Key exchange | Kyber 1024 by default; 512, 768, or 1024 per deployment |
| Operations | An optional control plane in the same image: fleet view, live configuration, versioned history, and rollback |
One binary frame on every transport
Every protected request and response travels as frames: a 12 byte envelope, then a kind specific payload. The same envelope carries HTTP, WebSocket, and TCP, so one parser, one set of bounds, and one error registry serve all three transports. The envelope is checked before any allocation, and ciphertext on the data path is never text encoded. FRAME VERSION 2
| Envelope field | Size | Rule |
|---|---|---|
| Signature | 3 | 0x89 0x4D 0x54. A body that starts with ASCII MTE is recognized forever and answered with 478 naming the minimum SDK versions. |
| Version | 1 | 2. Checked before any width dependent field, so a second layout can coexist later. |
| Kind | 1 | REQUEST 0, RESPONSE 1, DATA 2, ERROR 3, OPEN 4, OPEN_ACK 5, CLOSE 6, PING 7, PONG 8, GOAWAY 9, REKEY_REQUEST 10. Unknown kinds are rejected. |
| Flags | 1 | END (HTTP), MORE and TEXT (WebSocket), HALF (TCP). Bits 4 to 7 reserved and rejected when set; bit 6 is held for an authenticated control channel. |
| Reserved | 2 | Must be zero. |
| Length | 4 | Payload bytes. Compared to the advertised maxFrameBytes before any allocation; over the bound is 481 on HTTP and fatal on a connection. |
Why frames
- Verified in pieces. Each frame is verified before its plaintext is released, so a long body streams to the application without unverified bytes ever reaching it.
- Bounded memory. A body moves as frames of at most
maxFrameBytes, so a 1 GiB upload peaked at 44 MiB RSS on the loopback bench. - Bounds before bytes. The length is compared to
maxFrameBytesbefore anything is allocated. An oversized frame costs one 481 or one closed connection, never memory. - Raw bytes on the data path. Ciphertext travels as bytes in the body, never as Base64 text, in the URL, or in a header. Base64 would turn 3,072 ciphertext bytes into 4,096 characters; as raw bytes they stay 3,072. Base64 remains only in the pairing JSON, the token's text form, and saved codec state, all off the data path.
- No identifiers on the response. An HTTP REQUEST frame carries the raw 40 byte token and 16 byte pair id, and a RESPONSE frame carries none. The client id also rides every HTTP request as 32 hex characters in
X-MTE-Relay-Route, which lets a hash proxy keep a client on one pod. - Errors after headers. A failure after RESPONSE has started ends with an in band ERROR frame, so the client gets a typed error instead of a truncated body.
- One parser. The envelope is identical on HTTP, WebSocket, and TCP. The Go server and the TypeScript engine are tested against the same byte exact vectors, and an implementation that disagrees with a vector is wrong.
- Room to grow. The version byte is checked before any width dependent field, so a second layout can coexist with this one. Four flag bits and two reserved bytes must be zero, bit 6 is held for an authenticated control channel, and every value is registered.
HTTP mapping
POST / with application/octet-stream. The request body is one REQUEST then zero or more DATA. The response is one RESPONSE then zero or more DATA, chunked, never with Content-Length. Every framed response carries Cache-Control: no-store, no-transform, private, nosniff, X-Accel-Buffering: no, and X-MTE-Relay-Request-Id.
Verify first, release second
Every REQUEST, RESPONSE, and nonempty DATA frame is one complete codec operation, verified before any of its plaintext leaves. A corrupted or forged frame produces no plaintext in either direction. Corrupt request data never reaches the origin, and corrupt response data never reaches the caller. VERIFIED BEFORE RELEASE
Method, status, and flags are bound
The request metadata must carry v and method; the response metadata must carry v and status. The server compares them to the plaintext envelope; a mismatch is 483 header_mismatch, pair intact, logged as a security event. Every DATA plaintext starts with a copy of the flags byte, so a forged END, MORE, TEXT, or HALF is detected.
Unframed is never data
A response that is not a valid frame is a transport error on every transport, whatever the HTTP status or content type. A CDN error page, a WAF block, an ingress 404, or injected plaintext lands in one bucket, MteRelayTransportError, with the status attached for logging.
Completion is explicit
On HTTP, EOF before END is 477 eof_before_end and poisons the pair. A failure after RESPONSE has started ends with an in band ERROR frame instead of a truncated body. A bodiless answer (204, 304, 1xx, HEAD, Content-Length: 0) is one RESPONSE frame with END and one operation, so a 204 or a HEAD costs exactly one codec operation on each side and the pair stays in step.
Length 0 DATA rule
A zero length DATA performs no operation, so it may carry no flags. A direction that ends with no bytes sends a DATA whose plaintext is the flags byte alone. Without this, an on path party could append an unauthenticated empty END and turn a truncation into a clean completion.
Tests in unified-mte-relay/tests: TestCorruptResponseDataDeliversNoPlaintext, TestCorruptRequestDataNeverReachesTheOrigin, TestMethodByteRewriteIs483, TestMethodMismatchIs483, TestAnswerThatIsNotAFrameIsATransportError, TestTruncatedResponseFrameIsATransportError, TestBodilessAnswersAreOneFrame, TestServerRejectsFrameShapesBeforeDecode.
Control frames are plaintext in version 2
The initial OPEN, PING, PONG, CLOSE, GOAWAY, REKEY_REQUEST, and zero length DATA are plaintext in frame version 2; forging one costs a reset and nothing more. ERROR frames are unauthenticated too, and a forged pair state code costs the client one Kyber pairing, bounded by its replacement rate limits. Flag bit 6 is reserved for an authenticated control channel. Run every transport inside TLS.
Canonical metadata, one operation
A request's path, method, and headers, or a response's status and headers, travel as one canonical JSON object encrypted in one operation. Each MKE operation carries a fixed cost, 260 bytes on a TOKBYTES 8 build, so a message pays it once for all of its metadata. Canonical means every platform produces the same bytes, so byte exact test vectors are possible and a Go server and a TypeScript client are checked against the same fixtures.
| Member | Where | Rule |
|---|---|---|
v | required everywhere | The frame version, compared to the envelope. |
method | required on requests | RFC 9110 token, at most 32 bytes. Authoritative when the method byte is 255, so WebDAV and other methods work instead of silently becoming GET. |
status | required on responses | 100 to 599, compared to the RESPONSE status. |
path | requests | Path plus query, no leading slash, percent encoded as sent, at most 16 KiB. Readers refuse // and .. segments. |
headers | optional | Lowercase names, one key per name, a string or an array of strings when repeated. Limits: 256 names, 64 values per name, 8 KiB per value. |
target, port | TCP | An alias name from configuration, never a host and port; an optional port inside an allowed range. |
id | optional | Client correlation, unsigned integer. |
Canonical form
Keys sorted in byte order at every level, no insignificant whitespace, no HTML or slash escaping, integers in plain decimal. Readers reject duplicate keys; on a request that is 482 with the decoder state saved.
Repeated headers survive
Arrays keep every Set-Cookie, Vary, Link, and WWW-Authenticate value through a chain of Relays. An origin behind a chain of Relays can set several cookies or offer several auth schemes, and the client receives every value.
Bounds are advertised, not hardcoded
maxMetadataBytes is enforced per MTE type (defaults 61,439 for MKE and 7,168 for standard MTE) and published in discovery, so a client can check its metadata against the server's limit before sending it.
Pairing, tokens, and abuse limits
Pairing is a cross wired Kyber exchange. The request carries the token in the body and mteProfile, kyberStrength, and frameVersion for each pair. The server checks everything cheap before it does any expensive work, and it tells the client what it needs so both ends build identical codecs.
Kyber 1024 and a build profile
KYBER_STRENGTH defaults to 1024. It is the strongest of the three strengths, and the load run measured the same data path throughput with it as with 512: 9,190 against 9,152 req/s. Discovery publishes mteProfile: library version, TOKBYTES, DRBG, cipher, hash, verifiers, Kyber strength, and a probe digest over two known encodes. A client compares before pairing; the pair request repeats it so the server can refuse with 490 profile_mismatch. A client build against a server build interoperates; an ANY build against either does not. An outbound Relay refuses a different build at auth; a browser pointed at the wrong build fails on its first decode.
The 40 byte token
16 byte client id, 8 byte issued at, and 16 bytes of HMAC SHA256 under the domain secret. Text form is 54 characters of base64url on auth, pair, keepalive, and OPEN; the REQUEST frame carries the raw 40 bytes. Max age (default 24 hours, advertised) is checked at pair, keepalive, and the first OPEN; the client refreshes proactively by calling auth with its current token, keeping its id and pool. Checking a token needs no store lookup: the data path verifies the HMAC alone, and pair, keepalive, and the first OPEN also check the issued at field against the max age.
Pair ids and personalization
Pair ids are 16 random bytes, unique per client, collisions rejected, never re paired while live. Personalization strings follow one grammar: mte-relay/2/http/, 22 or more characters of randomness, then the Relay host, at most 1,024 bytes, checked on both strings. That blocks pair reset and cross transport replay.
Rate limits and replay
Auth and pair are limited per source, pair and keepalive per client id, never the data path. Defaults: auth 600 per minute, pair 600, keepalive 120, answered 488 rate_limited with Retry-After. Replay to the same Relay is 479 replay_rejected with the pair intact and a security event. sequenceWindow (minus 63) and timeWindow (1000) are pairing inputs taken from discovery, because a mismatch is no pair at all.
An error block infrastructure leaves alone
Relay generated errors live in 470 to 490, a block that is clean against every load balancer and CDN checked. Envoy and Istio outlier detection eject a pod after five consecutive 5xx; Cloudflare, Front Door, and App Gateway replace 5xx bodies with their own pages. A malformed, replayed, or unauthorized request gets a 4xx, which does not count toward ejecting the pod, and its ERROR frame reaches the client intact. True faults keep their 5xx on purpose.
| Code | Reason | Client action |
|---|---|---|
| 470 to 474 | pair_not_found, decoder_restore_failed, encoder_restore_failed, decode_failed, encode_failed | Replace that one pair. |
| 475 | invalid_client_id, invalid_token, unknown_key | Full repair at most once per 30 seconds; re auth; on unknown_key stop and surface to the operator. |
| 476 | pool_exhausted | Back off. |
| 477 | invalid_frame, eof_before_end | Surface with pair intact; on eof_before_end replace the pair. |
| 478 | frame_version_unsupported | Surface; the message names the accepted versions and minimum SDKs. |
| 479 | replay_rejected | Surface, pair intact, log a security event. |
| 480 | no_domain_for_host | Surface; the body names the Host the Relay saw. |
| 481 | frame_too_large, body_too_large, message_too_large | Surface, pair intact on HTTP; close and reopen on a connection. |
| 482, 483 | metadata_invalid, header_mismatch | Surface, pair intact; 483 is a security event. |
| 484, 489 | target_forbidden, port_out_of_range, authz_revoked, target_reset | Connections only; reset the local socket; stop retrying a forbidden or revoked alias. |
| 485, 486 | upstream_capacity, pair_busy | Back off; retry once then replace. |
| 487, 488 | rekey_overdue, connection_limit, rate_limited | Reconnect with a new OPEN; back off with jitter and honor Retry-After. |
| 490 | invalid_request, pair_exists, pair_limit, profile_mismatch | Validation on the JSON endpoints; profile_mismatch is a fatal setup error. |
| 500, 502, 503 | internal, configuration; upstream_connect_failed (nothing executed), upstream_response_failed (executed); state_store_unavailable, state_store_write_failed | True faults stay 5xx on purpose. The 502 split tells the caller whether the origin ran the request. 503 unavailable retries after Retry-After; write failed replaces the pair. |
Two carriers
The body of every Relay generated response is an ERROR frame: a uint16 code and canonical JSON with reason, optional message, and an optional nested cause from a MAR hop. The same code and reason travel in X-MTE-Relay-Error in case a proxy strips the body. A body under a Relay status that is not a frame is treated as a replaced page.
Registry driven clients
Every code has a registered default action. A receiver accepts an unknown reason of registry shape under a known code and applies the code's action, so a newer Relay does not break an older client. A registered code or reason is never reassigned to a new meaning.
Connections
ERROR is sent in band and the connection closes. On WebSocket the close code is 4000 plus the code minus 400, with the reason token as the close reason, and 1000 to 1015 have their own mapping for library and load balancer closes.
Clients read the server instead of hardcoding it
GET /api/mte-relay?frameVersions=2 negotiates the frame version before any Kyber work and returns a discovery document. The query parameter, not a header, keeps a cross origin fetch free of a preflight. Every limit an SDK needs comes from this document, so a deployment tunes the server and clients pick up the new limits the next time they read discovery.
What is advertised
discoverySchema, buildVersion, frameVersions, features, mteProfile, configVersion, kyberStrength, sequenceWindow, timeWindow, maxFrameBytes, maxMessageBytes, maxMetadataBytes, maxPairsPerBatch, maxPairsPerClient, maxPairBytes, maxPairSeconds, maxRequestSeconds, maxResponseSeconds, idleTimeoutSeconds, pingIntervalSeconds, drainSeconds, sessionTimeoutSeconds, tokenMaxAgeSeconds, transports, routeHeader, maxRequestBodyBytes, passThroughRoutes, forwardPlainHeaders, forwardBrowserCookies.
What SDKs do with it
Size DATA chunks from maxFrameBytes. Tune keepalive from sessionTimeoutSeconds. Refuse a body over maxRequestBodyBytes before uploading it. Send pass through routes as plain fetches. Clamp the pool to maxPairsPerClient. Refuse to pair on an mteProfile mismatch. Key behavior on features, never on buildVersion. The registry also asks clients to reread discovery when a response carries a higher X-MTE-Relay-Config-Version; the current SDKs do not do that yet.
Feature names today. The HTTP Relay advertises http, keepalive, plain-headers, browser-cookies, pass-through, and relay-error-header, with transports: ["http"]. The ws, rekey, goaway, and tcp names are registered for SocketX and TCPQ, which are separate binaries.
Keepalive, capacity, and per pair recovery
Pair state is a session. Traffic or one keepalive call refreshes every pair a client holds, and the timeout is advertised so SDKs time their keepalive to it. A client that stays inside the timeout keeps its pairs and skips the Kyber work of pairing again, so clients that return together refresh with one call each instead of stampeding the Relay with new pairings.
| Setting | Behavior |
|---|---|
| Keepalive | POST /api/mte-keepalive with {clientId, drop}: one call refreshes every pair of the client and deletes the dropped ones. Rate limited per client. |
| Idle eviction | SESSION_TIMEOUT_MINUTES 120, refreshed by traffic or keepalive; advertised so SDKs tune their interval. |
| Server codec pool | Keeps up to DEFAULT_POOL_SIZE 250 idle codecs and creates more on demand; MAX_POOL_SIZE_LIMIT 100,000 caps the default. |
| Browser SDK pairs | 1 at init, 4 minimum, 8 maximum, filled in the background once the first pair is ready, so the first request waits for a single pairing. |
| Pair retirement | The browser SDK retires a pair at 90 percent of the DRBG reseed interval, so a pair is replaced before it needs a reseed. |
| Go client and MAR outbound pool | 2 at start, 25 warm minimum, and a pair held by each request until its response ends. In 5.0.0: refills of at most 11, cap 100, 250 ms bounded wait. From 5.0.1: refills sized to the requests waiting and chained until the pool catches up, cap 1,024, 1 s bounded wait in arrival order. |
| Pairing caps | 100 pairs per batch and, per client, 200 in 5.0.0 and 2,048 from 5.0.1, both advertised. |
| Recovery | A registered action per code: replace one pair, full repair (rate limited), back off, or surface. |
Failures poison the right thing
A failed decoder state save poisons that pair and answers 503 state_store_write_failed instead of leaving stale keystream state in the store. A failed encoder save, which happens after the response is sent, poisons the pair so its next request answers 470. Any local decoder error, abort, early close, or EOF before END on a pool pair replaces that pair. Test: TestStoreWriteFailureReplacesThePair, TestSurfacedErrorLeavesTheOnePairUsable.
No transparent retry
The client re issues a failed application request on its own only once, and only for 486 pair_busy and 503 state_store_unavailable, where nothing ran upstream. Otherwise it returns a typed error that says whether the request is retryable; the caller, who knows whether the request was idempotent, decides. Go's Do retries only when GetBody is set or the body is empty.
Frames as the unit of transfer
Every body moves as bounded frames on the server, the Go client, and the browser upload path. Memory follows the frame size, not the body size, so a 1 GiB upload runs through a Relay in tens of megabytes.
DATA budget
One function, wire.DataBudget, sizes every chunk: under MKE the advertised bound less a 1,024 byte reserve; under standard MTE (maxFrameBytes - 16) / 8, because a plaintext of n bytes encodes to 8n plus 4. Nothing else caps a chunk, so MAX_FRAME_BYTES and the MTE type alone set its size.
64 KiB by measurement
MAX_FRAME_BYTES defaults to 65,536 (floor 4,096). On a 1 MiB body with everything else fixed, 64 KiB frames ran 45 percent faster than 1 MiB frames, at 7,445 extra bytes per round trip (0.36 percent). 16 KiB was slower than 1 MiB, so the curve has a floor and 64 KiB sits in it.
Response coalescing
RESPONSE_COALESCE_MS (default 2, per domain response_coalesce_ms, 0 turns it off) holds a response frame open after its first byte so a paced source of small writes shares frames. At 1,000 events per second with 100 byte events: 392 bytes per event at 0 ms, 210 at 2 ms, 143 at 10 ms. At 50 events per second the default does nothing; set 50 ms and pay that much latency per event.
Headers first
The server writes RESPONSE the moment the origin's status and headers arrive, without END, so the client sees them before the origin sends a body byte. END rides on the last data carrying frame. Test: TestFirstEventArrivesBeforeTheSecondIsSent.
Silence deadlines
The request read deadline is extended per frame, so a slow upload is bounded by silence, not by a fixed wall clock. Request, response, idle, and ping timeouts are per domain and advertised.
Memory
A 1 GiB upload runs at 277 MiB/s with a 44 MiB peak RSS on loopback. The browser SDK uses replayable blobs and bounded encryption batches, so a large upload never materializes whole plaintext and ciphertext buffers.
The control plane: UI and API for the whole fleet
V5 ships an optional control plane in the same image: MODE=control runs the panel, the default MODE=relay runs the data plane. The API is the product; the panel is one client of it. Workers poll, validate, apply what is valid, and report what they are running, and they keep serving their last good configuration if the panel goes away.
The UI
Dashboard
Workers online, in sync count, current config version, proxy count, and quick links. Refreshes every 5 seconds.
Fleet
One row per worker: hostname, id, address, version, uptime, last seen, a status badge (connecting, waiting for configuration, starting, cannot start, no routes, in sync, behind vN, update rejected, partial, stopping, stale), pending restart badges per field, and the rejection message when a worker refused an override set. Expand a row for the effective configuration it is running, with the origin of every value and secrets shown as keyed fingerprints. A restart guidance card renders the restart, scale, status, and logs commands for Kubernetes, OpenShift, Swarm, Compose, ECS, or systemd with your names filled in.
Proxies
A structured editor over DOMAIN_MAP, one page per host: routing (host, upstream, outbound toggle, personalization string, pass through routes), custom headers, security (client id secret with generate and reveal, a warning when a fleet is running auto generated secrets), and CORS (origins, methods, allowed and exposed headers, preflight max age, credentials, preflight passthrough). Add, rename, delete, an unsaved changes blocker, and a raw JSON fallback.
Configuration
Every schema field grouped, each badged live or restart, with description, default, reset per field, reset all, reveal on sensitive fields, a sticky save bar, and a dry run before every save whose warnings never block a write. Every schema field is listed by group; 5 apply live today: LOG_LEVEL, DOMAIN_MAP, MAX_REQUEST_BODY_MIB, MAX_CONTROL_BODY_MIB, FORWARD_HEADERS.
History
The last 50 versions with time, who, and which keys changed. Open any version, diff it, and roll back; a rollback installs the old snapshot as a new version, so the counter only moves forward and nothing is lost.
Service tokens and security
Named, scoped (read or config), fixed lifetime from 1 hour to 90 days, shown once, stored as a hash, revocable immediately, and attributed in the audit log as token:name. Admin sessions have a 15 minute sliding idle window and an 8 hour cap; the session signing secret rotates from the UI. Light, dark, and system themes; a prerendered login page; CSP hashes derived from the embedded bundle.
The API
| Route | Scope | Purpose |
|---|---|---|
GET /api/schema | read | Field schema: key, group, type, enum, dynamic or not, sensitive, default, description. Drives the whole Configuration page. |
GET and PUT /api/config | read, config | The sparse override document with its version as an ETag. Writes need If-Match; ?dry_run=true validates and reports the diff without writing. |
/api/config/domains/{host} | read, config | Per host DOMAIN_MAP reads, creates, replaces, moves, and deletes without touching other hosts. |
/api/config/history, /api/config/rollback | read, config | Version metadata, one stored version's document, and rollback by version number. |
GET /api/fleet, GET /api/effective-config | read | Every worker's last heartbeat; one worker's self reported running configuration with fingerprints for sensitive values. |
/api/tokens, /api/rotate-admin-secret | admin | Create, list, revoke service tokens; rotate the session secret. |
GET /api/poll, POST /api/report | worker | The worker poll with heartbeat headers, and the effective configuration report the panel requests with X-Effective-Report: wanted. |
Correctness machinery
Optimistic concurrency with 412 on a stale write. Fail safe apply: a worker that cannot validate an override set keeps its last good config, does not advance its applied version, and reports the error in its heartbeat. Boot sync: workers fetch overrides before validation and codec construction, so restart fields really take effect on restart. Live fields land in an immutable snapshot behind one atomic pointer load, so the hot path pays nothing. A live DOMAIN_MAP swap keeps each host's generated client_id_secret unless the override sets one, so an edit never invalidates active pairings.
Operations
Persistence is overrides.json, admin_jwt_secret, agent_tokens.json, and history/v<N>.json under CONTROL_PLANE_DATA_DIR, written atomically, so a copy at any moment is a valid backup. The panel is a deliberate singleton. Mixed version fleets degrade gracefully: unknown override keys are ignored with a warning and reported per worker. The API only grows within 5.x. An end to end suite deploys the panel, 3 workers, and 2 upstreams on minikube and asserts propagation of every live field, the fail safe path, and the restart path.
Everything per domain
Everything routes through DOMAIN_MAP, and each domain entry carries its own routing, CORS, headers, security, and timing. One Relay serves many hosts with different policies, and the whole map is a live field through the control plane.
| Per domain field | What it does |
|---|---|
upstream, cors_origins, cors_methods, client_id_secret | The origin, the CORS basics, and the secret that signs client ids. Set client_id_secret explicitly so every replica shares it. |
pass_through_routes | Exact paths or /prefix/* that bypass the frame; /api/mte- can never be pass through. |
outbound, personalization_string | outbound: true makes the upstream another Relay, reached through the outbound pair pool. personalization_string is an optional value appended to every personalization string the domain pairs with, after the prefix, the randomness, and the host. |
cors_allow_credentials, cors_allowed_headers, cors_exposed_headers, cors_max_age_seconds, cors_preflight_passthrough | Full CORS control per host. The Relay's own X-MTE-Relay-* headers and Retry-After are always exposed. |
headers | Custom upstream headers per domain. |
preserve_host, trust_forwarded_headers | preserve_host sends the client's hostname upstream; it is off by default because it breaks a load balancer that routes by host. trust_forwarded_headers keeps an incoming X-Forwarded-Host; it is the one setting that can reopen host spoofing, so it belongs only on Relay fed domains. |
forward_browser_cookies, forward_plain_headers | Forward chosen values from the plain hop. The frame wins on a name collision, and a hard exclude list can never be forwarded from the plain hop. |
request_seconds, response_seconds, idle_seconds, ping_seconds, response_coalesce_ms | Per domain timing; all but response_coalesce_ms are advertised in discovery. |
allow_plain_upstream | Explicit opt in for an outbound domain whose next Relay is reached over http://. Refused by default, because that hop carries the client token and both public keys. |
Environment variables
| Variable | Purpose |
|---|---|
DOMAIN_MAP | Every host, its upstream, and its per domain policy. Live through the control plane. |
DEFAULT_POOL_SIZE 250, MAX_POOL_SIZE_LIMIT 100,000 | Idle server codecs kept warm, and the cap on the default. Pools grow on demand. |
CLIENT_MIN_PAIRS 25, CLIENT_MAX_PAIRS 100, CLIENT_REFILL_THRESHOLD 10, CLIENT_PAIR_ACQUIRE_WAIT_MS 250 | The outbound pool's floor, ceiling, refill trigger, and acquire wait. One pair per request in flight through one outbound host; the inbound Relay's MAX_PAIRS_PER_CLIENT (200) must be at least CLIENT_MAX_PAIRS. From 5.0.1 the defaults are 1,024 pairs, a 1,000 ms wait, and 2,048 per client, and refills are sized to demand. |
FORWARD_HEADERS | Stamps all three forwarding headers; live via the control plane. |
MAX_FRAME_BYTES 65536, RESPONSE_COALESCE_MS 2, SESSION_TIMEOUT_MINUTES 120, MAX_REQUEST_BODY_MIB 0 (inclusive cap, 481 over it), MAX_CONTROL_BODY_MIB 4, KYBER_STRENGTH 1024, SEQUENCE_WINDOW, TIME_WINDOW, USE_MKE, HTTP_CLIENT_TIMEOUT_SECONDS | Protocol and limit knobs. |
MODE, CONTROL_PLANE_URL, CONTROL_PLANE_TOKEN, CONTROL_PLANE_POLL_SECONDS, CONTROL_PLANE_DATA_DIR, ADMIN_USERNAME, ADMIN_PW, ADMIN_JWT_SECRET, RELAY_RUNTIME | Control plane. Change ADMIN_PW before exposing the panel. |
REDIS_URL plus pool, timeout, and retry tuning; LOCAL_STATE_CACHE | Redis is required for 2 or more inbound replicas; outbound Relays keep their pairs in process. The cache is correct only behind a hash proxy on X-MTE-Relay-Route. |
WebSocket on the same frame
SocketX V2 carries WebSocket traffic on frame version 2: one frame per WebSocket message, every nonempty data frame verified, and the upgrade request itself inside the ciphertext. Control frames are plaintext in version 2, so run SocketX behind TLS. / answers 410 and names the real endpoints, so a client pointed at the wrong path learns where to go.
Endpoints and open sequence
GET /api/mte-relay?frameVersions=2 issues a token and the discovery document. GET /ws is the socket by default (WS_PATH, advertised as wsPath), subprotocol mte-relay.v2, one frame per binary message, permessage-deflate refused because compression makes ciphertext length a function of plaintext. First message OPEN (token, host, one pair request with two Kyber public keys), then OPEN_ACK, then REQUEST and RESPONSE 101 carry the upgrade path and headers inside the ciphertext. Two round trips and about 9.5 KB of Kyber material once per connection. Validation order is normative: token HMAC, frame bound, connection caps and open budgets, then Kyber, so a stranger never makes the server do the expensive part.
Connection semantics
One codec pair per connection, in memory only, never in a pool or a store, discarded at CLOSE. Fragmentation with MORE and a constant TEXT bit per run, bounded by maxMessageBytes, 1,024 fragments, and a wall clock deadline. Rekey is an encoded OPEN at 80 percent of maxPairBytes or maxPairSeconds; the server enforces 487 at the limit plus a 20 percent margin. PING and PONG keep the pair and every load balancer idle timer alive; GOAWAY drains over drainSeconds. Repeated headers keep every value, so a repeated Set-Cookie from the upstream survives the hop back.
Operational surface
Per source and per client id connection caps, open rate limits with burst, origin validation before the upgrade (absent origin accepted on purpose, present but disallowed refused), host matching most specific first, preserveHost, forwarded headers stamped by the server and refused from the client's metadata, /healthz, /readyz, and an echo endpoint. Billing per completed application message on AWS and Azure, failing open with /readyz reporting degraded.
Client
SocketXClient carries the standard WebSocket interface, so a caller swaps the constructor. Headers travel inside the ciphertext. send before open throws rather than buffering plaintext. Relay errors arrive as close events; relayCodeFromClose and defaultAction map them back to the registry. The client owns its reconnect policy.
TCP applications without an SDK
tcpq-agent runs beside the application, holds the client key, and turns each accepted local connection into one tunnel. tcpq-relay runs beside the destinations, exposes a frame listener and an HTTPS control plane, and dials only the alias the client named.
Authorization
A gateway cannot mint identities freely the way the HTTP Relay does, because tcpq-relay itself is the authorization boundary. Every client holds a preshared key issued by the operator; the client id in its token derives from the key id, so a token proves which key minted it with no store. Token refresh presents the key again, gateway tokens live minutes, revoking a key ends live tunnels within the policy refresh interval, and tcpq-relay refuses to start with an alias whose allow list is empty.
Aliases
A client names an alias, never a host and port. An alias carries host, port, allow, tls with sni and caFile (no insecure skip), an optional allowPorts range, allowPrivateNetwork off by default (which keeps an alias from reaching the cloud metadata endpoint), pinAddresses, resolveTtlSeconds, proxyProtocol v2, per alias and per client connection caps, and coalesceMs, off by default and never on anything a person types into.
Data path
OPEN, OPEN_ACK, REQUEST with method CONNECT and target, RESPONSE 200 after tcpq-relay has connected, then DATA both ways. Backpressure is normative: each direction is one goroutine that reads, converts, and writes before it reads again, so a slow target slows its sender instead of growing a buffer. HALF is a FIN in that direction. A target reset aborts the tunnel and is logged as target_reset, and the agent closes the local socket. Draining: fail readiness, wait for the balancer, SIGTERM sends GOAWAY and closes at drainSeconds.
Not in version 1
Rekey thresholds and the server side rekey budget (a tunnel runs on one pair for its life, bounded by maxLifetimeSeconds); SOCKS5 on the agent; the FTP control channel helper; a control plane push for revocation (today: edit the file and restart); per client banning; UDP; server initiated streams. Control frames are plaintext in frame version 2, so run the tunnel inside TLS, which tcpq-relay enforces. Tunnels do not survive a tcpq-relay restart.
What fits. Any single connection, client initiated TCP protocol whose peer is known ahead of time: SSH, SCP, SFTP, SMTP submission, IMAP, POP3, PostgreSQL, MySQL, MariaDB, SQL Server on a pinned port, standalone Redis, one MongoDB or LDAP node, MQTT, STOMP, single node AMQP, RDP to a direct host, VNC, Telnet, Modbus, DNP3, OPC UA, NFSv4.1, and anything inside its own TLS. Needs per node aliases: Kafka, Redis Cluster and sentinel, MongoDB replica sets, LDAP referrals, SQL Server named instances and read only routing, Oracle RAC, AMQP redirects, RDP brokers. Out: active FTP, FTPS, UDP, SNMP traps, NTP, RTP, WebRTC.
One spec, one registry, byte exact vectors
Normative text
spec/frame-v2.md is the normative text 5.0.0 implements, and it changes only through errata. Every implementation builds from this text, and the vectors below are the byte exact test every implementation must pass.
Registry
spec/registry.md reserves every value: versions, signatures, kinds, flag bits, MTE types, method bytes, codes and reasons, retired codes, metadata members, discovery fields, feature names, headers, paths, subprotocols, personalization prefixes, close reasons, and WebSocket close codes. A value once shipped is never reassigned. vectors/registry.json is the machine readable authority and both language tables are tested against it.
Vectors and conformance
spec/vectors/ holds envelope, request, response, data, error, control, metadata, path, personalization, discovery, probe, and WebSocket fixtures, byte identical between Go and the TypeScript engine. An implementation that disagrees with a vector is wrong. The metadata vectors include the four platform traps that produce valid but non canonical bytes.
Versioning policy
Three independent spaces: the frame version byte, discoverySchema, and feature names. A server accepts N and N minus 1 for 18 months after N ships; deprecation is announced in discovery at least 12 months ahead; after sunset the specific 478 stays for 12 more months; the MTE signature is detected forever. Each MAR hop negotiates independently and the effective feature set on a chain is the intersection.
Product separation
One module, three products that share wire, conn, shared, spec, and mte and nothing else. A customer running one product gets a binary that contains only that one; the build fails otherwise.
Measured performance
Two kinds of measurement: a loopback bench of the V5 code, run before the 5.0.0 build, that isolates the cost of the protocol on one connection, and a load run of the 5.0.0 image on a Kubernetes cluster that shows what a Relay does under hundreds of connections.
Loopback: the cost of the protocol
| Measurement | Result |
|---|---|
| 1 GiB upload, throughput and peak RSS | 277 MiB/s at 44 MiB |
| 1 GiB download, wire overhead | 0.028 percent |
| 256 MiB download from a 10 MiB/s origin, wire overhead | 0.670 percent at 2 ms coalescing, 0.049 percent at 50 ms |
| HTTP round trip, 1 KiB | 0.689 ms at 64 KiB frames |
| HTTP round trip, 1 MiB | 9.617 ms at 64 KiB frames |
| Bytes per round trip, 1 KiB payload | 4,261 |
Absolute costs on loopback
Below 1 KiB the Relay adds a flat cost per round trip: about 0.51 ms and 1,880 bytes on HTTP, about 0.23 ms and 546 bytes on SocketX and TCPQ (two 273 byte DATA frames). Above 100 KiB all three transports converge on 130 to 165 MiB/s per connection, which is the codec, not the carrier. Connection setup with Kyber: about 10 ms and 14,860 bytes on HTTP, 8.3 ms and 18,983 bytes on SocketX, once per connection.
Caveats
Standard MTE mode is not a usable setting: 2,832 ms at 100 KiB against 2.03 ms for MKE. The loopback figures are single connection latency on WSL2 loopback; there are no WAN figures.
Under load: 5.0.0 on a Kubernetes cluster
On October 1 to 2, 2026, 5.0.0 ran the load cells behind the performance page on a 30 CPU single node Kubernetes cluster with k6 in the cluster: plain HTTP, MKE, Kyber 512 with the 1024 default measured separately below, a 1,024 pair pool pinned so that no pairing ran inside a measurement, 60 second holds, four runs per cell. 131 million requests went through the Relays with no errors, and every response body was compared with what was sent. Each figure is the median of the four runs. Throughput is the plateau: the mean over the connection levels of 16 and up.
| Cell | Requests per second |
|---|---|
| One 2 CPU / 2 GiB Relay, 10 KB | 5,198 |
| One 2 CPU / 2 GiB Relay, 5 KB | 5,854 |
| One 2 CPU / 2 GiB Relay, 2 KB | 6,439 |
| One 4 CPU / 4 GiB Relay, 10 KB | 9,152 |
| One 8 CPU Relay, 10 KB | 13,568 |
| Two 2 CPU / 2 GiB Relays with Redis, 10 KB | 8,039 |
| Two 2 CPU / 2 GiB Relays with Redis, 5 KB | 8,953 |
| Two 4 CPU / 4 GiB Relays with Redis, 10 KB | 11,776 |
| Two 4 CPU / 4 GiB Relays with Redis, 5 KB | 13,391 |
Two Relays with Redis at 128 and 256 connections:
| Cell | Requests per second | p99 |
|---|---|---|
| Two 2 CPU Relays with Redis, 5 KB, 128 connections | 9,251 | 22.0 ms |
| Two 2 CPU Relays with Redis, 10 KB, 128 connections | 8,319 | 24.5 ms |
| Two 4 CPU Relays with Redis, 5 KB, 128 connections | 14,716 | 15.7 ms |
| Two 4 CPU Relays with Redis, 10 KB, 128 connections | 13,063 | 18.3 ms |
| Two 2 CPU Relays with Redis, 5 KB, 256 connections | 9,038 | 38.4 ms |
| Two 2 CPU Relays with Redis, 10 KB, 256 connections | 8,026 | 45.5 ms |
Light load, the mean round trip with one connection:
| Cell | Mean round trip |
|---|---|
| One 4 CPU Relay, 10 KB | 0.94 ms |
| One 8 CPU Relay, 10 KB | 0.93 ms |
| One 2 CPU Relay, 10 KB | 0.94 ms |
| One 2 CPU Relay, 5 KB | 0.87 ms |
| One 2 CPU Relay, 2 KB | 0.81 ms |
| Two 2 CPU Relays with Redis, 10 KB | 1.13 ms |
| Two 2 CPU Relays with Redis, 5 KB | 1.06 ms |
| Two 4 CPU Relays with Redis, 10 KB | 1.13 ms |
| Two 4 CPU Relays with Redis, 5 KB | 1.06 ms |
| Straight to the origin, no Relay | 0.12 ms |
Memory, the container working set of each Relay pod sampled at 64 and 128 connections, lowest to highest:
| Cell | Working set |
|---|---|
| One 4 CPU Relay, 10 KB | 92 to 144 MiB |
| One 2 CPU Relay, 10 KB | 100 to 152 MiB |
| One 2 CPU Relay, 2 KB | 42 to 91 MiB |
| Two 2 CPU Relays with Redis, 5 and 10 KB | 62 to 124 MiB |
Payload cost, fitted on the three 2 CPU peaks at 2, 5 and 10 KB: each request costs 141 µs plus 4.8 µs per KB of Relay pair capacity.
Cells run once: one 1 CPU / 1 GiB Relay, 2,763 req/s at 10 KB. Three 2 CPU Relays with Redis, 10,116 req/s at 10 KB and 11,563 at 5 KB. One 4 CPU Relay, 11,729 req/s at 1 KB and 2,757 at 100 KB; 8,337 req/s at 1,000 connections with p99 255 ms and no errors; 9,190 req/s with Kyber 1024 against 9,152 with Kyber 512, so the stronger default leaves data path throughput unchanged; and 8,337 req/s with response coalescing off, about 9 percent lower.
Caveats: the Relay image was develop build 11951, which differs from the 5.0.0 image only in its build identity stamp. Both sides ran the MAR image, because an ANY library build pairs only with ANY. The outbound pool was pinned at 1,024 pairs; the 5.0.0 defaults are 25 to 100. Cells with two or three Relays ran one Redis, for the inbound side; the outbound side needs none. The light load rows are the 5 second, one connection gate probe run after warm up. Memory comes from kubectl top. On the night of the run the rig's direct to origin reference peaked near 41,000 req/s, and the 8 CPU and two 4 CPU cells sat near a shared ceiling of about 14,000 req/s with Relay CPU to spare, so those rows understate the Relay.
Checklist for a V5 rollout
- Pin SDK and server versions together. Browser
mte-relay-browser5.0.0, public browser client 5.0.0, the Go client inunified-mte-relay, the SocketX TypeScript clientsocketxclient2.0.0, and the iOS and Android clients all speak frame version 2; the C#MteHttpClientis a solution skeleton today. An SDK on another frame version gets 478 on its first call, with the accepted versions and minimum SDKs in the message. - Match library builds. A client build pairs with a server build; an ANY build pairs only with ANY. An outbound Relay refuses a different build at auth, but a browser on the wrong build pairs and then fails on its first decode. 490
profile_mismatchcovers only a mismatch in the profile settings or Kyber strength. - Give every host its own domain entry. Set
DOMAIN_MAPwith an explicitclient_id_secretper domain so every replica shares it; write pass through routes as exact paths or/prefix/*; leaveallow_plain_upstreamoff unless an outbound domain must reach the next Relay overhttp://. - Size the outbound pool. Set
CLIENT_MAX_PAIRSto the peak requests in flight through one outbound host, and keep the inbound Relay'sMAX_PAIRS_PER_CLIENTat least that. - State store. Set
REDIS_URLwhen inbound Relays run 2 or more replicas; outbound Relays keep their pairs in process. EnableLOCAL_STATE_CACHEonly behind a consistent hash proxy onX-MTE-Relay-Route. Saved codec state is not encrypted at rest, so lock down who can reach and read the store; its access controls are the only protection. - WAF, ingress, CORS. Allow
application/octet-streamrequest bodies (CRS rule 920420 blocks them by default on Azure WAF and ModSecurity). Disable automatic retries on encrypted routes, because a retry of a request the Relay already decoded is a replay and gets 479, and disable response buffering, which holds back the headers first RESPONSE. If a domain customizescors_allowed_headers, includeX-MTE-Relay-Route; the Relay always exposes its own headers. - Observability. Alert on the 470 to 490 block and the
X-MTE-Relay-Errorheader. IngestX-MTE-Relay-Request-Id. Treat 500, 502, and 503 as the true fault signals. - Control plane, if wanted. Run one
MODE=controlinstance on a volume, setCONTROL_PLANE_URLand the shared token on workers, changeADMIN_PW, create scoped service tokens for CI, and take a backup ofoverrides.jsonbefore the first big edit. - Marketplace images. SocketX V2 images carry the AWS and Azure metering providers; the Relay 5.0.0 images do not.
- Start with one application. Validate it end to end, including the 470 to 490 alerts, before adding the next.
- SocketX and TCPQ are their own rollouts. SocketX V2 clients and server move together. Pilot TCPQ with the alias list, the key issuance flow, and the reconnect story agreed first.
YOUR NEXT STEP
Make the rollout predictable.
Pin the SDK and server versions together, work through the deployment checklist, and validate one application before expanding.