Транспорт 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(илиstream-up, если существуютDownloadSettings)
Выбор версии 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"
}Процесс Dial
Архитектура соединения
flowchart TD
subgraph "Сторона клиента"
APP[Запись приложения]
PIPE[Буфер Pipe]
UPQ[Горутина выгрузки]
DL[Reader загрузки]
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[Response Writer]
end
APP --> PIPE --> UPQ
UPQ --> POST1 & POST2 & POST3
POST1 & POST2 & POST3 --> UQ --> SRV
SRV --> RSP --> GET --> DLНастройка клиента
Функция Dial (splithttp/dialer.go:247-476) управляет соединением:
- Построение URL: схема + хост + путь + запрос (
dialer.go:257-276) - HTTP-клиент: Получается из
getHTTPClient, который управляет пулом соединений Xmux (dialer.go:278) - Выбор режима: Автоматический или явный (
dialer.go:280-289) - ID сессии: UUID, генерируемый для каждого соединения (кроме режима
stream-one) (dialer.go:291-295)
Пакетная выгрузка (режим packet-up)
В режиме packet-up записи буферизуются через pipe и отправляются как нумерованные POST-запросы (splithttp/dialer.go:396-472):
go func() {
var seq int64
for {
// Чтение пакетных данных из pipe выгрузки
chunk, err := uploadPipeReader.ReadMultiBuffer()
// POST с номером последовательности
go httpClient.PostPacket(ctx, url.String(), sessionId, seqStr, &chunk, ...)
seq += 1
}
}()Ключевые параметры:
- scMaxEachPostBytes: Максимальный размер полезной нагрузки на POST (по умолчанию 1 МБ, рандомизируемый диапазон)
- scMinPostsIntervalMs: Минимальная задержка между POST-запросами (по умолчанию 30 мс, рандомизируемая)
Потоковая выгрузка (режим 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)Тело ответа становится стороной reader для splitConn.
Отдельные настройки загрузки
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 dial, настраиваемым keepalive (dialer.go:145-200) - HTTP/2:
http2.Transportс пользовательскимDialTLSContext(dialer.go:201-214) - HTTP/1.1: Стандартный
http.TransportсDisableKeepAlives: true(chunked-загрузки работают нестабильно с keep-alives) (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) маршрутизирует запросы по типу:
- Проверка хоста/пути (
hub.go:91-101) - Проверка дополнения (
hub.go:131-138) - Извлечение сессии/последовательности (
hub.go:140) - Запросы выгрузки (POST с ID сессии + seq): полезная нагрузка поступает в
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(),
}
// Автоматическое удаление через 30 секунд, если GET не подключается
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 в cookies (по частям: 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).
Система дополнения (Padding)
SplitHTTP включает обширную систему дополнения для обфускации трафика:
X-Padding
Настраиваемое случайное дополнение, добавляемое к запросам и ответам (splithttp/xpadding.go):
- XPaddingBytes: Диапазон длины дополнения (рандомизируется для каждого запроса)
- Варианты размещения: header, cookie, query, body
- Режим обфускации: Когда
XPaddingObfsModeравен true, дополнение размещается по правилам конфигурации с произвольными методами
Размещение Session/Seq
Идентификаторы сессий и номера последовательностей могут размещаться в разных местах (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 Domain Socket: Для локальных цепочек прокси (
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: <случайное>
[байты полезной нагрузки, seq 0]
POST /path/session-uuid/1 HTTP/1.1
Content-Length: 32768
X-Padding: <случайное>
[байты полезной нагрузки, 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" для обратной совместимости.
- Keep-alive HTTP/1.1 отключен: Chunked-загрузки работают нестабильно с keep-alives и пользовательскими контекстами dial, поэтому HTTP/1.1 отключает keep-alives (
dialer.go:225). - Минимум scMaxEachPostBytes: Должен быть больше
buf.Size(по умолчанию ~8 КБ), иначе код вызовет panic (dialer.go:399-401). - Буферизация pipe выгрузки: Несколько вызовов
Writeавтоматически объединяются в более крупные POST-запросы через буфер pipe, что критически важно для пропускной способности (dialer.go:439-441). - TTL сессии 30 секунд: Если GET-запрос не поступает в течение 30 секунд после создания сессии, сессия удаляется (
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-поверх-TCP),
FakePacketConnоборачивает его (dialer.go:191).