Browser dialer
Hand every connection to a real browser tab so the TLS fingerprint is genuine, not imitated.
Every anti-censorship transport eventually loses the fingerprint arms race the same way: the tunnel makes a TLS handshake with a Go TLS stack, and that handshake does not look like a browser. uTLS answers by hand-copying Chrome’s ClientHello — cipher list, extension order, GREASE values — but a copy is always one Chrome release behind, and it can only fake the TLS layer, not the HTTP/2 frame ordering or the header casing above it. The browser dialer takes the opposite approach: it stops imitating a browser and outsources the entire request to one.
The handshake is a real browser’s, so there is nothing to imitate
When the browser dialer is active, Xray never touches TLS — the browser’s own network stack makes the connection, so the JA3/JA4 fingerprint, ALPN, HTTP/2 SETTINGS and header order are all genuinely the browser’s. uTLS can only mimic the ClientHello; here the entire request is issued by fetch() or new WebSocket() inside a live tab:
// dialer.html — the browser, not Xray, makes the request
const response = await fetch(task.url, requestInit); // GET / packet
const wss = new WebSocket(task.url, task.extra.protocol); // WS There is no divergence to detect because there is no imitation. If you run the tab in a stock Chrome, the peer sees exactly what Chrome sends when it loads any HTTPS page — down to the extensions a passive JA4 collector keys on.
transport/internet/splithttp/dialer.goXray serves the bridge page and blocks until a tab connects
The feature only exists if you set an environment variable; when you do, Xray itself becomes a tiny web server that hands out the bridge page and collects the websockets browsers open back to it. The init() reads xray.browser.dialer (normalized to XRAY_BROWSER_DIALER); an empty value means the whole subsystem stays off and HasBrowserDialer() returns false:
addr := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" })
if addr != "" {
token := uuid.New()
csrfToken := token.String()
webpage = bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken))
conns = make(chan *websocket.Conn, 256)
go http.ListenAndServe(addr, /* serve page / upgrade /websocket */)
} Point a browser at that address and it loads the embedded dialer.html. The page keeps exactly one spare websocket open back to /websocket?token=<csrfToken> — a 1-second setInterval (plus a re-check after every task) opens a fresh one whenever none is idle. The token is a per-process UUID baked into the HTML at startup, and upgrades without it are silently ignored; note it’s a liveness check more than a security boundary, since the page itself is served with Access-Control-Allow-Origin: *. Each upgraded socket is pushed into a 256-deep conns channel:
XRAY_BROWSER_DIALER=127.0.0.1:8080 xray run -c config.json
# then open http://127.0.0.1:8080 in the browser you want to fingerprint as Xray ships a task, the browser does the work and streams bytes back
Each dial is a small JSON “task” written onto the bridge socket; the browser executes it and the same websocket becomes the data pipe. The three task shapes map to the three things a browser can natively do:
type task struct {
Method string `json:"method"` // "WS", "GET", or an uplink method
URL string `json:"url"`
Extra any `json:"extra,omitempty"` // referrer, headers, cookies
StreamResponse bool `json:"streamResponse"`
} WS— the tab opens a real upstreamWebSocketand relays frames in both directions (WebSocket transport).GET+streamResponse— the tabfetch()es and pumps the response body reader chunk-by-chunk back over the bridge (SplitHTTP downlink).- any method,
streamResponse: false— a one-shotfetch()with a body, answeringok/fail(SplitHTTP uplink packets).
The handshake between the two sides is deliberately trivial: after sending the task, Xray calls CheckOK, which reads one message and requires the literal string ok before treating the socket as connected — anything else is surfaced as the error.
A nice detail hiding in Extra: headers, cookies and the referrer are forwarded so the browser’s request carries them, but the referrer is stripped to its path first — referrer.pathname + referrer.search + referrer.hash — because browsers silently reset a cross-origin referrer back to the current page. Cookies are set on document.cookie just before the fetch and cleared right after, so they scope to the one request.
Reach for the browser dialer on a plain VLESS+TCP outbound
The dialer is wired into WebSocket and SplitHTTP only. On other transports HasBrowserDialer() is never consulted, so setting the env var buys you nothing but an extra listening port.
Use it where a genuine browser fingerprint is the whole point
Run it in front of a CDN-fronted WebSocket or XHTTP endpoint when the adversary does active JA4/HTTP-fingerprinting. The browser makes the real handshake, so there is no library signature to catch — at the cost of keeping a browser process alive next to Xray.