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
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:
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): Usesioctlto create TUN device - macOS (
tun_darwin.go): Usesutunsystem 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:
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:
type Handler struct {
ctx context.Context
config *Config
stack Stack
policyManager policy.Manager
dispatcher routing.Dispatcher
tag string
sniffingRequest session.SniffingRequest
}Lifecycle
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:
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:
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
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.
Link Endpoint (stack_gvisor_endpoint.go)
The link endpoint bridges gVisor's packet processing to the TUN file descriptor:
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
gVisor is the heavy dependency: It's a full userspace TCP/IP stack (~100K+ lines). Consider alternatives like lwIP, smoltcp, or platform-specific APIs.
TUN is platform-specific: Each OS has different TUN creation APIs. On Android, the fd is passed from the VPN service.
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.DispatchLink is synchronous: Unlike
Dispatch(),DispatchLink()blocks. The goroutine is per-connection (created by gVisor's forwarder).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.
RACK/TLP disabled:
TCPRecovery(0)disables RACK/TLP loss recovery to fix connection stalls under high load — a known gVisor quirk.