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):
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:
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:
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:
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):
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):
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.
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:
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
AcceptProxyProtocolis true, the underlyinginternet.ListenSystemwraps the listener (hub.go:117-122,hub.go:145-147). - Config merging:
AcceptProxyProtocolfrom 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 Protocolsresponse, 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
AddHeaderfunction bypasses Go'stextproto.CanonicalMIMEHeaderKey, allowing exact-case header names. This can help match specific CDN or middlebox expectations. - bufio sizing trick: The
ConnRF.Readcreates abufio.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.NewListenerdirectly (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.