
做音视频通信的开发者几乎早晚都会撞上同一个拦路虎终端明明能上网可两个终端就是互相找不到。不管你是做WebRTC通话、自研VoIP网关还是写一个P2P文件传输模块只要涉及端到端直连就一定绕不开NAT穿越这个话题。而NAT穿越的第一步就是把STUN协议吃透。STUN的全称是Session Traversal Utilities for NAT中文一般叫NAT会话穿越工具。它的核心职责特别朴素让你的终端通过外部服务器问清楚“我在公网眼里长什么样”。听起来简单但正是这一步决定了后续能不能在两个NAT设备后面建立直连。这篇文章我会从协议设计原理、报文细节、NAT类型判定到服务端部署、客户端接入和实战排障把STUN整个链路拆开讲一遍。适合刚接触NAT穿越的开发者也适合已经在用WebRTC但一直对STUN一知半解的朋友。1. STUN到底解决了什么问题1.1 没有公网地址P2P就无从谈起IPv4地址早就枯竭了这是很多问题的根源。家庭路由器、公司网关、4G/5G基站都在做同一件事把大量内网设备映射到少量公网IP上。一个家庭路由器可能只有一个公网IP但后面挂了手机、电脑、电视、音箱十几台设备每台设备拿的都是192.168.x.x这种私网地址。私网地址有个致命限制不能被公网路由。你拿192.168.1.100去访问一个公网服务器数据包能出去但服务器回包的时候根本不知道往哪儿送。所以路由器必须在出口做一层映射把内网IP和端口替换成公网IP和某个端口同时记录映射关系形成NAT会话表。这个表就是路由器赖以生存的核心数据。问题在于两台设备如果都在各自的NAT后面彼此只知道对方的内网地址这个地址对对方来说毫无意义。A设备想直连B设备数据包发到B的公网IP和端口但B的NAT规则如果没有提前建立对应关系这个包会被直接丢弃。于是“直连”这个听起来很自然的需求在网络世界里处处碰壁。很多人一开始以为STUN能解决所有连接问题其实它解决的核心问题只有一个让终端拿到自己经NAT映射后的公网地址和端口。它管不了别人能不能通过这个地址访问你那需要后续的“打洞”流程配合但拿不到这个映射地址打洞就无从谈起。1.2 STUN的定位很特殊它只负责“照镜子”STUN不是一个“穿透工具”它更像面镜子。你站在房间里想知道门外的人看到你的那扇门是哪个门牌号、哪个窗口咋办你朝屋外喊一句让外面的人告诉你。STUN就是做这件事的。具体流程是这样你的终端向STUN服务器发一个UDP请求服务器收到后看到这个请求是从哪个IP和端口来的就原样记录下来封装在响应里返回给你。你在NAT后面发请求时路由器会临时建立一个映射服务器看到的源地址就是映射后的公网IP和端口。终端拿到这个映射地址就等于拿到了自己在公网上的“门牌号”。重点来了STUN服务器本身没有任何魔法。它既不修改你的NAT规则也不帮你转发数据更不管你能不能和对方打通。它只是把你进门时暴露出的那个“门面位置”告诉你。真正的连接建立还是要靠终端自己想办法向这个地址发起通信。这就引出了一个很重要的结论STUN的价值是“提供准确信息”。如果你的NAT行为比较友好拿到映射地址后基本就能P2P成功如果你家的NAT行为不友好STUN告诉你的地址只在特定场景下有效那就需要TURN这类中继协议兜底。理解了这个定位后面看ICE、TURN的配合就会顺很多。1.3 哪些场景必须用STUN我接触过的场景里最典型的几类需要STUNWebRTC音视频通话。浏览器的RTCPeerConnection建立之前会先通过ICE框架收集候选地址其中就包括向STUN服务器索取的服务器反射地址。VoIP电话和会议系统。SIP信令通了之后媒体流要尽量走P2P否则服务器中继带宽会爆掉。联机游戏和共享屏幕。这类场景对延迟敏感需要尽量直连。IM和文件传输工具尤其是发大文件走P2P能省大量服务器流量。如果你是纯网页WebRTC开发者大概率不需要自己写STUN报文浏览器已经内置了完整实现你只要在iceServers里正确配置一下就行。但如果你想排查为什么P2P一直连不通或者想自研一套音视频SDK那就必须把STUN从原理到报文都搞明白。这篇文章主要面向后两类人同时也兼顾只想知道“怎么配”的朋友。2. STUN协议的工作原理解读2.1 一次完整的Binding过程STUN协议最核心的流程就是Binding翻译过来叫绑定。它包含一个请求和一个响应后者再细分成功响应和错误响应。客户端发起Binding Request时报文类型字段填0x0001。请求里最重要的是一个随机的Transaction ID用来关联请求和响应。客户端把请求通过UDP发给STUN服务器标准端口是3478。服务器收到后从UDP头里取出源IP和源端口把它填入响应的属性区域然后构造一个Binding Response类型是0x0101Transaction ID原样带回。客户端收到响应发现Transaction ID对得上就读取属性里的地址拿到自己的公网映射地址。整个过程就这么简单一次请求拿一个地址。不需要握手、不需要状态维护、不需要在服务端保存任何session信息这为STUN服务器的水平扩展提供了很大便利。你可以像部署普通无状态服务一样在前面加负载均衡后面挂一堆实例完全没有会话同步问题。这里补充一个历史背景。早期的RFC 3489里STUN还承担了“探测NAT类型”的任务服务器会尝试从不同的IP和端口响应客户端客户端根据能不能收到这些响应来判断自己的NAT行为。但在RFC 5389发布之后STUN被重新定位为“工具集”只负责地址映射和连通性检查复杂的NAT类型探测被移到了RFC 5780、RFC 8440里。所以你现在看到的大多数STUN服务器只回Binding请求不做那些花哨的测试。这对工程实现是好事职责越单一越稳定。2.2 报文格式20字节头加属性区STUN报文结构非常规整没有复杂的嵌套层级尤其适合新手拆包观察。我用Wireshark抓过很多次STUN流量看熟之后基本扫一眼就能判断问题出在哪。报文头固定20字节字段如下字段长度说明Message Type2字节消息类型请求、响应或指示Message Length2字节不包括20字节头部在内的报文总长Magic Cookie4字节固定值0x2112A442用于版本区分和异或运算Transaction ID12字节随机数标识一次事务消息类型里0x0001是Binding Request0x0101是Binding Success Response0x0111是Binding Error Response。看到这三种类型基本就能定位一个STUN交互了。Magic Cookie是RFC 5389引入的它同时干了两件事让接收方一眼识别这是STUN报文而不是其他协议充当异或密钥的一部分。Transaction ID由客户端随机生成服务器回包时原样拷贝不做任何修改。头部后面的部分全是属性Attribute每个属性遵循TLV格式即Type、Length、Value。属性长度按4字节对齐不足的补零。常用属性如下属性名Type值作用MAPPED-ADDRESS0x0001返回映射地址RFC 3489老格式XOR-MAPPED-ADDRESS0x0020返回异或处理的映射地址RFC 5389推荐格式USERNAME0x0006鉴权时携带用户名MESSAGE-INTEGRITY0x0008HMAC-SHA1消息完整性校验ERROR-CODE0x0009错误响应中返回错误编号REALM0x0010鉴权域信息NONCE0x0015防重放随机数FINGERPRINT0x8028CRC32校验可选响应里的映射地址推荐使用XOR-MAPPED-ADDRESS。但它不是必须的很多老版本实现只认MAPPED-ADDRESS所以稳妥的客户端解析逻辑是两个属性都检查优先取XOR版本没有再取MAPPED版本。2.3 为什么映射地址要做异或很多人第一次看到XOR-MAPPED-ADDRESS时会犯嘀咕让服务器直接返回公网地址不香吗为什么还要做异或加密处理这里有两个原因一个是历史问题一个是隐私考量。RFC 3489时代用的是MAPPED-ADDRESS属性里直接存的是明文IP和端口。但在实际部署中有些中间设备会自作聪明地检查甚至修改SIP等信令协议里的地址信息这导致很多NAT会话建立失败。RFC 5389改用异或的设计就是不想让中间设备轻易“读懂”报文里的地址内容从而降低被篡改的概率。另外一个更直接的原因Magic Cookie是固定的客户端可以用它快速反解出真实地址服务器也可以用它快速识别报文合法性异或过程完全对称计算开销极低比加密划算得多。异或的具体算法很简单。IPv4的XOR-MAPPED-ADDRESS属性地址字段先与Magic Cookie的前4字节做按位异或端口先与Magic Cookie的高16位做按位异或。假设NAT映射后的公网地址是111.222.111.222:55555Magic Cookie是0x2112A442。端口0xD903与0x2112异或得到0xF811所以属性里存的是0xF811。IP部分0x6FDE6FDE与0x2112A442异或得到0x4ECC639C即78.204.203.156。客户端收到后拿Magic Cookie再异或一次就还原出真实地址了。IPv6逻辑类似但地址长度是16字节只和Magic Cookie前4字节异或一次剩余字节不变。这个细节如果你要自己写解析器千万注意别拿整个16字节去和4字节的Cookie异或代码写错会得到一堆莫名其妙的地址。2.4 为什么默认走UDPSTUN最常用的传输层协议是UDP原因很直接UDP轻量、无连接、没有握手延迟特别适合NAT探测和P2P打洞这种需要快速往返的场景。WebRTC的绝大多数媒体流也是UDPSTUN跟着走UDP映射出来的协议类型就和真实流量一致打洞成功率更高。但STUN并不强制只用UDP。RFC 5389定义了TCP和TLS传输方式在一些UDP被防火墙封锁的企业网络里客户端可以用TCP连接STUN服务器来获取映射地址。不过TCP方式有个问题TCP连接的源端口不一定等于后续UDP媒体流的源端口TCP探测得到的信息对UDP打洞帮助有限。所以我一般在客户端实现里把UDP作为首选只有确认UDP不通时才降级到TCP。3. NAT类型与STUN的实战关系3.1 四种NAT类型决定你能不能P2PSTUN拿到映射地址后能不能成功直连很大程度上取决于你的NAT属于哪种行为模型。业内一般把NAT分成四类我直接列个表NAT类型端口映射规则外部访问限制P2P前景Full Cone同一内网地址映射到固定公网地址端口允许任何外部主机访问非常好Restricted Cone同一内网地址映射固定公网地址端口只允许曾被内网主动访问过的外部IP访问较好Port Restricted Cone固定映射只允许曾被内网主动访问过的外部IP和端口访问中等Symmetric NAT每次会话都可能映射到不同公网端口外部访问限制同Port Restricted很差Full Cone最常见于老式家用路由器或某些企业网关的“友好模式”。外网任何端口都能通过固定映射访问内网设备安全性很差现在越来越少见。Symmetric NAT是目前移动网络和某些严格配置路由器的常见行为每次你向不同目标地址发数据路由器都可能分配一个全新端口STUN给你返回的映射地址只对“和STUN服务器之间的那一路”有效换个目标端口又不通了传统STUN打洞对它基本失效。我在一个项目里遇到过这样的真实案例终端A和B都是Symmetric NAT双方都成功从STUN服务器拿到了映射地址但打洞时各自暴露的端口和实际请求目标端口对不上ICE状态一直卡在checking阶段。最后只能切到TURN中继虽然延迟高了一些但总算稳定连通了。所以你要有心理预期STUN不是银弹NAT类型不友好时中继是必须的兜底。3.2 STUN如何探测NAT类型虽然现代ICE框架并不强制要求先做完整的NAT类型探测但了解探测原理对排查问题很有帮助。传统探测思路是这样的准备一个支持多IP、多端口回应的STUN服务器客户端依次发不同特征的Binding Request根据自己能否收到响应来判断NAT行为。具体做法分两步。第一步测“映射行为”。客户端向服务器IP1:PORT1发请求拿到映射地址1再向服务器IP2:PORT1发请求拿到映射地址2。如果两个映射地址的端口一致说明是锥形映射如果端口变了说明是对称型映射。第二步测“过滤行为”。客户端向服务器发一个带特殊要求的请求让服务器从另一个IP或另一个端口回应。如果从不同IP回来还能收到说明是Full Cone级别如果不同IP收不到但同IP不同端口能收到说明是Restricted Cone如果IP和端口稍有变化就收不到说明是Port Restricted Cone。RFC 5780和RFC 8440给出了更标准化的测试方法核心是CHANGE-REQUEST属性。但如果你只是连通性排查没必要自己完整实现这套算法。直接用ICE自带的候选对连通性检查更高效因为ICE本身就是通过实际发送STUN Binding请求来验证哪些候选对能通相当于把NAT探测和连通性验证合并了。3.3 实际项目里的NAT判定经验我在工程实践里很少单独调用NAT类型探测库更常用的是看ICE日志。日志里能看到host candidate和server-reflexive candidate如果server-reflexive的端口号在多次请求之间稳定不变说明NAT还算友好如果每次请求端口都跳变基本可以认定是Symmetric NAT直接做好使用TURN的心理准备。如果你非要自己搭建一套NAT探测服务可以找一套开源的探测客户端配合一台支持多IP的STUN服务器来跑。观察点很简单每次请求返回的地址、端口是否变化换源IP/端口后能否收到响应。把这几个结果套进上面的表NAT类型就能定出来了。有一点要注意很多在线NAT检测服务展示的结果是基于它们的服务器分布和探测路径的不一定代表你实际通信目标的视角。NAT行为是“一对”关系不是“你是什么类型”的绝对问题。你在和服务器A通信时可能是Full Cone在和服务器B通信时可能因为路由路径不同而表现出不同行为。所以精确的NAT类型探测意义有限ICE逐对验证才是真正可靠的方案。4. 工程落地从零搭一套STUN服务4.1 服务端选型与快速部署生产环境搭STUN服务我推荐coturn它集成了STUN和TURN维护活跃配置灵活。很多Linux发行版直接带包装起来没难度。安装好之后配置文件里可以这样设置# /etc/turnserver.conf listening-port3478 fingerprint no-tls no-dtls no-relayno-relay这个选项很关键它让coturn只响应STUN Binding请求不做TURN中继不分配中继端口。这样你得到的是一个纯STUN服务资源开销极小。如果你的客户端需要鉴权再加几行lt-cred-mech realmexample.com usercaller:secretpass这里用了长期凭证机制客户端要在请求里携带用户名和密码服务器用MESSAGE-INTEGRITY属性完成校验。鉴权能防滥用但在音视频场景里会多一次401/438的挑战响应交互增加一点延迟。如果服务只在可信内网使用可以不开鉴权省去麻烦。防火墙记得放行UDP 3478端口。很多人配置完连不通第一反应是查程序最后发现是云主机安全组漏了UDP规则。STUN默认走UDP而大多数云平台默认只放行TCP这个坑很常见。4.2 客户端接入ICE的标准配置如果你用的是WebRTC客户端接入STUN几乎不需要写协议代码。浏览器底层已经把Binding Request和响应解析都实现了你只需要在创建RTCPeerConnection时配置ICE服务器列表const config { iceServers: [ { urls: stun:203.0.113.10:3478 }, { urls: turn:203.0.113.10:3478, username: caller, credential: secretpass } ] }; const pc new RTCPeerConnection(config);浏览器会向STUN服务器发送Binding Request拿到server-reflexive candidate之后ICE就会拿这个候选去跟对端做连通性检查。这里我想强调一点配置列表里最好同时有STUN和TURN。只配STUN的话在Symmetric NAT环境里P2P失败后没有任何兜底通话直接断。加上TURN虽然走中继延迟高一点但至少链路是通的。自建STUN服务时域名解析建议走DNS SRV。RFC 5389定义了_stun._udp这样的SRV记录客户端如果配置了域名而不是IP可以自动发现服务端口。不过WebRTC规范里对SRV的支持不是所有环境都可靠所以我在生产环境一般直接写IP加端口稳当。4.3 自研客户端核心流程如果你不依赖WebRTC而是自己写协议栈核心逻辑也不复杂。构造Binding Request的重点是头部和Transaction ID。我写过一个简化的Python示例核心思路直接复用即可import socket import os import struct MAGIC_COOKIE 0x2112A442 BINDING_REQUEST 0x0001 XOR_MAPPED_ADDRESS 0x0020 def create_binding_request(): transaction_id os.urandom(12) header struct.pack(!HHI12s, BINDING_REQUEST, 0, MAGIC_COOKIE, transaction_id) return header, transaction_id def encode_xor_mapped_address(raw_ip, raw_port): # 响应里的端口和IP都经过异或需要还原 port raw_port ^ (MAGIC_COOKIE 16) ip struct.unpack(!I, raw_ip)[0] ^ MAGIC_COOKIE return ip, port sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(3) req, tid create_binding_request() sock.sendto(req, (203.0.113.10, 3478)) resp, addr sock.recvfrom(1024)收到响应后先比对Transaction ID然后从20字节头部之后按属性逐个解析。遇到Type为0x0020的属性取出其中的端口和IP字段按上面的函数还原就得到你的公网映射地址了。这一步做完STUN的“照镜子”使命就完成了。解析的时候要注意属性长度可能是4字节补齐的跳过补齐字节时别漏算否则下一个属性解析全部错位。我在早期实现里就因为这个bug查了半天抓包看到报文明明没问题代码解析出来的地址却一团乱。4.4 STUN、TURN和ICE之间的分工很多初学者把STUN和TURN混为一谈区分清楚了能少走很多弯路。STUN在整个体系里干两件事一是提供server-reflexive候选地址二是作为ICE连通性检查的探针。ICE阶段双方候选集里互相发STUN Binding Request谁能收到响应说明哪条路径通。TURN则完全不同。TURN服务器是一个中继节点当P2P路径都无法打通时客户端把数据上传给TURN服务器服务器再转给对端。代价是延迟增加、服务器带宽成本上升。TURN本质上是一种兜底方案。一次典型的ICE协商流程是这样的两端先收集各自的host candidate本机网卡地址、server-reflexive candidate通过STUN拿到、relay candidate通过TURN拿到。然后ICE把所有候选对配对按优先级排序逐对执行连通性检查。如果某对能通媒体流就走这条路径全部失败就用relay candidate兜底。理解了这张图你就明白为什么不能只部署STUN也不能一上来就全走TURN。前者解决不了Symmetric NAT后者浪费服务器带宽。合理方案是STUN保底、TURN兜底ICE自己选最优路径。5. 实战中绕不开的坑与排查技巧5.1 重试与超时设计STUN走UDPUDP不可靠丢包是家常便饭。客户端如果只发一个请求就干等在网络抖动时很容易失败。RFC 5389给出了标准重传策略初始RTO 500毫秒之后每次翻倍最多重传7次。算下来整个过期时间约39.5秒。但实际生产里等不了39秒音视频场景对时间敏感。我一般把总超时控制在5秒内重试间隔用500ms、1s、2s这样的阶梯。如果三次重试还没响应直接判定STUN服务器不可用切到备选服务器或直接启用TURN。这里的核心思想是“尽快失败快速降级”。还要注意Transaction ID的幂等性。同一个请求重传时Transaction ID要复用不能每次重传都重新生成。因为服务器可能已经处理了第一个请求并返回了响应只是响应在网络上丢了客户端重传时拿同一个ID才能正确匹配前面可能晚到的响应。5.2 鉴权与消息完整性公共STUN服务器一般不开鉴权但自建服务为了防盗用通常会开启。RFC 5389定义的鉴权流程并不复杂走的是挑战响应机制。客户端第一次发请求不带凭证服务器返回401错误同时带上REALM和NONCE。客户端收到后把USERNAME、REALM、NONCE以及请求里的地址信息拼起来用密码计算HMAC-SHA1塞进MESSAGE-INTEGRITY属性里重新发送。服务器校验通过就返回正常响应。计算消息完整性时有个细节FINGERPRINT属性要放在MESSAGE-INTEGRITY之后而且MESSAGE-INTEGRITY计算的是它之前的报文数据和它自身不包括FINGERPRINT。顺序写反会导致所有校验失败。另外HMAC的密钥不是直接拿密码做HMAC key而是先对用户名、REALM和密码做MD5生成长期密钥再做HMAC-SHA1。这些细微之处标准文档里写得很清楚实现时务必逐字核对。5.3 双层NAT和运营商级NAT很多用户看着路由器上的WAN口地址以为那就是自己的公网IP。但在宽带和移动网络环境下运营商很可能在用户侧之外又做了一层NAT也就是运营商级NAT。这种情况下你的STUN请求从终端出发经过家庭路由映射一次再经过运营商级NAT映射一次STUN服务器看到的是运营商NAT后面的那个公网地址而不是你路由器WAN口的地址。这种双层NAT最容易引发的问题是STUN返回的地址确实有效但外部客户端访问这个地址时流量要先到达运营商NAT如果运营商NAT没有对应的转发表项包就会被丢弃。ICE打洞在这里会变得很吃力。遇到双层NAT最可靠的方案就是两条路一是优先接入IPv6IPv6地址空间足够能从根本上避开NAT问题二是直接使用TURN中继放弃P2P幻想。实践中移动4G/5G环境很多都带运营商级NAT所以我的音视频客户端里总是默认同时启用TURN。5.4 兼容老客户端和异构实现STUN协议经历过一次大版本更新。RFC 3489的老客户端可能只认MAPPED-ADDRESS属性不认识XOR-MAPPED-ADDRESS。新服务器通常两个属性都返回但你不能假设所有服务器都这么兼容。我建议客户端的解析逻辑做成这样先找XOR-MAPPED-ADDRESS找不到就找MAPPED-ADDRESS。两个都没有才判定响应无效。这样无论新旧服务器都能正常工作。另一个容易踩的坑是FINGERPRINT属性。这个属性是可选的用来做整包校验但它的计算方式要求先把Message Length字段临时加8FINGERPRINT属性本身的长度也算进去算完CRC32再改回来。如果实现的时候没处理这个临时长度CRC校验永远失败。大多数客户端不校验FINGERPRINT也能正常工作但如果你要做健壮的实现这点必须处理好。5.5 问题排查速查表我把平时踩过的典型问题整理成一个表格遇到问题可以对号入座症状可能原因排查方向请求总是超时UDP被防火墙封锁检查安全组/防火墙UDP 3478STUN返回地址和路由器WAN口不一致多级NAT对比运营商IP考虑IPv6或TURN解析响应得到错误地址属性对齐/解析错位抓包对比报文格式鉴权总失败NONCE过期/时间不同步校准时钟重新请求全新NONCE某些客户端连不通只支持老版属性响应里同时返回两种地址属性P2P建立了但掉线NAT映射超时参考RFC 5389做保活请求排查STUN问题时Wireshark是最有用的工具。过滤条件直接写stun就能看到完整的请求响应交互。我遇到解析问题时第一步永远是抓包对比代码解析结果和Wireshark显示的十六进制数据往往一对比就发现是字节序或长度处理的问题。最后分享一点个人心得STUN不是一个“配置完就不用管”的组件它的行为直接影响你的音视频链路质量。我见过太多项目前期不重视NAT穿越上线后投诉一堆最后不得不加班补救。与其这样不如在最开始就把STUN服务部署好、把ICE链路调通、把TURN兜底配好。这套基础打牢了后面各种网络环境都能扛得住。