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
- Любого протокола, где ответ приходит с другого адреса
Архитектура
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Обработчик соединений
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 — любой сервер может отправить ответ на тот же адрес источника.
Приём пакетов
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-соединение
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:
// 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 (с адресом назначения для каждого пакета)
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(), который:
- Обрабатывает его через сетевой уровень gVisor
- Доставляет в TUN endpoint
- TUN-устройство отправляет его в ядро
- Ядро доставляет его приложению
Жизненный цикл соединения
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]Замечания по реализации
Карта по источнику: Карта соединений индексируется только по источнику (
map[net.Destination]*udpConn). Это Full-Cone: одно соединение на каждый адрес источника клиента, независимо от адреса назначения.Буферизация канала: Канал egress имеет ёмкость 16. Если он заполнен, пакеты отбрасываются. Это предотвращает проблемы с памятью, но может приводить к потере UDP-пакетов при пиковой нагрузке.
Нет очистки по тайм-ауту: Нет явной очистки неактивных UDP-соединений по тайм-ауту. Соединение очищается, когда диспетчер Xray завершает работу (по тайм-ауту бездействия диспетчера).
Проверка семейства адресов: Ответные пакеты должны соответствовать семейству адресов (IPv4 или IPv6) исходного соединения. Пакеты со смешанными семействами адресов молча отбрасываются.
Контрольные суммы необработанных пакетов: Контрольные суммы IP и UDP должны быть вычислены корректно. IPv4 требует контрольную сумму заголовка + контрольную сумму UDP с псевдозаголовком. IPv6 требует только контрольную сумму UDP с псевдозаголовком.
Адресация для каждого пакета: Поле
Buffer.UDPпозволяет ответам XUDP иметь различные адреса источника для каждого пакета. Это необходимо для протоколов, где сервер отвечает с адреса, отличного от того, на который клиент отправлял запрос.