WebSocket Transport
Introduction
The WebSocket transport wraps proxy traffic inside standard WebSocket frames, making it appear as legitimate WebSocket traffic to network observers and middleboxes. It uses the gorilla/websocket library for both client and server, supports TLS with uTLS fingerprinting, early data (0-RTT via Sec-WebSocket-Protocol header), and an optional browser dialer for running inside browser contexts.
Protocol Registration
Registered as "websocket" (transport/internet/websocket/ws.go:8):
const protocolName = "websocket"- Dialer:
websocket/dialer.go:42-44 - Listener:
websocket/hub.go:174-176 - Config:
websocket/config.go:33-37
Dial Flow
Entry Point
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 {
// Early data mode: defer actual dial until first Write
conn = &delayDialConn{...}
} else {
conn, err = dialWebSocket(ctx, dest, streamSettings, nil)
}
return stat.Connection(conn), nil
}WebSocket Dial Details
dialWebSocket (websocket/dialer.go:46-129) builds a websocket.Dialer and performs the upgrade:
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,
}
// ...
}Key aspects:
- System dial: The
NetDialcallback delegates tointernet.DialSystem, applying socket options. - TLS: When TLS is configured,
protocolbecomes"wss"anddialer.TLSClientConfigis set. - uTLS fingerprinting: When a fingerprint is set,
dialer.NetDialTLSContextis overridden to usetls.UClientwithWebsocketHandshakeContext(forceshttp/1.1ALPN) (websocket/dialer.go:66-87). - URI construction:
ws://orwss://with host and normalized path (websocket/dialer.go:90-94). - Browser dialer: If available, bypasses normal dialing entirely (
websocket/dialer.go:96-103). - Host header: Priority: config Host > TLS ServerName > destination address (
websocket/dialer.go:106-113).
Early Data Mechanism
When Ed > 0 is configured, the first Write call triggers the actual WebSocket connection. The initial bytes are sent as base64-encoded data in the Sec-WebSocket-Protocol header:
// websocket/dialer.go:114-117
if ed != nil {
header.Set("Sec-WebSocket-Protocol", base64.RawURLEncoding.EncodeToString(ed))
}The delayDialConn struct (websocket/dialer.go:131-184) implements this deferred connection:
sequenceDiagram
participant App as Application
participant DDC as delayDialConn
participant WS as WebSocket Server
App->>DDC: Write(data)
Note over DDC: First write triggers dial
alt len(data) <= Ed
DDC->>WS: WebSocket Upgrade + data in Sec-WebSocket-Protocol
DDC-->>App: len(data), nil
else len(data) > Ed
DDC->>WS: WebSocket Upgrade (no early data)
DDC->>WS: Write(data) via WebSocket frame
end
App->>DDC: Read(buf)
Note over DDC: Blocks until dialed channel signals
DDC->>WS: Read from WebSocketThe server extracts early data from the Sec-WebSocket-Protocol header (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 Flow
HTTP Server Setup
ListenWS (websocket/hub.go:98-162) sets up an HTTP server to handle WebSocket upgrades:
func ListenWS(ctx context.Context, address net.Address, port net.Port,
streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
// Create net.Listener (TCP or Unix)
// Optionally wrap with 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
}Request Handling
The requestHandler.ServeHTTP (websocket/hub.go:41-88) validates and upgrades connections:
- Host validation: If configured, validates
request.Hostagainstconfig.Host(hub.go:42-46) - Path validation: Exact match of
request.URL.Pathagainst configured path (hub.go:47-51) - Early data extraction: Decodes
Sec-WebSocket-Protocolheader (hub.go:55-59) - WebSocket upgrade: Uses gorilla's
upgrader.Upgradewith permissiveCheckOrigin(hub.go:32-39,hub.go:62) - X-Forwarded-For: Extracts real client IP from forwarded headers, respecting
TrustedXForwardedForconfig (hub.go:68-85)
The global 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 Wrapper
The connection struct (websocket/connection.go:19-22) wraps *websocket.Conn:
type connection struct {
conn *websocket.Conn
reader io.Reader
remoteAddr net.Addr
}Read Implementation
Reading is message-oriented (websocket/connection.go:45-59): each WebSocket message is read completely, and when one message is exhausted, the next is fetched via 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 // message exhausted, get next
continue
}
return nBytes, err
}
}The extra reader (early data) is consumed first if present.
Write Implementation
Writes produce binary WebSocket messages (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)
When HeartbeatPeriod is configured, a goroutine sends WebSocket Ping control frames (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
}
}
}()
}
// ...
}Graceful Close
On close, a WebSocket CloseMessage is sent before closing the underlying connection (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()
// ...
}Wire Format
The WebSocket transport uses standard RFC 6455 framing:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <hash>
Sec-WebSocket-Protocol: <base64 early data> (if Ed > 0)
[Binary WebSocket frames containing proxy data]Each Write call produces one binary WebSocket message. Each Read call reads from the current message until EOF, then moves to the next.
Config Options
From websocket/config.go:
- Path: WebSocket path, normalized with leading
/(config.go:11-20) - Host: Expected Host header value for server-side validation
- Header: Additional HTTP headers (map), defaults
User-Agentto Chrome UA (config.go:22-31) - Ed: Maximum early data size in bytes. Set to 0 to disable.
- HeartbeatPeriod: Interval in seconds for WebSocket Ping frames. 0 to disable.
- AcceptProxyProtocol: Enable PROXY protocol on the listener.
Implementation Notes
- gorilla/websocket: Used for both client and server. The server upgrader has zero-size buffers (
ReadBufferSize: 0, WriteBufferSize: 0) to minimize memory, while the client dialer uses 4KB buffers. - uTLS and WebSocket: When uTLS fingerprinting is used,
WebsocketHandshakeContextforceshttp/1.1in the ALPN extension, since WebSocket requires HTTP/1.1. - Browser dialer: When
browser_dialer.HasBrowserDialer()returns true, WebSocket connections are established via the browser's WebSocket API rather than Go's network stack. This is used for browser-based deployment scenarios. - Base64 encoding: Early data uses
RawURLEncoding(no padding, URL-safe characters) to be compatible with both V2Ray/V2Fly and Xray. - String replacer: Server-side uses
strings.NewReplacer("+", "-", "/", "_", "=", "")to normalize between standard and URL-safe base64 variants (hub.go:30). - Remote address: The
connectionwrapper overridesRemoteAddr()to support X-Forwarded-For, so the reported address may differ from the actual TCP peer.