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).

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