Cloudflare TURN 生产实战:WebRTC 中继凭证签发、ICE 重启与端口过滤指南
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
Cloudflare TURN 是跑在全球 anycast 网络(310+ 城市,不含中国网络)上的托管中继服务,当 NAT 或防火墙阻断 WebRTC 客户端与 SFU 的直连时,它作为流量中继兜底,保证通话可用。本文不按"先配好再写代码"的顺序,而是按"服务端签发 → 浏览器消费 → 掉线自愈"三层,拆出一套可直接上生产的 TURN 接入方案,覆盖凭证缓存、53 端口过滤、ICE 重启、限额排查与成本核算,读完即可落地一套健壮的代码。
把 TURN 塞进 RTCPeerConnection:直连优先、中继兜底
WebRTC 用RTCIceServer描述 ICE 服务器。客户端不自己硬编码服务器,而是向自己的后端拉临时凭证,再叠一个公开 STUN:
interface RTCIceServer { urls: string | string[]; username?: string; credential?: string; credentialType?: "password" | "oauth"; } async function getTURNConfig(): Promise<RTCIceServer[]> { const response = await fetch('/api/turn-credentials'); const data = await response.json(); return [ { urls: 'stun:stun.cloudflare.com:3478' }, { urls: [ 'turn:turn.cloudflare.com:3478?transport=udp', 'turn:turn.cloudflare.com:3478?transport=tcp', 'turns:turn.cloudflare.com:5349?transport=tcp', 'turns:turn.cloudflare.com:443?transport=tcp' ], username: data.username, credential: data.credential, credentialType: 'password' } ]; } const iceServers = await getTURNConfig(); const peerConnection = new RTCPeerConnection({ iceServers });为什么要分 STUN 与 TURN 两段:stun:stun.cloudflare.com:3478负责发现公网候选,turn:/turns:负责在直连失败时中继。两段一起交给RTCPeerConnection,由 ICE 协商自动择优——STUN 直连成功就省掉中继发费用,失败才落到 TURN。
按业务对"效率 vs 连通性"的取舍,可用iceTransportPolicy与bundlePolicy控制行为:
| 场景 | 关键配置 | 实际行为 |
|---|---|---|
| 视频会议 | iceTransportPolicy: 'all' | 先试 P2P 直连,失败才走中继 |
| IoT / 可预测连通 | iceTransportPolicy: 'relay' | 强制全部流量经 TURN 中继 |
| 屏幕共享 | bundlePolicy: 'max-bundle' | 多路媒体聚合到单条传输,降开销 |
成本差异要心里有数:与 Cloudflare Calls SFU 搭配时 TURN 免费,否则按$0.05/GB出站流量计费。视频会议用'all'能省下直连场景的中继费;只有对连通性可预测性要求高的场景才用'relay'。
从自己的后端拉凭证
凭证来自你自己的/api/turn-credentials,而不是浏览器直接打 Cloudflare 生成端点。这样密钥永远留在服务端,客户端只拿到一次性临时凭证。
凭证不该出现在浏览器里:Worker 端签发与缓存
签发动作放在一个 Cloudflare Worker 里完成。密钥放环境变量与 secrets:
# .env CLOUDFLARE_ACCOUNT_ID=your_account_id CLOUDFLARE_API_TOKEN=your_api_token TURN_KEY_ID=your_turn_key_id TURN_KEY_SECRET=your_turn_key_secretwrangler.jsonc里,非敏感的TURN_KEY_ID可以放vars,敏感密钥用wrangler secret put TURN_KEY_SECRET单独注入;生产环境可再绑定CREDENTIALS_CACHE这个 KV 命名空间做凭证缓存:
{ "name": "turn-credentials-api", "main": "src/index.ts", "vars": { "TURN_KEY_ID": "your-turn-key-id" }, "env": { "production": { "kv_namespaces": [ { "binding": "CREDENTIALS_CACHE", "id": "your-kv-namespace-id" } ] } } }为什么密钥要分开存:vars会随部署明文可见,wrangler secret put的 secret 不落盘到配置里。把TURN_KEY_SECRET放进vars等于把签发钥匙挂在墙上。
一次保存的密钥
创建 TURN Key 走POST /accounts/{account_id}/calls/turn_keys(Base URLhttps://api.cloudflare.com/client/v4,需 "Calls Write" 权限的 Token):
POST /accounts/{account_id}/calls/turn_keys Content-Type: application/json { "name": "my-turn-key" }响应里的key字段是实际密钥,仅创建时返回一次,必须立刻保存,之后uid、name、created、modified都能再查,但key查不回来。后续管理:GET列表、GET/PUT/DELETE /accounts/{account_id}/calls/turn_keys/{key_id}。
缓存未过期凭证
为避免每个客户端都打rtc.live.cloudflare.com的生成端点,Worker 侧可缓存未过期凭证,并在本地校验 TTL 上限:
class TURNCredentialsManager { private creds: { username: string; credential: string; urls: string[]; expiresAt: number; } | null = null; async getCredentials(keyId: string, keySecret: string): Promise<RTCIceServer[]> { const now = Date.now(); if (this.creds && this.creds.expiresAt > now) { return this.buildIceServers(this.creds); } const ttl = 3600; if (ttl > 172800) throw new Error('TTL max 48hrs'); const res = await fetch( `https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate`, { method: 'POST', headers: { 'Authorization': `Bearer ${keySecret}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ttl }) } ); const data = await res.json(); const filteredUrls = data.iceServers.urls.filter((url: string) => !url.includes(':53')); this.creds = { username: data.iceServers.username, credential: data.iceServers.credential, urls: filteredUrls, expiresAt: now + (ttl * 1000) - 60000 }; return this.buildIceServers(this.creds); } private buildIceServers(c: { username: string; credential: string; urls: string[] }): RTCIceServer[] { return [ { urls: 'stun:stun.cloudflare.com:3478' }, { urls: c.urls, username: c.username, credential: c.credential, credentialType: 'password' as const } ]; } }三个细节别漏:缓存有效期比 TTL 提前 1 分钟(- 60000)留出刷新窗口;过滤 53 端口在缓存写入时一次性完成;ttl > 172800的防御性校验与 API 侧约束一致——API 会直接拒绝超过48 小时(172800 秒)的请求,示例里常用ttl: 86400(24 小时)。
凭证生成的请求/响应契约:
POST https://rtc.live.cloudflare.com/v1/turn/keys/{key_id}/credentials/generate Authorization: Bearer {key_secret} Content-Type: application/json { "ttl": 86400 }核心响应字段:iceServers.urls(含 STUN 与多协议 TURN 地址)、username(形如1738035200:user123)、credential(Base64 编码的 HMAC)。要立即终止某会话,调POST .../credentials/revoke(body 传{"username": "..."}),返回204,计费立即停止,活跃连接在数秒内断开。
53 端口这条 URL 必须服务端拦掉
凭证生成响应里会混入turn:turn.cloudflare.com:53?transport=udp和turn:turn.cloudflare.com:80?transport=tcp这类地址。它们对非浏览器客户端可用,但Chrome 和 Firefox 会拦截 53 端口,浏览器端会静默失败。所以过滤逻辑要放在服务端,别依赖浏览器自己处理。
推荐尝试顺序(浏览器端):
| 顺序 | 端口/协议 | 定位 |
|---|---|---|
| 1 | 3478/udp | 首选,延迟最低 |
| 2 | 3478/tcp | UDP 被封网络的回退 |
| 3 | 5349/tls | 企业防火墙最可靠 |
| 4 | 443/tls | 备用 TLS 端口,防火墙友好 |
function filterICEServersForBrowser(urls: string[]): string[] { return urls .filter(url => !url.includes(':53')) // Remove port 53 .sort((a, b) => { if (a.includes('transport=udp')) return -1; if (b.includes('transport=udp')) return 1; if (a.includes('transport=tcp') && !a.startsWith('turns:')) return -1; if (b.includes('transport=tcp') && !b.startsWith('turns:')) return 1; return 0; }); }为什么不放浏览器:凭证列表由服务端统一生成后下发,浏览器拿到的已经是过滤+排序好的结果;一旦放行 53 端口 URL,ICE 会尝试该候选却永远收不到响应,白白拖慢建连。
掉线自愈:刷新、缓存与 ICE 重启
长通话里,凭证到期或网络切换会让iceconnectionstatechange进入failed。setConfiguration()能更新iceServers,但它不触发 ICE 重启——连接已经失败时必须配合restartIce()。
提前刷新 + 缓存管理器
刷新时机以 TTL 为基准:ttl * 1000 - 60000(提前 1 分钟)是推荐的刷新间隔。TTL 为 1 小时时约为 50 分钟:
async function refreshTURNCredentials(pc: RTCPeerConnection): Promise<void> { const newCreds = await fetch('/turn-credentials').then(r => r.json()); const config = pc.getConfiguration(); config.iceServers = newCreds.iceServers; pc.setConfiguration(config); // Note: setConfiguration() does NOT trigger ICE restart } const refreshInterval = ttl * 1000 - 60000; // 1 min early setInterval(() => refreshTURNCredentials(peerConnection), refreshInterval);只刷新不重启,凭证能续上但旧候选对可能已死,连接照样卡住;所以"刷新 + 重启"要成对出现。
failed / disconnected 时重启 ICE
把failed和disconnected都纳入恢复条件,防止移动网络切换时掉线。需要触发 ICE 重启的场景:TURN 服务器维护、anycast 路由调整、>1 小时的长会话凭证刷新、iceConnectionState === 'failed'。
pc.addEventListener('iceconnectionstatechange', async () => { if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') { console.warn('ICE connection degraded, restarting...'); // 1. 刷新凭证 await refreshTURNCredentials(pc); // 2. 触发 ICE 重启并重建 offer pc.restartIce(); const offer = await pc.createOffer({ iceRestart: true }); await pc.setLocalDescription(offer); // 3. 通过信令通道把 offer 发给对端 } });顺序不能乱:先刷新凭证(拿到新iceServers),再restartIce(),再用iceRestart: true造 offer 并经信令发给对方。只打日志不重启,连接就永远停在failed。
按分配计的限额与掉包排查
以下限额是按用户分配而非账户级,超限后果统一是丢包:
| 维度 | 限额 | 超限后果 |
|---|---|---|
| 唯一 IP 数 | >5 个新 IP/秒 | 丢包 |
| 包速率 | 入/出 5–10k pps | 丢包 |
| 数据速率 | 入/出 50–100 Mbps | 丢包 |
排错时高频错误与正确做法对照:
| 错误做法 | 正确做法 |
|---|---|
ttl: 604800(7 天) | ttl: 86400(24h),超 48h API 直接拒绝 |
硬编码 IPturn:141.101.90.1:3478 | 用域名turn:turn.cloudflare.com:3478,IP 变更有 14 天通知 |
浏览器端保留:53端口 | 服务端过滤!url.includes(':53') |
| 凭证到期不刷新 | setInterval提前 1 分钟刷新 |
| 只打日志不重启 | failed/disconnected时刷新凭证 +restartIce() |
TURN_KEY_SECRET放客户端 | 仅服务端签发,客户端请求/api/turn-credentials |
建连缓慢时依次排查:候选收集是否完整、到 Cloudflare 边缘的网络延迟、防火墙是否放行 WebRTC 端口(3478、5349、443),企业网络是否该改用 443 端口的 TURN over TLS。
用 getStats 判断走没走中继
靠三个事件/API 观察 ICE 过程:
pc.addEventListener('icecandidate', (event) => { if (event.candidate) { console.log('ICE candidate:', event.candidate.type, event.candidate.protocol); } }); pc.addEventListener('iceconnectionstatechange', () => { console.log('ICE state:', pc.iceConnectionState); }); const stats = await pc.getStats(); stats.forEach(report => { if (report.type === 'candidate-pair' && report.selected) { console.log('Selected:', report); } });icecandidate:看候选type(host/srflx/relay)与protocol,确认是否出现 relay 候选;iceconnectionstatechange:跟踪checking → connected → completed(或failed);getStats()里selected为 true 的candidate-pair:即当前实际选中的候选对,能判定流量到底走直连还是 TURN 中继。
企业网络、IPv6 与 TLS 的边界
部署边界上有两个容易踩的点:
- 客户端到 TURN:IPv4/IPv6 都支持;但中继地址只分配 IPv4(不支持 RFC 6156),TCP 中继(RFC 6062)也不支持——IPv6 客户端能接入,中继流量仍走 IPv4;
- TLS 版本:1.1/1.2/1.3 均支持。TLS 1.3 推荐
AEAD-AES128-GCM-SHA256、AEAD-AES256-GCM-SHA384、AEAD-CHACHA20-POLY1305-SHA256;TLS 1.2 推荐ECDHE-ECDSA-AES128-GCM-SHA256、ECDHE-RSA-AES128-GCM-SHA256等套件。
严格防火墙的 IP 白名单
可对turn.cloudflare.com白名单化:IPv4141.101.90.1/32、162.159.207.1/32,IPv62a06:98c1:3200::1/128、2606:4700:48::1/128。但这些 IP可能提前 14 天通知后变更,需用dig turn.cloudflare.com A/dig turn.cloudflare.com AAAA定期核对并设自动监控,在 14 天内更新白名单,否则连接会直接失败。
上线检查清单与参考索引
上线前逐项确认:
- 凭证仅服务端生成,绝不下发密钥
TURN_KEY_SECRET放 wrangler secrets,不进vars- TTL ≤ 预期会话时长,且 ≤ 48 小时(172800 秒)
- 凭证生成端点做限流
- 签发前先做客户端认证
- 提供凭证吊销 API 应对被攻陷的会话
- 不硬编码 IP,或建立 DNS 监控
- 浏览器客户端过滤 53 端口
参考索引(均在仓库skills/.curated/cloudflare-deploy/references/turn/下):
- TURN 凭证与 Key 管理 API:凭证生成/吊销、Key CRUD、TypeScript 类型与 TTL 约束的权威来源。
- TURN 配置指南:Worker 搭建、wrangler.jsonc、环境变量与 IP 白名单配置。
- TURN 实现模式:浏览器配置、端口过滤、刷新与 ICE 重启的完整代码范式。
- TURN 陷阱与排查:常见错误、按分配限额、安全清单与成本优化。
- TURN 服务概览:服务地址、端口清单与快速开始入口。
- cloudflare-deploy 决策树:网络连通性分支中 WebRTC 场景对应
turn/与realtime-sfu/、realtimekit/模块。
【免费下载链接】skillsSkills Catalog for Codex项目地址: https://gitcode.com/GitHub_Trending/skills4/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考