跳到正文

5G 软电话 WebRTC ICE/TURN 部署、排障与验收

本文记录浏览器软电话在 5G 网络下从 ICE 协商失败、接通无声到恢复双向音频的完整处理经验, 可作为同类环境的部署基线、故障排查和上线验收手册。

文中的 192.168.2.198123.57.205.60 和端口范围是当前环境的明确示例。复制到其他 环境前必须替换为现场地址,并同步校准 FRP、FreeSWITCH、防火墙、coturn 和应用配置。本文 不包含 shared secret、登录凭据或其他生产密钥。

1. 结论与当前基线

本次问题不是单独部署 TURN 就能解决,而是浏览器、coturn、FRP 和 FreeSWITCH 两侧都要形成 可达的 ICE 路径:

  • 浏览器通过公网 coturn 获取 relay candidate;
  • FreeSWITCH 只接受来自 coturn 公网 IP 的浏览器 candidate,避免选中 5G 运营商的 srflx candidate;
  • FreeSWITCH 在 SDP 中广告经 FRP 映射的公网 RTP 地址,不能广告主机私网地址;
  • coturn 控制流量和 relay 流量直接到公网机,不封装进 FRP;
  • 发往 FreeSWITCH 公网 RTP 端口的流量才经过 FRPS → FRPC。

当前配置基线如下:

组件地址或端口用途
FreeSWITCH192.168.2.198sip-ip / rtp-ip 的本机绑定地址
FreeSWITCH 公网 SDP123.57.205.60ext-sip-ip / ext-rtp-ip 的广告地址
FreeSWITCH RTP30000–30199/UDP经 FRPS / FRPC 映射到 FreeSWITCH
coturn123.57.205.60:3478/UDPSTUN 和 TURN/UDP
coturn123.57.205.60:3478/TCPTURN/TCP 回退
coturn relay123.57.205.60:31000–31040/UDP浏览器媒体 relay,不经过 FRP

2. 实际拓扑和媒体路径

text
手机浏览器(5G)
  ├─ SIP WS/WSS 信令 ───────────────> FRPS 公网入口 ─> FRPC ─> FreeSWITCH

  └─ STUN/TURN :3478/TCP+UDP ──────> coturn(公网机,直接访问,不经过 FRP)

                                         └─ relay :31000–31040/UDP
                                              ↕ WebRTC 音频
                                      FRPS :30000–30199/UDP

                                         └─ FRPC ─> FreeSWITCH
                                                  192.168.2.198:
                                                  30000–30199/UDP

需要区分两个公网 UDP 范围:

  • 31000–31040 是 coturn 自己分配的 relay 端口,coturn 必须直接监听并由公网防火墙放行;
  • 30000–30199 是 FreeSWITCH RTP 公网映射,FRPS 接收后由 FRPC 转发到 192.168.2.198:30000–30199
  • 两个范围不能重叠,也不能把 TURN relay 范围错误地配置成 FRP 转发范围;
  • SIP WS/WSS 走 FRP 只代表信令可达,不代表 WebRTC 媒体已经可达。

3. 故障演进与证据

3.1 阶段一:未部署 TURN,ICE 协商失败

手机切到 5G 后,浏览器通常只能提供 host 和运营商 NAT 形成的 srflx candidate。运营商 CGNAT、防火墙或不对称路由会使这些地址无法被 FreeSWITCH 稳定访问。

典型表现包括:

  • 浏览器没有可用的 relay candidate;
  • ICE gathering 虽能完成,但 candidate pair 无法进入可用状态;
  • SIP 呼叫可能收到 488 Not Acceptable Here,或页面直接报告 ICE / 媒体连接失败;
  • 同一设备在局域网可用,切换到 5G 后稳定复现。

此阶段需要先部署公网 TURN,并确认 UDP、TCP 两种 allocation 都成功。只增加一个 STUN 地址 不能解决运营商 CGNAT 下的媒体可达性。

3.2 阶段二:部署 TURN 后接通,但双方无声

coturn 上线后,浏览器能够获得 relay candidate,SIP 也能接通,但媒体仍可能完全不通。 本次现场同时出现了以下证据:

  • FreeSWITCH 收到 srflxrelay candidate 后,日志仍显示 Choose rtp candidate ... srflx
  • FreeSWITCH 的 200 OK SDP 中,c= 或本地 candidate 仍广告 192.168.2.198
  • ICE 会话已建立或正在检查,但 DTLS 长时间停在 HANDSHAKE
  • Opus 已协商,收发 RTP 帧或包计数仍为 0
  • SIP 状态显示接通,浏览器和对端却都听不到声音。

“已接通”只证明 SIP 信令完成,不能证明 ICE、DTLS-SRTP 和 RTP 已成功。

4. 两个根因

4.1 FRP 回送信令被误判为本地连接

浏览器的 SIP WS/WSS 先到公网 FRPS,再由内网 FRPC 回送 FreeSWITCH。FreeSWITCH 看到的 network_ip 因此是本机内网来源,会命中 localnet.auto。Sofia 将呼叫当成本地连接处理时, ext-rtp-ip 不会进入 SDP,最终把不可从公网访问的 192.168.2.198 发给浏览器。

当前 WebRTC 所在的 internal profile 使用:

xml
<param name="local-network-acl" value="none"/>

这会强制该 profile 走 NAT 分支,使 ext-rtp-ip=123.57.205.60 进入对外 SDP。该设置应按 profile 的实际用途评估;如果同一 profile 还服务复杂的纯内网终端,需要在变更前回归这些终端。

4.2 FreeSWITCH 1.10 不会自动回退到 TURN relay

FreeSWITCH 1.10 默认会优先选择允许的非 relay candidate。本次它选择了手机的运营商 srflx,即使同一份 SDP 中已经有 coturn relay,也不会在该路径不通时自动切换到 relay。

解决方式不是继续增加 TURN 地址,而是通过 apply-candidate-acl 只允许 coturn 公网 IP:

xml
<list name="softphone-turn-relay" default="deny">
  <node type="allow" cidr="123.57.205.60/32"/>
</list>
xml
<param name="apply-candidate-acl" value="softphone-turn-relay"/>

普通 SIP 终端不携带 WebRTC ICE candidate,因此不会被这条 candidate ACL 直接过滤。若更换 coturn 公网 IP,必须先更新 ACL,再切换 DNS 或应用下发地址。

5. 最终配置基线

5.1 coturn

Debian 12 公网主机的安装、配置权限、防火墙、systemd 启停、鉴权 allocation 验收和回滚步骤见 coturn/README.md。本节只保留跨组件配置基线和原理说明,仓库参考文件为 coturn/turnserver.conf.example

ini
listening-ip=0.0.0.0
listening-port=3478
external-ip=123.57.205.60/<coturn-private-ip>
min-port=31000
max-port=31040
realm=softphone.ai-voice-platform
use-auth-secret
static-auth-secret=<server-private-shared-secret>
fingerprint
no-tls
no-dtls

部署要求:

  • 公网安全组和主机防火墙放行 3478/TCP+UDP31000–31040/UDP
  • external-ip 左侧为公网 IP,右侧为 coturn 主机实际绑定的私网 IP;
  • 使用 coturn REST shared-secret 鉴权,不创建长期浏览器账号;
  • shared secret 只进入 coturn 私有配置、后端环境变量或密钥系统,不写入前端和仓库;
  • 当前未启用 TLS/TURNS,浏览器通过 turn:...?...transport=tcp 做 TCP 回退。

5.2 FreeSWITCH 和 FRP

freeswitch/vars.xml 中将绑定地址与公网广告地址分离:

xml
<X-PRE-PROCESS cmd="set" data="domain=192.168.2.198"/>
<X-PRE-PROCESS cmd="set" data="external_rtp_ip=123.57.205.60"/>
<X-PRE-PROCESS cmd="set" data="external_sip_ip=123.57.205.60"/>

freeswitch/sip_profiles/internal.xml 使用本机地址绑定, 使用公网地址生成 SDP,并应用 NAT 与 candidate 策略:

xml
<param name="rtp-ip" value="$${local_ip_v4}"/>
<param name="sip-ip" value="$${local_ip_v4}"/>
<param name="local-network-acl" value="none"/>
<param name="apply-candidate-acl" value="softphone-turn-relay"/>
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/>
<param name="ext-sip-ip" value="$${external_sip_ip}"/>

freeswitch/autoload_configs/acl.conf.xmlsoftphone-turn-relay 只允许 coturn 公网 IP; freeswitch/autoload_configs/switch.conf.xml 中 RTP 范围为:

xml
<param name="rtp-start-port" value="30000"/>
<param name="rtp-end-port" value="30199"/>

FRPS / FRPC 需要逐端口或按所用 FRP 版本支持的范围映射完整转发 30000–30199/UDP。公网安全组、FRPS 监听、FRPC 转发和 FreeSWITCH 主机防火墙四处必须一致。

5.3 应用后端与前端

callflow-servercallout-server 均通过受登录鉴权保护的 GET /api/softphone/ice-config 下发 ICE 配置。仓库和部署样例默认关闭 TURN:

json
{
  "turn": {
    "enabled": false,
    "host": "123.57.205.60",
    "port": 3478,
    "realm": "softphone.ai-voice-platform",
    "sharedSecret": "",
    "credentialTtlSeconds": 3600
  }
}

本地直连时接口返回:

json
{ "enabled": false, "iceServers": [], "expiresAt": null }

关闭状态不校验 host、realm 或 shared secret,不生成凭据。前端不会缓存关闭状态或安排刷新, 后续注册、拨号和接听会重新请求配置。

生产环境应通过私有配置把 enabled 改为 true,并通过 CALLFLOW_SERVER_TURN_SHARED_SECRETCALLOUT_TURN_SHARED_SECRET 或服务器私有配置注入 真实 shared secret。后端按 coturn REST 规范生成 <过期 Unix 时间>:softphone 用户名以及 HMAC-SHA1/Base64 credential,接口返回:

json
{
  "enabled": true,
  "iceServers": [
    { "urls": "stun:turn.example.com:3478" },
    {
      "urls": [
        "turn:turn.example.com:3478?transport=udp",
        "turn:turn.example.com:3478?transport=tcp"
      ],
      "username": "<expiry>:softphone",
      "credential": "<HMAC-SHA1/Base64>"
    }
  ],
  "expiresAt": "<ISO time>"
}

启用但缺少 host、realm 或 shared secret 时返回 503 ICE_CONFIG_UNAVAILABLE。所有成功和 配置错误响应均带 Cache-Control: no-store

前端对短期凭据执行以下策略:

  • 在到期前 5 分钟主动刷新;
  • 刷新失败时每 30 秒重试,尚未过期的缓存可以继续用于新 SDP;
  • 缓存已过期且刷新仍失败时,阻断新的软电话注册、呼叫或接听,不用过期凭据继续尝试;
  • 刷新不替换已经建立的 JsSIP UA 或正在通话的 PeerConnection,新凭据供后续 SDP 使用。

6. 标准变更步骤

FreeSWITCH profile 重启会中断注册并可能影响通话,应在维护窗口执行。

  1. 确认当前没有活动呼叫,并记录现有注册数:

    bash
    fs_cli -x "show calls count"
    fs_cli -x "sofia status profile internal reg"
  2. 备份将修改的 XML。路径按现场 conf_dir 调整:

    bash
    FS_CONF=/etc/freeswitch
    STAMP=$(date +%Y%m%d-%H%M%S)
    sudo cp -a "$FS_CONF/vars.xml" "$FS_CONF/vars.xml.bak.$STAMP"
    sudo cp -a "$FS_CONF/autoload_configs/acl.conf.xml" \
      "$FS_CONF/autoload_configs/acl.conf.xml.bak.$STAMP"
    sudo cp -a "$FS_CONF/autoload_configs/switch.conf.xml" \
      "$FS_CONF/autoload_configs/switch.conf.xml.bak.$STAMP"
    sudo cp -a "$FS_CONF/sip_profiles/internal.xml" \
      "$FS_CONF/sip_profiles/internal.xml.bak.$STAMP"
  3. 合并现场配置并做 XML 语法检查:

    bash
    xmllint --noout "$FS_CONF/vars.xml" \
      "$FS_CONF/autoload_configs/acl.conf.xml" \
      "$FS_CONF/autoload_configs/switch.conf.xml" \
      "$FS_CONF/sip_profiles/internal.xml"
  4. 依次加载 XML、刷新 ACL、验证 ACL 命中,再重启 Sofia profile:

    bash
    fs_cli -x "reloadxml"
    fs_cli -x "reloadacl"
    fs_cli -x "acl 123.57.205.60 softphone-turn-relay"
    fs_cli -x "sofia profile internal restart reloadxml"
  5. 等待软电话重新注册。注册恢复后只建立一通新测试呼叫,不用变更前已存在的通话判断结果:

    bash
    fs_cli -x "sofia status profile internal"
    fs_cli -x "sofia status profile internal reg"

任何命令返回错误、ACL 未命中或注册未恢复时,应停止扩大测试范围并按第 8 节回滚。

7. 验收清单与关键命令

7.1 短期凭据接口

以下命令中的地址和 token 都是占位变量。不要把真实登录凭据或 shared secret 写入脚本、工单或 命令行历史。

  1. 未登录请求必须返回 HTTP 401

    bash
    BASE_URL='https://management.example.com'
    curl -sS -o /dev/null -w '%{http_code}\n' \
      "$BASE_URL/api/softphone/ice-config"
  2. 使用已登录会话的 Bearer token 请求,响应必须是 200 且禁止缓存:

    bash
    ACCESS_TOKEN='REPLACE_WITH_LOGIN_BEARER_TOKEN'
    curl -sS -D /tmp/softphone-ice.headers \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      "$BASE_URL/api/softphone/ice-config" \
      -o /tmp/softphone-ice.json
    grep -i '^cache-control: *no-store' /tmp/softphone-ice.headers
    jq -e '.code == "0000" and (.data.enabled | type == "boolean")' \
      /tmp/softphone-ice.json
  3. 本地关闭模式应返回空列表和空过期时间:

    bash
    jq -e '
      .data.enabled == false
      and .data.iceServers == []
      and .data.expiresAt == null
    ' /tmp/softphone-ice.json
  4. 生产启用模式应检查响应同时包含 STUN、TURN/UDP、TURN/TCP,用户名时间戳和 expiresAt 与配置 TTL 一致。当前 TTL 是 3600 秒,应允许少量命令执行耗时:

    bash
    jq -e '.data.enabled == true and (.data.expiresAt | fromdateiso8601 > now)' \
      /tmp/softphone-ice.json
    jq '.data | {enabled, iceServers, expiresAt}' /tmp/softphone-ice.json
    jq -e '
      [.data.iceServers[].urls | if type == "array" then .[] else . end] as $urls
      | ($urls | map(startswith("stun:")) | any)
        and ($urls | map(contains("transport=udp")) | any)
        and ($urls | map(contains("transport=tcp")) | any)
    ' /tmp/softphone-ice.json
    TURN_USERNAME=$(jq -r \
      '.data.iceServers[] | select(.username != null) | .username' \
      /tmp/softphone-ice.json)
    NOW=$(date +%s)
    EXPIRES=${TURN_USERNAME%%:*}
    test "$EXPIRES" -gt "$NOW"
    test $((EXPIRES - NOW)) -le 3600
    test "$(date -d "$(jq -r '.data.expiresAt' /tmp/softphone-ice.json)" +%s)" \
      -eq "$EXPIRES"
  5. HMAC 校验只能在能够安全读取 shared secret 的后端或受控运维机执行:

    bash
    TURN_CREDENTIAL=$(jq -r \
      '.data.iceServers[] | select(.credential != null) | .credential' \
      /tmp/softphone-ice.json)
    EXPECTED=$(printf '%s' "$TURN_USERNAME" | \
      openssl dgst -sha1 -hmac "$TURN_SHARED_SECRET" -binary | openssl base64 -A)
    test "$TURN_CREDENTIAL" = "$EXPECTED"

验收后删除临时响应文件,并清除包含短期 token 的临时环境:

bash
rm -f /tmp/softphone-ice.headers /tmp/softphone-ice.json
unset ACCESS_TOKEN TURN_USERNAME TURN_CREDENTIAL EXPECTED TURN_SHARED_SECRET

浏览器端永远不应拿到 shared secret,只能拿到短期 username 和 credential。

7.2 TURN allocation

在独立外网或 5G 网络上使用 coturn 自带工具测试,不能只在 coturn 本机回环测试:

bash
TURN_HOST=123.57.205.60
turnutils_uclient -v -y -u "$TURN_USERNAME" -w "$TURN_CREDENTIAL" \
  -p 3478 "$TURN_HOST"
turnutils_uclient -v -y -t -u "$TURN_USERNAME" -w "$TURN_CREDENTIAL" \
  -p 3478 "$TURN_HOST"

第一条验证 UDP 到 TURN,第二条验证 TCP 到 TURN。两者都应完成 authenticated allocation; 日志或抓包中的 relayed address 必须位于 123.57.205.60:31000–31040。若工具版本的参数有 差异,以目标机 turnutils_uclient --help 为准,但必须分别覆盖 UDP 和 TCP。

同时在 coturn 主机确认监听和 allocation 日志:

bash
sudo ss -lntup | grep ':3478'
sudo ss -lunp | grep 'turnserver'
sudo journalctl -u coturn --since '10 minutes ago'

7.3 新通话的 ICE、DTLS 和 RTP

建立一通新的 5G 软电话测试呼叫,逐项确认:

  • 浏览器的选中 candidate pair 中,本地候选类型是 relay,地址是 123.57.205.60:310xx;可在 chrome://webrtc-internals 或等价 WebRTC 诊断页查看;
  • FreeSWITCH 日志的 Choose rtp candidate 选择同一个 123.57.205.60:310xx,不能再选 手机运营商 srflx
  • FreeSWITCH 200 OK SDP 的 c= 和媒体 candidate 广告 123.57.205.60:300xx,不能出现 192.168.2.198
  • ICE 进入成功状态,DTLS 完成握手而不是长期停在 HANDSHAKE
  • Opus 入站和出站 RTP 包计数持续增长;
  • 手机与对端分别朗读不同测试短句,确认双向音频,而不是只验证单向播放。

可用以下命令辅助检查,CALL_UUID 替换为本次新呼叫 UUID:

bash
CALL_UUID='REPLACE_WITH_NEW_CALL_UUID'
fs_cli -x "uuid_getvar $CALL_UUID remote_media_ip"
fs_cli -x "uuid_getvar $CALL_UUID remote_media_port"
fs_cli -x "uuid_getvar $CALL_UUID switch_r_sdp"
fs_cli -x "uuid_dump $CALL_UUID" | \
  grep -Ei 'candidate|ice|dtls|rtp_audio_(in|out)_packet_count|media_(ip|port)'
sudo grep -E 'Choose rtp candidate|DTLS|HANDSHAKE' \
  /var/log/freeswitch/freeswitch.log | tail -n 100

现场日志路径和变量可因 FreeSWITCH 构建或日志配置不同而变化。必要时短时启用 SIP trace 或 uuid_debug_media,采样完成立即关闭,避免长期产生大量日志或记录敏感 SIP 信息。

8. 回滚

如果变更后注册、内网 SIP 终端或媒体出现回归:

  1. 停止建立新呼叫并确认活动呼叫已结束;
  2. 将第 6 节同一时间戳的四份 .bak.<STAMP> 文件恢复到原路径;
  3. 执行 xmllint --noout,然后依次执行 reloadxmlreloadaclsofia profile internal restart reloadxml
  4. 等待软电话和其他终端重新注册,验证原有基线;
  5. 如果同时变更了 coturn 或应用 TURN 配置,也恢复对应私有配置并重启该组件,确保后端与 coturn 的 shared secret 始终成对一致。

不要只回滚 ACL 而保留 apply-candidate-acl 引用,否则新 WebRTC 呼叫可能没有任何可用 candidate。不要通过删除日志或覆盖现有 XML 来“回滚”。

9. 容量与安全注意事项

  • 31000–31040 只有 41 个 relay 端口。每个 allocation 会消耗 relay 资源,实际并发上限还 受浏览器行为、端口保留、coturn 配额、CPU 和带宽影响;上线前必须按峰值并发压测并留余量;
  • 30000–30199 也限制 FreeSWITCH 可用 RTP 会话规模。不要仅按端口个数直接等同通话数, 应结合 RTCP mux、会议、转接和并发腿数实测;
  • 经 TURN 的音频会增加公网机双向转发带宽和包处理量,应监控 allocation 数、丢包、抖动、 出入带宽、文件描述符和端口耗尽;
  • TURN 3478/TCP 是 UDP 受限网络的回退路径,不等于 TURNS。若部署 HTTPS 页面,应同时确保 SIP 信令符合浏览器的 WSS 和混合内容规则;
  • getUserMedia() 在常规浏览器中要求安全上下文,生产页面应使用 HTTPS;localhost 是开发 特例。手机通过 5G 打开普通 HTTP 公网 IP 页面时,麦克风可能直接被浏览器拒绝,TURN 无法 绕过此限制;
  • TURN shared secret、应用登录 token 和短期 TURN credential 都不应进入日志、截图、工单 或代码仓库。短期 credential 虽会过期,仍应按凭据处理。

10. 常见误区

  • 只配置 ext-rtp-ip FRP 回送源命中 localnet.auto 时,公网地址仍可能不进入 SDP; 必须同时确认 NAT 分支和实际 200 OK SDP。
  • 只执行 reloadxml ACL 缓存需要 reloadacl,Sofia profile 参数需要重启对应 profile 才能对新注册和新通话生效。
  • 认为 TURN 会自动覆盖 candidate 选择。 TURN 只提供 relay candidate;FreeSWITCH 1.10 仍可能优先选择 srflx,需要 apply-candidate-acl 明确限制。
  • 只看 SIP 200 OK。 SIP 接通不代表 ICE、DTLS 或 RTP 成功,必须检查 candidate pair、 DTLS 状态、RTP 计数和双向听感。
  • 把 TURN relay 也放进 FRP。 当前拓扑中 coturn 直接位于公网机,只有 SIP WS/WSS 和 FreeSWITCH RTP 映射经过 FRP。
  • 用旧通话验证新配置。 ICE candidate 和 SDP 在建连时确定;配置变更后必须等待重新注册并 建立新呼叫。
  • 把公网 IP 填进 sip-ip / rtp-ip 这两个参数必须绑定主机真实拥有的接口地址;公网 地址只用于 ext-sip-ip / ext-rtp-ip 广告。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。