Contents

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.

proxy/tun/handler.go proxy/tun/tun_linux.go proxy/tun/tun_darwin.go proxy/tun/README.md

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.

Raw packets become flows: OS routing → TUN fd → gVisor netstack → dispatcher → outbound
proxy/tun/stack_gvisor.go proxy/tun/stack_gvisor_endpoint.go

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

Routing 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.

Don't

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.

Do

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