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:
// 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.
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.
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.
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.
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.