Skip to content

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

mermaid
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]
    end

Key Components

ComponentFilePurpose
Handler (outbound)proxy/wireguard/client.goOutbound WireGuard tunnel
Server (inbound)proxy/wireguard/server.goInbound WireGuard endpoint
Tunnel interfaceproxy/wireguard/tun.goAbstract TUN device
gvisorNetproxy/wireguard/tun.gogVisor-based virtual TUN
netBindClientproxy/wireguard/bind.goClient-side UDP transport
netBindServerproxy/wireguard/bind.goServer-side UDP transport
gvisortun packageproxy/wireguard/gvisortun/gVisor network stack setup

Tunnel Interface

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

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

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

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

go
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

go
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):

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

go
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):

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

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

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

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

StrategyPreferFallback
FORCE_IPIPv4 + IPv6None
FORCE_IP4IPv4 onlyNone
FORCE_IP6IPv6 onlyNone
FORCE_IP46IPv4IPv6
FORCE_IP64IPv6IPv4

Source: proxy/wireguard/config.go:9-31

Implementation Notes

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

  2. Reserved bytes: The Reserved config 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

  1. Thread safety: The handler uses a sync.Mutex (wgLock) to protect tunnel creation and switching. Multiple concurrent connections share the same tunnel.

  2. Connection lifecycle: Each TCP/UDP connection through the tunnel is independently managed with standard Xray timeout policies. The tunnel itself persists across connections.

  3. gVisor TCP keep-alive: TCP connections through the server-side forwarder have keep-alive enabled to prevent hanging connections:

go
ep.SocketOptions().SetKeepAlive(true)

Source: proxy/wireguard/tun.go:168

  1. UDP linger: Server-side UDP endpoints have a 15-second linger timeout to ensure timely resource release:
go
ep.SocketOptions().SetLinger(tcpip.LingerOption{Enabled: true, Timeout: 15 * time.Second})

Source: proxy/wireguard/tun.go:191-193

  1. 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 = 3 setting reflects this.

Technical analysis for re-implementation purposes.