Contents

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.

Uplink and downlink are two independent, one-directional HTTP request lanes — neither needs the CDN to do full-duplex.
transport/internet/splithttp/dialer.go transport/internet/splithttp/hub.go

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)
}
packet-up: POSTs leave numbered, arrive shuffled, and uploadQueue releases them strictly in nextSeq order.

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.

transport/internet/splithttp/upload_queue.go transport/internet/splithttp/client.go

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:

ModeUplinkNeeds a sessionIdBest when
stream-oneone bidirectional requestnodirect/REALITY, no meddling proxy
stream-upone long-lived POSTyesCDN forwards streaming bodies
packet-upmany short POSTs (+ seq)yesCDN 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.

Don't

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.

Do

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.

transport/internet/splithttp/config.go transport/internet/splithttp/xpadding.go