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):
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 版本选择
decideHTTPVersion(splithttp/dialer.go:78-95)确定 HTTP 版本:
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"
}拨号流程
连接架构
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)编排连接:
- URL 构建:scheme + host + path + query(
dialer.go:257-276) - HTTP 客户端:从管理 Xmux 连接池的
getHTTPClient获取(dialer.go:278) - 模式选择:自动或显式(
dialer.go:280-289) - 会话 ID:每个连接生成 UUID(
stream-one模式除外)(dialer.go:291-295)
数据包上传(packet-up 模式)
在 packet-up 模式下,写入通过管道缓冲并作为编号的 POST 请求分发(splithttp/dialer.go:396-472):
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):
conn.reader, conn.remoteAddr, conn.localAddr, err =
httpClient2.OpenStream(ctx, requestURL2.String(), sessionId, nil, false)响应体成为 splitConn 的 reader 端。
独立下载设置
DownloadSettings 允许下载流使用完全不同的服务器/TLS/传输配置(dialer.go:302-339)。这使得上传通过一个 CDN 边缘节点、下载通过另一个的部署成为可能。
HTTP 客户端实现
DefaultDialerClient
DefaultDialerClient(splithttp/client.go:31-39)实现 DialerClient:
type DefaultDialerClient struct {
transportConfig *Config
client *http.Client
httpVersion string
uploadRawPool *sync.Pool
dialUploadConn func(ctxInner context.Context) (net.Conn, error)
}HTTP 传输创建
createHTTPClient(splithttp/dialer.go:97-241)创建适当的 http.RoundTripper:
- HTTP/3:
http3.Transport配合 QUIC 拨号,可配置 keepalive(dialer.go:145-200) - HTTP/2:
http2.Transport配合自定义DialTLSContext(dialer.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):
uploadConn = c.uploadRawPool.Get()
// ... 写入请求 ...
c.uploadRawPool.Put(uploadConn)H1Conn 包装器(splithttp/h1_conn.go)跟踪未读响应以支持 HTTP/1.1 流水线。
OpenStream
OpenStream(splithttp/client.go:45-117)使用 httptrace.ClientTrace 在 TCP 连接建立后捕获实际的远程/本地地址:
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
GotConn: func(connInfo httptrace.GotConnInfo) {
remoteAddr = connInfo.Conn.RemoteAddr()
localAddr = connInfo.Conn.LocalAddr()
gotConn.Close()
},
})服务端流程
请求处理器
requestHandler.ServeHTTP(splithttp/hub.go:90-396)根据类型路由请求:
- Host/路径验证(
hub.go:91-101) - 填充验证(
hub.go:131-138) - 会话/序列号提取(
hub.go:140) - 上传请求(带会话 ID + seq 的 POST):负载进入
uploadQueue.Push(hub.go:207-343) - 下载请求(GET 或 stream-one):打开流式响应(
hub.go:345-391)
会话管理
会话存储在 sync.Map 中,通过 upsertSession(splithttp/hub.go:50-88)管理:
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()
}()
}上传队列(数据包重组)
uploadQueue(splithttp/upload_queue.go)是一个优先队列,按序列号重新排序乱序的 POST 负载:
type uploadQueue struct {
pushedPackets chan Packet
heap uploadHeap // 按 Seq 的最小堆
nextSeq uint64
}Read 方法(upload_queue.go:85-143)按顺序递交数据:
- 在通道上等待数据包
- 推入堆中
- 弹出
Seq == nextSeq的数据包 - 如果下一个数据包乱序,等待更多数据包
- 限制堆大小为
maxPackets以防止内存耗尽
流式响应
对于下载流,服务端设置防缓冲头部(hub.go:354-363):
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-205,config.go:127-132):
| 放置位置 | 描述 |
|---|---|
body(默认) | 标准 POST 正文 |
header | Base64 编码在自定义头部中(分块:X-Data-0、X-Data-1、...) |
cookie | Base64 编码在 cookie 中(分块:data_0、data_1、...) |
连接多路复用(Xmux)
Xmux 系统(splithttp/mux.go)在多个代理会话间池化 HTTP 连接:
- XmuxManager:管理
XmuxClient实例池 - XmuxClient:包装带使用量跟踪的
DialerClient(HTTP 连接) - 配置选项:
MaxConcurrency、MaxConnections、CMaxReuseTimes、HMaxRequestTimes、HMaxReusableSecs、HKeepAlivePeriod
当 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 |
header | X-Session: uuid | X-Seq: 0 |
query | ?x_session=uuid | ?x_seq=0 |
cookie | Cookie: x_session=uuid | Cookie: x_seq=0 |
监听器设置
ListenXH(splithttp/hub.go:435-533)支持三种监听器类型:
- TCP(HTTP/1.1 + h2c):标准
http.Server设置SetUnencryptedHTTP2(true)(hub.go:516-529) - QUIC(HTTP/3):
quic.ListenEarly+http3.Server(hub.go:467-490) - 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)。 - 浏览器拨号器:
BrowserDialerClient(splithttp/browser_client.go)使用浏览器的 fetch API,用于无法直接访问网络的环境。 - 堆溢出保护:上传队列将堆大小限制为
maxPackets。超过时连接将被断开(upload_queue.go:127-131)。 - context.WithoutCancel:HTTP 请求使用
context.WithoutCancel(ctx)防止请求取消过早终止底层 HTTP 连接(client.go:62,client.go:134)。 - FakePacketConn:当 QUIC 需要 TCP 连接(例如 QUIC-over-TCP)时,
FakePacketConn对其进行包装(dialer.go:191)。