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 (или stream-up, если существуют DownloadSettings)

Выбор версии HTTP

decideHTTPVersion (splithttp/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"
}

Процесс Dial

Архитектура соединения

mermaid
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) управляет соединением:

  1. Построение URL: схема + хост + путь + запрос (dialer.go:257-276)
  2. HTTP-клиент: Получается из getHTTPClient, который управляет пулом соединений Xmux (dialer.go:278)
  3. Выбор режима: Автоматический или явный (dialer.go:280-289)
  4. ID сессии: UUID, генерируемый для каждого соединения (кроме режима stream-one) (dialer.go:291-295)

Пакетная выгрузка (режим packet-up)

В режиме packet-up записи буферизуются через pipe и отправляются как нумерованные POST-запросы (splithttp/dialer.go:396-472):

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

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

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

go
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-соединения:

go
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) маршрутизирует запросы по типу:

  1. Проверка хоста/пути (hub.go:91-101)
  2. Проверка дополнения (hub.go:131-138)
  3. Извлечение сессии/последовательности (hub.go:140)
  4. Запросы выгрузки (POST с ID сессии + seq): полезная нагрузка поступает в uploadQueue.Push (hub.go:207-343)
  5. Запросы загрузки (GET или stream-one): открытие потокового ответа (hub.go:345-391)

Управление сессиями

Сессии хранятся в sync.Map и управляются через upsertSession (splithttp/hub.go:50-88):

go
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-данные по номеру последовательности:

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-205, config.go:127-132):

РазмещениеОписание
body (по умолчанию)Стандартное тело POST
headerBase64 в произвольных заголовках (по частям: X-Data-0, X-Data-1, ...)
cookieBase64 в 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
headerX-Session: uuidX-Seq: 0
query?x_session=uuid?x_seq=0
cookieCookie: x_session=uuidCookie: x_seq=0

Настройка слушателя

ListenXH (splithttp/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.Server (hub.go:467-490)
  3. 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).

Технический анализ для целей повторной реализации.