WebSocket
Tunnel inside an HTTP/1.1 Upgrade so CDNs and reverse proxies pass it through untouched.
WebSocket earns its place for one reason: a WebSocket looks like any other real-time web app, so the CDNs and reverse proxies built to carry chat and live dashboards carry your tunnel for free. The client opens a connection with a standard HTTP/1.1 Upgrade: websocket request; once the server answers 101 Switching Protocols, the same socket becomes a bidirectional binary stream that middleboxes have already agreed to leave alone.
The whole tunnel is one HTTP Upgrade
Xray does not invent a protocol — it dials a real WebSocket and writes your bytes as binary frames. The client uses the gorilla/websocket dialer to send the handshake, picking ws:// or wss:// depending on whether TLS is configured, and requesting http/1.1 as the ALPN so no proxy tries to negotiate HTTP/2:
protocol := "ws"
tConfig := tls.ConfigFromStreamSettings(streamSettings)
if tConfig != nil {
protocol = "wss"
tlsConfig := tConfig.GetTLSConfig(tls.WithDestination(dest), tls.WithNextProto("http/1.1"))
dialer.TLSClientConfig = tlsConfig
}
// …
uri := protocol + "://" + host + wsSettings.GetNormalizedPath() After the 101, every Write is a single websocket.BinaryMessage and every Read pulls the next frame’s reader — the wrapper in connection.go is a thin net.Conn over the frame stream. To the network it is indistinguishable from a stock websocket app pushing binary data.
Path and Host are how you hide on a shared domain
The server rejects anything whose path or Host header doesn’t match the config, so one hostname can front many services and only the right URL reaches Xray. The request handler is strict — the path is compared byte-for-byte, the Host case-insensitively with any :port stripped first (IsValidHTTPHost), and anything else gets a 404:
if len(h.host) > 0 && !internet.IsValidHTTPHost(request.Host, h.host) {
writer.WriteHeader(http.StatusNotFound)
return
}
if request.URL.Path != h.path {
writer.WriteHeader(http.StatusNotFound)
return
} A probe hitting / or a wrong Host gets the same bland 404 Not Found a normal web server returns. On the client, the Host header is chosen by priority — explicit host first, then the TLS serverName, then the dial address — which is exactly what you need behind a CDN, where the TCP destination is an edge IP but the Host must name your domain:
{
"network": "ws",
"wsSettings": {
"path": "/tunnel",
"host": "cdn.example.com"
}
} Early data lets the first packet ride the handshake
A WebSocket normally costs one round trip before you can send anything; early-data (ed) smuggles your first payload into the handshake itself, so the request and the data arrive together. You enable it by appending ed=<bytes> to the path as a query parameter — Xray parses the number out and strips it back off the real path:
{ "path": "/tunnel?ed=2048" } With ed > 0 the dialer returns a delayDialConn that does not connect yet. It waits for your app’s first Write; if that write is within the byte budget, those bytes are base64-encoded (URL-safe, no padding) and sent as the Sec-WebSocket-Protocol header of the Upgrade request:
if ed != nil {
// RawURLEncoding is support by both V2Ray/V2Fly and XRay.
header.Set("Sec-WebSocket-Protocol", base64.RawURLEncoding.EncodeToString(ed))
} The server decodes that same header, hands the bytes to the connection as an extraReader consumed before any frame, and echoes the header back to complete the handshake. Sec-WebSocket-Protocol is a legitimate part of the WebSocket spec, so this looks like ordinary subprotocol negotiation.
Match the config on both ends, or you get a silent 404
The path is validated byte-for-byte (request.URL.Path != h.path), so a trailing slash or a case difference is a mismatch, not a warning. Only the Host check bends: IsValidHTTPHost lowercases both sides and strips a :port before comparing.
Assume paths normalize
Configuring the server with path: "tunnel" and the client with path: "/tunnel/". The server normalizes its side to /tunnel, the client requests /tunnel/, and every dial fails with a bare failed to dial to (…): 404 Not Found — only the server’s info log (failed to validate path) says which check mismatched.
Copy the exact path both ways
Use the identical leading-slash path on both ends (/tunnel). GetNormalizedPath only adds a leading / and defaults empty to / — it does not trim trailing slashes or fold case.
For a long-lived tunnel through a proxy that kills idle connections, set heartbeatPeriod (seconds): the connection then sends a WebSocket ping frame on that interval to keep the path warm.