SplitHTTP (XHTTP)
Split upload and download into separate ordinary HTTP requests so buffering, body-limiting CDNs carry the tunnel untouched.
CDNs are built to carry web pages, not tunnels. A reverse proxy will happily forward a GET but buffer an entire request body before passing it on, and many cap how large that body may be. A classic bidirectional proxy stream needs full-duplex — bytes flowing both ways over one request — which is exactly what a buffering CDN breaks. SplitHTTP’s answer is to stop asking for full-duplex at all: it carries the download on one long-lived GET and the upload as separate, ordinary HTTP requests, so every request on the wire is the kind a CDN already knows how to relay.
Uploads and downloads travel as separate requests
One SplitHTTP connection is stitched together from two unrelated HTTP requests — a splitConn whose writer is the uplink and whose reader is the downlink GET. The dialer opens the download first (OpenStream with a nil body issues a GET), then wires the upload path into the same conn:
// download: a long-lived GET the server streams into
conn.reader, conn.remoteAddr, conn.localAddr, err =
httpClient2.OpenStream(ctx, requestURL2.String(), sessionId, nil, false)
// …
// upload: separate POST(s), same sessionId
conn.writer = uploadWriter{uploadPipeWriter, maxUploadSize} Both requests share only a sessionId (a fresh UUID per connection), which the server uses to pair them back into one stream. Because they are distinct requests, a CDN can even route them through different edges — the design never assumes they share a socket.
The download is disguised as Server-Sent Events
A long GET only streams in real time if every middlebox agrees not to buffer it, so the server masquerades the downlink as SSE. Xray stacks three tricks in hub.go: X-Accel-Buffering: no (the nginx/apache-specific switch), Cache-Control: no-store (stops CDNs teeing the stream into their cache), and an SSE Content-Type so any other middlebox treats the response as an event stream it must flush through — then it flushes after every single write:
writer.Header().Set("X-Accel-Buffering", "no") // nginx/apache: don't buffer
writer.Header().Set("Cache-Control", "no-store") // stop CDNs teeing into cache
if !h.config.NoSSEHeader {
writer.Header().Set("Content-Type", "text/event-stream")
}
writer.WriteHeader(http.StatusOK)
writer.(http.Flusher).Flush() packet-up splits the uplink into many short POSTs, reassembled by sequence number
When even the upload must survive body-size limits, packet-up chops it into a stream of small POSTs, each tagged with a sequence number the server puts back in order. Each chunk travels as its own independent request, and independent requests can overtake each other — different HTTP/2 streams, pooled connections, CDN edges — so chunks can reach the server out of order; a per-session uploadQueue (a min-heap keyed by Seq) holds early arrivals until the one it needs shows up:
if packet.Seq == h.nextSeq {
copy(b, packet.Payload)
// … a partial read re-pushes the tail; a full read advances:
h.nextSeq = packet.Seq + 1
return n, nil
}
// misordered packet
if packet.Seq > h.nextSeq {
// too early — buffer it and wait for the gap to fill
heap.Push(&h.heap, packet)
} Two details make this practical. First, small conn.Write calls are batched through a buffered pipe before being cut into scMaxEachPostBytes-sized chunks — the source comment is blunt: “without batching, bandwidth is extremely limited.” Second, the reassembly buffer is bounded by scMaxBufferedPosts (default 30); if too many packets pile up waiting for a missing sequence number, the server tears the session down rather than grow memory without limit.
The mode picks how much CDN-friendliness you pay for in round trips
stream-up and packet-up share the streaming GET downlink and differ only in how the uplink is shaped; stream-one skips the separate GET entirely — its single request’s response body is the downlink. Each step trades extra requests for tolerance of stricter middleboxes:
| Mode | Uplink | Needs a sessionId | Best when |
|---|---|---|---|
stream-one | one bidirectional request | no | direct/REALITY, no meddling proxy |
stream-up | one long-lived POST | yes | CDN forwards streaming bodies |
packet-up | many short POSTs (+ seq) | yes | CDN buffers or size-limits request bodies |
When mode is unset or "auto", the dialer defaults to packet-up; with REALITY it prefers stream-one, upgrading to stream-up if a separate downloadSettings transport is configured. stream-one sends no sessionId because its single request already carries both directions — and that absence is exactly how the server polices it: a request without a sessionId gets 400 (“stream-one mode is not allowed”) when the operator pinned mode to packet-up (the gate in hub.go lets auto, stream-one, and stream-up through).
{
"network": "xhttp",
"xhttpSettings": {
"mode": "packet-up",
"path": "/assets",
"host": "cdn.example.com"
}
} xpadding erases the size fingerprint
Fixed-shape requests are a fingerprint, so every SplitHTTP request and response carries random padding. By default Xray hides it in a query string tucked inside the Referer header (x_padding), sized between 100 and 1000 bytes, and the server rejects any request whose padding falls outside the configured range:
if !h.config.IsPaddingValid(paddingValue, validRange.From, validRange.To, PaddingMethod(h.config.XPaddingMethod)) {
// …
writer.WriteHeader(http.StatusBadRequest)
return
} Padding has to survive header compression: HTTP/2 and HTTP/3 Huffman-code headers via HPACK/QPACK, which would silently shrink arbitrary padding on the wire. The default repeat-x method sidesteps that — X is assigned an exactly 8-bit Huffman code, so a run of X keeps its length. The tokenish method makes padding look like a random token instead: random base62 compresses by roughly 20%, so GenerateTokenishPaddingBase62 iteratively adjusts the string until its Huffman-encoded length hits the target, appending alternating X/Z (both 8-bit codes) to fine-tune.
Set scMaxEachPostBytes smaller on the server
The server enforces its own scMaxEachPostBytes as a hard ceiling and answers 413 Request Entity Too Large to any chunk that exceeds it. A client configured to send bigger POSTs than the server accepts fails every upload.
Make the server ceiling ≥ the client chunk size
Keep the server’s scMaxEachPostBytes at least as large as the client’s. The client picks a random size within its own range per connection, so size the server for the client’s upper bound.