83、NAT穿透:NAT类型、UDP打洞、STUN协议简介

做网络编程的,迟早要撞上NAT这道墙。

我记得刚入行那会儿,写了个P2P聊天软件,内网两台机器测得好好的,一放到公网就彻底哑火。折腾了两天,最后才发现是NAT在中间捣鬼。从那以后,我就把NAT穿透这块啃了个透。

说白了,NAT就是你家路由器的“门卫”。它把内网的私有IP映射成公网IP,让多个设备共享一个公网地址上网。但问题来了——外网主动找你,门卫不认识你,直接拒之门外。这就是NAT穿透要解决的核心矛盾。

NAT的四种类型

要打洞,先得知道墙长什么样。NAT根据映射和过滤策略,分为四种类型。我习惯用一个表格来对比,清晰明了:

NAT类型 映射规则 过滤规则 穿透难度
完全锥形(Full Cone) 同一内网IP:端口 → 固定公网IP:端口 任何外网主机都可访问 容易
受限锥形(Restricted Cone) 同上 仅允许内网曾发过数据的外网IP访问 中等
端口受限锥形(Port Restricted Cone) 同上 仅允许内网曾发过数据的外网IP:端口访问 较难
对称NAT(Symmetric NAT) 不同目标IP:端口 → 不同公网IP:端口 仅允许内网曾发过数据的目标IP:端口访问 极难

这里有个关键点:对称NAT是UDP打洞的噩梦。我在项目中遇到过好几次,客户端A和B都是对称NAT,UDP打洞成功率几乎为零。这时候就得考虑TURN中继了。

核心结论:打洞成功率取决于NAT类型组合。完全锥形+完全锥形几乎100%成功,对称NAT+对称NAT基本没戏。

UDP打洞原理

UDP打洞,说白了就是利用NAT的“记忆效应”。

你想想看,内网主机A向外网服务器S发了一个UDP包,NAT就会在它的映射表里记一笔:内网A:port ↔ 公网A':port' ↔ 服务器S:port。这时候,如果另一个主机B也往这个公网A':port'发数据,NAT一看——嗯,这个映射还在有效期内,放行!

这就是打洞的核心思路:让双方都先跟一个公共服务器通信,在各自的NAT上建立映射,然后互相往对方的公网地址发数据,把洞“打通”

具体流程我画了个图,你看一眼就明白了:

UDP打洞流程示意图 主机A(内网) 192.168.1.10:5000 STUN服务器S 公网IP:3478 主机B(内网) 10.0.0.5:6000 ① 注册请求 ② 返回A':203.0.113.5:5000 ③ 注册请求 ④ 返回B':198.51.100.7:6000 ⑤ 交换对方公网地址 ⑥ A → B'(打洞包) ⑦ B → A'(打洞包) ⑧ P2P连接建立成功! NAT-A:203.0.113.5 NAT-B:198.51.100.7 虚线表示打洞包,实线表示已建立的P2P连接

实战技巧:打洞包最好多发几次,间隔100ms左右。因为NAT映射有超时时间,有些路由器30秒没流量就关闭映射了。我一般会同时启动一个心跳线程,每15秒发一个空包保活。

STUN协议简介

STUN(Session Traversal Utilities for NAT),说白了就是一个“问路协议”。

你的程序不知道自己在公网眼里长什么样,那就问STUN服务器。STUN服务器会告诉你:“兄弟,你从我这看到的公网IP是xxx.xxx.xxx.xxx,端口是xxxxx”

STUN协议基于UDP,流程非常简单:

  1. 客户端发送Binding Request:往STUN服务器发一个请求,里面带上自己的内网地址。
  2. 服务器返回Binding Response:把客户端在公网侧的IP:端口塞进响应里返回。
  3. 客户端解析:拿到自己的公网地址,就知道NAT映射是什么了。

代码实现其实不复杂,我贴一个最简的STUN客户端核心逻辑:

// 最简STUN客户端 - 获取公网地址
// 完整实现需要处理RFC 5389中的各种属性

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

// STUN消息头
typedef struct {
    uint16_t type;      // 消息类型
    uint16_t length;    // 消息长度(不含头部)
    uint32_t cookie;    // 固定为0x2112A442
    uint8_t  tid[12];   // 事务ID
} stun_header_t;

// 发送Binding Request并解析响应
int stun_get_mapped_addr(const char *server_ip, uint16_t port,
                         struct sockaddr_in *mapped_addr) {
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0) return -1;

    struct sockaddr_in svr_addr;
    svr_addr.sin_family = AF_INET;
    svr_addr.sin_port = htons(port);
    inet_pton(AF_INET, server_ip, &svr_addr.sin_addr);

    // 构造Binding Request
    stun_header_t req;
    memset(&req, 0, sizeof(req));
    req.type = htons(0x0001);  // Binding Request
    req.cookie = htonl(0x2112A442);
    // 生成随机事务ID(简化处理)
    for (int i = 0; i < 12; i++) req.tid[i] = rand() & 0xFF;

    sendto(sock, &req, sizeof(req), 0,
           (struct sockaddr*)&svr_addr, sizeof(svr_addr));

    // 接收响应
    char buf[1024];
    socklen_t addr_len = sizeof(svr_addr);
    int n = recvfrom(sock, buf, sizeof(buf), 0,
                     (struct sockaddr*)&svr_addr, &addr_len);
    if (n < 0) { close(sock); return -1; }

    // 解析MAPPED-ADDRESS属性(属性类型0x0001)
    // 实际代码需要遍历所有属性,这里简化处理
    stun_header_t *resp = (stun_header_t*)buf;
    int offset = sizeof(stun_header_t);
    while (offset + 4 <= n) {
        uint16_t attr_type = ntohs(*(uint16_t*)(buf + offset));
        uint16_t attr_len  = ntohs(*(uint16_t*)(buf + offset + 2));
        if (attr_type == 0x0001) {  // MAPPED-ADDRESS
            uint8_t family = buf[offset + 5];
            if (family == 0x01) {  // IPv4
                mapped_addr->sin_family = AF_INET;
                mapped_addr->sin_port = *(uint16_t*)(buf + offset + 6);
                mapped_addr->sin_addr = *(struct in_addr*)(buf + offset + 8);
                close(sock);
                return 0;
            }
        }
        offset += 4 + attr_len;
        // 对齐到4字节边界
        if (attr_len % 4) offset += 4 - (attr_len % 4);
    }

    close(sock);
    return -1;
}

注意:上面的代码是教学简化版。生产环境中,你需要处理RFC 5389中定义的多种属性,包括FINGERPRINT校验、XOR-MAPPED-ADDRESS(现在主流STUN服务器都返回这个),以及鉴权机制。我曾经在生产环境里忘了处理XOR映射,结果拿到的地址全是错的,排查了一整天。

NAT类型检测

知道了STUN怎么用,我们就可以自己检测NAT类型了。方法其实不复杂,我总结一下:

  • 第一步:向STUN服务器发请求,拿到公网地址A1。如果拿不到,说明是UDP阻塞(连STUN都连不上)。
  • 第二步:换个IP再发一次请求,拿到公网地址A2。如果A1 != A2,说明是对称NAT(不同目标映射出不同地址)。
  • 第三步:让STUN服务器从另一个IP:端口往你的公网地址A1发数据。如果能收到,说明是完全锥形;收不到,再试从同一个IP但不同端口发。如果能收到,是受限锥形;收不到,就是端口受限锥形

嗯,这里要注意:检测过程中要处理好超时。我一般设3秒超时,重试2次。有些路由器对陌生端口的包会直接丢弃,但不影响检测结果。

经验之谈:国内很多家用路由器默认是端口受限锥形NAT。对称NAT多见于企业级防火墙和4G/5G移动网络。如果你做的是移动端P2P,一定要做好对称NAT的降级方案——比如走TURN中继。

打洞失败怎么办?

UDP打洞不是万能的。遇到对称NAT对对称NAT,或者某些严格的防火墙,打洞就是打不通。这时候有两条路:

  1. TCP打洞:利用TCP的TIME_WAIT状态做文章,但成功率比UDP还低,我不太推荐。
  2. TURN中继:所有数据都经过一个公网服务器转发。延迟高,但100%可靠。实际项目中,我通常先尝试UDP打洞,3秒内没连上就切TURN。

说白了,NAT穿透就是个概率游戏。你没法保证100%成功,但通过合理的策略组合,可以把成功率做到95%以上。剩下的5%,交给TURN兜底。

好了,这一章的内容就到这。NAT穿透是个实践性很强的话题,建议你搭个环境亲手试试——拿两台电脑,一台连手机热点(通常是对称NAT),一台连家里路由器,跑一下上面的STUN代码,看看结果是什么。


公众号:蓝海资料掘金营,微信deep3321