TUN
System-wide VPN mode: capture every packet via a virtual interface.
A SOCKS or HTTP inbound only tunnels apps that agree to use it. Anything without proxy
settings — games, smart TVs, the OS itself — goes straight out the default gateway. The tun inbound closes that gap: it creates a virtual network interface, and whatever the OS routes
into that interface gets proxied, no app cooperation required.
The inbound that never opens a port
TUN is not a listener — its input is a network interface, so listen and port are
ignored. The handler’s Network() returns an empty slice and Process() is a no-op stub;
both exist only to satisfy the proxy.Inbound interface. Instead of binding a socket, Init opens /dev/net/tun (a Wintun adapter on Windows, utunN on macOS; on Android and iOS a
pre-opened fd from VpnService/NetworkExtension is handed over via the xray.tun.fd env
var), sets the MTU, and brings the link up:
{
"inbounds": [
{
"port": 0,
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1492
}
}
]
} That is the entire config surface — name, MTU, user_level. Xray deliberately configures
no routes or rules on the interface, and on Linux no addresses either: the OS owns those, so
your routing stays consistent in one place (systemd-networkd, ip route, route add). The one
exception is macOS, where routing refuses a tunnel interface without addresses, so Xray assigns
the utun a synthetic link-local pair from 169.254.10.1/30.
A userspace TCP/IP stack turns packets back into streams
Xray can only route streams, so the raw IP packets from the interface are fed to gVisor’s
netstack, which reassembles them into net.Conns. A dispatch loop reads packets off the tun
device, reads the IP version nibble from the first byte to tag each one as IPv4 or IPv6
(anything else is discarded), and delivers it to the stack — gVisor’s fdbased endpoint does
this on Linux and Android, Xray’s own LinkEndpoint on macOS and Windows. The NIC is
created with spoofing and promiscuous mode enabled, so the stack accepts traffic for any destination address — it is impersonating the entire internet.
For TCP, tcp.NewForwarder catches every new flow: r.CreateEndpoint(&wq) performs the
three-way handshake in userspace, then hands the resulting connection to the handler with the
gVisor-side local address as the destination:
t.handler.HandleConnection(
gonet.NewTCPConn(&wq, ep),
// local address on the gVisor side is connection destination
net.TCPDestination(net.IPAddress(id.LocalAddress.AsSlice()), net.Port(id.LocalPort)),
) From there it is an ordinary Xray session: HandleConnection builds an inbound context (tag,
sniffing settings, user level) and calls dispatcher.DispatchLink — the same routing pipeline
every other inbound uses.
UDP gets full-cone NAT by keying on the source alone
A UDP “connection” is tracked only by its source addr:port — not the (source,
destination) pair a symmetric NAT would use. The custom handler in udp_fullcone.go replaces
gVisor’s strict forwarder: the first packet from a source creates a udpConn entry in udpConns[src] and dispatches it; every later packet from that source rides the same session
regardless of where it’s headed, and WriteMultiBuffer honors each reply buffer’s own b.UDP source address. Any remote host can therefore send packets back through the mapping — the
definition of full-cone NAT, and what makes NAT-punching games and VoIP work.
Replies never touch a gVisor UDP endpoint, because none was ever bound. Instead writeRawUDPPacket hand-builds the entire reply — UDP header, pseudo-header checksum, IPv4 or
IPv6 header — and injects it with stack.WriteRawPacket, so the OS sees a perfectly normal
inbound datagram on the interface.
One footgun: each session’s channel from the app toward the dispatcher buffers at most 16 packets; when the dispatcher can’t keep up, extra datagrams are silently discarded — real networks drop UDP too, so applications already tolerate this.
proxy/tun/udp_fullcone.goRouting everything through the tunnel is a loop, not a config
Xray’s own uplink traffic is subject to the same routing table — point the default route at xray0 naively and Xray tries to reach its proxy server through itself, forever.
Route 0.0.0.0/0 at the tun and call it done
Xray’s connection to the VLESS server now also enters xray0, gets proxied into itself, and
the network locks up in an infinite loop.
Pin the uplink route first, or use a separate route table
ip route add 123.123.123.123/32 via <provider gateway ip>
ip route add 0.0.0.0/0 dev xray0 Or keep xray0 as the default in a dedicated table (e.g. 1001) and steer LAN clients into it
with ip rules — the README walks through both.
Also remember: TUN is IP-to-IP, so DNS resolution happens before packets reach the interface. Unless your resolver’s traffic is itself routed through the tunnel, DNS queries leak every hostname you visit.
proxy/tun/README.md