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上建立映射,然后互相往对方的公网地址发数据,把洞“打通”。
具体流程我画了个图,你看一眼就明白了:
实战技巧:打洞包最好多发几次,间隔100ms左右。因为NAT映射有超时时间,有些路由器30秒没流量就关闭映射了。我一般会同时启动一个心跳线程,每15秒发一个空包保活。
STUN协议简介
STUN(Session Traversal Utilities for NAT),说白了就是一个“问路协议”。
你的程序不知道自己在公网眼里长什么样,那就问STUN服务器。STUN服务器会告诉你:“兄弟,你从我这看到的公网IP是xxx.xxx.xxx.xxx,端口是xxxxx”。
STUN协议基于UDP,流程非常简单:
- 客户端发送Binding Request:往STUN服务器发一个请求,里面带上自己的内网地址。
- 服务器返回Binding Response:把客户端在公网侧的IP:端口塞进响应里返回。
- 客户端解析:拿到自己的公网地址,就知道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,或者某些严格的防火墙,打洞就是打不通。这时候有两条路:
- TCP打洞:利用TCP的TIME_WAIT状态做文章,但成功率比UDP还低,我不太推荐。
- TURN中继:所有数据都经过一个公网服务器转发。延迟高,但100%可靠。实际项目中,我通常先尝试UDP打洞,3秒内没连上就切TURN。
说白了,NAT穿透就是个概率游戏。你没法保证100%成功,但通过合理的策略组合,可以把成功率做到95%以上。剩下的5%,交给TURN兜底。
好了,这一章的内容就到这。NAT穿透是个实践性很强的话题,建议你搭个环境亲手试试——拿两台电脑,一台连手机热点(通常是对称NAT),一台连家里路由器,跑一下上面的STUN代码,看看结果是什么。
公众号:蓝海资料掘金营,微信deep3321