企业网与移动网穿透
企业网与移动网穿透
公网理想环境下 ICE 几乎从不失败,真实世界里最难啃的是两块场景:企业网络(严苛防火墙、对称 NAT、TLS 拦截)和移动网络(CGNAT、4G/5G/Wi-Fi 切换、IPv6 双栈)。这篇文档把这些环境的失败模式、检测方法和应对策略一次说清。
不重述 NAT 类型(Full Cone / Restricted Cone / Symmetric 这些 STUN RFC 3489 的旧术语已被弃用),只讲真实网络下行为差异和对 ICE 选路的影响。
1. 真实网络中 ICE 失败的分布
按 Google、Twilio、声网公开过的统计数据(2023-2025)综合来看:
| 失败场景 | 全球占比 | 应对成本 |
|---|---|---|
| 对称 NAT(含企业 NAT) | 8-15% | 必须 TURN |
| 企业防火墙阻塞 UDP | 5-12% | TURN over TLS/443 |
| CGNAT(运营商级 NAT) | 3-8% | 必须 TURN |
| Wi-Fi/4G 切换未做 ICE Restart | 3-6% | 客户端侧实现 |
| TLS 中间人代理(公司 SSL 拦截) | 1-3% | 极难,部分场景无解 |
| IPv6-only 网络无 v4 回退 | < 1% | 双栈 STUN/TURN |
这就是为什么"只配 STUN"的方案在生产里一定会出问题——上面任何一项命中,都需要 TURN 兜底。
2. 对称 NAT 与端口预测
经典 NAT 行为分类已经被 RFC 4787(BEHAVE)取代。两个工程上重要的维度:
| 维度 | 行为 A | 行为 B |
|---|---|---|
| Mapping(同一内网源对不同外网目的) | Endpoint-Independent(同一公网映射) | Address/Port-Dependent(每个目的不同映射) |
| Filtering(外部能否回包) | Endpoint-Independent(任何外部都能回) | Address/Port-Dependent(只有刚连过的能回) |
WebRTC 圈说的"对称 NAT"=Address-and-Port-Dependent Mapping:同一台机器访问不同公网目的时分到不同源端口。这种 NAT 后,STUN 给出的映射对真实媒体路径无效——因为 STUN 服务器和对端是不同目的地,NAT 给的端口不一样。
本机 192.168.1.10:50000 → STUN 服务器 → NAT 映射成 203.0.113.5:40001(srflx)
本机 192.168.1.10:50000 → 对端公网 IP → NAT 映射成 203.0.113.5:40002(实际!)
↑
对端按 srflx 发包到 40001 → 丢
2.1 端口预测(Port Prediction)
学术界提出过通过多次 STUN 探测预测 NAT 下次会分配的端口。WebRTC 没实现,coturn 也没实现。原因:
- 现代企业 NAT 用随机或伪随机端口分配
- 即使预测对,还要赌对端发包"恰好"是这一刻
- TURN 中继成本远低于运维一套预测系统
结论:对称 NAT 后的客户端 = 必走 TURN。relay-relay 候选对会成为 selected pair。
2.2 用 STUN 测自己的 NAT 行为
# coturn 自带的 NAT 行为发现工具(RFC 5780)
turnutils_natdiscovery -m -f stun.l.google.com
turnutils_natdiscovery -p -f stun.l.google.com
# 输出可能:
# Mapping behavior is: Address and Port Dependent ← 对称 NAT
# Filtering behavior is: Address and Port Dependent
部署期摸清自己机房 / 客户群的 NAT 行为分布很有用,决定 TURN 实例容量规划。
3. CGNAT(运营商级 NAT)
移动网络(4G/5G)和家用宽带越来越多走 CGNAT:运营商在自己的边界做一层 NAT,用户分到的 100.64.0.0/10(共享地址段)才是"真公网"。CGNAT 的特点:
- 行为基本是对称的(不同目的不同映射)
- NAT 表项寿命短(部分运营商 30-60 秒就回收 UDP 表项)
- 同一运营商两台手机互相访问可能 hairpin 失败(NAT 不支持回环)
工程影响:
- 同运营商两个手机直连可能反而比走 TURN 慢,因为运营商 hairpin 不可靠。
- UDP NAT 表项 30 秒回收 → 必须发心跳保活。WebRTC 自带的 STUN consent freshness 每 5 秒一次,正好压制这个问题。
- 关掉省电模式 / 后台进程时表项更短,需要监听
pageshow/freeze事件主动 ICE Restart。
4. 企业防火墙的三种"恶意度"
| 等级 | 行为 | WebRTC 应对 |
|---|---|---|
| 1 级(90%) | 开放 80/443 TCP,封 UDP | TURN over TLS 443 |
| 2 级(8%) | 开放 80/443,深度包检测 SNI | TURN-TLS 走真实域名,避免 SNI 异常 |
| 3 级(< 2%) | TLS 中间人代理(自签 CA) | 几乎无解,建议提示用户切换网络 |
4.1 检测当前网络等级
启动时跑一次"网络体检",决定走哪条路:
async function probeNetwork() {
const tests = [
{ url: 'stun:stun.example.com:3478', label: 'stun-udp' },
{ urls: 'turn:turn.example.com:3478?transport=udp', username, credential, label: 'turn-udp' },
{ urls: 'turn:turn.example.com:3478?transport=tcp', username, credential, label: 'turn-tcp' },
{ urls: 'turns:turn.example.com:443?transport=tcp', username, credential, label: 'turn-tls' },
];
const results = await Promise.all(tests.map(async (server) => {
const pc = new RTCPeerConnection({ iceServers: [server], iceTransportPolicy: 'all' });
pc.addTransceiver('audio');
await pc.setLocalDescription();
return new Promise((resolve) => {
const seen = new Set();
const timeout = setTimeout(() => { pc.close(); resolve({ label: server.label, types: [...seen] }); }, 5000);
pc.onicecandidate = ({ candidate }) => {
if (!candidate) {
clearTimeout(timeout); pc.close();
resolve({ label: server.label, types: [...seen] });
return;
}
seen.add(candidate.type);
};
});
}));
return results;
}
// 上报到日志,统计各级网络分布
console.log(await probeNetwork());
// [
// { label: 'stun-udp', types: ['host', 'srflx'] }, ← 1 级以下
// { label: 'turn-udp', types: ['host', 'srflx', 'relay'] },← UDP 通
// { label: 'turn-tcp', types: ['host', 'relay'] }, ← UDP 不通但 TCP 通
// { label: 'turn-tls', types: ['host', 'relay'] }, ← 2/3 级,靠 TLS
// ]
线上把这份诊断埋点上报,能精准看出"丢失"用户卡在哪一层网络。
4.2 SSL 拦截(3 级)的特征
公司在出口部署 SSL 解密代理时:
- 客户端看到的证书 issuer 是公司自签 CA
- TURN-TLS 握手时浏览器会拒绝(除非系统装了那张 CA)
- 即使装了 CA,代理重新打包的 TLS 流可能改写 ALPN,TURN 协议层失败
这种环境基本无解。一些做法:
- 配合 IT:在代理白名单里把 TURN 域名加上"绕过解密"
- 提示用户切到手机热点
- 极端情况降级到电话拨入(PSTN 网关)
5. IPv6 双栈
国内运营商 IPv6 普及率已超过 70%(2025 年),但企业网络很多仍是 v4-only,CDN/TURN 也不全部双栈。三种典型场景:
| 客户端 A | 客户端 B | 是否能直连 | 备注 |
|---|---|---|---|
| v4 | v4 | ✅ | 经典路径 |
| v6 | v6 | ✅ | 性能最好,无 NAT |
| v4 | v6 | ❌ | 必须中继 |
| v4-only TURN | v6 客户端 | ❌ | TURN 必须双栈 |
5.1 配置 coturn 双栈
listening-ip=0.0.0.0
listening-ip=::
relay-ip=0.0.0.0
relay-ip=::
external-ip=203.0.113.10
external-ip=2001:db8::1
DNS 同时配 A / AAAA 记录,浏览器会同时探测两条路径并取最快的(Happy Eyeballs,RFC 8305)。
5.2 客户端 IPv6 候选
启用 IPv6 后浏览器会同时收集 v4 和 v6 的 host / srflx 候选。收集时间会变长——每个 STUN 服务器都要在两个协议族上各跑一次。
// 限制为 v4-only(特殊场景,如某些老 SFU 不支持 v6)
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun4.example.com' }],
// 没有官方 API 强制禁 v6,只能不配 v6 STUN
});
6. 网络切换:4G ↔ Wi-Fi
移动场景核心痛点。切换瞬间发生的事:
- 操作系统给应用一个新的 IP(默认路由变化)。
- 旧 IP 上的 socket 还在,但发包黑洞。
- ICE 检查失败 →
iceConnectionState = disconnected → failed。 - 不做 ICE Restart 的话,通话直接挂。
// iOS / Android WebView / 桌面浏览器都支持 NetworkInformation API
navigator.connection?.addEventListener('change', () => {
if (pc.connectionState === 'connected' || pc.connectionState === 'disconnected') {
// 短延迟,等 OS 稳定
setTimeout(() => pc.restartIce(), 1500);
}
});
// 兜底:监听 ICE failed
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') pc.restartIce();
};
iOS Safari 的 NetworkInformation API 不完整,靠 online/offline 事件不够。最稳的兜底是 iceConnectionState = failed 触发 restart。
6.1 切换期间的体验优化
ICE Restart 至少需要 1-3 秒,期间用户看到画面卡死。三种处理:
- 静音不停画:保留最后一帧让用户感知"在重连"。
- 音频优先:Restart 期间降级到只有音频,画面恢复后再升码率。
- 预热第二条链路:高级方案,同时维护两个 PC(4G + Wi-Fi 各一),切换时只换主链路指针。代价是带宽 ×2,仅适合极端高可用场景。
绝大多数产品做第 1 种就够。
7. 移动端的端口寿命与心跳
WebRTC 自带 STUN Consent Freshness(RFC 7675):每 5 秒发一次 STUN Binding 给对端,确保路径仍然通。这同时也给 NAT 表项续命。
但有两种情况心跳失效:
- 应用切到后台,浏览器节流定时器(Chrome 后台 1 分钟一次)
- 屏幕锁定时 iOS Safari 直接挂起整个 WebView
后台时 NAT 表项过期 → 回到前台后必然 failed → 必须 restart。监听 visibilitychange:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && pc.connectionState === 'connected') {
// 检查实际是否还能通:拿一份 stats,看 RTT 是否合理
pc.getStats().then(stats => {
const pair = [...stats.values()].find(r =>
r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded'
);
if (!pair || Date.now() - pair.lastPacketReceivedTimestamp > 5000) {
pc.restartIce();
}
});
}
});
8. 企业代理与 HTTP CONNECT
少数极端环境只允许走 HTTP/HTTPS 代理。WebRTC 标准里 TURN 不支持 HTTP CONNECT 代理。变通方案:
- Chrome 启动参数
--proxy-server+--force-webrtc-ip-handling-policy=default_public_interface_only让 WebRTC 也走代理 - 部分企业部署"WebRTC 网关",把流量转成 WebSocket 包传出
- LiveKit / Twilio 等商用方案有专门的"代理友好"模式
这类需求 5 年前还有很多,现在基本都靠 TURN-TLS-443 解决了。如果客户提出"我们公司必须走 HTTP 代理",先确认 443 真的封死再考虑代理方案。
9. 完整的"分层降级"建链策略
生产代码不能只配一个 TURN,必须分层降级。优先级从高到低:
const iceServers = [
// 第 1 层:直连(host 候选)—— 最快,0 中继成本
{ urls: 'stun:stun.example.com:3478' },
// 第 2 层:UDP 中继 —— 大多数 NAT 后能用
{ urls: 'turn:turn-cn.example.com:3478?transport=udp', username, credential },
{ urls: 'turn:turn-us.example.com:3478?transport=udp', username, credential },
// 第 3 层:TCP 中继 —— UDP 被防火墙的环境
{ urls: 'turn:turn-cn.example.com:3478?transport=tcp', username, credential },
// 第 4 层:TLS/443 —— 严格企业网络
{ urls: 'turns:turn-cn.example.com:443?transport=tcp', username, credential },
{ urls: 'turns:turn-us.example.com:443?transport=tcp', username, credential },
];
const pc = new RTCPeerConnection({
iceServers,
iceTransportPolicy: 'all',
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
});
浏览器会并行尝试所有 server,按优先级和到达顺序选 selected pair。第一个能用的胜出。
10. 调试工具速查
| 工具 | 用途 | 在哪用 |
|---|---|---|
chrome://webrtc-internals | 看候选、selected pair、ICE 状态 | Chrome / Edge |
about:webrtc | 同上 | Firefox |
turnutils_natdiscovery | 测自己的 NAT 行为 | 服务端 / 用户机器 |
turnutils_uclient | 测 TURN 是否可用 | 服务端 |
| trickle-ice 网页 | 一键看候选类型 | 用户浏览器 |
tcpdump 'udp port 3478 or tcp port 5349' | 抓 STUN/TURN 包 | 服务端 |
mtr <peer-ip> | 看链路质量 | 服务端 |
11. 反模式
| 反模式 | 后果 | 替代 |
|---|---|---|
| 只支持 v4 | 部分 v6-only 环境失败 | TURN 双栈 |
| 4G/Wi-Fi 切换重建 PC | DTLS 重新握手,体验差 | restartIce |
| 后台时主动 close PC | 切回前台要全流程重建 | 用 visibilitychange + restartIce |
| 用 STUN 探测 NAT 类型决定是否走 TURN | NAT 行为可能突变(运营商重启边界) | 永远配 TURN,让 ICE 自动选 |
| 一个 TURN 实例打天下 | 跨大洲延迟高 | 多区域部署 + 客户端按地理选 |
| 不上报真实 selected pair 类型 | 出问题不知道用户卡在哪层 | 通话结束上报 candidateType |
| TURN 长期凭据 | 被滥用当代理 | 短期 HMAC 凭据 |
12. 权威资料
- IETF RFC 4787 NAT Behavioral Requirements for UDP: https://www.rfc-editor.org/rfc/rfc4787
- IETF RFC 5780 NAT Behavior Discovery Using STUN: https://www.rfc-editor.org/rfc/rfc5780
- IETF RFC 6888 CGN Requirements: https://www.rfc-editor.org/rfc/rfc6888
- IETF RFC 7675 STUN Consent Freshness: https://www.rfc-editor.org/rfc/rfc7675
- IETF RFC 8305 Happy Eyeballs v2: https://www.rfc-editor.org/rfc/rfc8305
- IETF RFC 8445 ICE: https://www.rfc-editor.org/rfc/rfc8445
- Google IPv6 统计(用于了解部署进度): https://www.google.com/intl/en/ipv6/statistics.html
- 核对日期:2026-06-22