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.