Транспорт WebSocket
Введение
Транспорт WebSocket оборачивает прокси-трафик в стандартные WebSocket-фреймы, заставляя его выглядеть как легитимный WebSocket-трафик для сетевых наблюдателей и промежуточных устройств. Он использует библиотеку gorilla/websocket как для клиента, так и для сервера, поддерживает TLS с отпечатками uTLS, ранние данные (0-RTT через заголовок Sec-WebSocket-Protocol) и опциональный браузерный дайлер для работы в контексте браузера.
Регистрация протокола
Зарегистрирован как "websocket" (transport/internet/websocket/ws.go:8):
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):
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 и выполняет обновление:
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,
}
// ...
}Ключевые аспекты:
- Системный dial: Callback
NetDialделегирует вызовinternet.DialSystem, применяя параметры сокета. - TLS: Когда настроен TLS,
protocolстановится"wss", и устанавливаетсяdialer.TLSClientConfig. - Отпечатки uTLS: Когда установлен отпечаток,
dialer.NetDialTLSContextпереопределяется для использованияtls.UClientсWebsocketHandshakeContext(принудительно устанавливает ALPNhttp/1.1) (websocket/dialer.go:66-87). - Построение URI:
ws://илиwss://с хостом и нормализованным путем (websocket/dialer.go:90-94). - Браузерный дайлер: Если доступен, полностью обходит обычное подключение (
websocket/dialer.go:96-103). - Заголовок Host: Приоритет: Host из конфигурации > TLS ServerName > адрес назначения (
websocket/dialer.go:106-113).
Механизм ранних данных
Когда настроен Ed > 0, первый вызов Write инициирует фактическое WebSocket-соединение. Начальные байты отправляются в виде данных, закодированных в base64, в заголовке Sec-WebSocket-Protocol:
// websocket/dialer.go:114-117
if ed != nil {
header.Set("Sec-WebSocket-Protocol", base64.RawURLEncoding.EncodeToString(ed))
}Структура delayDialConn (websocket/dialer.go:131-184) реализует это отложенное соединение:
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):
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-обновлений:
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) проверяет и обновляет соединения:
- Проверка хоста: Если настроен, проверяет
request.Hostпоconfig.Host(hub.go:42-46) - Проверка пути: Точное совпадение
request.URL.Pathс настроенным путем (hub.go:47-51) - Извлечение ранних данных: Декодирование заголовка
Sec-WebSocket-Protocol(hub.go:55-59) - Обновление WebSocket: Использует
upgrader.Upgradeиз gorilla с разрешающимCheckOrigin(hub.go:32-39,hub.go:62) - X-Forwarded-For: Извлечение реального IP клиента из заголовков переадресации с учетом настройки
TrustedXForwardedFor(hub.go:68-85)
Глобальный upgrader (websocket/hub.go:32-39):
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:
type connection struct {
conn *websocket.Conn
reader io.Reader
remoteAddr net.Addr
}Реализация чтения
Чтение ориентировано на сообщения (websocket/connection.go:45-59): каждое WebSocket-сообщение читается полностью, и когда одно сообщение исчерпано, следующее извлекается через conn.NextReader():
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):
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):
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):
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-узла.