Contents

XMUX: end TLS-in-TLS and connection caps

One HTTP connection, many streams — fixing both the double-TLS fingerprint and the censor's parallel-connection heuristic.

XHTTP already turns your tunnel into ordinary-looking HTTPS requests. But without xmux, a busy page load still spawns a fresh HTTP connection — and therefore a fresh outer TLS handshake — for every simultaneous app stream. That is expensive twice over: it re-pays the TLS-in-TLS cost that Mux exists to avoid, and it lights up the “one client, N simultaneous connections to a single IP” pattern that some censors score as suspicious. XMUX folds those streams onto a small, reused pool of HTTP connections. This page is the operational playbook: when to reach for each knob, the exact JSON, and the catch.

One pool of HTTP connections carries every stream

XMUX never dials per stream — it hands each new stream to a manager that reuses connections it already has open. Every Dial calls getHTTPClient, which looks up a per-destination XmuxManager and asks GetXmuxClient for a connection. The manager keeps a slice of XmuxClients (each wrapping one HTTP client = one TLS connection) and picks one instead of building another.

N app streams collapse onto a small pool of reused HTTP connections, each paying its TLS handshake once

Selection runs top to bottom in XmuxManager.GetXmuxClient:

  1. Prune any client that is closed, out of reuses (leftUsage == 0), out of requests (LeftRequests <= 0), or past its UnreusableAt deadline.
  2. If the pool is empty, or maxConnections is set and the pool is still smaller than it, make a new connection.
  3. Otherwise keep only clients whose live-stream count OpenUsage is under maxConcurrency, and pick one at random (crypto/rand, not round-robin) — so load spreads evenly.
  4. If every client is saturated, make a new one.
transport/internet/splithttp/mux.go transport/internet/splithttp/dialer.go

Two knobs, and you must choose exactly one

maxConcurrency and maxConnections are mutually exclusive — setting both is a config error. They are two answers to “how wide should the pool get?“:

  • maxConcurrency caps streams per connection. A connection stays eligible until it holds that many live streams (OpenUsage), then the next stream forces a new connection. This is the true multiplexing lever — raise it and a whole page load rides one TLS session.
  • maxConnections caps the number of connections. The manager opens up to that many, then packs every further stream onto the existing ones with no concurrency ceiling.
{
	"network": "xhttp",
	"xhttpSettings": {
		"mode": "packet-up",
		"path": "/assets",
		"xmux": {
			// pack up to 16–32 streams onto each HTTP connection
			"maxConcurrency": "16-32",
			// retire a connection after this many hand-outs (0 = no limit) / requests / seconds…
			"cMaxReuseTimes": "0-0",
			"hMaxRequestTimes": "600-900",
			"hMaxReusableSecs": "1800-3000",
			// H2 ping cadence, in seconds (0 = Chrome's 45s, <0 = off)
			"hKeepAlivePeriod": 0
		}
	}
}

Every range accepts "from-to" or a bare number; Xray picks a fresh random value inside the range each time (per manager for the caps, per connection for the lifetimes). To cap the fleet instead, swap the first line for "maxConnections": "4-8" and drop maxConcurrency.

Don't

Leave xmux at its defaults for a browser profile

Omit xmux entirely and Xray fills in maxConcurrency of 1 — one live stream per connection. Reuse still happens serially, but a burst of parallel requests still opens a burst of TLS connections. That is the exact parallel-connection shape you were trying to hide.

Do

Raise concurrency so a page load is one handshake

Set "maxConcurrency": "16-32". Thirty resources now share one or two HTTP/2 connections, each with a single outer TLS handshake — the TLS-in-TLS cost is paid once, and a censor sees one long-lived connection, not thirty short parallel ones.

Connections retire on purpose, so you look like a browser

A reused connection is not reused forever — three independent timers force rotation, and that churn is the point. A real browser does not keep one HTTP/2 connection alive for hours; it opens fresh ones. XMUX imitates that:

  • cMaxReuseTimes (leftUsage) — how many times a connection may be handed out before retirement. 0 disables the limit.
  • hMaxRequestTimes (LeftRequests) — how many HTTP requests a connection may carry.
  • hMaxReusableSecs (UnreusableAt) — a wall-clock lifetime after which it is dropped.

Whichever fires first prunes the client on the next GetXmuxClient, and new streams land on a fresh connection.

Keepalive is tuned to blend in, not just to survive NAT

hKeepAlivePeriod sets the HTTP/2 ping cadence in seconds, and its default is chosen to match Chrome. When you leave it at 0, createHTTPClient uses net.ChromeH2KeepAlivePeriod (45s) for the H2 ReadIdleTimeout — so your pings look like a browser’s, not like a bespoke tunnel heartbeat. A negative value disables the ping entirely; idle connections are then reaped only by the transport’s IdleConnTimeout (net.ConnIdleTimeout, 300s), a timer that runs whether or not pings do. On HTTP/3 the knob is a suppressor, not a cadence: at 0 Xray applies its own net.QuicgoH3KeepAlivePeriod (10s) as the QUIC keepalive, and any non-zero value merely cancels that default without installing your number — an explicit H3 keepalive belongs in quicParams.keepAlivePeriod. Only override it if you actually know your CDN or middlebox idles connections faster than that.

transport/internet/splithttp/config.proto transport/internet/splithttp/config.go transport/internet/splithttp/client.go

XMUX rides on top of an XHTTP transport, so pair it with a real TLS or REALITY outer layer — the connections it reuses are only worth reusing if each one’s handshake already looks legitimate.