Skip to content

SplitHTTP(XHTTP)传输

简介

SplitHTTP(内部称为 XHTTP)是一种多功能的基于 HTTP 的传输协议,将双向代理流量拆分到不同的 HTTP 请求中。下载(服务端到客户端)使用长连接 GET 响应流。上传(客户端到服务端)使用流式 POST 请求或带重组的有序独立 POST 请求。它支持 HTTP/1.1、HTTP/2(h2c 和 TLS)以及 HTTP/3(QUIC),还支持 REALITY、连接多路复用(Xmux)以及丰富的填充/混淆选项。

协议注册

"splithttp" 名称注册(transport/internet/splithttp/splithttp.go:3):

go
const protocolName = "splithttp"
  • 拨号器splithttp/dialer.go:243-245
  • 监听器splithttp/hub.go:564-566
  • 配置splithttp/config.go:296-300

运行模式

SplitHTTP 支持多种模式,通过 Mode 配置字段选择(splithttp/dialer.go:281-289):

模式上传下载使用场景
stream-one单个双向流同一流全双工,类似 WebSocket
stream-up + stream-down流式 POST流式 GET独立流,CDN 友好
packet-up + stream-down有序 POST 数据包流式 GET最大 CDN 兼容性(默认)

自动选择逻辑:

  • 默认:packet-up
  • 使用 REALITY 时:stream-one(如果存在 DownloadSettings 则为 stream-up

HTTP 版本选择

decideHTTPVersionsplithttp/dialer.go:78-95)确定 HTTP 版本:

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"
}

拨号流程

连接架构

mermaid
flowchart TD
    subgraph "客户端"
        APP[应用程序写入]
        PIPE[管道缓冲]
        UPQ[上传协程]
        DL[下载读取器]
    end

    subgraph "HTTP 层"
        POST1["POST /path/session/0"]
        POST2["POST /path/session/1"]
        POST3["POST /path/session/N"]
        GET["GET /path/session (SSE)"]
    end

    subgraph "服务端"
        UQ[上传队列 + 堆]
        SRV[服务端处理器]
        RSP[响应写入器]
    end

    APP --> PIPE --> UPQ
    UPQ --> POST1 & POST2 & POST3
    POST1 & POST2 & POST3 --> UQ --> SRV
    SRV --> RSP --> GET --> DL

客户端设置

Dial 函数(splithttp/dialer.go:247-476)编排连接:

  1. URL 构建:scheme + host + path + query(dialer.go:257-276
  2. HTTP 客户端:从管理 Xmux 连接池的 getHTTPClient 获取(dialer.go:278
  3. 模式选择:自动或显式(dialer.go:280-289
  4. 会话 ID:每个连接生成 UUID(stream-one 模式除外)(dialer.go:291-295

数据包上传(packet-up 模式)

packet-up 模式下,写入通过管道缓冲并作为编号的 POST 请求分发(splithttp/dialer.go:396-472):

go
go func() {
    var seq int64
    for {
        // 从上传管道读取批量数据
        chunk, err := uploadPipeReader.ReadMultiBuffer()
        // 带序列号的 POST
        go httpClient.PostPacket(ctx, url.String(), sessionId, seqStr, &chunk, ...)
        seq += 1
    }
}()

关键参数:

  • scMaxEachPostBytes:每个 POST 的最大负载(默认 1MB,可随机化范围)
  • scMinPostsIntervalMs:POST 之间的最小延迟(默认 30ms,可随机化)

流式上传(stream-up 模式)

stream-up 模式下,单个长连接 POST 通过 httpClient.OpenStream 承载所有上传数据(dialer.go:385-394)。

下载流

stream-one 外的所有模式,GET 请求打开流式响应(dialer.go:376-384):

go
conn.reader, conn.remoteAddr, conn.localAddr, err =
    httpClient2.OpenStream(ctx, requestURL2.String(), sessionId, nil, false)

响应体成为 splitConnreader 端。

独立下载设置

DownloadSettings 允许下载流使用完全不同的服务器/TLS/传输配置(dialer.go:302-339)。这使得上传通过一个 CDN 边缘节点、下载通过另一个的部署成为可能。

HTTP 客户端实现

DefaultDialerClient

DefaultDialerClientsplithttp/client.go:31-39)实现 DialerClient

go
type DefaultDialerClient struct {
    transportConfig *Config
    client          *http.Client
    httpVersion     string
    uploadRawPool   *sync.Pool
    dialUploadConn  func(ctxInner context.Context) (net.Conn, error)
}

HTTP 传输创建

createHTTPClientsplithttp/dialer.go:97-241)创建适当的 http.RoundTripper

  • HTTP/3http3.Transport 配合 QUIC 拨号,可配置 keepalive(dialer.go:145-200
  • HTTP/2http2.Transport 配合自定义 DialTLSContextdialer.go:201-214
  • HTTP/1.1:标准 http.Transport 设置 DisableKeepAlives: true(分块下载在 keep-alive 下有缺陷)(dialer.go:215-228

H1 连接池

对于 HTTP/1.1 POST 上传,原始 TCP 连接通过 sync.Pool 池化(splithttp/client.go:218-262):

go
uploadConn = c.uploadRawPool.Get()
// ... 写入请求 ...
c.uploadRawPool.Put(uploadConn)

H1Conn 包装器(splithttp/h1_conn.go)跟踪未读响应以支持 HTTP/1.1 流水线。

OpenStream

OpenStreamsplithttp/client.go:45-117)使用 httptrace.ClientTrace 在 TCP 连接建立后捕获实际的远程/本地地址:

go
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
    GotConn: func(connInfo httptrace.GotConnInfo) {
        remoteAddr = connInfo.Conn.RemoteAddr()
        localAddr = connInfo.Conn.LocalAddr()
        gotConn.Close()
    },
})

服务端流程

请求处理器

requestHandler.ServeHTTPsplithttp/hub.go:90-396)根据类型路由请求:

  1. Host/路径验证hub.go:91-101
  2. 填充验证hub.go:131-138
  3. 会话/序列号提取hub.go:140
  4. 上传请求(带会话 ID + seq 的 POST):负载进入 uploadQueue.Pushhub.go:207-343
  5. 下载请求(GET 或 stream-one):打开流式响应(hub.go:345-391

会话管理

会话存储在 sync.Map 中,通过 upsertSessionsplithttp/hub.go:50-88)管理:

go
func (h *requestHandler) upsertSession(sessionId string) *httpSession {
    // 快速路径:从 sync.Map 加载
    // 慢速路径:带互斥锁创建新会话
    s := &httpSession{
        uploadQueue:      NewUploadQueue(maxBufferedPosts),
        isFullyConnected: done.New(),
    }
    // 如果 GET 在 30 秒内未连接则自动回收
    go func() {
        time.Sleep(30 * time.Second)
        shouldReap.Close()
    }()
}

上传队列(数据包重组)

uploadQueuesplithttp/upload_queue.go)是一个优先队列,按序列号重新排序乱序的 POST 负载:

go
type uploadQueue struct {
    pushedPackets chan Packet
    heap          uploadHeap  // 按 Seq 的最小堆
    nextSeq       uint64
}

Read 方法(upload_queue.go:85-143)按顺序递交数据:

  1. 在通道上等待数据包
  2. 推入堆中
  3. 弹出 Seq == nextSeq 的数据包
  4. 如果下一个数据包乱序,等待更多数据包
  5. 限制堆大小为 maxPackets 以防止内存耗尽

流式响应

对于下载流,服务端设置防缓冲头部(hub.go:354-363):

go
writer.Header().Set("X-Accel-Buffering", "no")     // nginx
writer.Header().Set("Cache-Control", "no-store")     // CDN
writer.Header().Set("Content-Type", "text/event-stream")  // SSE 提示

上行数据放置

上传数据可以放在 HTTP 请求的不同部分(hub.go:196-205config.go:127-132):

放置位置描述
body(默认)标准 POST 正文
headerBase64 编码在自定义头部中(分块:X-Data-0X-Data-1、...)
cookieBase64 编码在 cookie 中(分块:data_0data_1、...)

连接多路复用(Xmux)

Xmux 系统(splithttp/mux.go)在多个代理会话间池化 HTTP 连接:

  • XmuxManager:管理 XmuxClient 实例池
  • XmuxClient:包装带使用量跟踪的 DialerClient(HTTP 连接)
  • 配置选项MaxConcurrencyMaxConnectionsCMaxReuseTimesHMaxRequestTimesHMaxReusableSecsHKeepAlivePeriod

XmuxClient 超过请求限制或超时后,自动创建新的 HTTP 连接(dialer.go:448-450)。

填充系统

SplitHTTP 包含一套丰富的流量混淆填充系统:

X-Padding

添加到请求和响应的可配置随机填充(splithttp/xpadding.go):

  • XPaddingBytes:填充长度范围(每次请求随机化)
  • 放置选项:header、cookie、query、body
  • 混淆模式:当 XPaddingObfsMode 为 true 时,按配置规则使用自定义方法放置填充

会话/序列号放置

会话 ID 和序列号可以放在不同位置(config.go:162-239):

放置位置会话示例序列号示例
path(默认)/base/uuid//base/uuid/0
headerX-Session: uuidX-Seq: 0
query?x_session=uuid?x_seq=0
cookieCookie: x_session=uuidCookie: x_seq=0

监听器设置

ListenXHsplithttp/hub.go:435-533)支持三种监听器类型:

  1. TCP(HTTP/1.1 + h2c):标准 http.Server 设置 SetUnencryptedHTTP2(true)hub.go:516-529
  2. QUIC(HTTP/3):quic.ListenEarly + http3.Serverhub.go:467-490
  3. Unix 域套接字:用于本地代理链路(hub.go:458-466

TLS 和 REALITY 可以包装 TCP 监听器(hub.go:503-511)。

线格式示例

Packet-Up 模式

客户端 -> 服务端(上传,重复):
POST /path/session-uuid/0 HTTP/1.1
Content-Length: 65536
X-Padding: <random>

[负载字节,seq 0]

POST /path/session-uuid/1 HTTP/1.1
Content-Length: 32768
X-Padding: <random>

[负载字节,seq 1]

客户端 <- 服务端(下载,单个长连接):
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

[流式响应字节...]

Stream-One 模式

POST /path/ HTTP/2
Content-Type: application/grpc

[双向流式传输,上传在请求体中,下载在响应体中]

实现说明

  • 名称:日志消息中内部称为"XHTTP",为向后兼容注册为"splithttp"。
  • HTTP/1.1 keep-alive 禁用:分块传输下载在 keep-alive 和自定义拨号上下文下存在缺陷,因此 HTTP/1.1 禁用了 keep-alive(dialer.go:225)。
  • scMaxEachPostBytes 最小值:必须大于 buf.Size(默认约 8KB),否则代码会 panic(dialer.go:399-401)。
  • 上传管道缓冲:多次 Write 调用通过管道缓冲自动批量合并为更大的 POST 请求,这对带宽至关重要(dialer.go:439-441)。
  • 30 秒会话 TTL:如果会话创建后 30 秒内 GET 请求未到达,会话将被回收(hub.go:74-77)。
  • 浏览器拨号器BrowserDialerClientsplithttp/browser_client.go)使用浏览器的 fetch API,用于无法直接访问网络的环境。
  • 堆溢出保护:上传队列将堆大小限制为 maxPackets。超过时连接将被断开(upload_queue.go:127-131)。
  • context.WithoutCancel:HTTP 请求使用 context.WithoutCancel(ctx) 防止请求取消过早终止底层 HTTP 连接(client.go:62client.go:134)。
  • FakePacketConn:当 QUIC 需要 TCP 连接(例如 QUIC-over-TCP)时,FakePacketConn 对其进行包装(dialer.go:191)。

用于重新实现目的的技术分析。