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.