TINC 源码解读 第三篇 - UDP 打洞
本文是 TINC 源码系列分析的第三篇,重点探讨 TINC 如何在 NAT 环境下建立 P2P 直连。通过 UDP 打洞技术,TINC 能够让位于不同 NAT 后的节点直接通信,大幅提升性能。
1. UDP 打洞概览⌗
问题背景⌗
在互联网上,大多数终端设备都位于 NAT(Network Address Translation)或防火墙后面。这意味着两个内网节点无法直接建立 P2P 连接——它们的内网地址在公网上不可见。传统的解决方案是使用中央服务器中继,但这会增加延迟和服务器成本。
核心概念⌗
UDP 打洞 是一种 NAT 穿透技术,允许两个在 NAT 后面的节点建立直接的 UDP 连接。基本原理是:
- 两个节点都在 NAT 后
- 无法直接建立 P2P 连接
- 通过第三方(根节点)转发连接信息
- 两个节点同时向对方发送 UDP 包
- NAT 记住出站连接,允许回复通过
- 建立直接的 UDP 通道
优势:
- ✅ 避免 TCP 中继的开销
- ✅ 降低延迟
- ✅ 减少根节点流量
- ✅ 提高可靠性和吞吐量
2. TINC 中的 UDP 打洞机制⌗
节点状态位⌗
TINC 使用位字段记录节点状态,其中 udp_confirmed 是关键标志位,表示 UDP 连接已双向确认可用:
typedef union node_status_t {
struct {
bool validkey: 1; // 有有效的加密密钥
bool waitingforkey: 1; // 等待密钥回复
bool visited: 1; // Dijkstra 访问标记
bool reachable: 1; // 在拓扑中可达
bool indirect: 1; // 不可直接到达
bool sptps: 1; // 支持 SPTPS 握手
bool udp_confirmed: 1; // ★ UDP 已确认可用
bool send_locally: 1; // 从本地网络发送
bool udppacket: 1; // 最后收到 UDP 包
bool validkey_in: 1; // 已发送有效密钥给对方
bool has_address: 1; // 知道对方公网地址
bool ping_sent: 1; // 已发送 UDP 探针包
};
uint32_t value;
} node_status_t;
关键状态变量⌗
struct node_t {
// UDP 直连相关
bool udp_confirmed; // UDP 连接已确认双向
address_cache_t *address_cache; // 最近地址缓存
// 打洞探测相关
bool ping_sent; // UDP 探针已发送
struct timeval udp_ping_sent; // 探针发送时间
int udp_ping_rtt; // 往返时间 (微秒)
// MTU 探测
int mtuprobes; // MTU 探针计数
uint16_t mtu; // 当前 MTU
uint16_t minmtu; // 最小 MTU
uint16_t maxmtu; // 最大 MTU
// 包大小统计
uint32_t maxrecentlen; // 最近最大包长
};
3. UDP 打洞流程⌗
详细步骤⌗
步骤 1: TCP 握手连接⌗
Node A Root Server Node B
| | |
|------ TCP CONNECT ---->| |
| |<------ TCP CONNECT ------|
| | |
步骤 2: 交换拓扑信息⌗
Node A Root Server Node B
| | |
|- ADD_EDGE A, Root ---->| |
| | (Root learns A's addr) |
| | |
| |-- ADD_EDGE B, Root ----->|
| | |
|<- ADD_EDGE B, Root ----| |
| (A learns B exists) | |
步骤 3: 密钥交换⌗
Node A Root Server Node B
| | |
|-- REQ_KEY A->B ------->| |
| (request B's key) | |
| |---- REQ_KEY A->B ------->|
| | |
| |<---- ANS_KEY B->A -------|
| | (B's key) |
|<-- ANS_KEY B->A -------| |
| (forwarded by Root) | |
步骤 4: UDP 打洞 (关键阶段)⌗
Node A (LAN: 192.168.1.100:1194) Node B (LAN: 192.168.2.50:1194)
| |
| A knows B's public: 203.0.113.20:5001 | B knows A's public: 203.0.113.10:5000
| |
+-- UDP Probe (TYPE=0) ----+ |
| NAT A opens hole: | |
| LAN1194 <-> WAN5000 | |
| +----------->| Forward to B's LAN
| | B's NAT opened
| +------ UDP Reply (TYPE=1) --+
| | NAT B: LAN1194 <-> WAN5001|
| | |
|<-- UDP Reply received <--+ |
| From: 203.0.113.20:5001 |
| NAT A allows reply (hole remains open) |
| |
v (Both sides confirm UDP reachable)
udp_confirmed = true [SUCCESS]
Both NAT holes established and verified
Start direct UDP communication
步骤 5: 直接 UDP 通信⌗
Node A Node B
| |
|-------- Encrypted VPN packet (UDP) ----------->|
| (No longer through Root, direct forward) |
| |
|<------- Encrypted VPN packet (UDP) ------------|
| |
|--------- High-speed bidirectional flow ------->|
代码流程示意⌗
// 1. 发送 UDP 探针
void send_udp_probe_packet(node_t *n, size_t len) {
vpn_packet_t packet;
DATA(packet)[0] = 0; // 探针请求类型
// 发送到 n 的公网地址
send_udppacket(n, &packet);
// 标记已发送
n->status.ping_sent = true;
gettimeofday(&n->udp_ping_sent, NULL);
}
// 2. 接收探针回复
static void udp_probe_h(node_t *n, vpn_packet_t *packet, length_t len) {
// 收到对方的探针回复
if(!n->status.udp_confirmed) {
n->status.udp_confirmed = true; // ✓ 双向 UDP 已建立
// 缓存这个 UDP 地址
if(!n->address_cache) {
n->address_cache = open_address_cache(n);
}
add_recent_address(n->address_cache, &n->connection->address);
}
}
4. 地址缓存机制⌗
缓存结构⌗
为了避免每次都进行 DNS 查询,TINC 维护一个最近地址缓存。缓存采用 MRU(Most Recently Used)策略,保留最近 8 个已验证的 UDP 地址:
#define MAX_CACHED_ADDRESSES 8
typedef struct address_cache_t {
node_t *node; // 关联节点
splay_tree_t *config_tree; // 配置树
config_t *cfg; // 当前配置
addrinfo *ai; // 地址信息
addrinfo *aip; // 当前指针
unsigned int tried; // 尝试计数
// 最近 8 个已验证的 UDP 地址
struct {
unsigned int version; // 缓存版本
unsigned int used; // 已使用数量
sockaddr_t address[8]; // 地址数组 (最近优先)
} data;
} address_cache_t;
缓存操作⌗
- 添加地址:当成功通过 UDP 接收到来自某个地址的包时,将该地址加入缓存
- MRU 策略:最近使用的地址移到第 0 位,其他地址向后移动
- 获取地址:优先尝试缓存的地址,避免 DNS 查询
- 重置缓存:新连接尝试时清空缓存
5. NAT 类型检测与适应⌗
常见 NAT 类型⌗
| NAT 类型 | 打洞成功率 | 特点 |
|---|---|---|
| Full Cone NAT | ✅ 95-99% | 任何外部地址都能回复 |
| Address-Restricted Cone | ✅ 70-90% | 只有之前通信过的源 IP 能回复 |
| Port-Restricted Cone | ⚠️ 30-60% | 只有特定的源 IP:端口能回复 |
| Symmetric NAT | ❌ 0-5% | 每个目标地址分配不同源端口,无法打洞 |
TINC 的适应机制⌗
TINC 在协议选项中编码节点的特性,根据选项决定通信策略:
#define OPTION_INDIRECT 0x0001 // 间接连接(已知是 Symmetric NAT)
#define OPTION_TCPONLY 0x0002 // 仅 TCP,不支持 UDP
#define OPTION_PMTU_DISCOVERY 0x0004
#define OPTION_CLAMP_MSS 0x0008
// 根据选项决定使用策略:
if(e->options & OPTION_TCPONLY) {
// 强制使用 TCP 中继
use_tcp_relay();
} else if(e->options & OPTION_INDIRECT) {
// 尝试打洞,但可能需要中继
attempt_udp_hole_punch();
} else {
// 尽量使用 UDP 直连
prefer_udp_direct();
}
6. UDP 数据传输优化⌗
发送策略⌗
TINC 发送包时会检查 UDP 连接状态,优先使用最快的路径:
static void send_udppacket(node_t *n, vpn_packet_t *origpkt) {
if(n->status.udp_confirmed) {
// ✓ 已确认 UDP 可用
// 1. 使用缓存的 UDP 地址
const sockaddr_t *sa = get_recent_address(n->address_cache);
if(!sa) {
// 缓存无有效地址,降级到 TCP
goto use_tcp;
}
// 2. 加密包
encrypt_packet(n, origpkt);
// 3. 直接通过 UDP 发送
sendto(udp_socket, pkt, len, 0, sa, salen);
} else {
// UDP 未确认,使用 TCP 中继
use_tcp:
send_tcppacket(n->connection, origpkt);
}
}
接收处理⌗
static bool receive_udppacket(node_t *n, vpn_packet_t *inpkt) {
// 收到 UDP 包
// 1. 更新最近地址缓存
add_recent_address(n->address_cache, &peer_addr);
// 2. 解密
decrypt_packet(n, inpkt);
// 3. 路由转发
route_packet(inpkt);
// 4. 如果是首次 UDP 通信
if(!n->status.udp_confirmed) {
n->status.udp_confirmed = true;
logger(LOG_INFO, "UDP direct connection established with %s", n->name);
}
return true;
}
7. 超时与降级处理⌗
超时配置⌗
// 参数配置
timeout_t udp_probe_timeout; // 探针超时
#define UDP_PROBE_INTERVAL 3 // 3 秒发送一次
#define UDP_PROBE_TIMEOUT 10 // 10 秒未响应即超时
// 打洞流程时间线
0s ├─ 发送第 1 个 UDP 探针
│ status.ping_sent = true
│
3s ├─ 发送第 2 个 UDP 探针
│
6s ├─ 发送第 3 个 UDP 探针
│
10s ├─ 超时检查
│ if (now - last_udp_reply > 10s)
│ status.udp_confirmed = false
│ switch to TCP relay
│
... ├─ 继续定期发送 UDP 探针尝试重连
降级逻辑⌗
// TCP/UDP 混合转发 - 智能选择最优路径
void send_packet_to_node(node_t *n, vpn_packet_t *pkt) {
// 优先级 1: UDP 直连(最快)
if(n->status.udp_confirmed && is_recent_success) {
return send_udppacket(n, pkt);
}
// 优先级 2: SPTPS over UDP(新式,安全)
if(n->status.sptps) {
return send_sptps_udppacket(n, pkt);
}
// 优先级 3: TCP 转发(可靠,较慢)
if(n->connection && n->connection->status.active) {
return send_tcppacket(n->connection, pkt);
}
// 优先级 4: 通过其他节点中继(绕行)
if(n->via && n->via != n) {
return send_via_relay(n->via, n, pkt);
}
// 无法送达
return false;
}
8. MTU 探测与优化⌗
MTU 发现流程⌗
建立 UDP 直连后,TINC 会自动探测最优 MTU 大小,避免包分片导致的性能下降。
1. 初始 MTU = 1500 (以太网标准)
2. 发送递增大小的探针包
├─ 1500 字节 ─→ OK ✓
├─ 1450 字节 ─→ OK ✓
├─ 1400 字节 ─→ OK ✓
├─ 1350 字节 ─→ ICMP Fragmented (失败)
│
└─ 回退: MTU = 1350 + padding
3. 最终 MTU 确定后
└─ 所有包都限制在该大小内
└─ 避免分片,提高性能
代码实现:
void mtu_probe_timeout_handler(void *data) {
node_t *n = data;
if(n->mtuprobes == -1) {
// MTU 已确定,不需要探测
return;
}
if(n->mtuprobes++ > MAX_PROBES) {
// 探针已发送足够次数
try_fix_mtu(n); // 确定最终 MTU
return;
}
// 继续发送 MTU 探针
size_t len = n->maxmtu - n->mtuprobes * 128;
send_udp_probe_packet(n, len);
}
9. 协议扩展 (UDP_INFO)⌗
TINC 通过 UDP_INFO 协议消息来交换 UDP 地址信息。这个消息在元协议(Meta Protocol,基于 TCP)中传输。
UDP_INFO 消息格式⌗
格式: UDP_INFO originator_node address_info
流程:
1. Node A → Root: "UDP_INFO myself 203.0.113.10:5000"
(告诉 Root 我的 UDP 地址)
2. Root → Node B: "UDP_INFO nodeA 203.0.113.10:5000"
(告诉 B, A 的 UDP 地址)
3. Node B → Root: "UDP_INFO myself 203.0.113.20:5001"
4. Root → Node A: "UDP_INFO nodeB 203.0.113.20:5001"
实现优势⌗
- 内网节点无法自己检测公网地址,需要第三方帮助
- UDP_INFO 机制允许节点告诉其他节点自己的公网 UDP 地址
- 这是打洞成功的必要条件
10. 实际场景分析⌗
场景 A: 双方都在 Full Cone NAT 后⌗
Node A (10.0.0.5:1194) Node B (10.0.0.6:1194)
NAT: 203.0.113.10:5000 NAT: 203.0.113.20:5001
时间线:
0ms A UDP 包 → Root → Root 转发给 B
NAT A 记录: 内网 1194 ↔ 外网 5000 ↔ 203.0.113.20:5001
5ms B 收到 A 的探针
回复 UDP 包 → 203.0.113.10:5000
NAT B 记录: 内网 1194 ↔ 外网 5001 ↔ 203.0.113.10:5000
10ms A 收到 B 的回复
udp_confirmed = true ✓
后续所有包直接交换,不经过 Root
成功率: ✓ 100% (Full Cone NAT 最容易打洞成功)
场景 B: 一方在 Symmetric NAT 后⌗
Node A (Symmetric NAT) Node B (Full Cone NAT)
A 的 NAT 行为:
每次发送到不同地址时使用不同的源端口
├─ → 203.0.113.20 from 5000
├─ → 203.0.113.30 from 5001
└─ → 203.0.113.40 from 5002
问题:
Root 看到多个不同的源端口,无法告诉 B 一个确定的 UDP 地址
结果: UDP 打洞失败 ❌
降级: 使用 TCP 中继
11. 性能指标⌗
典型延迟与吞吐量⌗
TCP 中继路径:
- 延迟:100-500ms(往返)
- 吞吐量:50-200 Mbps
UDP 直连路径:
- 延迟:10-100ms(往返,取决于网络)
- 吞吐量:200-1000+ Mbps(接近原生网络)
吞吐量提升:UDP 直连相比 TCP 中继提升 5-10 倍
成功率统计⌗
| NAT 场景 | 打洞成功率 | 降级方案 |
|---|---|---|
| Full Cone NAT | 95-99% | TCP 中继 |
| Restricted Cone | 70-90% | TCP 中继 |
| Port-Restricted | 30-60% | TCP 中继 |
| Symmetric NAT | 0-5% | TCP 中继(必须) |
12. 调试与监控⌗
查看打洞状态⌗
# 运行时查询
tinc -n myvpn info <node>
# 输出示例:
Node: remotenode
Address: 203.0.113.20 port 655
Cipher: aes-256-cbc
Digest: sha512
Magic: 12345678
Status: active validkey udp_confirmed
# udp_confirmed 出现 → UDP 直连已建立 ✓
调试日志⌗
# 启用 UDP 调试
RUST_LOG=debug tincd -n myvpn -d DEBUG
# 关键日志:
[DEBUG] Sending UDP probe to nodeB (203.0.113.20:1194)
[DEBUG] Got UDP probe reply from nodeB
[DEBUG] UDP direct connection confirmed with nodeB
[DEBUG] Caching recent address for nodeB: 203.0.113.20:1194
[DEBUG] MTU for nodeB: 1372 bytes
网络捕获⌗
# 捕获 UDP 直连包
tcpdump -i eth0 -n 'udp port 1194' -w vpn.pcap
# 分析包内容
wireshark vpn.pcap
# 查看: 源/目标 IP:端口、包大小、发送频率
13. 常见问题与解决⌗
问题 1: UDP 打洞失败,始终显示 TCP⌗
诊断:
$ tinc -n myvpn info remotenode | grep -i udp
现象:无 udp_confirmed 标志
原因可能:
- 节点在 Symmetric NAT 后
- 防火墙阻止 UDP
- ISP 限制 UDP
- 路由策略问题
解决:
- 确认两节点都支持 UDP
- 检查防火墙规则:
sudo ufw allow 1194/udp - 尝试 UPnP 端口映射(如支持)
- 确认 UDP_INFO 消息交换成功
问题 2: 间歇性 UDP 中断⌗
现象:UDP 时而工作,时而不工作
原因:
- 网络波动导致超时
- NAT 刷新时间过短
- MTU 设置不当导致分片丢失
解决:
- 降低 UDP 探针超时:
udp_probe_timeout = 15 - 增加打洞频率:
udp_probe_interval = 2 - 调整 MTU:
minmtu = 1280
问题 3: MTU 不匹配⌗
现象:大包通过 UDP 时丢失
原因:中间网络 MTU < 节点配置的 MTU
解决:
- 启用 MTU 自动探测
- 手动配置:
PMTU_SIZE 1280 - 启用 PMTU 发现:DF 位设置
总结⌗
TINC 的 UDP 打洞机制展示了如何在复杂的 NAT 环境下实现高效的 P2P 通信。关键设计包括:
核心流程:
TCP 握手 + 拓扑同步
↓
UDP_INFO 交换公网地址
↓
同时发送 UDP 探针(打洞)
↓
建立双向 UDP 连接
↓
优先使用 UDP 直连
↓
失败自动降级到 TCP
关键特性:
- ✅ 自适应 NAT 类型
- ✅ 地址缓存优化
- ✅ 动态 MTU 发现
- ✅ 失败自动降级
- ✅ 支持 IPv4 和 IPv6
- ✅ 低延迟高吞吐量
性能提升:
| 连接方式 | 吞吐量 |
|---|---|
| TCP 中继 | 50-200 Mbps |
| UDP 直连 | 200-1000 Mbps |
| 提升倍数 | 5-10 倍 |
这种精心设计的适应性系统使 TINC 在各种网络环境下都能提供最优的性能,是现代 VPN 软件的最佳实践。
系列预告⌗
- 第一篇:TINC 协议深度解析
- 第二篇:TINC 模块架构与代码组织
- 第三篇:UDP 打洞机制
- 第四篇:TINC 项目 - 模块依赖与数据流图