Skip to content

TUN & IP Stack Overview

The TUN inbound creates a virtual network interface and uses a userspace IP stack to convert raw IP packets into application-layer connections that Xray-core can route.

Source: proxy/tun/

Architecture

mermaid
flowchart LR
    App([Application]) -->|"IP packets"| TUN["TUN Interface<br/>(kernel)"]
    TUN -->|"raw L3 frames"| EP["Link Endpoint<br/>(gVisor bridge)"]
    EP -->|"L3→L4"| Stack["gVisor IP Stack<br/>(TCP/UDP)"]

    Stack -->|"TCP: gonet.TCPConn"| Handler["TUN Handler"]
    Stack -->|"UDP: raw packets"| UDPH["UDP Handler<br/>(Full-Cone)"]

    Handler -->|"DispatchLink()"| Dispatcher["Xray Dispatcher"]
    UDPH -->|"HandleConnection()"| Handler

    Dispatcher -->|"via routing"| Outbound["Outbound<br/>(Freedom/VLESS/...)"]

Components

TUN Interface (tun.go, tun_*.go)

Platform-specific TUN device creation:

go
type Tun interface {
    Start() error  // begin reading/writing
    Close() error
    // Platform-specific: creates fd, sets MTU, etc.
}

type TunOptions struct {
    Name string  // e.g., "utun5" (macOS), "tun0" (Linux)
    MTU  uint32  // default: 1500
}

Each platform has its own NewTun():

  • Linux (tun_linux.go): Uses ioctl to create TUN device
  • macOS (tun_darwin.go): Uses utun system interface
  • Windows (tun_windows.go): Uses Wintun driver
  • Android (tun_android.go): Uses fd passed from Java layer

gVisor IP Stack (stack_gvisor.go)

The userspace TCP/IP stack processes raw IP packets:

go
type stackGVisor struct {
    ctx         context.Context
    tun         GVisorTun           // bridge to TUN device
    idleTimeout time.Duration
    handler     *Handler            // connection handler
    stack       *stack.Stack        // gVisor network stack
    endpoint    stack.LinkEndpoint  // link to TUN
}

Handler (handler.go)

The handler bridges gVisor connections to Xray's dispatcher:

go
type Handler struct {
    ctx             context.Context
    config          *Config
    stack           Stack
    policyManager   policy.Manager
    dispatcher      routing.Dispatcher
    tag             string
    sniffingRequest session.SniffingRequest
}

Lifecycle

mermaid
sequenceDiagram
    participant Config
    participant Handler
    participant TUN as TUN Device
    participant Stack as gVisor Stack
    participant Dispatcher

    Config->>Handler: Init(ctx, pm, dispatcher)
    Handler->>TUN: NewTun(options)
    TUN-->>Handler: tunInterface
    Handler->>Stack: NewStack(ctx, options, handler)
    Stack-->>Handler: stackGVisor
    Handler->>Stack: Start()
    Stack->>Stack: Create gVisor stack<br/>(IPv4, IPv6, TCP, UDP)
    Stack->>Stack: Set TCP forwarder
    Stack->>Stack: Set UDP forwarder
    Handler->>TUN: Start()
    TUN->>TUN: Begin reading packets

    Note over TUN,Stack: Running...

    TUN->>Stack: Raw IP packet
    Stack->>Stack: Parse L3/L4 headers
    alt TCP
        Stack->>Stack: TCP handshake
        Stack->>Handler: HandleConnection(tcpConn, dest)
    else UDP
        Stack->>Handler: HandlePacket(src, dst, data)
    end
    Handler->>Dispatcher: DispatchLink(ctx, dest, link)

TCP Handling

gVisor's TCP forwarder intercepts all TCP SYN packets:

go
tcpForwarder := tcp.NewForwarder(ipStack, 0, 65535, func(r *tcp.ForwarderRequest) {
    go func(r *tcp.ForwarderRequest) {
        var wq waiter.Queue
        var id = r.ID()

        // Complete TCP 3-way handshake
        ep, err := r.CreateEndpoint(&wq)

        // Configure socket options
        options := ep.SocketOptions()
        options.SetKeepAlive(false)
        options.SetReuseAddress(true)

        // Create Go net.Conn from gVisor endpoint
        conn := gonet.NewTCPConn(&wq, ep)

        // Dispatch to Xray routing
        handler.HandleConnection(conn,
            net.TCPDestination(
                net.IPAddress(id.LocalAddress.AsSlice()),
                net.Port(id.LocalPort),
            ))

        ep.Close()
        r.Complete(false)
    }(r)
})

The "local address" on the gVisor side is the destination of the original packet (the server the app wanted to reach).

UDP Handling (Full-Cone NAT)

UDP uses a custom handler instead of gVisor's forwarder, to support Full-Cone NAT. See UDP Full-Cone NAT for details.

HandleConnection (handler.go:104)

Every connection (TCP or UDP) goes through this:

go
func (t *Handler) HandleConnection(conn net.Conn, destination net.Destination) {
    defer conn.Close()

    ctx := context.WithCancel(t.ctx)
    ctx = session.ContextWithInbound(ctx, &session.Inbound{
        Name:          "tun",
        Tag:           t.tag,
        CanSpliceCopy: 3,  // TUN cannot splice
        Source:        net.DestinationFromAddr(conn.RemoteAddr()),
    })
    ctx = session.ContextWithContent(ctx, &session.Content{
        SniffingRequest: t.sniffingRequest,
    })

    // Create transport link from connection
    link := &transport.Link{
        Reader: buf.NewReader(conn),
        Writer: buf.NewWriter(conn),
    }

    // Synchronous dispatch (blocks until transfer completes)
    t.dispatcher.DispatchLink(ctx, destination, link)
}

Key difference from other inbounds: TUN uses DispatchLink() (synchronous) instead of Dispatch() (async), because the connection is already established by gVisor.

gVisor Stack Configuration

go
func createStack(ep stack.LinkEndpoint) (*stack.Stack, error) {
    opts := stack.Options{
        NetworkProtocols:   []stack.NetworkProtocolFactory{
            ipv4.NewProtocol,
            ipv6.NewProtocol,
        },
        TransportProtocols: []stack.TransportProtocolFactory{
            tcp.NewProtocol,
            udp.NewProtocol,
        },
        HandleLocal: false,
    }
    gStack := stack.New(opts)

    // Create NIC (Network Interface Card)
    gStack.CreateNIC(defaultNIC, ep)

    // Route ALL traffic through this NIC
    gStack.SetRouteTable([]tcpip.Route{
        {Destination: header.IPv4EmptySubnet, NIC: defaultNIC},
        {Destination: header.IPv6EmptySubnet, NIC: defaultNIC},
    })

    // Enable spoofing (accept any destination IP)
    gStack.SetSpoofing(defaultNIC, true)
    // Enable promiscuous (accept any destination MAC)
    gStack.SetPromiscuousMode(defaultNIC, true)

    // TCP tuning
    gStack.SetTransportProtocolOption(tcp.ProtocolNumber,
        &tcpip.CongestionControlOption("cubic"))
    gStack.SetTransportProtocolOption(tcp.ProtocolNumber,
        &tcpip.TCPSACKEnabled(true))
    // ... buffer sizes, etc.
}

Spoofing + Promiscuous are essential: without them, gVisor would only accept packets destined for its own IP, but TUN traffic has arbitrary destination IPs.

The link endpoint bridges gVisor's packet processing to the TUN file descriptor:

go
type tunEndpoint struct {
    tun        Tun
    dispatcher stack.NetworkDispatcher
    mtu        uint32
}
  • Inbound: Reads raw packets from TUN fd → delivers to gVisor stack
  • Outbound: gVisor generates response packets → writes to TUN fd

Implementation Notes

  1. gVisor is the heavy dependency: It's a full userspace TCP/IP stack (~100K+ lines). Consider alternatives like lwIP, smoltcp, or platform-specific APIs.

  2. TUN is platform-specific: Each OS has different TUN creation APIs. On Android, the fd is passed from the VPN service.

  3. No listening port: TUN inbound declares Network() []net.Network { return []net.Network{} } — it doesn't listen on any port. Traffic comes from the TUN device.

  4. DispatchLink is synchronous: Unlike Dispatch(), DispatchLink() blocks. The goroutine is per-connection (created by gVisor's forwarder).

  5. TCP buffer sizes matter: The gVisor TCP buffer sizes (8MB RX, 6MB TX) are tuned for high throughput. Too small → poor performance. Too large → memory waste.

  6. RACK/TLP disabled: TCPRecovery(0) disables RACK/TLP loss recovery to fix connection stalls under high load — a known gVisor quirk.

Technical analysis for re-implementation purposes.