Skip to content

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):

go
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):

go
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:

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,
    }
    // ...
}

Key aspects:

  1. System dial: The NetDial callback delegates to internet.DialSystem, applying socket options.
  2. TLS: When TLS is configured, protocol becomes "wss" and dialer.TLSClientConfig is set.
  3. uTLS fingerprinting: When a fingerprint is set, dialer.NetDialTLSContext is overridden to use tls.UClient with WebsocketHandshakeContext (forces http/1.1 ALPN) (websocket/dialer.go:66-87).
  4. URI construction: ws:// or wss:// with host and normalized path (websocket/dialer.go:90-94).
  5. Browser dialer: If available, bypasses normal dialing entirely (websocket/dialer.go:96-103).
  6. 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:

go
// 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:

mermaid
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 WebSocket

The server extracts early data from the Sec-WebSocket-Protocol header (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 Flow ​

HTTP Server Setup ​

ListenWS (websocket/hub.go:98-162) sets up an HTTP server to handle WebSocket upgrades:

go
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:

  1. Host validation: If configured, validates request.Host against config.Host (hub.go:42-46)
  2. Path validation: Exact match of request.URL.Path against configured path (hub.go:47-51)
  3. Early data extraction: Decodes Sec-WebSocket-Protocol header (hub.go:55-59)
  4. WebSocket upgrade: Uses gorilla's upgrader.Upgrade with permissive CheckOrigin (hub.go:32-39, hub.go:62)
  5. X-Forwarded-For: Extracts real client IP from forwarded headers, respecting TrustedXForwardedFor config (hub.go:68-85)

The global 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 Wrapper ​

The connection struct (websocket/connection.go:19-22) wraps *websocket.Conn:

go
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():

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  // 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):

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) ​

When HeartbeatPeriod is configured, a goroutine sends WebSocket Ping control frames (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
                }
            }
        }()
    }
    // ...
}

Graceful Close ​

On close, a WebSocket CloseMessage is sent before closing the underlying connection (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()
    // ...
}

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-Agent to 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, WebsocketHandshakeContext forces http/1.1 in 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 connection wrapper overrides RemoteAddr() to support X-Forwarded-For, so the reported address may differ from the actual TCP peer.

Technical analysis for re-implementation purposes.