Skip to content

UDP Full-Cone NAT ​

Обработчик TUN реализует Full-Cone NAT для UDP-трафика, позволяя ответным пакетам с любого удалённого адреса достигать исходного клиента — не только с того адреса, на который пакет был изначально отправлен.

Исходный код: proxy/tun/udp_fullcone.go

Типы NAT ​

Symmetric NAT:       Client:1234 → Server_A:80  ✓
                     Server_B:80 → Client:1234  ✗ (другой сервер, заблокировано)

Full-Cone NAT:       Client:1234 → Server_A:80  ✓
                     Server_B:80 → Client:1234  ✓ (любой сервер может ответить)

Full-Cone NAT необходим для:

  • Приложений WebRTC / P2P
  • Игровых серверов (на основе UDP)
  • Протоколов STUN/TURN
  • Любого протокола, где ответ приходит с другого адреса

Архитектура ​

mermaid
flowchart LR
    subgraph TUN["TUN-устройство"]
        App([Приложение])
    end

    subgraph UDPHandler["Обработчик UDP-соединений"]
        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"]
        Handler["Обработчик TUN"]
        Dispatcher["Диспетчер"]
    end

    App -->|"UDP-пакет<br/>src=10.0.0.1:5000<br/>dst=8.8.8.8:53"| UDPHandler
    UDPHandler -->|"поиск по src"| Map
    Map -->|"новое соединение"| Conn1
    Conn1 -->|"HandleConnection()"| Handler
    Handler -->|"DispatchLink()"| Dispatcher

    Dispatcher -->|"ответный пакет"| Conn1
    Conn1 -->|"writeRawUDPPacket()"| App

Обработчик соединений ​

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
}

Ключевая идея: соединения индексируются только по адресу источника, а не по паре (источник, назначение). Именно это делает NAT типа Full-Cone — любой сервер может отправить ответ на тот же адрес источника.

Приём пакетов ​

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
}

Виртуальное UDP-соединение ​

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

udpConn реализует net.Conn для использования с диспетчером Xray:

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 (с адресом назначения для каждого пакета) ​

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
}

Поле b.UDP позволяет XUDP указывать различный адрес назначения для каждого пакета (Full-Cone: ответ с любого адреса).

Конструирование необработанных пакетов ​

Возвратные UDP-пакеты должны быть сконструированы как необработанные IP-пакеты (поскольку UDP-форвардер gVisor не используется):

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          |
+--+--+--+--+--+--+--+--+

Пакет внедряется обратно в стек gVisor через WriteRawPacket(), который:

  1. Обрабатывает его через сетевой уровень gVisor
  2. Доставляет в TUN endpoint
  3. TUN-устройство отправляет его в ядро
  4. Ядро доставляет его приложению

Жизненный цикл соединения ​

mermaid
sequenceDiagram
    participant App as Приложение
    participant TUN
    participant UH as Обработчик UDP
    participant UC as udpConn
    participant Xray as Диспетчер Xray

    App->>TUN: UDP-пакет (src=A, dst=B)
    TUN->>UH: HandlePacket(A, B, data)
    UH->>UH: udpConns[A] не найден
    UH->>UC: Создание udpConn(src=A, dst=B)
    UH-->>Xray: go HandleConnection(conn, B)
    UH->>UC: egress <- data

    Xray->>UC: Read() [блокируется на канале egress]
    UC-->>Xray: data
    Xray->>Xray: Маршрутизация + пересылка на outbound

    Note over Xray: Ответ приходит (возможно от C, а не от B)

    Xray->>UC: Write(response) или WriteMultiBuffer
    UC->>UH: writeRawUDPPacket(response, B→A)
    UH->>TUN: Необработанный IP+UDP-пакет
    TUN->>App: UDP-ответ

    App->>TUN: Ещё один пакет (src=A, dst=D)
    TUN->>UH: HandlePacket(A, D, data)
    UH->>UH: udpConns[A] существует!
    UH->>UC: egress <- data (повторное использование соединения)

    Note over UC: Соединение закрывается, когда Xray завершает работу
    UC->>UH: connectionFinished(A)
    UH->>UH: delete udpConns[A]

Замечания по реализации ​

  1. Карта по источнику: Карта соединений индексируется только по источнику (map[net.Destination]*udpConn). Это Full-Cone: одно соединение на каждый адрес источника клиента, независимо от адреса назначения.

  2. Буферизация канала: Канал egress имеет ёмкость 16. Если он заполнен, пакеты отбрасываются. Это предотвращает проблемы с памятью, но может приводить к потере UDP-пакетов при пиковой нагрузке.

  3. Нет очистки по тайм-ауту: Нет явной очистки неактивных UDP-соединений по тайм-ауту. Соединение очищается, когда диспетчер Xray завершает работу (по тайм-ауту бездействия диспетчера).

  4. Проверка семейства адресов: Ответные пакеты должны соответствовать семейству адресов (IPv4 или IPv6) исходного соединения. Пакеты со смешанными семействами адресов молча отбрасываются.

  5. Контрольные суммы необработанных пакетов: Контрольные суммы IP и UDP должны быть вычислены корректно. IPv4 требует контрольную сумму заголовка + контрольную сумму UDP с псевдозаголовком. IPv6 требует только контрольную сумму UDP с псевдозаголовком.

  6. Адресация для каждого пакета: Поле Buffer.UDP позволяет ответам XUDP иметь различные адреса источника для каждого пакета. Это необходимо для протоколов, где сервер отвечает с адреса, отличного от того, на который клиент отправлял запрос.

Технический анализ для целей повторной реализации.