Skip to content

UDP Full-Cone NAT ​

The TUN handler implements Full-Cone NAT for UDP traffic, allowing return packets from any remote address to reach the original client — not just from the address the packet was originally sent to.

Source: proxy/tun/udp_fullcone.go

NAT Types Explained ​

Symmetric NAT:       Client:1234 → Server_A:80  ✓
                     Server_B:80 → Client:1234  ✗ (different server, blocked)

Full-Cone NAT:       Client:1234 → Server_A:80  ✓
                     Server_B:80 → Client:1234  ✓ (any server can respond)

Full-Cone NAT is required for:

  • WebRTC / P2P applications
  • Game servers (UDP-based)
  • STUN/TURN protocols
  • Any protocol where the response comes from a different address

Architecture ​

mermaid
flowchart LR
    subgraph TUN["TUN Device"]
        App([Application])
    end

    subgraph UDPHandler["UDP Connection Handler"]
        Map["Connection Map<br/>src → udpConn"]
        Conn1["udpConn A<br/>(src=10.0.0.1:5000)"]
        Conn2["udpConn B<br/>(src=10.0.0.1:6000)"]
    end

    subgraph Xray["Xray Routing"]
        Handler["TUN Handler"]
        Dispatcher["Dispatcher"]
    end

    App -->|"UDP packet<br/>src=10.0.0.1:5000<br/>dst=8.8.8.8:53"| UDPHandler
    UDPHandler -->|"lookup by src"| Map
    Map -->|"new conn"| Conn1
    Conn1 -->|"HandleConnection()"| Handler
    Handler -->|"DispatchLink()"| Dispatcher

    Dispatcher -->|"response packet"| Conn1
    Conn1 -->|"writeRawUDPPacket()"| App

Connection Handler ​

go
type udpConnectionHandler struct {
    sync.Mutex
    udpConns map[net.Destination]*udpConn  // keyed by SOURCE address
    handleConnection func(conn net.Conn, dest net.Destination)
    writePacket      func(data []byte, src, dst net.Destination) error
}

The key insight: connections are mapped by source address only, not by (source, destination) pair. This is what makes it Full-Cone — any server can send back to the same source.

Packet Ingress ​

go
func (u *udpConnectionHandler) HandlePacket(src, dst net.Destination, data []byte) bool {
    u.Lock()
    conn, found := u.udpConns[src]
    if !found {
        // New source: create connection
        egress := make(chan []byte, 16)
        conn = &udpConn{handler: u, egress: egress, src: src, dst: dst}
        u.udpConns[src] = conn

        // Dispatch to Xray routing (in goroutine)
        go u.handleConnection(conn, dst)
    }
    u.Unlock()

    // Forward packet data to the connection's egress channel
    select {
    case conn.egress <- data:  // delivered
    default:                    // channel full, discard
    }
    return true
}

Virtual UDP Connection ​

go
type udpConn struct {
    handler *udpConnectionHandler
    egress  chan []byte        // incoming packets (from TUN)
    src     net.Destination    // client source address
    dst     net.Destination    // original destination
}

The udpConn implements net.Conn for use with Xray's dispatcher:

go
// Read: receive packets from the egress channel
func (c *udpConn) Read(p []byte) (int, error) {
    data, ok := <-c.egress
    if !ok { return 0, io.EOF }
    return copy(p, data), nil
}

// Write: construct raw UDP packet back to source
func (c *udpConn) Write(p []byte) (int, error) {
    // REVERSE src/dst: response goes from dst → src
    err := c.handler.writePacket(p, c.dst, c.src)
    return len(p), err
}

WriteMultiBuffer (with per-packet destination) ​

go
func (c *udpConn) WriteMultiBuffer(mb buf.MultiBuffer) error {
    for _, b := range mb {
        dst := c.dst
        if b.UDP != nil {
            dst = *b.UDP  // Use per-packet destination from XUDP
        }
        // Validate address family matches
        if dst.Address.Family() != c.dst.Address.Family() {
            continue
        }
        // Send reversed: dst→src
        c.handler.writePacket(b.Bytes(), dst, c.src)
    }
    return nil
}

The b.UDP field allows XUDP to specify a different destination for each packet (Full-Cone: return from any address).

Raw Packet Construction ​

Return UDP packets must be constructed as raw IP packets (since gVisor's UDP forwarder isn't used):

IPv4 Packet:
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Ver|IHL|  TOS  |         Total Length           |    TTL=64  |
| Protocol=17(UDP)|  Header Checksum  |   Source IP (4 bytes)  |
|  Destination IP (4 bytes)  |                                  |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
UDP Header:
+--+--+--+--+--+--+--+--+
| Src Port  | Dst Port  |
|  Length    | Checksum  |
+--+--+--+--+--+--+--+--+
|       Payload          |
+--+--+--+--+--+--+--+--+

The packet is injected back into the gVisor stack via WriteRawPacket(), which:

  1. Processes it through gVisor's network layer
  2. Delivers it to the TUN endpoint
  3. TUN device sends it to the kernel
  4. Kernel delivers it to the application

Connection Lifecycle ​

mermaid
sequenceDiagram
    participant App as Application
    participant TUN
    participant UH as UDP Handler
    participant UC as udpConn
    participant Xray as Xray Dispatcher

    App->>TUN: UDP packet (src=A, dst=B)
    TUN->>UH: HandlePacket(A, B, data)
    UH->>UH: udpConns[A] not found
    UH->>UC: Create udpConn(src=A, dst=B)
    UH-->>Xray: go HandleConnection(conn, B)
    UH->>UC: egress <- data

    Xray->>UC: Read() [blocks on egress channel]
    UC-->>Xray: data
    Xray->>Xray: Route + forward to outbound

    Note over Xray: Response arrives (possibly from C, not B)

    Xray->>UC: Write(response) or WriteMultiBuffer
    UC->>UH: writeRawUDPPacket(response, B→A)
    UH->>TUN: Raw IP+UDP packet
    TUN->>App: UDP response

    App->>TUN: Another packet (src=A, dst=D)
    TUN->>UH: HandlePacket(A, D, data)
    UH->>UH: udpConns[A] exists!
    UH->>UC: egress <- data (reuse connection)

    Note over UC: Connection closed when Xray finishes
    UC->>UH: connectionFinished(A)
    UH->>UH: delete udpConns[A]

Implementation Notes ​

  1. Source-keyed map: The connection map is keyed by source only (map[net.Destination]*udpConn). This is Full-Cone: one connection per client source, regardless of destination.

  2. Channel buffering: The egress channel has capacity 16. If it's full, packets are dropped. This prevents memory issues but may lose UDP packets under burst load.

  3. No timeout cleanup: There's no explicit timeout-based cleanup of idle UDP connections. The connection is cleaned up when the Xray dispatcher finishes (triggered by the dispatcher's idle timeout).

  4. Address family validation: Return packets must match the address family (IPv4 or IPv6) of the original connection. Mixed-family packets are silently dropped.

  5. Raw packet checksums: Both IP and UDP checksums must be calculated correctly. IPv4 needs header checksum + pseudo-header UDP checksum. IPv6 needs only pseudo-header UDP checksum.

  6. Per-packet addressing: The Buffer.UDP field allows XUDP responses to have different source addresses per packet. This is essential for protocols where the server responds from a different address than the one the client sent to.

Technical analysis for re-implementation purposes.