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 иметь различные адреса источника для каждого пакета. Это необходимо для протоколов, где сервер отвечает с адреса, отличного от того, на который клиент отправлял запрос.

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