Contents

Dialing & sockopts

How outbound connections are actually made — happy eyeballs, sockopts, TFO, fragment.

Every transport — RAW, WebSocket, XHTTP, gRPC — ends up in the same funnel when it needs a real socket. Understanding that funnel explains where sockopt applies, why dialerProxy disables half of it, and why the happyEyeballs block does nothing until you set one field.

Every transport funnels through one system dialer

internet.Dial looks up the transport by name, but the actual socket is always opened by DialSystemeffectiveSystemDialer. A transport dialer like tcp.Dial only decides what to wrap around the returned connection (TLS, REALITY, fake headers); the resolve/race/branch logic lives in one place for all of them.

transport/internet/dialer.go transport/internet/system_dialer.go
DialSystem's three exits: a single-IP system dial (default), a happy-eyeballs race, or a pipe into another outbound (dialerProxy).

DefaultSystemDialer builds a net.Dialer with a hard 16-second timeout and keepalive defaults copied from Chrome — probing every 45 seconds, enabled even when you configure nothing. Set tcpKeepAliveIdle or tcpKeepAliveInterval negative to turn keepalive off entirely.

domainStrategy resolves in-process, then picks one IP at random

With a domainStrategy set, Xray resolves the domain through its own DNS client and rewrites the destination to a single IP — chosen by dice.Roll, not by racing. LookupForIP also filters address families by your bind address: if the outbound has a sendThrough IPv4 source, IPv6Enable is forced off, so only A records are eligible — and the second, fallback lookup that could widen the family set only runs when there is no bind source (localAddr == nil). UseIP* strategies fall back to letting the OS resolve on DNS failure; ForceIP* strategies make the dial fail instead.

Happy eyeballs races sockets, staggered per RFC 8305

TcpRaceDial sorts the resolved IPs by interleaving families (interleave per turn, prioritizeIPv6 picks who starts), then launches one dial every tryDelayMs, capped at maxConcurrentTry in flight. The first successful connect() wins, the shared context is cancelled, and every losing socket is closed. If all attempts fail you get the last error.

transport/internet/happy_eyeballs.go
{
	"sockopt": {
		"domainStrategy": "UseIPv4v6",
		"happyEyeballs": {
			"tryDelayMs": 250,
			"prioritizeIPv6": false,
			"interleave": 1,
			"maxConcurrentTry": 4
		}
	}
}

The two settings interact: domainStrategy decides which families are even in the list (UseIPv6v4 still resolves both; UseIPv4 alone gives the race nothing to interleave), and happy eyeballs decides the order and pacing of dialing them. Each racer is a full effectiveSystemDialer.Dial, so sockopts below apply to every attempt.

Sockopts touch the raw fd before connect()

Options are applied inside the dialer’s Control hook — after socket(), before connect() — which is why TFO can work at all. On Linux, applyOutboundSocketOptions sets SO_MARK (mark), SO_BINDTODEVICE (interface), TCP_CONGESTION, TCP_USER_TIMEOUT, and arbitrary customSockopt entries. tcpFastOpen is asymmetric: outbounds set TCP_FASTOPEN_CONNECT (any positive value collapses to 1), so the first data write rides the SYN; inbounds set TCP_FASTOPEN where the number is the real pending-TFO queue length (true means 256). Omitting the key means “don’t touch the kernel default” — only an explicit false disables it.

transport/internet/sockopt.go transport/internet/sockopt_linux.go

dialerProxy never opens a socket

With sockopt.dialerProxy set, DialSystem doesn’t dial anything — redirect() builds two in-memory pipes and dispatches them into the named outbound handler. The “connection” your transport gets back is a cnc.NewConnection over those pipes; the real socket is opened by the tail outbound, under its stream settings. Internal components (Observatory probes, DNS-over-outbound) enter the same way through tagged.Dialer, which forces an outbound tag on the session and dispatches through the router.

transport/internet/tagged/tagged.go
Don't

Put mark/interface on the outbound that has dialerProxy

Those sockopts configure a socket that is never created. DialSystem also ignores the outbound’s sendThrough gateway and skips happy eyeballs when dialerProxy is set.

Do

Put socket-level sockopts on the tail outbound

The outbound named in dialerProxy performs the actual connect(), so mark, interface, tcpFastOpen, and happyEyeballs belong in its streamSettings.sockopt.

Fragment splits TLS records, not TCP packets

The finalmask fragment mask wraps the fresh socket right after DialSystem and rewrites outgoing writes; in tlshello mode it re-frames the ClientHello as several smaller TLS records, each with its own 5-byte header and correct length. DPI that matches the SNI inside one record misses it, yet the stream stays valid TLS.

transport/internet/finalmask/fragment/conn.go
{
	"finalmask": {
		"tcp": [
			{
				"type": "fragment",
				"settings": { "packets": "tlshello", "length": "5-10", "delay": "1-2" }
			}
		]
	}
}

With delay: 0 all the re-framed records are accumulated and sent in one Write — record splitting only, likely a single TCP segment. A non-zero delay writes each record separately and sleeps between them, forcing real packet boundaries. Numeric packets ranges (e.g. "1-3") instead slice the raw bytes of those writes without touching record framing — which would corrupt nothing but relies purely on packet timing to defeat the DPI.