XHTTP: a step further
When raw TCP is not enough, carry the tunnel inside ordinary HTTP so CDNs and reverse proxies pass it through untouched.
Plain reality over TCP gives you a perfect handshake, but it demands one thing you do not always have: a raw TCP path from client to server that nobody rewrites. The moment a CDN, a load balancer, or a corporate reverse proxy sits in the middle, the REALITY stream is gone — those boxes speak HTTP, not opaque TLS records. XHTTP (the evolved SplitHTTP transport) solves this by dressing the tunnel as ordinary HTTP requests, so every HTTP-aware middlebox forwards it without noticing.
transport/internet/splithttp/dialer.go transport/internet/splithttp/config.protoReach for XHTTP the moment an HTTP proxy is in the path
Choose XHTTP when your traffic must survive a box that only understands HTTP — a CDN edge, a shared :443 behind nginx, or a platform that terminates TLS for you. Plain REALITY-over-TCP wins on latency and simplicity when you own an unfiltered IP; XHTTP wins when you must hide behind infrastructure you do not control.
The uplink is a POST to your path; the downlink is a long-lived GET the server answers as a stream. To the CDN it is a web app being used; to a censor tapping the wire it is a TLS session to the CDN’s domain.
One config, three shapes — mode decides which
mode is the single knob that reshapes the whole transport, and left at its default auto Xray picks the shape for you based on what else is configured. From the dialer: no REALITY → packet-up; REALITY present → stream-one; REALITY plus downloadSettings → stream-up.
mode | Uplink | Survives a buffering proxy? |
|---|---|---|
packet-up | many small bounded POSTs | Yes — each POST completes on its own |
stream-up | one long streamed POST + split GET | Needs a flushing path |
stream-one | single full-duplex request, up + down | Only through a true passthrough proxy |
Behind a body-buffering CDN, pin packet-up
A CDN that buffers request bodies will freeze any streamed uplink, because it waits for the POST to finish before forwarding a byte. packet-up sidesteps this: each upload is its own discrete, size-bounded POST that completes immediately, so the CDN forwards it and moves on.
{
"protocol": "vless",
"streamSettings": {
"network": "xhttp",
"security": "tls",
"tlsSettings": {
"serverName": "cdn-fronted.example.com"
},
"xhttpSettings": {
"mode": "packet-up",
"host": "backend.example.com",
"path": "/xhttp",
"xPaddingBytes": { "from": 100, "to": 1000 }
}
}
} Every key here exists in the transport config: mode, host, path, and xPaddingBytes (a { from, to } range). The server inbound mirrors it — same path, and pin the same mode so it rejects mismatched shapes:
{
"streamSettings": {
"network": "xhttp",
"xhttpSettings": {
"mode": "packet-up",
"path": "/xhttp"
}
}
} host and path are how the front-end finds your backend
host sets the HTTP Host / :authority independently of the TLS SNI, which is the whole trick to CDN fronting: the TLS handshake advertises the CDN’s domain (serverName), while host carries the origin name your reverse proxy routes on. From the dialer, host wins; if empty it falls back to the TLS SNI, then the REALITY server name, then the destination address.
Leave host empty behind a shared reverse proxy
With no host, the request authority defaults to the SNI. If several backends share one :443, the proxy cannot tell them apart and routes you to the wrong origin — or a 404.
Set host to the origin your proxy keys on
Give host the exact vhost your reverse proxy matches, and a distinctive path. Host plus path is enough for nginx or a CDN to route the tunnel like any other route.
path is normalized to always start and end with /, and any ?query you append is split off and preserved on every request — so /xhttp?token=a1b2c3 is a valid path, and the query is one more thing your proxy can route or filter on.
The catch: stream-one needs a proxy that does not buffer
stream-one collapses upload and download into a single full-duplex HTTP request — the leanest, lowest-overhead shape — but it only works end to end if every hop flushes both directions concurrently. A CDN or reverse proxy that buffers request bodies breaks it outright. Reserve stream-one for a direct origin or a genuine passthrough (nginx grpc_pass / an HTTP/2 tunnel that streams), and fall back to packet-up the instant a buffering edge sits in the path. For padding against packet-length fingerprinting, xPaddingBytes defaults to { from: 100, to: 1000 } and is applied on both request and response even if you never set it.
For the wire-level mechanics — upload queue, sequence numbers, XMUX connection reuse — see splithttp. For fronting specifics, see cdn.