Cloudflare TURN 实战:WebRTC 凭证管理与 ICE 重启的 5 个关键点
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
凌晨三点,值班被叫醒:一通 48 小时的长通话,中继流量在整点归零,ICE 状态卡死在 failed。排查半天,原因并不玄乎——TURN 凭证刚好过期。Cloudflare 临时凭证的 TTL 上限就是 172800 秒,到期那一刻,连接没有缓冲、没有告警,直接断。
开源仓库 skills(Skills Catalog for Codex)里的 Cloudflare TURN 生产实现模式,把凭证管理、ICE 重启、连接排查这条链路的坑全踩过了。这里按一条主线拆给你:先建判断框架,再跑通最小链路,然后处理掉线恢复,最后过一遍上线检查。
⚠️ 30 秒决策地图:端口与传输策略怎么选
写代码之前先花 30 秒定策略,后面所有代码都只是把策略翻译成配置:
| 场景 | 端口优先级 | iceTransportPolicy | 为什么这么选 |
|---|---|---|---|
| 视频会议 / P2P 通话 | 3478/udp 打头 | all | 直连零成本、延迟最低,中继只当兜底 |
| 公司网封了 UDP | 3478/tcp | all | TCP 握手机率比 UDP 穿透高 |
| 严格企业防火墙 | 443、5349(turns) | all | 443 基本不会被拦,TLS 还顺带加密 |
| IoT / 弱网设备 | 3478/udp 打头 | relay | 连通性优先于成本,强制走中继 |
| 屏幕共享等多路流 | 任意 | all + max-bundle | 多路流聚合成一条通道,省开销 |
这里有个坑:API 返回的 URL 列表里混着:53和:80两个地址,它们在非浏览器端合法可用,但 Chrome / Firefox 会直接拦截 53 端口流量——relay 候选悄悄缺失,控制台没有任何报错。所以过滤必须放在服务端做,浏览器端只负责用。
成本上多一句:搭配 Cloudflare Calls SFU 使用时 TURN 免费,单独用按 $0.05/GB 出站计费,这也是iceTransportPolicy默认用all而非relay的原因之一。
最小可运行链路:从建 Key 到浏览器拿到 iceServers
后端签发:密钥永远留在服务端
先创建 TURN Key,拿到只在创建时返回一次的密钥:
POST /accounts/{account_id}/calls/turn_keys # body: { "name": "prod-turn" } # 响应里的 key 字段只在创建时出现,必须当场存进 secrets注意key字段不会二次下发,丢了只能删掉重建。
然后是 Worker 侧的签发逻辑,核心是"代签 + 过滤"两件事:
export default { async fetch(req: Request, env: Env) { const res = await fetch( `https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate`, { method: 'POST', headers: { Authorization: `Bearer ${env.TURN_KEY_SECRET}` }, body: JSON.stringify({ ttl: 3600 }) } ); const { iceServers: s } = await res.json(); // 浏览器会静默丢弃 :53 的地址,服务端先过滤再下发 const urls = s.urls.filter(u => !u.includes(':53')); return Response.json({ urls, username: s.username, credential: s.credential }); } };细节在expiresAt的取法上:缓存写入时留 1 分钟提前量(ttl * 1000 - 60000),给刷新窗口留余地,别等凭证真过期了才动手。完整端点契约见 凭证 API 参考,需要缓存未过期凭证可参考 Worker 集成配置。
浏览器端:iceServers 的正确写法
浏览器只做一件事——从自己的后端拿现成的 iceServers:
const r = await fetch('/api/turn-credentials'); const { urls, username, credential } = await r.json(); const iceServers = [ { urls: 'stun:stun.cloudflare.com:3478' }, // STUN 只负责发现公网候选 { urls, username, credential, credentialType: 'password' } // TURN 负责直连失败时兜底 ]; const pc = new RTCPeerConnection({ iceServers });关键设计是 STUN 和 TURN 同时给:STUN 便宜快,TURN 兜底,由 ICE 协商自动择优。千万别在前端拼 key secret 直接打生成端点——密钥一旦进浏览器 bundle,等于公开。
连接失效与恢复:48 小时掉线、ICE failed、网络切换
凭证过期、ICE 进入 failed、移动网络切换,三种诱因,恢复路径只有一条:换凭证 → 重启 ICE → 重发 offer。分开处理只会写出三份重复逻辑。
TURN 凭证 48 小时过期怎么办:提前一分钟热替换
// 到期前热替换 iceServers,而不是等断了再补救 const fresh = await fetch('/api/turn-credentials').then(r => r.json()); const cfg = pc.getConfiguration(); cfg.iceServers = [ { urls: 'stun:stun.cloudflare.com:3478' }, { urls: fresh.urls, username: fresh.username, credential: fresh.credential, credentialType: 'password' } ]; pc.setConfiguration(cfg);这里有个最反直觉的点:setConfiguration()只换配置,不触发 ICE 重启。连接还活着时它静默生效;连接已经 failed 时它什么都不改变——必须配合下一节的重启组合拳。定时刷新间隔用ttl * 1000 - 60000,TTL 是 1 小时就每 50 分钟跑一次。
ICE 重启组合拳,以及如何确认流量真的走了中继
// failed 和 disconnected 都触发,防移动网络切换时的假性掉线 pc.addEventListener('iceconnectionstatechange', async () => { if (pc.iceConnectionState !== 'failed' && pc.iceConnectionState !== 'disconnected') return; await refreshCreds(pc); // 先换新凭证 const offer = await pc.createOffer({ iceRestart: true }); // 带重启标志重建 offer await pc.setLocalDescription(offer); signal.send(offer); // 经信令通道发给对端 });注意顺序不能换:先换凭证再重启,否则 ICE 拿着过期凭证重试,白烧一次协商。
恢复后想确认流量到底走直连还是中继,看选中候选对即可:
const stats = await pc.getStats(); stats.forEach(rep => { // selected 为 true 的候选对才是当前实际承载流量的 if (rep.type === 'candidate-pair' && rep.selected) { console.log('当前选中:', rep.localCandidateId); } });如果候选对里迟迟不出现 relay 类型,多半是 53 端口过滤漏了或者防火墙没放行 3478/5349/443。48 小时掉线的完整排查思路见 常见陷阱与排查,更完整的恢复流程变体见 实现模式参考。
✅ 上线前加固:生产检查清单
单分配限额与高频错误对照
限额是按用户分配而非账户级,超了不报错、只丢包:
| 维度 | 限额 | 超限后果 |
|---|---|---|
| 新 IP 出现速率 | 每秒 >5 个 | 丢包 |
| 包速率 | 入/出 5–10k pps | 丢包 |
| 数据速率 | 入/出 50–100 Mbps | 丢包 |
高频翻车姿势与修正:
| 错误写法 | 后果 | 修正 |
|---|---|---|
用ttl: 604800申请 7 天凭证 | API 直接拒绝 | 改成 86400,48 小时是硬顶 |
| iceServers 里写死 IP | IP 变更(提前 14 天通知)后全线掉线 | 用turn.cloudflare.com域名 |
| 53 端口 URL 原样下发 | 浏览器 relay 候选静默缺失 | 服务端过滤后再返回 |
| 状态 failed 只打日志 | 长通话无法自救 | 刷新凭证 + 带 iceRestart 重建 offer |
| 密钥硬编码进前端 | 凭证生成端点被任意刷 | 只有后端能调用生成端点 |
防火墙白名单与协议边界
严格防火墙环境需要给turn.cloudflare.com配白名单时,用dig turn.cloudflare.com A/dig turn.cloudflare.com AAAA定期核对,IP 变更有 14 天通知窗口,监控脚本要在这个窗口内更新白名单,细节在 配置指南。
协议边界两条,写进架构文档里免得以后背锅:
- 中继分配的地址只有 IPv4(无 RFC 6156),IPv6 客户端能接入但中继流量走 IPv4;TCP 中继(RFC 6062)同样不支持
- TLS 1.1/1.2/1.3 全支持,企业侧优先放行 443 与 5349 上的 TURN over TLS
上线前把这份清单过一遍:
- 密钥只存 wrangler secrets,签发端点仅后端可达
- 签发凭证前先做客户端认证,端点有限流
- TTL ≤ 48 小时,且覆盖预期通话时长
- 浏览器端 URL 已过滤 53 端口
- 被攻陷会话可走吊销端点(计费即停、连接数秒内断开)
- 用了 IP 白名单就有 DNS 监控,14 天窗口内能更新
- 单用户中继流量预算在 5–10k pps / 50–100 Mbps 内
- 搭配 Calls SFU 的场景确认走了免费 TURN 路径
延伸阅读
- TURN 服务概览:地址与端口清单
- Calls SFU 模块:TURN 与 SFU 自动协调
- RealtimeKit 集成参考
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考