WireGuard
Xray-core integrates WireGuard as both an outbound and inbound protocol, using the wireguard-go userspace implementation and gVisor's network stack (netstack) to create virtual tunnel interfaces entirely in user space. This allows WireGuard tunneling without OS-level TUN device privileges (on most platforms).
Overview
- Direction: Inbound + Outbound
- Transport: UDP (WireGuard protocol runs over UDP)
- Encryption: Noise protocol framework (Curve25519, ChaCha20-Poly1305, BLAKE2s)
- Authentication: Public/Private key pairs + optional Pre-Shared Key
- UDP: Full support (via userspace network stack)
- TCP: Full support (via userspace network stack)
- Kernel TUN: Supported on Linux (optional, off by default for inbound)
Architecture
graph TD
subgraph "Xray-core Process"
A[Outbound Handler] --> B[gVisor netstack]
B --> C[wireguard-go Device]
C --> D[netBindClient]
D --> E[internet.Dialer]
end
E --> F[WireGuard Peer UDP]
subgraph "Inbound (Server Mode)"
G[UDP Listener] --> H[netBindServer]
H --> I[wireguard-go Device]
I --> J[gVisor netstack]
J --> K[TCP/UDP Forwarder]
K --> L[Routing Dispatcher]
endKey Components
| Component | File | Purpose |
|---|---|---|
Handler (outbound) | proxy/wireguard/client.go | Outbound WireGuard tunnel |
Server (inbound) | proxy/wireguard/server.go | Inbound WireGuard endpoint |
Tunnel interface | proxy/wireguard/tun.go | Abstract TUN device |
gvisorNet | proxy/wireguard/tun.go | gVisor-based virtual TUN |
netBindClient | proxy/wireguard/bind.go | Client-side UDP transport |
netBindServer | proxy/wireguard/bind.go | Server-side UDP transport |
gvisortun package | proxy/wireguard/gvisortun/ | gVisor network stack setup |
Tunnel Interface
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
}Source: proxy/wireguard/tun.go:33-38
The Tunnel interface abstracts the virtual network device. After calling BuildDevice() with an IPC configuration string, the tunnel provides standard Go net.Conn interfaces for TCP and UDP.
gVisor Network Stack
The primary implementation uses gVisor's userspace TCP/IP stack:
func createGVisorTun(localAddresses []netip.Addr, mtu int,
handler promiscuousModeHandler) (Tunnel, error) {
tun, n, stack, err := gvisortun.CreateNetTUN(localAddresses, mtu, handler != nil)
// ...
}Source: proxy/wireguard/tun.go:139-144
Promiscuous Mode (Server)
When a handler function is provided (server mode), gVisor sets up TCP and UDP forwarders that capture all incoming packets:
// TCP Forwarder
tcpForwarder := tcp.NewForwarder(stack, 0, 65535, func(r *tcp.ForwarderRequest) {
ep, err := r.CreateEndpoint(&wq)
r.Complete(false)
ep.SocketOptions().SetKeepAlive(true)
handler(net.TCPDestination(...), gonet.NewTCPConn(&wq, ep))
})
stack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
// UDP Forwarder
udpForwarder := udp.NewForwarder(stack, func(r *udp.ForwarderRequest) {
ep, _ := r.CreateEndpoint(&wq)
ep.SocketOptions().SetLinger(tcpip.LingerOption{Enabled: true, Timeout: 15 * time.Second})
handler(net.UDPDestination(...), gonet.NewUDPConn(&wq, ep))
})
stack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket)Source: proxy/wireguard/tun.go:150-201
Outbound Handler
File: proxy/wireguard/client.go
Initialization
func New(ctx context.Context, conf *DeviceConfig) (*Handler, error) {
endpoints, hasIPv4, hasIPv6, err := parseEndpoints(conf)
return &Handler{
conf: conf,
dns: d,
endpoints: endpoints,
hasIPv4: hasIPv4,
hasIPv6: hasIPv6,
}, nil
}Source: proxy/wireguard/client.go:62-78
The WireGuard device is lazily initialized on first use and recreated when the dialer changes:
func (h *Handler) processWireGuard(ctx context.Context, dialer internet.Dialer) error {
h.wgLock.Lock()
defer h.wgLock.Unlock()
if h.bind != nil && h.bind.dialer == dialer && h.net != nil {
return nil // Already initialized with same dialer
}
// Create new bind and tunnel
h.bind = &netBindClient{
netBind: netBind{dns: h.dns, ...},
ctx: ctx,
dialer: dialer,
reserved: h.conf.Reserved,
}
h.net, err = h.makeVirtualTun()
}Source: proxy/wireguard/client.go:94-142
Processing Connections
func (h *Handler) Process(ctx context.Context, link *transport.Link,
dialer internet.Dialer) error {
// 1. Initialize/reuse WireGuard tunnel
h.processWireGuard(ctx, dialer)
// 2. Resolve DNS if destination is a domain
if addr.Family().IsDomain() {
ips, _, err = h.dns.LookupIP(addr.Domain(), dns.IPOption{
IPv4Enable: h.hasIPv4 && h.conf.preferIP4(),
IPv6Enable: h.hasIPv6 && h.conf.preferIP6(),
})
}
// 3. Dial through the virtual tunnel
if command == protocol.RequestCommandTCP {
conn, err := h.net.DialContextTCPAddrPort(ctx, addrPort)
// Copy data bidirectionally
} else {
conn, err := h.net.DialUDPAddrPort(netip.AddrPort{}, addrPort)
// Copy data bidirectionally
}
}Source: proxy/wireguard/client.go:145-252
IPC Configuration
The WireGuard device is configured via an IPC request string (same format as wg set):
func (h *Handler) createIPCRequest() string {
var request strings.Builder
request.WriteString(fmt.Sprintf("private_key=%s\n", h.conf.SecretKey))
for _, peer := range h.conf.Peers {
request.WriteString(fmt.Sprintf("public_key=%s\n", peer.PublicKey))
if peer.PreSharedKey != "" {
request.WriteString(fmt.Sprintf("preshared_key=%s\n", peer.PreSharedKey))
}
request.WriteString(fmt.Sprintf("endpoint=%s:%s\n", addr, port))
for _, ip := range peer.AllowedIps {
request.WriteString(fmt.Sprintf("allowed_ip=%s\n", ip))
}
if peer.KeepAlive != 0 {
request.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", peer.KeepAlive))
}
}
return request.String()
}Source: proxy/wireguard/client.go:272-338
Endpoint DNS resolution: If the peer endpoint is a domain, it is resolved using either the dialer's pre-resolved IP or the DNS client:
if addr.Family().IsDomain() {
dialerIp := h.bind.dialer.DestIpAddress()
if dialerIp != nil {
addr = net.ParseAddress(dialerIp.String())
} else {
ips, _, _ = h.dns.LookupIP(addr.Domain(), ...)
addr = net.IPAddress(ips[dice.Roll(len(ips))])
}
}Source: proxy/wireguard/client.go:296-321
Inbound Handler (Server)
File: proxy/wireguard/server.go
Network Support
The server only accepts UDP connections (WireGuard's transport protocol):
func (*Server) Network() []net.Network {
return []net.Network{net.Network_UDP}
}Source: proxy/wireguard/server.go:75-77
Packet Processing
The server reads UDP packets from the transport layer and feeds them into the WireGuard device:
func (s *Server) Process(ctx context.Context, network net.Network,
conn stat.Connection, dispatcher routing.Dispatcher) error {
reader := buf.NewPacketReader(conn)
for {
mpayload, err := reader.ReadMultiBuffer()
for _, payload := range mpayload {
v, ok := <-s.bindServer.readQueue
// Feed packet to wireguard-go
v.bytes = payload.Read(v.buff)
v.endpoint = nep
v.waiter.Done()
}
}
}Source: proxy/wireguard/server.go:80-120
Connection Forwarding
When the gVisor stack receives decrypted TCP/UDP connections from the tunnel, the forwardConnection handler dispatches them:
func (s *Server) forwardConnection(dest net.Destination, conn net.Conn) {
defer conn.Close()
link, err := s.info.dispatcher.Dispatch(ctx, dest)
// Bidirectional copy between tunnel conn and dispatched link
task.Run(ctx, requestDone, responseDone)
}Source: proxy/wireguard/server.go:122-190
TUN Mode Selection
File: proxy/wireguard/config.go
The handler chooses between kernel TUN and gVisor TUN:
func (c *DeviceConfig) createTun() tunCreator {
if !c.IsClient {
return createGVisorTun // Server always uses gVisor
}
if c.NoKernelTun {
return createGVisorTun
}
if kernelTunSupported, _ := KernelTunSupported(); !kernelTunSupported {
return createGVisorTun
}
return createKernelTun // Linux only
}Source: proxy/wireguard/config.go:33-54
Kernel TUN (Linux)
File: proxy/wireguard/tun_linux.go
On Linux with appropriate permissions, a real kernel TUN device can be used for better performance. The kernel handles TCP/IP processing natively.
Default TUN (Other platforms)
File: proxy/wireguard/tun_default.go
On non-Linux platforms, only gVisor TUN is available.
Domain Strategy
WireGuard supports DNS resolution strategies with fallback:
| Strategy | Prefer | Fallback |
|---|---|---|
FORCE_IP | IPv4 + IPv6 | None |
FORCE_IP4 | IPv4 only | None |
FORCE_IP6 | IPv6 only | None |
FORCE_IP46 | IPv4 | IPv6 |
FORCE_IP64 | IPv6 | IPv4 |
Source: proxy/wireguard/config.go:9-31
Implementation Notes
Lazy initialization: The WireGuard tunnel is not created at configuration time. It is initialized on the first
Process()call and reused for subsequent connections. If the dialer changes (e.g., different outbound routing), the tunnel is recreated.Reserved bytes: The
Reservedconfig field sets 3 bytes in the WireGuard UDP header, used by some providers (e.g., Cloudflare WARP) for routing.
Source: proxy/wireguard/client.go:129
Thread safety: The handler uses a
sync.Mutex(wgLock) to protect tunnel creation and switching. Multiple concurrent connections share the same tunnel.Connection lifecycle: Each TCP/UDP connection through the tunnel is independently managed with standard Xray timeout policies. The tunnel itself persists across connections.
gVisor TCP keep-alive: TCP connections through the server-side forwarder have keep-alive enabled to prevent hanging connections:
ep.SocketOptions().SetKeepAlive(true)Source: proxy/wireguard/tun.go:168
- UDP linger: Server-side UDP endpoints have a 15-second linger timeout to ensure timely resource release:
ep.SocketOptions().SetLinger(tcpip.LingerOption{Enabled: true, Timeout: 15 * time.Second})Source: proxy/wireguard/tun.go:191-193
- No encryption in Xray layer: WireGuard handles all encryption internally via the Noise protocol. Xray simply pipes decrypted IP packets through the virtual network stack. The
CanSpliceCopy = 3setting reflects this.