WebSocket 传输
简介
WebSocket 传输将代理流量封装在标准 WebSocket 帧中,使其在网络观察者和中间设备看来像是合法的 WebSocket 流量。它在客户端和服务端均使用 gorilla/websocket 库,支持带 uTLS 指纹的 TLS、早期数据(通过 Sec-WebSocket-Protocol 头部实现 0-RTT),以及可选的浏览器拨号器以在浏览器环境中运行。
协议注册
以 "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
拨号流程
入口点
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 拨号详情
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,
}
// ...
}关键方面:
- 系统拨号:
NetDial回调委托给internet.DialSystem,应用套接字选项。 - TLS:配置 TLS 时,
protocol变为"wss",并设置dialer.TLSClientConfig。 - uTLS 指纹:设置指纹时,
dialer.NetDialTLSContext被覆盖以使用tls.UClient配合WebsocketHandshakeContext(强制http/1.1ALPN)(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: 首次写入触发拨号
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):
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 服务器设置
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)验证并升级连接:
- Host 验证:如已配置,验证
request.Host是否匹配config.Host(hub.go:42-46) - 路径验证:
request.URL.Path与配置路径的精确匹配(hub.go:47-51) - 早期数据提取:解码
Sec-WebSocket-Protocol头部(hub.go:55-59) - WebSocket 升级:使用 gorilla 的
upgrader.Upgrade,CheckOrigin宽松匹配(hub.go:32-39,hub.go:62) - X-Forwarded-For:从转发头部提取真实客户端 IP,遵循
TrustedXForwardedFor配置(hub.go:68-85)
全局升级器(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
}
}如果存在额外读取器(早期数据),则优先消费。
写入实现
写入产生二进制 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
}心跳(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:客户端和服务端均使用。服务端升级器使用零大小缓冲区(
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 对端不同。