Skip to content

HTTPUpgrade Transport

Introduction

HTTPUpgrade is a lightweight transport that mimics a WebSocket handshake (HTTP/1.1 Upgrade: websocket) but does not use WebSocket framing. After the upgrade handshake completes, the connection becomes a raw TCP stream. This makes it simpler and more efficient than the full WebSocket transport, while still passing through middleboxes that expect HTTP Upgrade flows. It supports TLS, uTLS fingerprinting, PROXY protocol, and early data.

Protocol Registration

Registered as "httpupgrade" (transport/internet/httpupgrade/httpupgrade.go:3):

go
const protocolName = "httpupgrade"
  • Dialer: httpupgrade/dialer.go:134-136
  • Listener: httpupgrade/hub.go:165-167
  • Config: httpupgrade/config.go:19-23

Dial Flow

Handshake

dialhttpUpgrade (httpupgrade/dialer.go:46-115) performs a manual HTTP Upgrade:

go
func dialhttpUpgrade(ctx context.Context, dest net.Destination,
    streamSettings *internet.MemoryStreamConfig) (net.Conn, error) {
    // 1. Dial raw TCP via internet.DialSystem
    pconn, _ := internet.DialSystem(ctx, dest, streamSettings.SocketSettings)

    // 2. Optionally wrap with TLS
    if tConfig != nil {
        // Apply TLS with uTLS fingerprinting or standard Go TLS
        conn = tls.UClient(pconn, tlsConfig, fingerprint) // or tls.Client
    }

    // 3. Build HTTP request
    req := &http.Request{
        Method: http.MethodGet,
        URL:    &requestURL,
        Header: make(http.Header),
    }
    req.Header.Set("Connection", "Upgrade")
    req.Header.Set("Upgrade", "websocket")

    // 4. Write request directly to connection
    req.Write(conn)

    // 5. Wrap with ConnRF for response reading
    connRF := &ConnRF{Conn: conn, Req: req, First: true}
    return connRF, nil
}

Unlike the WebSocket transport, this does NOT use gorilla/websocket. The HTTP request is written directly to the TCP stream and the response is parsed manually.

Response Reading (ConnRF)

The ConnRF struct (httpupgrade/dialer.go:19-44) intercepts the first Read call to parse the HTTP response:

go
type ConnRF struct {
    net.Conn
    Req   *http.Request
    First bool
}

func (c *ConnRF) Read(b []byte) (int, error) {
    if c.First {
        c.First = false
        reader := bufio.NewReaderSize(c.Conn, len(b))
        resp, err := http.ReadResponse(reader, c.Req)
        if resp.Status != "101 Switching Protocols" ||
            strings.ToLower(resp.Header.Get("Upgrade")) != "websocket" ||
            strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
            return 0, errors.New("unrecognized reply")
        }
        // Drain buffered bytes from bufreader
        return reader.Read(b[:reader.Buffered()])
    }
    return c.Conn.Read(b)
}

Key design: The bufio.Reader is sized to exactly len(b) to ensure any data after the HTTP response headers (which may arrive in the same TCP segment) is captured and returned in this first read.

Early Data

When Ed == 0 (the default), the response is read immediately during dial to confirm the upgrade succeeded (dialer.go:107-112). When Ed > 0, response reading is deferred to the first application Read call, allowing the dial to complete faster.

Header Handling

Custom headers are added via the AddHeader function (dialer.go:120-122) which bypasses Go's MIME header canonicalization:

go
func AddHeader(header http.Header, key, value string) {
    header[key] = append(header[key], value)
}

This preserves the exact casing of header names (e.g., "Web*S*ocket" instead of "Websocket").

Listen Flow

Server Architecture

ListenHTTPUpgrade (httpupgrade/hub.go:115-163) creates a raw TCP listener (not an HTTP server):

go
func ListenHTTPUpgrade(ctx context.Context, address net.Address, port net.Port,
    streamSettings *internet.MemoryStreamConfig, addConn internet.ConnHandler) (internet.Listener, error) {
    // Create TCP/Unix listener via internet.ListenSystem
    // Optionally wrap with TLS
    serverInstance := &server{
        config:         transportConfiguration,
        addConn:        addConn,
        innnerListener: listener,
    }
    go serverInstance.keepAccepting()
    return serverInstance, nil
}

Unlike the WebSocket transport (which uses http.Server), HTTPUpgrade accepts raw connections and manually parses HTTP requests.

Connection Handling

server.Handle (httpupgrade/hub.go:34-42) and server.upgrade (hub.go:45-103):

go
func (s *server) upgrade(conn net.Conn) (stat.Connection, error) {
    connReader := bufio.NewReader(conn)
    req, _ := http.ReadRequest(connReader)

    // Validate host and path
    if len(s.config.Host) > 0 && !internet.IsValidHTTPHost(host, s.config.Host) {
        return nil, errors.New("bad host")
    }
    if req.URL.Path != path {
        return nil, errors.New("bad path")
    }

    // Validate upgrade headers
    if connection != "upgrade" || upgrade != "websocket" {
        return nil, errors.New("unrecognized request")
    }

    // Send 101 response
    resp := &http.Response{
        Status:     "101 Switching Protocols",
        StatusCode: 101,
        Proto:      "HTTP/1.1",
        ProtoMajor: 1,
        ProtoMinor: 1,
        Header:     http.Header{},
    }
    resp.Header.Set("Connection", "Upgrade")
    resp.Header.Set("Upgrade", "websocket")
    resp.Write(conn)

    return stat.Connection(newConnection(conn, remoteAddr)), nil
}

X-Forwarded-For

Real client IP is extracted from forwarded headers (hub.go:83-100), respecting the TrustedXForwardedFor socket setting.

Wire Format

Client -> Server:
GET /path HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
User-Agent: Mozilla/5.0 ...
[custom headers]

Server -> Client:
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket

[raw bidirectional TCP stream]

After the handshake, data flows as raw bytes -- no WebSocket framing, no length prefixes, no masking. This is the key difference from the full WebSocket transport.

mermaid
sequenceDiagram
    participant Client
    participant Server

    Client->>Server: GET /path HTTP/1.1\r\nUpgrade: websocket\r\n...
    Server->>Client: HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n
    Note over Client,Server: Raw TCP stream begins
    Client->>Server: [proxy data bytes]
    Server->>Client: [proxy data bytes]

Connection Wrapper

The connection struct (httpupgrade/connection.go:5-19) is minimal:

go
type connection struct {
    net.Conn
    remoteAddr net.Addr
}

func (c *connection) RemoteAddr() net.Addr {
    return c.remoteAddr
}

It only overrides RemoteAddr() to support X-Forwarded-For. All other methods delegate to the underlying net.Conn.

PROXY Protocol Support

Both client and server support PROXY protocol:

  • Server: Inherited from socket settings. When AcceptProxyProtocol is true, the underlying internet.ListenSystem wraps the listener (hub.go:117-122, hub.go:145-147).
  • Config merging: AcceptProxyProtocol from transport config OR socket settings triggers PROXY protocol (hub.go:121).

Config Options

From httpupgrade/config.go:

  • Path: URL path, normalized with leading / (config.go:8-17)
  • Host: Expected Host header for server-side validation
  • Header: Custom HTTP headers (map)
  • AcceptProxyProtocol: Enable PROXY protocol on the listener
  • Ed: Early data size. When non-zero, response parsing is deferred.

Implementation Notes

  • No WebSocket framing: After the 101 Switching Protocols response, data is sent as raw bytes. This eliminates WebSocket overhead (2-14 bytes per frame) and the masking requirement.
  • No gorilla dependency: Unlike the WebSocket transport, HTTPUpgrade does not use gorilla/websocket. It constructs and parses HTTP messages directly.
  • Stateless server: Each connection is handled independently. The server does not maintain session state.
  • Header case preservation: The AddHeader function bypasses Go's textproto.CanonicalMIMEHeaderKey, allowing exact-case header names. This can help match specific CDN or middlebox expectations.
  • bufio sizing trick: The ConnRF.Read creates a bufio.ReaderSize(conn, len(b)) limited to the caller's buffer size. This ensures the buffered reader never reads more than can be returned, preventing data loss from double-buffering.
  • TLS on listener: Unlike gRPC (which uses gRPC's credential system), HTTPUpgrade wraps the net.Listener with tls.NewListener directly (hub.go:149-153).
  • Comparison with WebSocket: HTTPUpgrade is strictly simpler and more efficient for proxy use. WebSocket transport should be preferred only when WebSocket-specific features (heartbeat pings, per-message compression, browser dialer) are needed.

Technical analysis for re-implementation purposes.