本文是 TINC 源码系列分析的第三篇,重点探讨 TINC 如何在 NAT 环境下建立 P2P 直连。通过 UDP 打洞技术,TINC 能够让位于不同 NAT 后的节点直接通信,大幅提升性能。

1. UDP 打洞概览

问题背景

在互联网上,大多数终端设备都位于 NAT(Network Address Translation)或防火墙后面。这意味着两个内网节点无法直接建立 P2P 连接——它们的内网地址在公网上不可见。传统的解决方案是使用中央服务器中继,但这会增加延迟和服务器成本。

核心概念

UDP 打洞 是一种 NAT 穿透技术,允许两个在 NAT 后面的节点建立直接的 UDP 连接。基本原理是:

  1. 两个节点都在 NAT 后
  2. 无法直接建立 P2P 连接
  3. 通过第三方(根节点)转发连接信息
  4. 两个节点同时向对方发送 UDP 包
  5. NAT 记住出站连接,允许回复通过
  6. 建立直接的 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 标志

原因可能

  1. 节点在 Symmetric NAT 后
  2. 防火墙阻止 UDP
  3. ISP 限制 UDP
  4. 路由策略问题

解决

  • 确认两节点都支持 UDP
  • 检查防火墙规则:sudo ufw allow 1194/udp
  • 尝试 UPnP 端口映射(如支持)
  • 确认 UDP_INFO 消息交换成功

问题 2: 间歇性 UDP 中断

现象:UDP 时而工作,时而不工作

原因

  1. 网络波动导致超时
  2. NAT 刷新时间过短
  3. MTU 设置不当导致分片丢失

解决

  1. 降低 UDP 探针超时:udp_probe_timeout = 15
  2. 增加打洞频率:udp_probe_interval = 2
  3. 调整 MTU:minmtu = 1280

问题 3: MTU 不匹配

现象:大包通过 UDP 时丢失

原因:中间网络 MTU < 节点配置的 MTU

解决

  1. 启用 MTU 自动探测
  2. 手动配置:PMTU_SIZE 1280
  3. 启用 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 软件的最佳实践。


系列预告