Skip to content

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):

go
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:

go
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):

  1. TLS: If tls.ConfigFromStreamSettings returns non-nil, a TLS handshake is performed. uTLS fingerprinting is used if a fingerprint is configured (tls.UClient), otherwise standard Go TLS (tls.Client).
  2. REALITY: If reality.ConfigFromStreamSettings returns non-nil (and TLS was not configured), the REALITY handshake is performed via reality.UClient.

Header Obfuscation

After security, an optional ConnectionAuthenticator wraps the connection (tcp/dialer.go:95-106):

go
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:

go
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:

go
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):

go
streamSettings.SocketSettings.AcceptProxyProtocol =
    l.config.AcceptProxyProtocol || streamSettings.SocketSettings.AcceptProxyProtocol

When 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) when TcpMptcp is set (system_dialer.go:121-123)
  • Socket Control: A Control callback applies platform-specific options via applyOutboundSocketOptions (system_dialer.go:124-143)

Linux Socket Options (sockopt_linux.go)

The Linux implementation (transport/internet/sockopt_linux.go:43-138) supports:

OptionSyscallConfig Field
SO_MARKSOL_SOCKET, SO_MARKconfig.Mark
SO_BINDTODEVICEBindToDeviceconfig.Interface
TCP_FASTOPEN_CONNECTSOL_TCP, TCP_FASTOPEN_CONNECTconfig.Tfo (outbound)
TCP_FASTOPENSOL_TCP, TCP_FASTOPENconfig.Tfo (inbound)
TCP_CONGESTIONSOL_TCP, TCP_CONGESTIONconfig.TcpCongestion
TCP_WINDOW_CLAMPIPPROTO_TCP, TCP_WINDOW_CLAMPconfig.TcpWindowClamp
TCP_USER_TIMEOUTIPPROTO_TCP, TCP_USER_TIMEOUTconfig.TcpUserTimeout
TCP_MAXSEGIPPROTO_TCP, TCP_MAXSEGconfig.TcpMaxSeg
IP_TRANSPARENTSOL_IP, IP_TRANSPARENTconfig.Tproxy
IPV6_RECVORIGDSTADDRVariousconfig.ReceiveOriginalDestAddress
CustomUser-defined level/optconfig.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_IF instead of SO_BINDTODEVICE (sockopt_darwin.go:136-151)
  • Transparent proxy uses /dev/pf and DIOCNATLOOK ioctl 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):

go
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:

go
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:

go
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

mermaid
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 conn

Key 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):

go
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 applyInboundSocketOptions via getControlFunc (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.Listener when 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, DefaultSystemDialer creates a PacketConnWrapper that implements net.Conn over net.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 PacketConn for 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).

Technical analysis for re-implementation purposes.