SplitHTTP (XHTTP) Transport
Introduction
SplitHTTP (internally called XHTTP) is a versatile HTTP-based transport that splits bidirectional proxy traffic across separate HTTP requests. Download (server-to-client) uses a long-lived GET response stream. Upload (client-to-server) uses either streaming POST requests or sequenced individual POST requests with reassembly. It supports HTTP/1.1, HTTP/2 (h2c and TLS), and HTTP/3 (QUIC), along with REALITY, connection multiplexing (Xmux), and extensive padding/obfuscation options.
Protocol Registration
Registered as "splithttp" (transport/internet/splithttp/splithttp.go:3):
const protocolName = "splithttp"- Dialer:
splithttp/dialer.go:243-245 - Listener:
splithttp/hub.go:564-566 - Config:
splithttp/config.go:296-300
Operation Modes
SplitHTTP supports multiple modes selected by the Mode config field (splithttp/dialer.go:281-289):
| Mode | Upload | Download | Use Case |
|---|---|---|---|
stream-one | Single bidirectional stream | Same stream | Full duplex, like WebSocket |
stream-up + stream-down | Streaming POST | Streaming GET | Separate streams, CDN-friendly |
packet-up + stream-down | Sequenced POST packets | Streaming GET | Most CDN-compatible (default) |
Auto-selection logic:
- Default:
packet-up - With REALITY:
stream-one(orstream-upifDownloadSettingsexists)
HTTP Version Selection
decideHTTPVersion (splithttp/dialer.go:78-95) determines the HTTP version:
func decideHTTPVersion(tlsConfig *tls.Config, realityConfig *reality.Config) string {
if realityConfig != nil { return "2" }
if tlsConfig == nil { return "1.1" }
if len(tlsConfig.NextProtocol) != 1 { return "2" }
if tlsConfig.NextProtocol[0] == "http/1.1" { return "1.1" }
if tlsConfig.NextProtocol[0] == "h3" { return "3" }
return "2"
}Dial Flow
Connection Architecture
flowchart TD
subgraph "Client Side"
APP[Application Write]
PIPE[Pipe Buffer]
UPQ[Upload Goroutine]
DL[Download Reader]
end
subgraph "HTTP Layer"
POST1["POST /path/session/0"]
POST2["POST /path/session/1"]
POST3["POST /path/session/N"]
GET["GET /path/session (SSE)"]
end
subgraph "Server Side"
UQ[Upload Queue + Heap]
SRV[Server Handler]
RSP[Response Writer]
end
APP --> PIPE --> UPQ
UPQ --> POST1 & POST2 & POST3
POST1 & POST2 & POST3 --> UQ --> SRV
SRV --> RSP --> GET --> DLClient Setup
The Dial function (splithttp/dialer.go:247-476) orchestrates the connection:
- URL construction: scheme + host + path + query (
dialer.go:257-276) - HTTP client: Obtained from
getHTTPClientwhich manages Xmux connection pooling (dialer.go:278) - Mode selection: Auto or explicit (
dialer.go:280-289) - Session ID: UUID generated per connection (except
stream-onemode) (dialer.go:291-295)
Packet Upload (packet-up mode)
For packet-up mode, writes are buffered through a pipe and dispatched as numbered POST requests (splithttp/dialer.go:396-472):
go func() {
var seq int64
for {
// Read batched data from upload pipe
chunk, err := uploadPipeReader.ReadMultiBuffer()
// POST with sequence number
go httpClient.PostPacket(ctx, url.String(), sessionId, seqStr, &chunk, ...)
seq += 1
}
}()Key parameters:
- scMaxEachPostBytes: Maximum payload per POST (default 1MB, randomizable range)
- scMinPostsIntervalMs: Minimum delay between POSTs (default 30ms, randomizable)
Stream Upload (stream-up mode)
In stream-up mode, a single long-lived POST carries all upload data via httpClient.OpenStream (dialer.go:385-394).
Download Stream
For all modes except stream-one, a GET request opens a streaming response (dialer.go:376-384):
conn.reader, conn.remoteAddr, conn.localAddr, err =
httpClient2.OpenStream(ctx, requestURL2.String(), sessionId, nil, false)The response body becomes the reader side of the splitConn.
Separate Download Settings
DownloadSettings allows the download stream to use a completely different server/TLS/transport configuration (dialer.go:302-339). This enables setups where upload goes through one CDN edge and download through another.
HTTP Client Implementation
DefaultDialerClient
DefaultDialerClient (splithttp/client.go:31-39) implements DialerClient:
type DefaultDialerClient struct {
transportConfig *Config
client *http.Client
httpVersion string
uploadRawPool *sync.Pool
dialUploadConn func(ctxInner context.Context) (net.Conn, error)
}HTTP Transport Creation
createHTTPClient (splithttp/dialer.go:97-241) creates the appropriate http.RoundTripper:
- HTTP/3:
http3.Transportwith QUIC dial, configurable keepalive (dialer.go:145-200) - HTTP/2:
http2.Transportwith customDialTLSContext(dialer.go:201-214) - HTTP/1.1: Standard
http.TransportwithDisableKeepAlives: true(chunked downloads are buggy with keep-alives) (dialer.go:215-228)
H1 Connection Pooling
For HTTP/1.1 POST uploads, raw TCP connections are pooled via sync.Pool (splithttp/client.go:218-262):
uploadConn = c.uploadRawPool.Get()
// ... write request ...
c.uploadRawPool.Put(uploadConn)The H1Conn wrapper (splithttp/h1_conn.go) tracks unread responses to support HTTP/1.1 pipelining.
OpenStream
OpenStream (splithttp/client.go:45-117) uses httptrace.ClientTrace to capture the actual remote/local addresses once the TCP connection is established:
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
GotConn: func(connInfo httptrace.GotConnInfo) {
remoteAddr = connInfo.Conn.RemoteAddr()
localAddr = connInfo.Conn.LocalAddr()
gotConn.Close()
},
})Server Flow
Request Handler
requestHandler.ServeHTTP (splithttp/hub.go:90-396) routes requests based on type:
- Host/path validation (
hub.go:91-101) - Padding validation (
hub.go:131-138) - Session/sequence extraction (
hub.go:140) - Upload requests (POST with session ID + seq): payload goes to
uploadQueue.Push(hub.go:207-343) - Download requests (GET or stream-one): opens streaming response (
hub.go:345-391)
Session Management
Sessions are stored in a sync.Map and managed via upsertSession (splithttp/hub.go:50-88):
func (h *requestHandler) upsertSession(sessionId string) *httpSession {
// Fast path: Load from sync.Map
// Slow path: Create new with mutex
s := &httpSession{
uploadQueue: NewUploadQueue(maxBufferedPosts),
isFullyConnected: done.New(),
}
// Auto-reap after 30 seconds if GET never connects
go func() {
time.Sleep(30 * time.Second)
shouldReap.Close()
}()
}Upload Queue (Packet Reassembly)
The uploadQueue (splithttp/upload_queue.go) is a priority queue that reorders out-of-order POST payloads by sequence number:
type uploadQueue struct {
pushedPackets chan Packet
heap uploadHeap // min-heap by Seq
nextSeq uint64
}The Read method (upload_queue.go:85-143) delivers data in sequence order:
- Wait for packets on the channel
- Push to heap
- Pop packets with
Seq == nextSeq - If the next packet is out of order, wait for more
- Constrain heap size to
maxPacketsto prevent memory exhaustion
Streaming Response
For download streams, the server sets anti-buffering headers (hub.go:354-363):
writer.Header().Set("X-Accel-Buffering", "no") // nginx
writer.Header().Set("Cache-Control", "no-store") // CDNs
writer.Header().Set("Content-Type", "text/event-stream") // SSE hintUplink Data Placement
Upload data can be placed in different parts of the HTTP request (hub.go:196-205, config.go:127-132):
| Placement | Description |
|---|---|
body (default) | Standard POST body |
header | Base64 in custom headers (chunked: X-Data-0, X-Data-1, ...) |
cookie | Base64 in cookies (chunked: data_0, data_1, ...) |
Connection Multiplexing (Xmux)
The Xmux system (splithttp/mux.go) pools HTTP connections across multiple proxy sessions:
- XmuxManager: Manages a pool of
XmuxClientinstances - XmuxClient: Wraps a
DialerClient(HTTP connection) with usage tracking - Config options:
MaxConcurrency,MaxConnections,CMaxReuseTimes,HMaxRequestTimes,HMaxReusableSecs,HKeepAlivePeriod
When an XmuxClient exceeds its request limit or age, a new HTTP connection is created automatically (dialer.go:448-450).
Padding System
SplitHTTP includes an extensive padding system for traffic obfuscation:
X-Padding
Configurable random padding added to requests and responses (splithttp/xpadding.go):
- XPaddingBytes: Range for padding length (randomized per request)
- Placement options: header, cookie, query, body
- Obfuscation mode: When
XPaddingObfsModeis true, padding is placed per-config rules with custom methods
Session/Seq Placement
Session IDs and sequence numbers can be placed in different locations (config.go:162-239):
| Placement | Session Example | Seq Example |
|---|---|---|
path (default) | /base/uuid/ | /base/uuid/0 |
header | X-Session: uuid | X-Seq: 0 |
query | ?x_session=uuid | ?x_seq=0 |
cookie | Cookie: x_session=uuid | Cookie: x_seq=0 |
Listener Setup
ListenXH (splithttp/hub.go:435-533) supports three listener types:
- TCP (HTTP/1.1 + h2c): Standard
http.ServerwithSetUnencryptedHTTP2(true)(hub.go:516-529) - QUIC (HTTP/3):
quic.ListenEarly+http3.Server(hub.go:467-490) - Unix Domain Socket: For local proxy chains (
hub.go:458-466)
TLS and REALITY can wrap the TCP listener (hub.go:503-511).
Wire Format Examples
Packet-Up Mode
Client -> Server (upload, repeated):
POST /path/session-uuid/0 HTTP/1.1
Content-Length: 65536
X-Padding: <random>
[payload bytes, seq 0]
POST /path/session-uuid/1 HTTP/1.1
Content-Length: 32768
X-Padding: <random>
[payload bytes, seq 1]
Client <- Server (download, single long-lived):
GET /path/session-uuid HTTP/1.1
HTTP/1.1 200 OK
X-Accel-Buffering: no
Content-Type: text/event-stream
Cache-Control: no-store
[streaming response bytes...]Stream-One Mode
POST /path/ HTTP/2
Content-Type: application/grpc
[bidirectional streaming, upload in request body, download in response body]Implementation Notes
- Name: Internally called "XHTTP" in log messages, registered as "splithttp" for backward compatibility.
- HTTP/1.1 keep-alive disabled: Chunked transfer downloads are buggy with keep-alives and custom dial contexts, so HTTP/1.1 disables keep-alives (
dialer.go:225). - scMaxEachPostBytes minimum: Must be larger than
buf.Size(default ~8KB) or the code panics (dialer.go:399-401). - Upload pipe buffering: Multiple
Writecalls are automatically batched into larger POST requests through the pipe buffer, which is critical for bandwidth (dialer.go:439-441). - 30-second session TTL: If a GET request does not arrive within 30 seconds of session creation, the session is reaped (
hub.go:74-77). - Browser dialer:
BrowserDialerClient(splithttp/browser_client.go) uses the browser's fetch API for environments without direct network access. - Heap overflow protection: The upload queue limits its heap size to
maxPackets. If exceeded, the connection is torn down (upload_queue.go:127-131). - context.WithoutCancel: HTTP requests use
context.WithoutCancel(ctx)to prevent request cancellation from killing the underlying HTTP connection prematurely (client.go:62,client.go:134). - FakePacketConn: When QUIC needs a TCP connection (e.g., QUIC-over-TCP),
FakePacketConnwraps it (dialer.go:191).