一、NAT穿透失败是什么感觉

WebRTC联调的时候,最让人头皮发麻的,就是明明双方都上线了,可画面就是出不来。你打开调试面板,看到ICE的状态在checking上面停留了十几秒,然后慢慢变成一个刺眼的failed。你刷新、重试、换浏览器,结果还是一样。这时候有经验的人会告诉你,问题大概率出在NAT穿透上。

NAT穿透这个概念听起来很专业,其实说白了就是解决“两个躲在路由器后面的设备怎么互相找到对方”的问题。家里的宽带、公司网络、手机热点,几乎每个设备都处在一层看不见的围墙里。WebRTC要在这层围墙上开个洞,或者绕道而行。而STUN和TURN,就是专门干这个的工具。

二、先搞懂NAT和WebRTC的关系

2.1 NAT是啥

我们常说的NAT,就是网络地址转换。它让你家里能同时用一根网线上网,让多台设备共享一个公网IP。当你的设备访问外网时,NAT会把你的内网IP和端口改写成公网IP和端口,记录一张映射表。外网回包的时候,它再根据这张表把包送回原来的设备。

NAT有很多种性格,有的很开放,有的非常死板。最开放的是全锥型,只要你的设备主动往外发过包,外部任何主机都能通过这个映射回来的端口联系你。限制锥型稍微保守一点,它只允许之前被访问过的主机发来的包进入。端口限制锥型不仅要认主机,还要认端口。最死板的是对称型,它给每一次新的目的地址都分配一个全新的公网端口,这意味着你问STUN服务器得到的地址,跟另一个设备实际联系你时用的地址完全不一样。

2.2 WebRTC怎么找路

WebRTC自己不会傻乎乎地只找一个地址。它使用ICE框架来选出最合适的路。ICE会先收集三类候选:host类型是本机网卡上的内网IP;srflx类型是向STUN服务器问出来的公网地址;relay类型是TURN服务器分配的中转地址。之后双方把这些候选列表打包进SDP,通过信令服务器交换。拿到对方的候选后,双方开始做connectivity check,用打洞的方式测试哪条路能通,然后选一条通了的路径开始传媒体。

听起来很完美,但坑就在候选收集和协商的过程中。任何一个环节被截断,或者某类候选压根没产生,连接就会失败。

三、ICE候选收集阶段

3.1 候选类型

三类候选的优先级不同。host候选最优,因为本机直连不用绕路,适合同处一个局域网的情况。srflx候选次之,它代表经过NAT映射后的公网地址,适合大多数家庭网络。relay候选最差,因为它要经过TURN服务器转发,延迟高还消耗服务器资源,但它是最后的安全网。ICE会先尝试host,再依次尝试srflx和relay。如果你的场景里允许强制走中转,可以把iceTransportPolicy设成relay,忽略前两类候选。

3.2 收集候选的代码示例

技术栈:JavaScript (Node.js)

// 使用 wrtc 包模拟 WebRTC 的 ICE 收集过程
const { RTCPeerConnection } = require('wrtc');

// 配置 STUN 和 TURN 服务器
const config = {
  iceServers: [
    // 先走 STUN,用来获取反射地址
    { urls: 'stun:stun.l.google.com:19302' },
    // 再配置 TURN,作为无法直连时的中转
    {
      urls: 'turn:turn.example.com:3478',
      username: 'user',
      credential: 'pass'
    }
  ],
  // 默认是 all,如果设成 relay,就只用 TURN
  iceTransportPolicy: 'all'
};

const pc = new RTCPeerConnection(config);

// 每次都触发,直到 candidate 为 null
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 打印候选的原始字符串
    console.log('候选:', event.candidate.candidate);
    // 打印类型
    console.log('类型:', event.candidate.type);
    // 打印候选优先级值,数值大的优先
    console.log('优先级:', event.candidate.priority);
  } else {
    console.log('收集结束');
  }
};

// 数据通道或媒体流会触发收集过程
pc.createDataChannel('chat');
pc.createOffer()
  .then(offer => pc.setLocalDescription(offer))
  .catch(err => console.error('创建offer失败:', err));

这段代码跑起来之后,正常情况下能看到host和srflx候选。如果TURN配置正确,还会看到relay候选。如果只看到host,说明STUN和TURN请求都没出去。如果看到srflx但没relay,说明STUN能用,但TURN配置有毛病。

四、STUN/TURN协商阶段

4.1 STUN帮你问地址

STUN服务器的工作方式特别直白:你发一个Binding Request给它,它把收到的UDP包的源地址原原本本写进响应里返回。这个源地址就是本机在公网世界里的“照妖镜”。拿到这个地址,你就能告诉对方“我是谁,我在哪”,然后尝试打洞。

但STUN有个致命短板,就是它只能应付非对称NAT。前面说的对称NAT,每次发起连接都用不同的端口,所以用STUN问出来的那个公网地址根本不适用于后续的直连。这时候就必须靠TURN了。

4.2 TURN当传话人

TURN服务器的思路很朴素:打不通就别打了,我帮你们传。它会分配一个中继地址,两边都把媒体数据发给TURN,TURN再转发给对方。这么做牺牲了性能,但保证了连通性。很多视频会议系统把TURN作为最后的保命手段,只有直连失败时才启用。

4.3 配置与协商示例

ICE协商的信令里必须包含SDP和候选信息。下面是一个简化版的信令消息,展示了SDP中带有的candidates列表。技术栈:JSON(配置格式)

{
  "type": "offer",
  "sdp": {
    "sessionId": "1234567890",
    "iceUfrag": "aB3dEfGh",
    "icePwd": "xYz123456",
    "candidates": [
      {
        "foundation": "1",
        "ip": "192.168.1.100",
        "port": 54321,
        "type": "host",
        "transport": "udp",
        "priority": 2130706431
      },
      {
        "foundation": "2",
        "ip": "203.0.113.5",
        "port": 34567,
        "type": "srflx",
        "transport": "udp",
        "priority": 1694498815
      },
      {
        "foundation": "3",
        "ip": "198.51.100.2",
        "port": 49152,
        "type": "relay",
        "transport": "udp",
        "priority": 1677729535
      }
    ],
    "media": [
      {
        "kind": "data",
        "mlineIndex": 0
      }
    ]
  }
}

注意,实际浏览器的SDP格式比这个复杂得多,而且候选通常用a=candidate行表示。这里只是为了让你直观看到三类候选应该同时出现。如果收到的对端SDP里缺少relay候选,那就说明对方的TURN没有生效。

为了让候选更可靠,还可以在创建RTCPeerConnection时显式配置TURN,并把传输方式设为tcp或tls,用来穿透UDP封锁。技术栈:JavaScript (Node.js)

// 使用 TCP 协议的 TURN 服务器
const pc = new RTCPeerConnection({
  iceServers: [
    {
      urls: 'turn:turn.example.com:3478?transport=tcp',
      username: 'user',
      credential: 'pass'
    }
  ]
});

// 监听失败状态
pc.onconnectionstatechange = () => {
  console.log('连接状态:', pc.connectionState);
  if (pc.connectionState === 'failed') {
    console.log('连接失败,检查 TURN 是否可达');
  }
};

五、典型场景复盘

5.1 场景一:对称NAT遇到STUN不工作

小王家网络是典型的运营商大内网,再叠加一层对称NAT。他做视频通话时,收集到的候选里有host和srflx,但srflx地址根本打不进去。原因是对方尝试通过这个地址联系小王时,小王的路由器会认为这是一条新的连接,于是分配一个新端口,和srflx里的端口对不上。最后ICE只能宣告失败。

要验证是不是对称NAT,可以找一个公网服务器,连续发送两次STUN Binding请求,如果两次返回的源端口不一样,那就是对称NAT。解决办法很简单:给iceServers加上可用的TURN服务器,并且把TURN的地址填对。

5.2 场景二:TURN服务器配置错误

老张自己搭了一套TURN服务,信誓旦旦说没问题。结果客户端始终只拿到srflx候选。后来一查,他把URL写成了stun:turn.example.com:3478,协议写错了,客户端没把这一项当TURN用。还有一次,他忘了在iceServers里填credential。浏览器对缺少认证信息的TURN配置是直接忽略的,不会报错,只会悄悄少一个relay候选。

所以检查配置时,一定要看一眼协议前缀。STUN用stun://或stun:,TURN用turn:或turns:。同时要确认用户名和密码不是临时生成的,TURN服务器端也要开启对应的认证机制。

5.3 场景三:防火墙把UDP端口封了

有些办公网络严格得吓人,只允许TCP 443出外网,UDP一概不管。STUN用UDP,TURN默认也用UDP。只要UDP被封锁,所有打洞和中继都成了空谈。你怎么调ICE都没用,因为包根本发不出去。

这类场景的破局点是改用TCP或TLS传输的TURN服务器。TURN over TCP使用和HTTPS相同的80或443端口,至少能穿透大部分防火墙。更进一步,TURNS是TURN over TLS,加密后流量看起来像HTTPS,适合更严格的网络环境。

5.4 场景四:ICE候选不完整

小李写了一个简易信令服务,结果发现连接成功率忽高忽低。排查后发现,他在onicecandidate第一次触发时就把SDP发给对方了,但那时后续的srflx和relay候选还没收集完。一个不全的候选列表自然打不通洞。正确的做法是等在onicecandidate事件里收到event.candidate === null,这代表所有候选都收集完成,再发送最终的SDP。

这个问题在慢速网络下很常见,因为收集候选需要额外时间。如果使用局域网直连,host候选很快就齐了;但一旦涉及公网,就要把时间留足。

六、完整排查链路

6.1 从日志看起

每个支持WebRTC的浏览器都留了后门。Chrome里打开chrome://webrtc-internals,Firefox里打开about:webrtc,这里能看到候选类型、ICE状态、连通性检查的详情。先看候选列表,如果只有host,问题出在STUN/TURN网络可达性上。如果有srflx,说明STUN通了;如果有relay,说明TURN通了。如果都有还是失败,那可能是候选配对或者信令过程中丢数据。

6.2 分步排查代码示例

下面写一个用Node.js检查STUN可达性的脚本。技术栈:JavaScript (Node.js)

const dgram = require('dgram');

// 发送一个最简单的 STUN Binding 请求
function testStun(host, port) {
  const socket = dgram.createSocket('udp4');
  const msg = Buffer.alloc(20);
  // STUN Binding Request 类型为 0x0001
  msg.writeUInt16BE(0x0001, 0);
  // 消息长度暂时为 0
  msg.writeUInt16BE(0, 2);
  // Magic Cookie 固定为 0x2112A442
  msg.writeUInt32BE(0x2112A442, 4);
  // 事务ID 12字节,随意填
  for (let i = 8; i < 20; i++) {
    msg[i] = Math.floor(Math.random() * 256);
  }

  socket.send(msg, port, host, (err) => {
    if (err) {
      console.error('发送失败:', err.message);
      socket.close();
      return;
    }
    console.log(`已向 ${host}:${port} 发送 STUN 请求`);
  });

  // 收到响应就说明网络通
  socket.once('message', (data) => {
    const type = data.readUInt16BE(0);
    console.log('收到 STUN 响应, 消息类型:', type);
    socket.close();
  });

  // 3秒超时
  setTimeout(() => {
    console.error('STUN 超时,UDP 端口可能被封锁');
    socket.close();
  }, 3000);
}

testStun('stun.l.google.com', 19302);

这个脚本只用了Node.js内置的dgram模块,不需要额外依赖。如果它超时,你可以再试试TCP的STUN,或者直接查防火墙规则。

6.3 检查ICE连接状态

在浏览器端,可以通过connectionState和iceConnectionState来监听连接状态的变化,及时定位失败原因。技术栈:JavaScript (Node.js)

// 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => {
  const state = pc.iceConnectionState;
  console.log('ICE 状态:', state);
  if (state === 'failed') {
    // 候选都不通,检查是否缺少 relay
    console.log('检查 TURN 服务器是否可用');
  }
  if (state === 'disconnected') {
    // 曾经连通,后来断了,可能是 NAT 映射过期
    console.log('连接中断,可能需要重启 ICE');
  }
};

七、技术优缺点与注意事项

7.1 优缺点

STUN的优点在于免费、轻量、响应快,不需要消耗自己的服务器流量。它适合大部分家庭网络,部署也极其简单,有大量公共STUN服务器可用。缺点也很明显:在对称NAT下无能为力,而且依赖UDP,容易被防火墙拦截。

TURN的优点是无敌的连通性,不管什么NAT都能突破,只要服务器可达。缺点是贵,流量全走服务器,几十路视频通话就可能把带宽吃光,同时延迟也更高。ICE的优点是自动选路,能根据实际网络选择最优路径;缺点是调试困难,状态机复杂,稍微配置不对就默默失败。

7.2 注意事项

第一,不要依赖公共TURN服务器。公共STUN可以随便用,但TURN涉及数据中转,公共服务既不稳定也不安全。第二,TURN会话需要定期刷新,默认生命周期为600秒。如果客户端在后台挂太久,中继地址会失效,必须实现刷新逻辑。第三,注意服务器防火墙要放行TURN的端口范围,包括UDP和TCP。第四,尽量使用支持TURN over TLS的服务器,能躲过低层过滤。第五,在测试阶段可以强制ICE使用relay模式,确认TURN链路本身没问题,再放开到all模式。

八、文章总结

NAT穿透失败看起来神秘,实际上它是有一张清晰的流程图可以跟踪的。你要先确认ICE候选有没有收齐,然后看STUN是否拿到了公网地址,再看TURN是否提供了relay候选,最后检查这些候选有没有完整地交换给对端。典型问题无非就是对称NAT、协议写错、UDP被封、候选丢失这几种。掌握了这些套路,以后遇到WebRTC连不上,你就能按图索骥,一步步找到根源,而不是乱试一通。