Skip to content

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):

go
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):

ModeUploadDownloadUse Case
stream-oneSingle bidirectional streamSame streamFull duplex, like WebSocket
stream-up + stream-downStreaming POSTStreaming GETSeparate streams, CDN-friendly
packet-up + stream-downSequenced POST packetsStreaming GETMost CDN-compatible (default)

Auto-selection logic:

  • Default: packet-up
  • With REALITY: stream-one (or stream-up if DownloadSettings exists)

HTTP Version Selection ​

decideHTTPVersion (splithttp/dialer.go:78-95) determines the HTTP version:

go
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 ​

mermaid
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 --> DL

Client Setup ​

The Dial function (splithttp/dialer.go:247-476) orchestrates the connection:

  1. URL construction: scheme + host + path + query (dialer.go:257-276)
  2. HTTP client: Obtained from getHTTPClient which manages Xmux connection pooling (dialer.go:278)
  3. Mode selection: Auto or explicit (dialer.go:280-289)
  4. Session ID: UUID generated per connection (except stream-one mode) (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
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):

go
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:

go
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.Transport with QUIC dial, configurable keepalive (dialer.go:145-200)
  • HTTP/2: http2.Transport with custom DialTLSContext (dialer.go:201-214)
  • HTTP/1.1: Standard http.Transport with DisableKeepAlives: 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):

go
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:

go
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:

  1. Host/path validation (hub.go:91-101)
  2. Padding validation (hub.go:131-138)
  3. Session/sequence extraction (hub.go:140)
  4. Upload requests (POST with session ID + seq): payload goes to uploadQueue.Push (hub.go:207-343)
  5. 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):

go
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:

go
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:

  1. Wait for packets on the channel
  2. Push to heap
  3. Pop packets with Seq == nextSeq
  4. If the next packet is out of order, wait for more
  5. Constrain heap size to maxPackets to prevent memory exhaustion

Streaming Response ​

For download streams, the server sets anti-buffering headers (hub.go:354-363):

go
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 hint

Upload data can be placed in different parts of the HTTP request (hub.go:196-205, config.go:127-132):

PlacementDescription
body (default)Standard POST body
headerBase64 in custom headers (chunked: X-Data-0, X-Data-1, ...)
cookieBase64 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 XmuxClient instances
  • 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 XPaddingObfsMode is 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):

PlacementSession ExampleSeq Example
path (default)/base/uuid//base/uuid/0
headerX-Session: uuidX-Seq: 0
query?x_session=uuid?x_seq=0
cookieCookie: x_session=uuidCookie: x_seq=0

Listener Setup ​

ListenXH (splithttp/hub.go:435-533) supports three listener types:

  1. TCP (HTTP/1.1 + h2c): Standard http.Server with SetUnencryptedHTTP2(true) (hub.go:516-529)
  2. QUIC (HTTP/3): quic.ListenEarly + http3.Server (hub.go:467-490)
  3. 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 Write calls 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), FakePacketConn wraps it (dialer.go:191).

Technical analysis for re-implementation purposes.