做音视频通信和P2P联机的人,基本都绕不开STUN。你可能翻过协议文档,发现它短小精悍,但真到排查NAT穿透问题时,又总觉得哪里差一口气。这篇东西我不打算复述RFC原文,而是用一个老开发者的视角,把STUN从“它解决什么问题”到“报文长什么样”,再到“生产环境里怎么用、怎么坑”完整串一遍。适合正在调WebRTC通话、做多人游戏联机、搞VoIP终端的人,也适合刚接触P2P网络、想弄明白NAT穿透原理的入门者。
1. 没有STUN的世界:NAT和它制造的“看不见的墙”
1.1 IPv4地址不够用,于是NAT出现了
聊STUN之前,得先说说它存在的理由。IPv4地址一共就43亿个,全球设备早把这个池子用爆了。于是NAT被大规模部署在路由器、防火墙、运营商网关上,让整个内网共用一个或少数几个公网IP。你家路由器就是最典型的例子:手机、电脑、电视都拿一个192.168.x.x的内网地址,出外网的一瞬间,路由器把源IP和源端口改成一个公网IP上的某个端口,然后把应答原路送回。
这个设计解决了一个大问题:节约公网IPv4地址。但它也顺手造了一个新问题:外网主机没办法主动找到内网主机。你家里那台服务器跑了个服务,监听在9000端口,外网的朋友直接访问你的公网IP和9000端口,大概率被拒绝,因为NAT没在这个端口上建立对应关系。
这个状态很像小区门口的门卫。你要出门办事,门卫给你开个临时通行证,上面写着“你出去这段时间,谁来找你就领到你这个房间”。但如果你在屋里不动,门卫根本不知道你在,外面的人敲门也没有应答。而NAT穿透要做的,就是想办法让你先从屋里出去一趟、留下一个可用的映射,再让外面的人顺着这个映射找到你。
1.2 NAT的脾气:四种典型映射行为
NAT真的不是一个统一的行为模式。不同的路由器、不同的防火墙策略、甚至同一个路由器不同版本固件,映射行为都可能不一样。业内把常见行为归纳成四种类型,做穿透时你最好把它们刻在脑子里。
- Full Cone:内网主机发起一个会话后,NAT建立映射,此后任何外网主机都能通过这个映射访问内网主机。
- Restricted Cone:只有内网主机之前发送过数据的外网IP,才能通过这个映射访问内网主机。
- Port Restricted Cone:在Restricted Cone基础上,还要求外网端口必须匹配。
- Symmetric:最麻烦的一种。每次内网主机向不同的外部目标发数据,NAT都分配一个新的公网端口。同一个内网端口,对不同外网目标的映射是隔离的。
这四种类型对穿透的影响完全不同。前三种还有机会靠“预先把洞打通”实现直接通信,到了Symmetric,直接通信基本没戏,只能走中继兜底。
NAT行为为什么会分这么多流派?因为路由器的工作模式差异很大。有的厂商实现的是纯粹地址翻译,只改IP和端口;有的会把NAT和状态防火墙做在一起,对出方向的会话做严格校验,只有确认过“内网先发过包”才放行入方向的流量。安全策略越激进,NAT对入站流量越不友好,穿透就越难。
2. STUN核心原理:一次“照镜子”的过程
2.1 一条消息搞定地址发现:Binding Request和Response
STUN的全称是Session Traversal Utilities for NAT,中文常翻译成“NAT会话穿透工具”。老版RFC 3489时代它叫Simple Traversal of UDP through NAT,后来改名了,因为它做的事不止是“打洞”,还包括NAT行为发现、连通性检测这些更细的功能。
说直白点,STUN干的事情就是照镜子。你站在屋里,想知道门外的门卫看到的你是什么样子,于是你让一个站在外面的人帮忙描述。STUN服务器就是这个“外面的人”。客户端向STUN服务器发送一个Binding Request,服务器收到后,会从自己的视角看到这个请求的来源IP和来源端口,然后把这个IP和端口放进Binding Response里返回给客户端。
打个比方:你从内网出去,经过路由器NAT之后,请求的源IP变成了公网IP,源端口变成了NAT分配的那个映射端口。STUN服务器看到的正是这个转换后的地址。它把这个映射地址告诉客户端,客户端就知道了“原来外面的人看到我是这样一个公网地址和端口”。这就是STUN最基本的用途:发现你的公网映射地址。
你在自己家里跑一下就明白。Windows也好、Linux也好,随便找个STUN客户端或者写几行代码发一个Binding Request,通常两三秒内就能收到回复,里面是一个公网IP和端口。这个公网IP可能和路由器WAN口地址一致,也可能不一致,不一致时后面会讲,那意味着你处在多层NAT或运营商大网NAT后面。
2.2 协议格式里的关键字段
STUN协议设计得相当小巧。所有STUN消息都包含一个20字节的头部,后面跟若干属性。头部格式如下:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0 0| STUN Message Type | Message Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Magic Cookie | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Transaction ID (96 bits) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+Message Type前两位固定为0,用于区分方法。常用的就是Binding,它的类型值是0x0001。Message Length表示属性区总长度,最小为0。Magic Cookie是固定值0x2112A442,用来快速判断一个包是不是STUN消息,同时在计算XOR地址时充当密钥的一部分。Transaction ID是一个12字节的随机值,客户端生成,响应会原样带回,用来把请求和响应匹配上。
属性区里最关键的是MAPPED-ADDRESS或XOR-MAPPED-ADDRESS。旧版RFC 3489用的是MAPPED-ADDRESS,后来RFC 5389改成了XOR-MAPPED-ADDRESS。为什么加XOR?因为有些老NAT设备会有ALG行为,把报文里看起来像IP地址的文本做改写,XOR之后能破坏这种可以被直接识别的明文地址,也不容易让NAT误改。
XOR-MAPPED-ADDRESS的结构是这样:第一个字节是保留位,第二个字节是地址族,1代表IPv4,2代表IPv6,接下来是两字节端口和四或十六字节地址。端口值要与Magic Cookie的高16位做异或,IPv4地址要与整个Magic Cookie异或,IPv6地址则要异或Magic Cookie加Transaction ID的前16字节。这些细节在写解析代码时很关键,字节序错一个符号,拿到的端口就是错的。
另外,RFC 5389之后还引入了MESSAGE-INTEGRITY和FINGERPRINT。MESSAGE-INTEGRITY用来做报文完整性校验,通常配合长期凭证认证使用;FINGERPRINT则是一个可选的CRC32校验值,放在报文明文末尾,用来进一步辅助NAT设备和中间设备识别STUN。普通公开查询通常用不到这些,但涉及需要鉴权的STUN服务时,这些属性会成为关键。
2.3 STUN不做什么事
把STUN的功能边界说清楚,比记住它的功能更重要。STUN不建立数据隧道,不转发你的音视频数据,不做加密传输。它只是带回来一个“你从外面看是什么样子”的地址,顺便可以做做连通性测试。真正通信的时候,数据依然走客户端和客户端之间的UDP路径,STUN服务器不插在中间。
所以STUN非常省资源。查询一次就是一个UDP请求加一个UDP响应,消息体撑死了也就几十字节。它可以支撑超高并发,这也是公共STUN服务能够免费跑起来的基本前提。你完全可以把它想成一个公共电话亭:你过去问一句话,得到答案就走,不需要在电话亭里完成整个通话。
这个特性决定了STUN的成本和延迟都极低。如果哪天你看到有人说“我们用STUN转发媒体流”,那他大概率是把STUN和TURN搞混了。
3. 想绕过NAT,先要知道NAT是什么性格
3.1 RFC 3489时代的三连测试
STUN早期有个野心:通过一组精心设计的组合请求,把NAT类型识别出来。这就是RFC 3489里的经典测试方案。它让客户端分别向一个或两个STUN服务器、不同的IP和端口组合发送Binding Request,通过比较收到的MAPPED-ADDRESS和行为来判断类型。
具体逻辑大致是这样的:
| 测试 | 目标服务器 | 观察点 |
|---|---|---|
| Test 1 | 服务器A,IP地址为IP-A,端口为端口A | 是否能收到响应,MAPPED-ADDRESS是否与本地猜测一致 |
| Test 2 | 服务器A,IP为IP-A,端口为端口B | 与Test 1结果比较,看MAPPED-ADDRESS是否变化 |
| Test 3 | 第二个服务器B,IP为IP-B | 与Test 1结果比较,看MAPPED-ADDRESS是否变化 |
如果Test 1收不到响应,基本可以断定客户端在完全开放的网络上,没有NAT拦截,即Open Internet。如果Test 1能收到,但Test 2和Test 3的响应收不到,可能是Symmetric NAT在不认识的主机或端口上拒绝了入站流量。如果两次返回的MAPPED-ADDRESS一致,说明NAT映射是Endpoint-Independent的,再配合能否收到来自不同IP的响应,进一步区分Full Cone、Restricted Cone和Port Restricted Cone。
这套方案在早期解决了不少问题,现在拿去跑一遍也还能用,但已经不能完全覆盖真实网络环境。原因很简单:现代运营商网络里普遍存在多层NAT,设备行为越来越复杂,某些NAT对外部IP和端口变化的反应并不像测试假设得那么纯净。所以新的思路不再追求把NAT分成固定的四类,而是把映射行为和过滤行为分开描述。
3.2 RFC 5780的更细探测思路
RFC 5780把NAT行为分成两个维度:映射行为(Mapping)和过滤行为(Filtering)。每个维度又分成三档:
- Endpoint-Independent Mapping:不管外部目标是谁,只要内网四元组相同,映射的公网端口就保持一致。
- Address-Dependent Mapping:同一个内网四元组,只有目标IP变化时,才分配不同的映射端口。
- Address-and-Port-Dependent Mapping:目标IP或端口任一变化,就分配新的映射端口。
过滤行为也类似:
- Endpoint-Independent Filtering:接收任何外网主机发来的包,只要映射存在。
- Address-Dependent Filtering:只接收内网主机曾经向该IP发送过包的地址的入站流量。
- Address-and-Port-Dependent Filtering:IP和端口都必须匹配内网主机发过的目标。
这个分类比老的“Full Cone/Symmetric”更精准,因为它能把看似都是“Symmetric NAT”的设备区分成不同的行为组合。比如有个设备是Address-Dependent Mapping但Endpoint-Independent Filtering,它与另一个设备是Address-and-Port-Dependent Mapping但Address-Dependent Filtering,两者在打洞场景下的表现完全不同。
识别这些行为,RFC 5780的思路是在一个特定端口上监听,然后向目标服务器发送多组包含不同源端口和不同目标地址的组合请求。STUN服务器或者客户端自己会对不同组合返回的MAPPED-ADDRESS和响应可达性做分类,从而推导出NAT行为。我在实际调试中很少自己实现这套探测,更多时候依赖现成的库或者直接看ICE日志里的候选对,但理解它对你排查“为什么这个网络打洞死活不通”特别有帮助。
3.3 别把NAT类型判断当成一次性结论
我踩过最大的坑,就是把NAT类型测试结果当成固定结论。同一个办公室网络,上午测出来是Port Restricted Cone,下午可能因为出口路由切换或者边界防火墙策略调整就变成了另一个行为。更典型的是手机走4G/5G网络,运营商的NAT策略随时可能变化,甚至同一个手机在不同基站区域的表现都不一样。
所以你在做穿透方案时,不要把NAT类型判断放在启动时刷一次就完了,关键是要在会话建立后持续用STUN做连通性探测。一旦发现原来的候选地址不可达,要能及时重新收集候选、切换路径。这也是ICE框架里会有“新候选出现”和“连接检查超时”这些机制的深层原因。
4. 在真实项目里STUN的位置
4.1 一套ICE组合拳:host、srflx、relay
WebRTC里做音视频通信,绝大多数时候走的是ICE流程。ICE要做的事是从一堆候选地址里挑出一条两端都能打通的路径,而STUN在其中扮演了极其重要的角色。
ICE收集候选地址时,会产生三类候选:
- host候选:直接是本机的IP地址和端口,比如电脑的以太网口、Wi-Fi网卡上绑定的内网地址。
- srflx候选:通过STUN查询得到的服务器反射候选,也就是NAT映射后的公网IP和端口。
- relay候选:通过TURN分配到的中继地址,数据最终经过中继服务器转发。
三类候选在ICE里的优先级不同。host候选因为直接可达,延迟最低,优先级最高;srflx候选照顾了需要穿越NAT的情况,优先级居中;relay候选延迟和带宽成本都高,只能做兜底,优先级最低。
为什么有了host候选还要收集srflx?因为你在内网里能直接通信,不代表对面也在同一个内网。两个人在不同城市的家里连视频,各自的host候选都是一堆192.168.x.x,放到互联网层面根本不可达。这时候必须靠srflx候选把自己“照镜子”之后得到的公网映射交出来,才有可能打通一条端到端路径。
4.2 打洞环节仍然靠STUN
ICE的连通性检查阶段,两端会分别向对方的候选地址发送STUN Binding Request。这个过程被称为“打洞”。
打洞的原理其实很朴素。你的NAT只愿意放行“你主动发起过的会话”的入站流量。于是你和对面同时往对方的地址发一个包,两边都在自己的NAT上留下了“我刚刚发过这个外部地址”的记录,两个方向都有出站记录后,入站方向就被放行了,一条双向的UDP通道就此打通。
这里用的“包”不是普通数据包,而是STUN Binding Request。一方面,它天然和STUN协议兼容,ICE可以复用STUN的Transaction ID机制做请求响应匹配;另一方面,Binding Request体积小、目的明确,NAT设备普遍不会对它做特殊处理。
想判断打洞是否成功,就看能否在超时时间内收到对方回应的STUN Binding Response。收到了,就说明双向路径已经打通,ICE会把这对候选标记为可用,然后把真正的音频和视频流切到这条UDP通道上。
4.3 保活和会话维持
一旦打洞成功,后续还有个隐蔽问题:NAT映射是有寿命的。如果在一段时间内没有数据包经过这条映射,NAT就会把映射删掉,下次再发数据就得重新打洞。这个超时时间因设备而异,有的短到30秒,有的长到5分钟。
为了维持映射不老化,需要定期发送保活包。WebRTC和ICE协议栈里,典型的做法是每隔一个固定周期发送STUN Binding Indication。Binding Indication不需要响应,消息体里通常没有Transaction ID要求,把它当成一个“我是活的”信号即可。周期一般取5到15秒之间,保证远小于最短NAT超时时间,同时又不至于太频繁造成网络噪声。
这个细节在自研联机系统时特别容易被忽略。我曾经见过一个P2P协议,联机成功后不再发包,结果空闲30秒左右连接就断了。后来加上保活包后,一条连接能稳定挂几个小时。
5. 动手实操:从零写一个STUN客户端
5.1 环境准备
工具不需要多复杂,一台能上网的电脑装个Python 3就够。所有逻辑只用标准库完成,不需要额外装第三方包。目标是通过UDP向一个公共STUN服务器的3478端口发送Binding Request,拿到服务器视角下的公网IP和端口。
公共STUN服务器网上能搜到不少,端口几乎清一色是3478。需要注意的是,公共服务只适合开发联调用,正式产品不要依赖,后面会解释原因。实操时你可以先在本机跑起来,然后分别在家里网络、手机热点、公司网络下对比返回结果。
5.2 核心代码实现
先写一个函数构造STUN Binding Request。按照RFC 5389,头部是Message Type(2字节)、Message Length(2字节)、Magic Cookie(4字节)、Transaction ID(12字节)。这里用struct.pack直接拼二进制流。
import struct import random import socket STUN_BINDING_REQUEST = 0x0001 MAGIC_COOKIE = 0x2112A442 SERVER_IP = "127.0.0.1" # 替换为实际STUN服务器IP SERVER_PORT = 3478 def build_binding_request(): transaction_id = bytes(random.getrandbits(8) for _ in range(12)) # 类型、长度、magic cookie header = struct.pack(">HHI", STUN_BINDING_REQUEST, 0, MAGIC_COOKIE) return header + transaction_id这里>HHI表示以大端字节序打包:一个2字节无符号整数、一个2字节无符号整数、一个4字节无符号整数。Message Length这里为0,因为我们没有追加任何属性。
然后用UDP发送请求并等待响应。注意必须用同一个socket接收,因为Transaction ID要匹配。
def request_mapped_address(server, port=3478, timeout=3): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) request = build_binding_request() sock.sendto(request, (server, port)) try: data, addr = sock.recvfrom(2048) except socket.timeout: print("请求超时") return None finally: sock.close() return parse_stun_response(data)响应解析时,先跳过20字节头部,然后遍历属性区。重点关注类型为0x0020的XOR-MAPPED-ADDRESS。
def parse_stun_response(data): if len(data) < 20: return None msg_type, msg_len, cookie = struct.unpack(">HHI", data[:8]) if cookie != MAGIC_COOKIE: return None attributes = data[20:20 + msg_len] pos = 0 while pos + 4 <= len(attributes): attr_type, attr_len = struct.unpack(">HH", attributes[pos:pos + 4]) value = attributes[pos + 4:pos + 4 + attr_len] if attr_type == 0x0020: # XOR-MAPPED-ADDRESS family = value[1] if family == 0x01: # IPv4 xport = struct.unpack(">H", value[2:4])[0] port = xport ^ (MAGIC_COOKIE >> 16) xaddr = struct.unpack(">I", value[4:8])[0] ip = socket.inet_ntoa(struct.pack(">I", xaddr ^ MAGIC_COOKIE)) return ip, port pos += 4 + ((attr_len + 3) // 4) * 4 return None解析时有几个细节务必要注意。第一,属性区长度可能小于头部里的msg_len,因为尾部可能还有padding,但你在取attributes切片时用的是msg_len,所以遍历时按4字节对齐跳转即可。第二,XOR端口时是与Magic Cookie的高16位异或,也就是0x2112。第三,所有字节序都是网络字节序,即大端,Python的>就能搞定,千万别用默认的小端。
跑一遍,正常会得到类似“公网IP: 203.x.x.x,端口: 54321”的输出。这个IP和端口,就是你的NAT给本次会话分配的映射地址。再对比一下ifconfig或ipconfig里看到的本地IP,能直观感受NAT把你“变成”了另一个地址。
5.3 抓包看STUN交互过程
代码能跑了,我强烈建议你抓一次包。用Wireshark打开,抓取udp.port == 3478的流量,然后重新运行脚本,你会看到典型的四次交互。客户端发出的包是Binding Request,服务器返回的是Binding Response,里面带着XOR-MAPPED-ADDRESS属性。
抓包能看到什么?你可以顺着帧看详细字段。IPv4头里的源地址和目的地址,UDP头里的源端口和目的端口,再往上是STUN头。Wireshark会把XOR-MAPPED-ADDRESS自动解码出来,显示已知的公网IP和端口。久而久之,你会发现STUN协议一眼就能识别出来,因为它的Magic Cookie固定是0x2112A442,抓包界面上一排排这个值,非常显眼。
我还习惯在确认问题到底在哪一层时用tcpdump。比如怀疑UDP被防火墙丢弃,可以先在服务器端tcpdump看有没有请求进来,服务端收到了、但客户端没收到响应,说明响应路径上有问题。对比两端的结果,问题定位会快很多。
6. 生产环境常见坑与排查手册
6.1 一张速查表解决大部分问题
项目做多了,STUN相关的故障类型也就那么几种。我整理了一张表,基本覆盖日常会遇到的坑。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| STUN请求超时 | UDP被防火墙丢弃,STUN服务器不可达 | 检查udp 3478端口是否放行,换TCP变体验证 |
| 拿到了IP,但和路由器WAN口IP不一致 | 存在多层NAT,比如运营商CGNAT | 在路由器和外网分别抓包确认出口地址 |
| 返回的端口与预期差距很大 | 有多个内网出口或多WAN负载均衡 | 检查本机路由表,确认流量走哪条链路 |
| 不同网络环境返回的NAT类型不同 | NAT行为本身随环境变化 | 多次测量,不要用一次性结论 |
| 打洞成功但短暂空闲后断开 | NAT映射老化 | 加STUN Binding Indication保活 |
| 拿到了srflx候选但连通性检查不过 | NAT是Symmetric | 开启TURN中继兜底 |
| 请求返回成功但解析地址错误 | 字节序或XOR逻辑写错 | 抓包对比正确地址,检查代码 |
6.2 排查命令和思路
先看本机能不能到达STUN服务器。最简单的办法是用nc发个空UDP包到3478端口,但UDP没有连接概念,你发过去什么响应都收不到也正常。用我们前面的Python脚本其实最靠谱,能直接拿到完整响应。
如果脚本超时,先用ping确认到服务器IP的连通性,再用nc -u -v -w 3向服务器发一个字节,看有没有回包。再不行,直接在路由器的WAN口上抓包,判断包到底有没有出去。很多公司网络环境的防火墙会拦UDP,触发的现象就是所有UDP穿透方案都无效,但TCP正常,这时候还要想TCP/TLS模式的STUN兜底方案。
还有一种比较隐蔽的情况:本机同时有多个网卡,比如有线加Wi-Fi都连着。默认路由走的是Wi-Fi,但你绑定socket时用了有线的IP,发出的包到STUN服务器后,服务器看到的映射来自错误的链路。这种问题看srflx候选为什么和预期不一致时,要检查socket绑定地址和路由表。
6.3 别把公共STUN服务器当生产依赖
开发阶段用公共STUN服务器很方便,但生产环境千万不要把它写死为核心依赖。公共节点不受你控制,可用性、流控策略、稳定性都没有保障。之前就有过某个公共STUN节点下线后,一批在配置里写死它的应用集体出现连接失败的情况。自建或者使用云厂商提供的NAT穿透服务是更稳妥的选择。
自建时,多数团队选coturn。它同时实现了STUN和TURN,部署也不复杂。配置文件里需要注意几个点:监听端口、认证机制、中继地址范围、带宽限制。如果是公网服务器,iptables要放行UDP 3478端口以及TURN的转发端口范围。生产环境认证必开,不然任何人可以用你的TURN服务器转发流量,损耗带宽还是小事,被投诉滥用才是大问题。
7. STUN和TURN的边界:什么时候只能“绕路”
7.1 Symmetric NAT下STUN失效的原因
STUN也有失灵场景,最典型的就是Symmetric NAT。前面提过,Symmetric NAT对每个新的外部目标分配一个不同的映射端口。这在打洞时是致命的,举个例子就明白了。
假设你和我分别在两个Symmetric NAT后面。你用STUN向A服务器查询,得到映射端口P1。我也用STUN向同一服务器查询,得到映射端口P2。你尝试向我的P2端口打洞,但你的NAT看到的是一个新目标,于是在这条路径上又分配了新端口P3,而不是P1。结果你发给我的包,源端口是P3,我这边记录的是P2,两边根本对不上。反过来,我向你打洞也面临同样的问题。
这种情况下,没有任何STUN技巧能挽救直接P2P通信,因为NAT的行为本质上不可预测。想继续通信只能走TURN中继:双方都不再互相直连,而是分别把数据传输给一个中间服务器,由服务器转发。这就是TURN存在的意义,也是为什么我们把TURN叫作STUN的“终极兜底”。
7.2 TURN的代价与选型
TURN能让连接“必定能通”,代价是高昂的资源消耗。所有音视频流量都经过中继服务器,带宽成本成倍增加。一次一对一通话,如果双方都在TURN后面,服务器要吞吐双份的媒体流。到了多人会议,中继带宽会急剧膨胀。
因此实际生产里要定义一个合理的策略。ICE协议天然支持这种逐级兜底:优先试host候选,打不通就靠srflx候选打洞,仍不行再启用relay候选。只有在纯Symmetric NAT或者防火墙策略极端严格的环境下,流量才会真的走到TURN。我习惯把TURN的转发带宽当作项目的风险成本来监控,而不是一个固定的免费福利。线上有过几次中继带宽打满的情况,都是在音视频高并发时发生的,加了按用量限流和配额告警后才放心。
TURN服务本身的协议还是STUN那一套。客户端通过Allocate请求向TURN服务器申请一个中继地址,后续再用Send和Data机制收发媒体。所以把STUN协议吃透,再去看TURN会轻松很多,属性多一些,但底层的报文格式、事务机制、XOR寻址思路全都是延续的。
最后聊几句实在的
我最早接触STUN时,以为它就是“查一下公网IP”的工具,后来做实时通信项目才发现,它背后牵扯的是整个NAT穿透体系的根基。STUN的报文很简单,但它把“NAT是个什么性格”“你能不能直接互连”“如果不行还有没有出路”这些关键问题都串起来了。弄懂它的逻辑,你再看ICE候选排序、TURN中继兜底,就不会觉得那些配置是魔法,而是一套层层递进的求生策略。
再分享一个调试技巧:遇到STUN相关的问题,先抓包再看代码。有一半的“端口解析错”“地址拿不到”问题,看一眼报文里XOR-MAPPED-ADDRESS的值和你的解析逻辑就豁然开朗。先把协议交互这件事用眼睛确认了,再怀疑自己的实现。如果报文层面就是错的,那代码写得再漂亮也没用。希望这篇梳理能让你少走一些我走过的弯路。