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.