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 DialSystem → effectiveSystemDialer. 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.
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.
{
"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.
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.
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.
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.
{
"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.