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.
Selection runs top to bottom in XmuxManager.GetXmuxClient:
- Prune any client that is closed, out of reuses (
leftUsage == 0), out of requests (LeftRequests <= 0), or past itsUnreusableAtdeadline. - If the pool is empty, or
maxConnectionsis set and the pool is still smaller than it, make a new connection. - Otherwise keep only clients whose live-stream count
OpenUsageis undermaxConcurrency, and pick one at random (crypto/rand, not round-robin) — so load spreads evenly. - If every client is saturated, make a new one.
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?“:
maxConcurrencycaps 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.maxConnectionscaps 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.
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.
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.0disables 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.
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.