Skip to content

WebSocket 传输

简介

WebSocket 传输将代理流量封装在标准 WebSocket 帧中,使其在网络观察者和中间设备看来像是合法的 WebSocket 流量。它在客户端和服务端均使用 gorilla/websocket 库,支持带 uTLS 指纹的 TLS、早期数据(通过 Sec-WebSocket-Protocol 头部实现 0-RTT),以及可选的浏览器拨号器以在浏览器环境中运行。

协议注册

"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

拨号流程

入口点

websocket.Dialwebsocket/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 拨号详情

dialWebSocketwebsocket/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. 系统拨号NetDial 回调委托给 internet.DialSystem,应用套接字选项。
  2. TLS:配置 TLS 时,protocol 变为 "wss",并设置 dialer.TLSClientConfig
  3. uTLS 指纹:设置指纹时,dialer.NetDialTLSContext 被覆盖以使用 tls.UClient 配合 WebsocketHandshakeContext(强制 http/1.1 ALPN)(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: 首次写入触发拨号
    alt len(data) <= Ed
        DDC->>WS: WebSocket 升级 + Sec-WebSocket-Protocol 中携带数据
        DDC-->>App: len(data), nil
    else len(data) > Ed
        DDC->>WS: WebSocket 升级(无早期数据)
        DDC->>WS: 通过 WebSocket 帧写入 Write(data)
    end
    App->>DDC: Read(buf)
    Note over DDC: 阻塞直到拨号通道发出信号
    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)
    }
}

监听流程

HTTP 服务器设置

ListenWSwebsocket/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.ServeHTTPwebsocket/hub.go:41-88)验证并升级连接:

  1. Host 验证:如已配置,验证 request.Host 是否匹配 config.Hosthub.go:42-46
  2. 路径验证request.URL.Path 与配置路径的精确匹配(hub.go:47-51
  3. 早期数据提取:解码 Sec-WebSocket-Protocol 头部(hub.go:55-59
  4. WebSocket 升级:使用 gorilla 的 upgrader.UpgradeCheckOrigin 宽松匹配(hub.go:32-39hub.go:62
  5. X-Forwarded-For:从转发头部提取真实客户端 IP,遵循 TrustedXForwardedFor 配置(hub.go:68-85

全局升级器(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
    }
}

如果存在额外读取器(早期数据),则优先消费。

写入实现

写入产生二进制 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
}

心跳(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:客户端和服务端均使用。服务端升级器使用零大小缓冲区(ReadBufferSize: 0, WriteBufferSize: 0)以最小化内存占用,而客户端拨号器使用 4KB 缓冲区。
  • uTLS 与 WebSocket:使用 uTLS 指纹时,WebsocketHandshakeContext 在 ALPN 扩展中强制使用 http/1.1,因为 WebSocket 需要 HTTP/1.1。
  • 浏览器拨号器:当 browser_dialer.HasBrowserDialer() 返回 true 时,WebSocket 连接通过浏览器的 WebSocket API 而非 Go 的网络栈建立。用于基于浏览器的部署场景。
  • Base64 编码:早期数据使用 RawURLEncoding(无填充,URL 安全字符),以兼容 V2Ray/V2Fly 和 Xray。
  • 字符串替换器:服务端使用 strings.NewReplacer("+", "-", "/", "_", "=", "") 规范化标准 base64 与 URL 安全 base64 变体之间的差异(hub.go:30)。
  • 远程地址connection 包装器覆盖 RemoteAddr() 以支持 X-Forwarded-For,因此报告的地址可能与实际 TCP 对端不同。

用于重新实现目的的技术分析。