Contents

WireGuard

Running a userspace WireGuard endpoint as an outbound.

Xray’s wireguard outbound is not a wrapper around the kernel’s WireGuard — it is a complete WireGuard endpoint living inside the Xray process: wireguard-go does the Noise crypto, and a gVisor network stack plays the role of the TUN device. No root, no wg-quick, no interface shows up in ip link. Every connection Xray routes to this outbound is injected into that private network stack and comes out the other side as encrypted WireGuard UDP.

The entire VPN runs in one process, with no TUN device

A normal WireGuard setup needs a kernel interface; Xray replaces it with gVisor’s userspace TCP/IP stack. makeVirtualTun builds a Tunnel whose “network card” is gvisortun.CreateNetTUN — packets wireguard-go decrypts are parsed by gVisor, and when the handler needs a connection through the tunnel it just dials into that stack:

proxy/wireguard/client.go proxy/wireguard/tun.go
// tun.go — the whole "interface" is this in-memory object
type Tunnel interface {
	BuildDevice(ipc string, bind conn.Bind) error
	DialContextTCPAddrPort(ctx context.Context, addr netip.AddrPort) (net.Conn, error)
	DialUDPAddrPort(laddr, raddr netip.AddrPort) (net.Conn, error)
	Close() error
}

Process in client.go resolves the target with Xray’s internal DNS (following domainStrategy, with forceIPv4v6/forceIPv6v4 retrying the other family), then calls h.net.DialContextTCPAddrPort — or DialUDPAddrPort for UDP — a handshake performed entirely between gVisor and the remote side, through the encrypted tunnel. There is one exception: on Linux (not Android), a client running with CAP_NET_ADMIN uses a real kernel TUN (createKernelTun in tun_linux.go) unless you set "noKernelTun": true; everywhere else, and always for the inbound, createTun() falls back to gVisor.

WireGuard’s UDP socket is actually an Xray dialer

wireguard-go never opens a socket here — Xray hands it a custom conn.Bind whose send path is dialer.Dial. That is the whole trick that makes this outbound composable. netBindClient in bind.go implements wireguard-go’s conn.Bind interface: the first Send to an endpoint calls bind.dialer.Dial(ctx, endpoint.dst) (via connectTo), caches the connection, and writes the encrypted datagrams into it; a goroutine per endpoint pumps received bytes into a readQueue that the device’s receive workers block on.

proxy/wireguard/bind.go
Plain traffic enters the in-process netstack; the encrypted WG packets leave through a normal Xray dialer — so any transport can carry them

Because the dialer is the standard internet.Dialer every outbound gets, the WireGuard packets inherit the outbound’s streamSettings — including sockopt.dialerProxy, which forwards the “UDP” flow through another outbound entirely. That is how you run WireGuard where raw UDP dies: chain it inside VLESS-over-WebSocket, and the censor sees only your outer TLS.

{
	"protocol": "wireguard",
	"settings": {
		"secretKey": "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=",
		"address": ["172.16.0.2/32"],
		"peers": [
			{
				"publicKey": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
				"endpoint": "engage.cloudflareclient.com:2408"
			}
		],
		"reserved": [78, 135, 76]
	},
	"streamSettings": {
		"sockopt": { "dialerProxy": "vless-ws-out" }
	}
}

Note also that nothing is built at startup: processWireGuard constructs the bind and the device on the first proxied connection, and tears down and rebuilds both whenever Xray hands it a different dialer.

The inbound listens with Xray’s ear, not WireGuard’s

As an inbound, wireguard-go never binds a port either — listen_port=1337 in the IPC config is a decoy. The comment in createIPCRequest says it outright: “placeholder, we’ll handle actual port listening on Xray”. Server.Process receives UDP packets from a normal Xray inbound connection and feeds them straight into the same readQueue the bind’s receive workers consume. Decrypted flows are captured promiscuously: a gVisor tcp.NewForwarder plus a raw UDP handler grab every connection peers open and pass them to forwardConnection, which dispatches them into Xray’s router like any other inbound traffic.

proxy/wireguard/server.go proxy/wireguard/wireguard.go

Chain it when UDP is the problem, not the answer

WireGuard’s handshake has one of the most recognizable signatures in the censorship arms race — chaining hides the packets; running it bare does not.

Don't

Point the outbound straight at a WG endpoint on a hostile network

The fixed-size handshake messages and steady UDP flow are trivially fingerprintable, and many networks drop non-QUIC UDP outright — your tunnel never comes up.

Do

Ride the WG packets inside an existing TLS tunnel

Set sockopt.dialerProxy to a VLESS/Trojan outbound. The bind turns each WG “socket” into a stream through that outbound, so the wire shows only your outer transport — while apps still get real IP-level VPN semantics from the netstack.