Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.
VERSION 5.0.0Engineering guide / October 2026

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

FRAME VERSION 205 /
v5ONE SHARED PROTOCOL
HTTPWebSocketTCP

One foundation. Three products.
Released on their own schedules.

12 bytesShared frame envelope
470 to 490Relay error codes
Kyber 1024Default key exchange
One specShared byte exact vectors
01The release

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.

Item5.0.0
VersionsMTE Relay Server (MRS) and MTE API Relay (MAR) 5.0.0; SocketX Server 2.0.0; TCPQ 1
Wire protocolFrame version 2: a 12 byte envelope and a kind specific payload, one layout for HTTP, WebSocket, and TCP
Client SDKsmte-relay-browser 5.0.0, public client 5.0.0; the Go, SocketX TypeScript, iOS, and Android clients speak the same frame
Relay errorsHTTP 470 to 490 with reason tokens; 500, 502, and 503 for true faults
Key exchangeKyber 1024 by default; 512, 768, or 1024 per deployment
OperationsAn optional control plane in the same image: fleet view, live configuration, versioned history, and rollback
Web and mobile appsMTE Relay V5HTTPYour APIs
Chat and live dashboardsSocketX V2WebSocketReal time services
SSH, mail, databasesTCPQ V1TCPApproved destinations
Frame version 2 is the shared foundation. Each transport has its own product and release schedule.
02Binary frames

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

12 byte envelope
Signature3 B
Version1 B
Kind1 B
Flags1 B
Reserved2 B
Length4 B
Kind specific payload REQUEST · RESPONSE · DATA · ERROR · CONTROL
The envelope carries framing information. Encrypted metadata binds the method and status; nonempty DATA binds its flags. Control frames remain plaintext in version 2 and require TLS protection.
Envelope fieldSizeRule
Signature30x89 0x4D 0x54. A body that starts with ASCII MTE is recognized forever and answered with 478 naming the minimum SDK versions.
Version12. Checked before any width dependent field, so a second layout can coexist later.
Kind1REQUEST 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.
Flags1END (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.
Reserved2Must be zero.
Length4Payload 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 maxFrameBytes before 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.

03Message integrity

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

01ReceiveOne bounded frame
02VerifyComplete codec operation
03ReleaseOnly verified plaintext
Frames are individually verified. The codec remains stateful: a decoder accepts a frame at most 63 places out of order.

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.

04Canonical metadata

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.

MemberWhereRule
vrequired everywhereThe frame version, compared to the envelope.
methodrequired on requestsRFC 9110 token, at most 32 bytes. Authoritative when the method byte is 255, so WebDAV and other methods work instead of silently becoming GET.
statusrequired on responses100 to 599, compared to the RESPONSE status.
pathrequestsPath plus query, no leading slash, percent encoded as sent, at most 16 KiB. Readers refuse // and .. segments.
headersoptionalLowercase 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, portTCPAn alias name from configuration, never a host and port; an optional port inside an allowed range.
idoptionalClient 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.

05Pairing and identity

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.

06Errors and recovery

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.

CodeReasonClient action
470 to 474pair_not_found, decoder_restore_failed, encoder_restore_failed, decode_failed, encode_failedReplace that one pair.
475invalid_client_id, invalid_token, unknown_keyFull repair at most once per 30 seconds; re auth; on unknown_key stop and surface to the operator.
476pool_exhaustedBack off.
477invalid_frame, eof_before_endSurface with pair intact; on eof_before_end replace the pair.
478frame_version_unsupportedSurface; the message names the accepted versions and minimum SDKs.
479replay_rejectedSurface, pair intact, log a security event.
480no_domain_for_hostSurface; the body names the Host the Relay saw.
481frame_too_large, body_too_large, message_too_largeSurface, pair intact on HTTP; close and reopen on a connection.
482, 483metadata_invalid, header_mismatchSurface, pair intact; 483 is a security event.
484, 489target_forbidden, port_out_of_range, authz_revoked, target_resetConnections only; reset the local socket; stop retrying a forbidden or revoked alias.
485, 486upstream_capacity, pair_busyBack off; retry once then replace.
487, 488rekey_overdue, connection_limit, rate_limitedReconnect with a new OPEN; back off with jitter and honor Retry-After.
490invalid_request, pair_exists, pair_limit, profile_mismatchValidation on the JSON endpoints; profile_mismatch is a fatal setup error.
500, 502, 503internal, configuration; upstream_connect_failed (nothing executed), upstream_response_failed (executed); state_store_unavailable, state_store_write_failedTrue 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.

07Discovery

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.

08Sessions and pools

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.

SettingBehavior
KeepalivePOST /api/mte-keepalive with {clientId, drop}: one call refreshes every pair of the client and deletes the dropped ones. Rate limited per client.
Idle evictionSESSION_TIMEOUT_MINUTES 120, refreshed by traffic or keepalive; advertised so SDKs tune their interval.
Server codec poolKeeps up to DEFAULT_POOL_SIZE 250 idle codecs and creates more on demand; MAX_POOL_SIZE_LIMIT 100,000 caps the default.
Browser SDK pairs1 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 retirementThe 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 pool2 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 caps100 pairs per batch and, per client, 200 in 5.0.0 and 2,048 from 5.0.1, both advertised.
RecoveryA 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.

09Streaming

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.

10Control plane

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.

Console + APIValidate · version · audit
Your Relay fleetApply valid changes · report status
Precedence: override, then environment, then default. Workers keep serving their last good configuration if the panel becomes unreachable.
RELAY CONTROL PLANEVersion 5.0.0
MTE Relay Control Plane: See worker health, versions, and configuration sync across your fleet.
See worker health, versions, and configuration sync across your fleet. Select the image to view full size.

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​

RouteScopePurpose
GET /api/schemareadField schema: key, group, type, enum, dynamic or not, sensitive, default, description. Drives the whole Configuration page.
GET and PUT /api/configread, configThe 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, configPer host DOMAIN_MAP reads, creates, replaces, moves, and deletes without touching other hosts.
/api/config/history, /api/config/rollbackread, configVersion metadata, one stored version's document, and rollback by version number.
GET /api/fleet, GET /api/effective-configreadEvery worker's last heartbeat; one worker's self reported running configuration with fingerprints for sensitive values.
/api/tokens, /api/rotate-admin-secretadminCreate, list, revoke service tokens; rotate the session secret.
GET /api/poll, POST /api/reportworkerThe 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.

11Configuration

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 fieldWhat it does
upstream, cors_origins, cors_methods, client_id_secretThe origin, the CORS basics, and the secret that signs client ids. Set client_id_secret explicitly so every replica shares it.
pass_through_routesExact paths or /prefix/* that bypass the frame; /api/mte- can never be pass through.
outbound, personalization_stringoutbound: 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_passthroughFull CORS control per host. The Relay's own X-MTE-Relay-* headers and Retry-After are always exposed.
headersCustom upstream headers per domain.
preserve_host, trust_forwarded_headerspreserve_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_headersForward 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_msPer domain timing; all but response_coalesce_ms are advertised in discovery.
allow_plain_upstreamExplicit 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​

VariablePurpose
DOMAIN_MAPEvery host, its upstream, and its per domain policy. Live through the control plane.
DEFAULT_POOL_SIZE 250, MAX_POOL_SIZE_LIMIT 100,000Idle 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 250The 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_HEADERSStamps 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_SECONDSProtocol 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_RUNTIMEControl plane. Change ADMIN_PW before exposing the panel.
REDIS_URL plus pool, timeout, and retry tuning; LOCAL_STATE_CACHERedis 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.
12SocketX V2

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.

13TCPQ V1

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.

14Shared specification

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.

15Measurements

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​

MeasurementResult
1 GiB upload, throughput and peak RSS277 MiB/s at 44 MiB
1 GiB download, wire overhead0.028 percent
256 MiB download from a 10 MiB/s origin, wire overhead0.670 percent at 2 ms coalescing, 0.049 percent at 50 ms
HTTP round trip, 1 KiB0.689 ms at 64 KiB frames
HTTP round trip, 1 MiB9.617 ms at 64 KiB frames
Bytes per round trip, 1 KiB payload4,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.

CellRequests per second
One 2 CPU / 2 GiB Relay, 10 KB5,198
One 2 CPU / 2 GiB Relay, 5 KB5,854
One 2 CPU / 2 GiB Relay, 2 KB6,439
One 4 CPU / 4 GiB Relay, 10 KB9,152
One 8 CPU Relay, 10 KB13,568
Two 2 CPU / 2 GiB Relays with Redis, 10 KB8,039
Two 2 CPU / 2 GiB Relays with Redis, 5 KB8,953
Two 4 CPU / 4 GiB Relays with Redis, 10 KB11,776
Two 4 CPU / 4 GiB Relays with Redis, 5 KB13,391

Two Relays with Redis at 128 and 256 connections:

CellRequests per secondp99
Two 2 CPU Relays with Redis, 5 KB, 128 connections9,25122.0 ms
Two 2 CPU Relays with Redis, 10 KB, 128 connections8,31924.5 ms
Two 4 CPU Relays with Redis, 5 KB, 128 connections14,71615.7 ms
Two 4 CPU Relays with Redis, 10 KB, 128 connections13,06318.3 ms
Two 2 CPU Relays with Redis, 5 KB, 256 connections9,03838.4 ms
Two 2 CPU Relays with Redis, 10 KB, 256 connections8,02645.5 ms

Light load, the mean round trip with one connection:

CellMean round trip
One 4 CPU Relay, 10 KB0.94 ms
One 8 CPU Relay, 10 KB0.93 ms
One 2 CPU Relay, 10 KB0.94 ms
One 2 CPU Relay, 5 KB0.87 ms
One 2 CPU Relay, 2 KB0.81 ms
Two 2 CPU Relays with Redis, 10 KB1.13 ms
Two 2 CPU Relays with Redis, 5 KB1.06 ms
Two 4 CPU Relays with Redis, 10 KB1.13 ms
Two 4 CPU Relays with Redis, 5 KB1.06 ms
Straight to the origin, no Relay0.12 ms

Memory, the container working set of each Relay pod sampled at 64 and 128 connections, lowest to highest:

CellWorking set
One 4 CPU Relay, 10 KB92 to 144 MiB
One 2 CPU Relay, 10 KB100 to 152 MiB
One 2 CPU Relay, 2 KB42 to 91 MiB
Two 2 CPU Relays with Redis, 5 and 10 KB62 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.

16Deployment checklist

Checklist for a V5 rollout​

  1. Pin SDK and server versions together. Browser mte-relay-browser 5.0.0, public browser client 5.0.0, the Go client in unified-mte-relay, the SocketX TypeScript client socketxclient 2.0.0, and the iOS and Android clients all speak frame version 2; the C# MteHttpClient is 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.
  2. 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_mismatch covers only a mismatch in the profile settings or Kyber strength.
  3. Give every host its own domain entry. Set DOMAIN_MAP with an explicit client_id_secret per domain so every replica shares it; write pass through routes as exact paths or /prefix/*; leave allow_plain_upstream off unless an outbound domain must reach the next Relay over http://.
  4. Size the outbound pool. Set CLIENT_MAX_PAIRS to the peak requests in flight through one outbound host, and keep the inbound Relay's MAX_PAIRS_PER_CLIENT at least that.
  5. State store. Set REDIS_URL when inbound Relays run 2 or more replicas; outbound Relays keep their pairs in process. Enable LOCAL_STATE_CACHE only behind a consistent hash proxy on X-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.
  6. WAF, ingress, CORS. Allow application/octet-stream request 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 customizes cors_allowed_headers, include X-MTE-Relay-Route; the Relay always exposes its own headers.
  7. Observability. Alert on the 470 to 490 block and the X-MTE-Relay-Error header. Ingest X-MTE-Relay-Request-Id. Treat 500, 502, and 503 as the true fault signals.
  8. Control plane, if wanted. Run one MODE=control instance on a volume, set CONTROL_PLANE_URL and the shared token on workers, change ADMIN_PW, create scoped service tokens for CI, and take a backup of overrides.json before the first big edit.
  9. Marketplace images. SocketX V2 images carry the AWS and Azure metering providers; the Relay 5.0.0 images do not.
  10. Start with one application. Validate it end to end, including the 470 to 490 alerts, before adding the next.
  11. 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.