Skip to content

Транспорт HTTPUpgrade

Введение

HTTPUpgrade -- это легковесный транспорт, который имитирует рукопожатие WebSocket (HTTP/1.1 Upgrade: websocket), но не использует фреймирование WebSocket. После завершения рукопожатия обновления соединение становится сырым TCP-потоком. Это делает его проще и эффективнее полного транспорта WebSocket, при этом позволяя проходить через промежуточные устройства, ожидающие HTTP Upgrade. Он поддерживает TLS, отпечатки uTLS, протокол PROXY и ранние данные.

Регистрация протокола

Зарегистрирован как "httpupgrade" (transport/internet/httpupgrade/httpupgrade.go:3):

go
const protocolName = "httpupgrade"
  • Дайлер: httpupgrade/dialer.go:134-136
  • Слушатель: httpupgrade/hub.go:165-167
  • Конфигурация: httpupgrade/config.go:19-23

Процесс Dial

Рукопожатие

dialhttpUpgrade (httpupgrade/dialer.go:46-115) выполняет ручное HTTP Upgrade:

go
func dialhttpUpgrade(ctx context.Context, dest net.Destination,
    streamSettings *internet.MemoryStreamConfig) (net.Conn, error) {
    // 1. Dial сырого TCP через internet.DialSystem
    pconn, _ := internet.DialSystem(ctx, dest, streamSettings.SocketSettings)

    // 2. Опциональная обертка TLS
    if tConfig != nil {
        // Применение TLS с отпечатками uTLS или стандартным Go TLS
        conn = tls.UClient(pconn, tlsConfig, fingerprint) // или tls.Client
    }

    // 3. Построение HTTP-запроса
    req := &http.Request{
        Method: http.MethodGet,
        URL:    &requestURL,
        Header: make(http.Header),
    }
    req.Header.Set("Connection", "Upgrade")
    req.Header.Set("Upgrade", "websocket")

    // 4. Запись запроса напрямую в соединение
    req.Write(conn)

    // 5. Обертка ConnRF для чтения ответа
    connRF := &ConnRF{Conn: conn, Req: req, First: true}
    return connRF, nil
}

В отличие от транспорта WebSocket, здесь НЕ используется gorilla/websocket. HTTP-запрос записывается напрямую в TCP-поток, а ответ разбирается вручную.

Чтение ответа (ConnRF)

Структура ConnRF (httpupgrade/dialer.go:19-44) перехватывает первый вызов Read для разбора HTTP-ответа:

go
type ConnRF struct {
    net.Conn
    Req   *http.Request
    First bool
}

func (c *ConnRF) Read(b []byte) (int, error) {
    if c.First {
        c.First = false
        reader := bufio.NewReaderSize(c.Conn, len(b))
        resp, err := http.ReadResponse(reader, c.Req)
        if resp.Status != "101 Switching Protocols" ||
            strings.ToLower(resp.Header.Get("Upgrade")) != "websocket" ||
            strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
            return 0, errors.New("unrecognized reply")
        }
        // Чтение буферизованных байтов из bufreader
        return reader.Read(b[:reader.Buffered()])
    }
    return c.Conn.Read(b)
}

Ключевое решение: bufio.Reader имеет размер точно len(b), чтобы гарантировать, что любые данные после HTTP-заголовков ответа (которые могут прийти в том же TCP-сегменте) будут захвачены и возвращены при этом первом чтении.

Ранние данные

Когда Ed == 0 (по умолчанию), ответ читается сразу при dial для подтверждения успешного обновления (dialer.go:107-112). Когда Ed > 0, чтение ответа откладывается до первого вызова Read приложением, что позволяет быстрее завершить dial.

Обработка заголовков

Пользовательские заголовки добавляются через функцию AddHeader (dialer.go:120-122), которая обходит каноникализацию MIME-заголовков Go:

go
func AddHeader(header http.Header, key, value string) {
    header[key] = append(header[key], value)
}

Это сохраняет точный регистр имен заголовков (например, "Web*S*ocket" вместо "Websocket").

Процесс Listen

Архитектура сервера

ListenHTTPUpgrade (httpupgrade/hub.go:115-163) создает сырой TCP-слушатель (не HTTP-сервер):

go
func ListenHTTPUpgrade(ctx context.Context, address net.Address, port net.Port,
    streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
    // Создание TCP/Unix-слушателя через internet.ListenSystem
    // Опциональная обертка TLS
    serverInstance := &server{
        config:         transportConfiguration,
        addConn:        addConn,
        innnerListener: listener,
    }
    go serverInstance.keepAccepting()
    return serverInstance, nil
}

В отличие от транспорта WebSocket (который использует http.Server), HTTPUpgrade принимает сырые соединения и вручную разбирает HTTP-запросы.

Обработка соединений

server.Handle (httpupgrade/hub.go:34-42) и server.upgrade (hub.go:45-103):

go
func (s *server) upgrade(conn net.Conn) (stat.Connection, error) {
    connReader := bufio.NewReader(conn)
    req, _ := http.ReadRequest(connReader)

    // Проверка хоста и пути
    if len(s.config.Host) > 0 && !internet.IsValidHTTPHost(host, s.config.Host) {
        return nil, errors.New("bad host")
    }
    if req.URL.Path != path {
        return nil, errors.New("bad path")
    }

    // Проверка заголовков обновления
    if connection != "upgrade" || upgrade != "websocket" {
        return nil, errors.New("unrecognized request")
    }

    // Отправка ответа 101
    resp := &http.Response{
        Status:     "101 Switching Protocols",
        StatusCode: 101,
        Proto:      "HTTP/1.1",
        ProtoMajor: 1,
        ProtoMinor: 1,
        Header:     http.Header{},
    }
    resp.Header.Set("Connection", "Upgrade")
    resp.Header.Set("Upgrade", "websocket")
    resp.Write(conn)

    return stat.Connection(newConnection(conn, remoteAddr)), nil
}

X-Forwarded-For

Реальный IP клиента извлекается из заголовков переадресации (hub.go:83-100) с учетом настройки TrustedXForwardedFor в параметрах сокета.

Формат данных на проводе

Клиент -> Сервер:
GET /path HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
User-Agent: Mozilla/5.0 ...
[произвольные заголовки]

Сервер -> Клиент:
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket

[сырой двунаправленный TCP-поток]

После рукопожатия данные передаются как сырые байты -- без фреймирования WebSocket, без префиксов длины, без маскирования. Это ключевое отличие от полного транспорта WebSocket.

mermaid
sequenceDiagram
    participant Client as Клиент
    participant Server as Сервер

    Client->>Server: GET /path HTTP/1.1\r\nUpgrade: websocket\r\n...
    Server->>Client: HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n
    Note over Client,Server: Начинается сырой TCP-поток
    Client->>Server: [байты данных прокси]
    Server->>Client: [байты данных прокси]

Обертка соединения

Структура connection (httpupgrade/connection.go:5-19) минимальна:

go
type connection struct {
    net.Conn
    remoteAddr net.Addr
}

func (c *connection) RemoteAddr() net.Addr {
    return c.remoteAddr
}

Она переопределяет только RemoteAddr() для поддержки X-Forwarded-For. Все остальные методы делегируются базовому net.Conn.

Поддержка протокола PROXY

И клиент, и сервер поддерживают протокол PROXY:

  • Сервер: Наследуется из настроек сокета. Когда AcceptProxyProtocol равен true, базовый internet.ListenSystem оборачивает слушатель (hub.go:117-122, hub.go:145-147).
  • Объединение конфигурации: AcceptProxyProtocol из конфигурации транспорта ИЛИ настроек сокета активирует протокол PROXY (hub.go:121).

Параметры конфигурации

Из httpupgrade/config.go:

  • Path: URL-путь, нормализованный с ведущим / (config.go:8-17)
  • Host: Ожидаемый заголовок Host для серверной проверки
  • Header: Произвольные HTTP-заголовки (карта)
  • AcceptProxyProtocol: Включение протокола PROXY на слушателе
  • Ed: Размер ранних данных. При ненулевом значении разбор ответа откладывается.

Примечания по реализации

  • Без фреймирования WebSocket: После ответа 101 Switching Protocols данные отправляются как сырые байты. Это устраняет накладные расходы WebSocket (2-14 байт на фрейм) и требование маскирования.
  • Без зависимости от gorilla: В отличие от транспорта WebSocket, HTTPUpgrade не использует gorilla/websocket. Он формирует и разбирает HTTP-сообщения напрямую.
  • Сервер без состояния: Каждое соединение обрабатывается независимо. Сервер не поддерживает состояние сессии.
  • Сохранение регистра заголовков: Функция AddHeader обходит textproto.CanonicalMIMEHeaderKey Go, позволяя использовать точный регистр имен заголовков. Это может помочь соответствовать ожиданиям конкретных CDN или промежуточных устройств.
  • Трюк с размером bufio: ConnRF.Read создает bufio.ReaderSize(conn, len(b)), ограниченный размером буфера вызывающего. Это гарантирует, что буферизованный reader никогда не прочитает больше, чем может быть возвращено, предотвращая потерю данных из-за двойной буферизации.
  • TLS на слушателе: В отличие от gRPC (который использует систему учетных данных gRPC), HTTPUpgrade оборачивает net.Listener напрямую через tls.NewListener (hub.go:149-153).
  • Сравнение с WebSocket: HTTPUpgrade строго проще и эффективнее для проксирования. Транспорт WebSocket следует предпочитать только тогда, когда нужны специфические функции WebSocket (heartbeat-пинги, посообщенное сжатие, браузерный дайлер).

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