TCP Transport
Introduction
The TCP transport is the default and most fundamental transport in Xray-core. It provides raw TCP connections with optional TLS/REALITY security, HTTP header obfuscation, and advanced socket-level tuning. This document covers the dial/listen flow, socket option support, Happy Eyeballs dual-stack racing, and transparent proxy (TProxy) support.
Protocol Registration
The TCP transport registers under the name "tcp" (transport/internet/tcp/tcp.go:3):
const protocolName = "tcp"Registration happens in three init() functions:
- Dialer:
tcp/dialer.go:110-112--RegisterTransportDialer("tcp", Dial) - Listener:
tcp/hub.go:138-140--RegisterTransportListener("tcp", ListenTCP) - Config creator:
tcp/config.go:8-12--RegisterProtocolConfigCreator("tcp", ...)
Dial Flow
Entry Point
tcp.Dial (tcp/dialer.go:20-108) is called by the transport dispatch layer:
func Dial(ctx context.Context, dest net.Destination,
streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) {
conn, err := internet.DialSystem(ctx, dest, streamSettings.SocketSettings)
if err != nil {
return nil, err
}
// Apply TLS or REALITY...
// Apply header authenticator...
return stat.Connection(conn), nil
}Security Layer Application
After the raw TCP connection is established, security is applied in priority order (tcp/dialer.go:27-93):
- TLS: If
tls.ConfigFromStreamSettingsreturns non-nil, a TLS handshake is performed. uTLS fingerprinting is used if a fingerprint is configured (tls.UClient), otherwise standard Go TLS (tls.Client). - REALITY: If
reality.ConfigFromStreamSettingsreturns non-nil (and TLS was not configured), the REALITY handshake is performed viareality.UClient.
Header Obfuscation
After security, an optional ConnectionAuthenticator wraps the connection (tcp/dialer.go:95-106):
tcpSettings := streamSettings.ProtocolSettings.(*Config)
if tcpSettings.HeaderSettings != nil {
headerConfig, _ := tcpSettings.HeaderSettings.GetInstance()
auth, _ := internet.CreateConnectionAuthenticator(headerConfig)
conn = auth.Client(conn)
}This enables HTTP header obfuscation where the TCP stream is wrapped to look like normal HTTP traffic.
Listen Flow
Entry Point
tcp.ListenTCP (tcp/hub.go:30-95) creates a TCP listener:
func ListenTCP(ctx context.Context, address net.Address, port net.Port,
streamSettings *internet.MemoryStreamConfig, handler internet.ConnHandler) (internet.Listener, error) {
// ...
listener, err = internet.ListenSystem(ctx, &net.TCPAddr{
IP: address.IP(),
Port: int(port),
}, streamSettings.SocketSettings)
// Configure TLS / REALITY / header auth
go l.keepAccepting()
return l, nil
}Connection Accept Loop
The keepAccepting goroutine (tcp/hub.go:97-126) accepts connections and applies security:
func (v *Listener) keepAccepting() {
for {
conn, err := v.listener.Accept()
// ...
go func() {
if v.tlsConfig != nil {
conn = tls.Server(conn, v.tlsConfig)
} else if v.realityConfig != nil {
conn, err = reality.Server(conn, v.realityConfig)
}
if v.authConfig != nil {
conn = v.authConfig.Server(conn)
}
v.addConn(stat.Connection(conn))
}()
}
}Unix Domain Socket Support
Both dial and listen support Unix domain sockets. The listener checks for port == 0 to switch to Unix mode (tcp/hub.go:44-55).
PROXY Protocol
The TCP listener merges PROXY protocol acceptance from both the TCP config and socket settings (tcp/hub.go:37-41):
streamSettings.SocketSettings.AcceptProxyProtocol =
l.config.AcceptProxyProtocol || streamSettings.SocketSettings.AcceptProxyProtocolWhen enabled, internet.ListenSystem wraps the listener with proxyproto.Listener (system_listener.go:170-173).
System Dialer: Socket Options
The DefaultSystemDialer.Dial (transport/internet/system_dialer.go:51-146) is where raw OS connections are created. It configures:
- Keep-Alive: Chrome-like defaults (45s idle, 45s interval) via Go 1.24+'s
net.KeepAliveConfig(system_dialer.go:91-117) - Multipath TCP:
dialer.SetMultipathTCP(true)whenTcpMptcpis set (system_dialer.go:121-123) - Socket Control: A
Controlcallback applies platform-specific options viaapplyOutboundSocketOptions(system_dialer.go:124-143)
Linux Socket Options (sockopt_linux.go)
The Linux implementation (transport/internet/sockopt_linux.go:43-138) supports:
| Option | Syscall | Config Field |
|---|---|---|
| SO_MARK | SOL_SOCKET, SO_MARK | config.Mark |
| SO_BINDTODEVICE | BindToDevice | config.Interface |
| TCP_FASTOPEN_CONNECT | SOL_TCP, TCP_FASTOPEN_CONNECT | config.Tfo (outbound) |
| TCP_FASTOPEN | SOL_TCP, TCP_FASTOPEN | config.Tfo (inbound) |
| TCP_CONGESTION | SOL_TCP, TCP_CONGESTION | config.TcpCongestion |
| TCP_WINDOW_CLAMP | IPPROTO_TCP, TCP_WINDOW_CLAMP | config.TcpWindowClamp |
| TCP_USER_TIMEOUT | IPPROTO_TCP, TCP_USER_TIMEOUT | config.TcpUserTimeout |
| TCP_MAXSEG | IPPROTO_TCP, TCP_MAXSEG | config.TcpMaxSeg |
| IP_TRANSPARENT | SOL_IP, IP_TRANSPARENT | config.Tproxy |
| IPV6_RECVORIGDSTADDR | Various | config.ReceiveOriginalDestAddress |
| Custom | User-defined level/opt | config.CustomSockopt |
macOS Socket Options (sockopt_darwin.go)
The Darwin implementation (transport/internet/sockopt_darwin.go:103-192) has platform-specific differences:
- TCP_FASTOPEN uses platform constants:
TCP_FASTOPEN_SERVER = 0x01,TCP_FASTOPEN_CLIENT = 0x02(sockopt_darwin.go:19-21) - Interface binding uses
IP_BOUND_IF/IPV6_BOUND_IFinstead ofSO_BINDTODEVICE(sockopt_darwin.go:136-151) - Transparent proxy uses
/dev/pfandDIOCNATLOOKioctl for original destination lookup (sockopt_darwin.go:40-101)
TFO Value Parsing
The TFO config value is parsed specially (transport/internet/sockopt.go:21-30):
func (v *SocketConfig) ParseTFOValue() int {
if v.Tfo == 0 { return -1 } // not set
tfo := int(v.Tfo)
if tfo < 0 { tfo = 0 } // explicitly disabled
return tfo
}On Linux outbound, any positive value becomes 1 for TCP_FASTOPEN_CONNECT. On Linux inbound, the value is passed directly to TCP_FASTOPEN as the queue length.
Custom Socket Options
The CustomSockopt mechanism (sockopt_linux.go:93-129) allows arbitrary setsockopt calls:
for _, custom := range config.CustomSockopt {
if custom.System != "" && custom.System != runtime.GOOS { continue }
if !strings.HasPrefix(network, custom.Network) { continue }
// Apply int or string setsockopt
}This supports filtering by OS and network type (e.g., "tcp" matches tcp4/tcp6).
Happy Eyeballs (RFC 8305)
When multiple IPs are resolved and Happy Eyeballs is enabled, TcpRaceDial (transport/internet/happy_eyeballs.go:16-97) implements RFC 8305:
IP Sorting
sortIPs (happy_eyeballs.go:100-156) interleaves IPv4 and IPv6 addresses:
func sortIPs(ips []net.IP, prioritizeIPv6 bool, interleave uint32) []net.IP {
// Separate into ip4 and ip6 slices
// Interleave: alternate ip4/ip6 based on interleave count
// prioritizeIPv6 controls which family goes first
}With default interleave=1, the result looks like: [v6, v4, v6, v4, ...] (or [v4, v6, ...] if IPv6 is not prioritized).
Race Dial Algorithm
sequenceDiagram
participant C as TcpRaceDial
participant T as Timer
participant G1 as Goroutine 1
participant G2 as Goroutine 2
participant G3 as Goroutine 3
C->>T: Start (0ms delay for first)
T->>G1: tcpTryDial(IP[0])
T->>T: Reset(tryDelayMs)
T->>G2: tcpTryDial(IP[1])
G1-->>C: result{conn, nil}
C->>C: Cancel context (stop others)
C->>C: Wait for active goroutines
C-->>C: Return winning connKey parameters from HappyEyeballs config:
- TryDelayMs: Delay between starting each new attempt (default 250ms per RFC 8305)
- MaxConcurrentTry: Maximum simultaneous connection attempts
- PrioritizeIpv6: Whether IPv6 goes first in the interleaved list
- Interleave: How many addresses of one family before switching
The first connection to succeed wins. All others are closed. The algorithm handles cancellation and failure gracefully (happy_eyeballs.go:35-96).
Activation Conditions
Happy Eyeballs is only used when all conditions are met (dialer.go:263):
- HappyEyeballs config is not nil
- TryDelayMs > 0 and MaxConcurrentTry > 0
- At least 2 IPs resolved
- No DialerProxy configured
- Destination is TCP
Transparent Proxy (TProxy)
Linux
On Linux, TProxy works by setting IP_TRANSPARENT on sockets (sockopt_linux.go:131-135), allowing the process to bind to non-local addresses. Original destination recovery uses SO_ORIGINAL_DST (tcp/sockopt_linux.go:18-52):
func GetOriginalDestination(conn stat.Connection) (net.Destination, error) {
// Uses getsockopt(SO_ORIGINAL_DST) to recover the original destination
// from a redirected connection (via iptables REDIRECT/TPROXY)
}macOS
On macOS, original destination is recovered via PF (sockopt_darwin.go:40-101) using DIOCNATLOOK ioctl on /dev/pf.
System Listener
The DefaultListener.Listen (transport/internet/system_listener.go:78-175) creates OS-level listeners with:
- Socket control: Applies
applyInboundSocketOptionsviagetControlFunc(system_listener.go:24-42) - SO_REUSEPORT: Always set on listeners (
system_listener.go:39) - Unix domain sockets: Supports abstract sockets (Linux
@prefix), file permissions, and file locking (system_listener.go:115-166) - PROXY protocol: Wraps with
proxyproto.Listenerwhen enabled (system_listener.go:170-173) - Multipath TCP:
lc.SetMultipathTCP(true)when configured (system_listener.go:111-113)
Implementation Notes
- Default protocol: When no transport is specified, TCP is used (
config.go:59-63). - UDP wrapping: For UDP destinations,
DefaultSystemDialercreates aPacketConnWrapperthat implementsnet.Connovernet.PacketConn(system_dialer.go:54-89). - Keep-alive defaults: The dialer mirrors Chrome's keep-alive behavior: 45s idle + 45s interval (
system_dialer.go:91-96). Listeners default to keep-alive disabled (system_listener.go:92). - FakePacketConn: Used to wrap TCP connections as
PacketConnfor QUIC-over-TCP scenarios (system_dialer.go:258-283). - Dialer timeout: Hard-coded to 16 seconds (
system_dialer.go:113). - REALITY on listener: When REALITY is configured, the listener spawns a goroutine for
DetectPostHandshakeRecordsLens(tcp/hub.go:78).