Skip to content

Транспорт WebSocket

Введение

Транспорт WebSocket оборачивает прокси-трафик в стандартные WebSocket-фреймы, заставляя его выглядеть как легитимный WebSocket-трафик для сетевых наблюдателей и промежуточных устройств. Он использует библиотеку gorilla/websocket как для клиента, так и для сервера, поддерживает TLS с отпечатками uTLS, ранние данные (0-RTT через заголовок Sec-WebSocket-Protocol) и опциональный браузерный дайлер для работы в контексте браузера.

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

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

go
const protocolName = "websocket"
  • Дайлер: websocket/dialer.go:42-44
  • Слушатель: websocket/hub.go:174-176
  • Конфигурация: websocket/config.go:33-37

Процесс Dial

Точка входа

websocket.Dial (websocket/dialer.go:21-40):

go
func Dial(ctx context.Context, dest net.Destination,
    streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) {
    if streamSettings.ProtocolSettings.(*Config).Ed > 0 {
        // Режим ранних данных: фактическое соединение откладывается до первого Write
        conn = &delayDialConn{...}
    } else {
        conn, err = dialWebSocket(ctx, dest, streamSettings, nil)
    }
    return stat.Connection(conn), nil
}

Детали WebSocket Dial

dialWebSocket (websocket/dialer.go:46-129) создает websocket.Dialer и выполняет обновление:

go
func dialWebSocket(ctx context.Context, dest net.Destination,
    streamSettings *internet.MemoryStreamConfig, ed []byte) (net.Conn, error) {
    wsSettings := streamSettings.ProtocolSettings.(*Config)

    dialer := &websocket.Dialer{
        NetDial: func(network, addr string) (net.Conn, error) {
            return internet.DialSystem(ctx, dest, streamSettings.SocketSettings)
        },
        ReadBufferSize:   4 * 1024,
        WriteBufferSize:  4 * 1024,
        HandshakeTimeout: time.Second * 8,
    }
    // ...
}

Ключевые аспекты:

  1. Системный dial: Callback NetDial делегирует вызов internet.DialSystem, применяя параметры сокета.
  2. TLS: Когда настроен TLS, protocol становится "wss", и устанавливается dialer.TLSClientConfig.
  3. Отпечатки uTLS: Когда установлен отпечаток, dialer.NetDialTLSContext переопределяется для использования tls.UClient с WebsocketHandshakeContext (принудительно устанавливает ALPN http/1.1) (websocket/dialer.go:66-87).
  4. Построение URI: ws:// или wss:// с хостом и нормализованным путем (websocket/dialer.go:90-94).
  5. Браузерный дайлер: Если доступен, полностью обходит обычное подключение (websocket/dialer.go:96-103).
  6. Заголовок Host: Приоритет: Host из конфигурации > TLS ServerName > адрес назначения (websocket/dialer.go:106-113).

Механизм ранних данных

Когда настроен Ed > 0, первый вызов Write инициирует фактическое WebSocket-соединение. Начальные байты отправляются в виде данных, закодированных в base64, в заголовке Sec-WebSocket-Protocol:

go
// websocket/dialer.go:114-117
if ed != nil {
    header.Set("Sec-WebSocket-Protocol", base64.RawURLEncoding.EncodeToString(ed))
}

Структура delayDialConn (websocket/dialer.go:131-184) реализует это отложенное соединение:

mermaid
sequenceDiagram
    participant App as Приложение
    participant DDC as delayDialConn
    participant WS as WebSocket-сервер

    App->>DDC: Write(data)
    Note over DDC: Первая запись инициирует dial
    alt len(data) <= Ed
        DDC->>WS: WebSocket Upgrade + данные в Sec-WebSocket-Protocol
        DDC-->>App: len(data), nil
    else len(data) > Ed
        DDC->>WS: WebSocket Upgrade (без ранних данных)
        DDC->>WS: Write(data) через WebSocket-фрейм
    end
    App->>DDC: Read(buf)
    Note over DDC: Блокируется до сигнала канала dialed
    DDC->>WS: Чтение из WebSocket

Сервер извлекает ранние данные из заголовка Sec-WebSocket-Protocol (websocket/hub.go:55-59):

go
if str := request.Header.Get("Sec-WebSocket-Protocol"); str != "" {
    if ed, err := base64.RawURLEncoding.DecodeString(replacer.Replace(str)); err == nil && len(ed) > 0 {
        extraReader = bytes.NewReader(ed)
        responseHeader.Set("Sec-WebSocket-Protocol", str)
    }
}

Процесс Listen

Настройка HTTP-сервера

ListenWS (websocket/hub.go:98-162) настраивает HTTP-сервер для обработки WebSocket-обновлений:

go
func ListenWS(ctx context.Context, address net.Address, port net.Port,
    streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
    // Создание net.Listener (TCP или Unix)
    // Опциональная обертка TLS
    l.server = http.Server{
        Handler: &requestHandler{
            host: wsSettings.Host,
            path: wsSettings.GetNormalizedPath(),
            ln:   l,
        },
        ReadHeaderTimeout: time.Second * 4,
        MaxHeaderBytes:    8192,
    }
    go l.server.Serve(l.listener)
    return l, err
}

Обработка запросов

requestHandler.ServeHTTP (websocket/hub.go:41-88) проверяет и обновляет соединения:

  1. Проверка хоста: Если настроен, проверяет request.Host по config.Host (hub.go:42-46)
  2. Проверка пути: Точное совпадение request.URL.Path с настроенным путем (hub.go:47-51)
  3. Извлечение ранних данных: Декодирование заголовка Sec-WebSocket-Protocol (hub.go:55-59)
  4. Обновление WebSocket: Использует upgrader.Upgrade из gorilla с разрешающим CheckOrigin (hub.go:32-39, hub.go:62)
  5. X-Forwarded-For: Извлечение реального IP клиента из заголовков переадресации с учетом настройки TrustedXForwardedFor (hub.go:68-85)

Глобальный upgrader (websocket/hub.go:32-39):

go
var upgrader = &websocket.Upgrader{
    ReadBufferSize:   0,
    WriteBufferSize:  0,
    HandshakeTimeout: time.Second * 4,
    CheckOrigin: func(r *http.Request) bool { return true },
}

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

Структура connection (websocket/connection.go:19-22) оборачивает *websocket.Conn:

go
type connection struct {
    conn       *websocket.Conn
    reader     io.Reader
    remoteAddr net.Addr
}

Реализация чтения

Чтение ориентировано на сообщения (websocket/connection.go:45-59): каждое WebSocket-сообщение читается полностью, и когда одно сообщение исчерпано, следующее извлекается через conn.NextReader():

go
func (c *connection) Read(b []byte) (int, error) {
    for {
        reader, err := c.getReader()
        // ...
        nBytes, err := reader.Read(b)
        if errors.Cause(err) == io.EOF {
            c.reader = nil  // сообщение исчерпано, получить следующее
            continue
        }
        return nBytes, err
    }
}

Если присутствует дополнительный reader (ранние данные), он потребляется первым.

Реализация записи

Записи создают двоичные WebSocket-сообщения (websocket/connection.go:75-80):

go
func (c *connection) Write(b []byte) (int, error) {
    if err := c.conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
        return 0, err
    }
    return len(b), nil
}

Механизм Heartbeat (Ping)

Когда настроен HeartbeatPeriod, горутина отправляет управляющие фреймы WebSocket Ping (websocket/connection.go:26-35):

go
func NewConnection(conn *websocket.Conn, remoteAddr net.Addr,
    extraReader io.Reader, heartbeatPeriod uint32) *connection {
    if heartbeatPeriod != 0 {
        go func() {
            for {
                time.Sleep(time.Duration(heartbeatPeriod) * time.Second)
                if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Time{}); err != nil {
                    break
                }
            }
        }()
    }
    // ...
}

Корректное закрытие

При закрытии отправляется WebSocket CloseMessage перед закрытием базового соединения (websocket/connection.go:89-101):

go
func (c *connection) Close() error {
    c.conn.WriteControl(websocket.CloseMessage,
        websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
        time.Now().Add(time.Second*5))
    c.conn.Close()
    // ...
}

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

Транспорт WebSocket использует стандартное фреймирование RFC 6455:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <hash>
Sec-WebSocket-Protocol: <base64 ранние данные>  (если Ed > 0)

[Двоичные WebSocket-фреймы с данными прокси]

Каждый вызов Write создает одно двоичное WebSocket-сообщение. Каждый вызов Read читает из текущего сообщения до EOF, затем переходит к следующему.

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

Из websocket/config.go:

  • Path: Путь WebSocket, нормализованный с ведущим / (config.go:11-20)
  • Host: Ожидаемое значение заголовка Host для серверной проверки
  • Header: Дополнительные HTTP-заголовки (карта), по умолчанию User-Agent установлен в Chrome UA (config.go:22-31)
  • Ed: Максимальный размер ранних данных в байтах. Установите 0 для отключения.
  • HeartbeatPeriod: Интервал в секундах для WebSocket Ping-фреймов. 0 для отключения.
  • AcceptProxyProtocol: Включение протокола PROXY на слушателе.

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

  • gorilla/websocket: Используется как для клиента, так и для сервера. Серверный upgrader имеет буферы нулевого размера (ReadBufferSize: 0, WriteBufferSize: 0) для минимизации памяти, тогда как клиентский дайлер использует буферы по 4 КБ.
  • uTLS и WebSocket: При использовании отпечатков uTLS WebsocketHandshakeContext принудительно устанавливает http/1.1 в расширении ALPN, поскольку WebSocket требует HTTP/1.1.
  • Браузерный дайлер: Когда browser_dialer.HasBrowserDialer() возвращает true, WebSocket-соединения устанавливаются через WebSocket API браузера, а не через сетевой стек Go. Это используется для сценариев развертывания в браузере.
  • Кодировка Base64: Ранние данные используют RawURLEncoding (без заполнения, URL-безопасные символы) для совместимости как с V2Ray/V2Fly, так и с Xray.
  • Замена строк: На стороне сервера используется strings.NewReplacer("+", "-", "/", "_", "=", "") для нормализации между стандартным и URL-безопасным вариантами base64 (hub.go:30).
  • Удаленный адрес: Обертка connection переопределяет RemoteAddr() для поддержки X-Forwarded-For, поэтому сообщаемый адрес может отличаться от фактического TCP-узла.

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