webrtc-to-freeswitch:一个浏览器软电话的架构、流程与协议全解

Posted on 一 14 9月 2026 in Tech

Abstract webrtc-to-freeswitch:一个浏览器软电话的架构、流程与协议全解
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-09-14
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 它是什么、有什么用:一个浏览器直连 FreeSWITCH 的 WebRTC 音频客户端
  • 整体架构:两个 workspace、一个 origin,后端只管配置不碰 SIP
  • 协议分层:信令走 SIP over WSS,媒体走 WebRTC(DTLS-SRTP + ICE)
  • 完整流程:从加载配置、注册,到一次通话的信令与媒体时序
  • 状态机:注册和通话的两组有限状态,以及重连策略
  • FreeSWITCH 侧配置:internal.xml 里的 WSS binding、candidate ACL、ext-rtp-ip
  • 怎么用:几条命令跑起来,以及排障清单

一、它是什么,有什么用

浏览器早就能打电话了。不用装 Zoiper,不用装 Linphone,一个网页就能注册到你的 SIP 服务器、拨号、接听。底层是两条独立的线:信令走 SIP over WebSocket,媒体走 WebRTC

上一篇我把 SIP.js 的官方 demo 拆开,补上注册、鉴权和那座 WSS 到 SIP 的桥,最后浏览器确实打进了 FreeSWITCH。但那还是一段"能演示"的代码。这篇要讲的 webrtc-to-freeswitch,是把那段代码做成了一个规格先行、能测、能部署的参考客户端

它能做什么:

  • 用 SIP over 安全 WebSocket(WSS)向一台已有的 FreeSWITCH 注册,连接和注册状态显式可见,断线有节制地重连,报错说人话。
  • 一次一路音频通话:打出、接入,支持接听、拒接、取消、挂断、静音、DTMF。
  • 麦克风按需获取,远端音频自动播放,被浏览器 autoplay 策略挡住时给一个"启用音频"的手势。

它明确不做:视频、转移、保持、会议、通话记录、录音、PSTN 对接,以及任何 FreeSWITCH 服务端的配置——它假设你已经有一台配好的 FreeSWITCH。

谁用得上:想给内部系统嵌一个网页软电话的、想做客服/呼叫中心前端原型的、或者单纯想搞明白"浏览器打电话到底怎么打通的"人。


二、整体架构:两个 workspace,一个 origin

项目是两个独立工作区:

浏览器 (Vue 3 SPA)
  │
  ├──── GET /api/config ─────►  FastAPI 后端 (只下发非机密配置)
  │     GET /health/*
  │
  ├──── SIP over WSS ────────►  FreeSWITCH (mod_sofia, :7443)   ← 信令
  │
  └──── WebRTC / DTLS-SRTP ──►  FreeSWITCH (RTP 媒体, ICE)      ← 媒体
  • 前端 frontend/:Vue 3 + TypeScript + Vite,sip.js 固定版本、藏在唯一一个 SIP 适配器后面。SIP 信令和 WebRTC 媒体都是浏览器↔FreeSWITCH 直连,不经过后端。
  • 后端 backend/:Python + FastAPI + Pydantic settings,用 uv 管理。只暴露三个接口:GET /api/configGET /health/liveGET /health/ready。生产环境里它顺便把打包好的前端从同一个 origin 提供出去。

这里有一条最反直觉、也最重要的设计:后端完全不碰 SIP。它不是 SIP 代理,不做鉴权中转。SIP 用户名、密码、WSS 地址、SIP 域名,全部由用户在浏览器里输入。密码只活在当前 SIP 服务实例的内存里,断开就清空,从不发给 FastAPI、从不进 URL、从不进日志、从不写进浏览器持久存储。刷新页面就得重输。

代码里连这个保证都写死成了断言——适配器每次拿到密码,都会去 localStorage 里比对一遍,确认它没被写进存储:

private assertNoLeak(password: string): void {
  if (!password) return;
  const stored = this.storage?.getItem("sipPassword");
  if (stored === password) {
    throw new Error("password leaked to storage");
  }
}

那后端下发的 /api/config 里有什么?一个白名单响应,而且是显式拼出来的,不是把 settings 对象序列化了事:

{
  "environment": "production",
  "sipWebSocketUrl": "wss://fs.example.com:7443",  // 只是预填表单,可为空
  "sipDomain": "fs.example.com",
  "iceServers": [                                  // STUN/TURN,直接喂给 RTCPeerConnection
    { "urls": ["stun:stun.example:3478"] }
  ],
  "registration": { "maxRetries": 5, "baseDelayMs": 1000, "maxDelayMs": 15000 },
  "dtmf": { "preferredMethod": "rtp" }
}

注意 iceServers 里的 TURN username/credentialICE 传输层凭据,和 SIP 账号密码是两码事,可以下发;SIP 密码永远不在这里。

为什么这么分?因为如果让后端做 SIP 代理,你就得在后端塞一个完整 SIP 协议栈、信令多一跳延迟、还把凭据和媒体的信任边界整个扩大了。得不偿失。


三、协议分层:一次通话上跑着几种协议

浏览器打电话,表面是"点一下按钮",底下叠着好几层协议。分清楚它们,排障时才知道该看哪一层。

协议 谁负责 传什么
信令传输 WebSocket(wss://) SIP.js UserAgent 承载 SIP 消息的管道
信令 SIP over WebSocket(RFC 7118) SIP.js Registerer / Inviter / Invitation REGISTER、INVITE、BYE、INFO
媒体协商 SDP + ICE RTCPeerConnection codec、candidate、指纹
媒体加密 DTLS-SRTP 浏览器 WebRTC 栈 密钥交换 + 加密语音
媒体传输 SRTP over RTP 浏览器 ↔ FreeSWITCH 加密后的音频包
DTMF RFC 4733 telephone-event 或 SIP INFO SIP 适配器 按键音

有一条鸿沟必须先记住:浏览器发出的媒体一定是 WebRTC——DTLS-SRTP 加密、走 ICE 收集 candidate。而 FreeSWITCH 的普通分机默认用明文 RTP。这条沟怎么填,一半靠浏览器,一半靠 FreeSWITCH 的 internal.xml 配置(第六节细讲)。

DTMF 的处理是个不错的例子,能看出这层协议怎么落地。项目默认用 RTP telephone-event,但它会先检查协商出的 SDP 里有没有 telephone-event,没有就自动降级到 SIP INFO:

const canRtp = preferredMethod === "rtp" && this.hasTelephoneEvent(sessionId);
if (canRtp && handler?.sendDtmf?.(digit)) {
  return "rtp";
}
// 降级:发一个 SIP INFO application/dtmf-relay
await record.session.info({
  requestOptions: {
    body: { contentType: "application/dtmf-relay", content: `Signal=${digit}\r\nDuration=160` },
  },
});
return "info";

hasTelephoneEvent 就是直接去翻 peerConnection 的 local/remote SDP 里有没有 telephone-event 字样——协议细节暴露得很直白。


四、完整流程:一次通话到底发生了什么

把时序拆成三段:启动配置、注册、通话。

4.1 启动:拉配置

浏览器加载 SPA 后第一件事是 GET /api/config。后端如果配置校验没过,/health/ready 返回 503、/api/config 也返回 503,前端会进"配置加载重试"状态而不是白屏。配置拿到后,WSS 地址和 SIP 域名预填到注册表单里,但用户名密码永远是空的、要用户手输。

4.2 注册

用户填完 SIP 端点和账号,点连接。SIP 适配器用底层 UserAgent(不是省事的 SimpleUser)建连:

const userAgent = new UserAgent({
  uri,                                          // sip:user@domain
  authorizationUsername: input.username,
  authorizationPassword: this.password,          // 只在内存
  transportOptions: { server: input.webSocketUrl },  // wss://fs:7443
  sessionDescriptionHandlerFactoryOptions: {
    peerConnectionConfiguration: { iceServers: input.iceServers },
  },
  delegate: { onInvite, onNotify, onDisconnect },
});
await userAgent.start();     // 建 WebSocket
await registerer.register(); // 发 REGISTER,digest 鉴权

信令上的时序:

浏览器                         FreeSWITCH
  │── WebSocket 握手 (wss) ───►│
  │── REGISTER ───────────────►│
  │◄── 401 Unauthorized ───────│  (带 nonce)
  │── REGISTER + Authorization ►│  (digest 摘要)
  │◄── 200 OK ─────────────────│  注册成功

鉴权失败(401/403/407)会被明确归类成 authentication 错误,不自动重试——那只会制造注册风暴。

一个容易踩的细节:FreeSWITCH 注册成功后常会发一条 out-of-dialog NOTIFY(message-summary,语音信箱指示灯)。这个客户端从不 SUBSCRIBE,所以适配器的做法是"收下、回 200、丢弃",避免 SIP.js 默认回 481 造成日志噪音:

onNotify: (notification) => { void notification.accept(); },

4.3 通话(以拨出为例)

浏览器                                   FreeSWITCH
  │── 请麦克风权限 (getUserMedia) ──(本地)
  │── ICE 收集 candidate ──────(本地/STUN/TURN)
  │── INVITE + SDP offer ──────────────►│   (含 DTLS 指纹、ICE candidate)
  │◄── 100 Trying ──────────────────────│
  │◄── 180 Ringing ─────────────────────│   → 状态: outgoing-ringing
  │◄── 200 OK + SDP answer ─────────────│   → 状态: active
  │── ACK ──────────────────────────────►│
  │◄═══ DTLS 握手 ═══════════════════════►│   (媒体面)
  │◄═══ SRTP 音频 ═══════════════════════►│   通话中
  │── BYE ──────────────────────────────►│   挂断
  │◄── 200 OK ──────────────────────────│   → 状态: ended

麦克风是按需获取的:拨出或接听时才请求权限,注册和拒接都不碰麦克风。远端音频从 peerConnection.getReceivers() 里把 audio track 收进一个 MediaStream,接到一个专用 <audio> 元素上。如果 play() 被浏览器 autoplay 策略拒了,通话保持活着,UI 弹出一个"启用音频"的按钮让用户手动触发——这是个很常见、很多 demo 直接忽略的坑。


五、状态机:让非法状态根本表达不出来

"来电时正在通话怎么办"这种问题,如果用几个布尔变量 isRingingisActive 去凑,迟早会撞上"既在响铃又在通话"这种非法组合。项目的做法是用两组有限状态机,合法迁移写死在表里:

注册状态:disconnectedconnectingregisteringregisteredreconnectingfailed

通话状态:idleoutgoing-dialing/incoming-ringingoutgoing-ringingactiveterminatingended

每个状态允许哪些事件,直接是一张表,非法迁移会直接抛异常:

const LEGAL: Record<CallStatus, ReadonlySet<CallEvent["type"]>> = {
  idle: new Set(["outgoing-start", "incoming"]),
  "outgoing-ringing": new Set(["accepted", "terminate", "ended"]),
  active: new Set(["terminate", "ended"]),
  // ...
};

只有一个权威 session 引用:第二个拨出请求本地直接拒,通话中来的新邀请也直接拒、不替换当前通话。SIP.js 的乱序事件靠比对一个生成的 session id 忽略掉。

重连也是状态机的一部分。传输意外断开进 reconnecting,重试延迟是 min(baseDelayMs * 2^(n-1), maxDelayMs) 加满抖动,大致 1、2、4、8、15 秒,最多 5 次,之后进 failed 需手动重来。鉴权错误和配置错误不自动重试。这些参数全部来自后端下发的 registration 配置块。


六、FreeSWITCH 侧配置:最容易踩坑的一段

前面反复说"直连一台已配好的 FreeSWITCH"。这台 FreeSWITCH 到底要配什么?核心全在 conf/sip_profiles/internal.xml(或者你专门建一个 WSS profile)里。

6.1 开 WSS binding

浏览器的 SIP over WebSocket 需要 mod_sofia 监听一个 WSS 端口:

<param name="wss-binding" value=":7443"/>

这一行让 FreeSWITCH 在 7443 上接受安全 WebSocket。生产环境浏览器必须wss:// 且证书要被浏览器信任——自签证书会直接报安全连接错误。要么在 FreeSWITCH 上装可信证书,要么在前面架一个反向代理终结 TLS,证书的 hostname 必须和 UI 里填的 WSS 地址一致。

6.2 candidate ACL:让 ICE 找到正确的地址

WebRTC 靠 ICE 交换 candidate 来打通媒体通道。FreeSWITCH 需要知道:哪些本地地址值得作为 candidate 发给浏览器。这就是 apply-candidate-acl:

<param name="apply-candidate-acl" value="localnet.auto"/>
<param name="apply-candidate-acl" value="rfc1918.auto"/>
<param name="apply-candidate-acl" value="wan_v4.auto"/>
<param name="apply-candidate-acl" value="any_v4.auto"/>

这几条把本地网段、RFC1918 私网、WAN v4、以及所有 v4 地址都纳入 candidate。如果不配,FreeSWITCH 可能只发一个浏览器根本连不上的地址,表现就是信令全通、注册成功、INVITE 也 200 了,但就是没声音——这是 WebRTC 最经典、最折磨人的症状。

6.3 ext-rtp-ip / ext-sip-ip:告诉 FreeSWITCH 它对外的地址

FreeSWITCH 如果在 NAT 后面、或者绑在一个内网网卡上,它自己不一定知道对外该用哪个 IP。手动指定:

<!-- internal ip address for rtp and sip by walter -->
<param name="ext-rtp-ip" value="10.100.200.30"/>
<param name="ext-sip-ip" value="10.100.200.30"/>

ext-sip-ip 影响 SIP 消息里 Contact/Via 用什么地址,ext-rtp-ip 影响 SDP 里 RTP 媒体地址写什么。在实验环境里,这两个填 FreeSWITCH 那台机器浏览器能直接访问到的地址(我这里是内网 10.100.200.30);在公网部署里,通常填公网 IP,并配合 STUN/TURN。填错的直接后果同样是"信令通、没声音"。

6.4 别忘了 codec 和 DTLS-SRTP

WebRTC 媒体一定是加密的,所以 FreeSWITCH 的媒体路径要开 DTLS-SRTP,并提供浏览器能协商的编解码(Opus 和/或 PCMU/PCMA)。这部分和 apply-candidate-aclext-rtp-ip 一起,才算把"WebRTC 加密媒体 vs 普通 RTP"那条鸿沟真正填平。

改完配置记得 reloadxml,或者用项目自带的 Fabric 任务(见下节)。


七、怎么用

好消息:不需要任何 SIP 账号密码就能把本地界面跑起来。

前提:Node.js 20 LTS、Python 3.11+ 和 uv,真要打电话再准备一台配好 WSS 的 FreeSWITCH。

cp backend/.env.example backend/.env

# 后端 —— http://127.0.0.1:8000
make backend-sync
make backend-dev

# 前端 —— http://127.0.0.1:5173
make frontend-install
make frontend-dev

# 确认后端活着
curl http://127.0.0.1:8000/health/live   # {"status":"live"}

浏览器打开 Vite 地址,界面能加载、配置能拉到。等有了 FreeSWITCH,在注册表单里填 WSS 地址、SIP 域名、用户名、密码——密码只在这里输,别写进任何环境变量

项目还带了一套 Fabric 任务(fabfile.py),能 SSH 到 FreeSWITCH 主机上 docker execfs_cli、抓包、拉日志,排障时非常省事:

cp ops.env.example .env   # 设 FS_HOST / FS_USER / FS_SSH_KEY
uv sync
uv run fab fs-cli --cmd='sofia status'
uv run fab fs-log --pattern='Call-ID: ...' --context=10
uv run fab pcap-start && uv run fab pcap-stop
uv run fab pcap-pull --remote=/tmp/fs-xxxx.pcap

排障速查表

症状 该看哪层
/health/ready 返回 503 后端 APP_* 配置校验没过,看 reason 字段
UI 一直在"配置加载重试" 后端没起,或 /api/config 挂了
认证错误 分机/密码被拒;检查 FreeSWITCH 用户,不会自动重试
证书/安全连接报错 WSS 证书不被信任,或 hostname 对不上
注册成功、能拨号、200 了,但没声音 ICE/candidate ACL、ext-rtp-ip、TURN、codec,或 autoplay 被挡("启用音频")

最后那一行,九成九是 FreeSWITCH 的 apply-candidate-aclext-rtp-ip 没配对。信令和媒体是两条独立的线,信令通不代表媒体通——这是整篇文章最想让你记住的一句话。


要点回收

  • 它是什么:浏览器直连 FreeSWITCH 的 WebRTC 音频客户端,规格先行、能测能部署。
  • 架构:两个 workspace、一个 origin;后端只下发非机密配置,从不碰 SIP 密码
  • 协议:信令走 SIP over WSS,媒体走 WebRTC(DTLS-SRTP + ICE),两条线独立。
  • 流程:拉配置 → 注册(digest)→ INVITE/SDP/ICE → SRTP 通话 → BYE,状态用有限状态机管。
  • FreeSWITCH 配置三件套:wss-binding 开端口、apply-candidate-acl 让 ICE 找对地址、ext-rtp-ip/ext-sip-ip 告诉它对外地址。
  • 黄金排障法则:信令通 ≠ 媒体通;没声音先查 candidate ACL 和 ext-rtp-ip。

可执行清单

  • [ ] FreeSWITCH:wss-binding :7443 + 可信证书(或反代终结 TLS)。
  • [ ] FreeSWITCH:配齐 apply-candidate-acl,别让 ICE 只发一个连不上的地址。
  • [ ] FreeSWITCH:ext-rtp-ip/ext-sip-ip 填浏览器真能访问到的地址。
  • [ ] FreeSWITCH:开 DTLS-SRTP,提供 Opus / PCMU / PCMA。
  • [ ] 客户端:密码只在浏览器内存,永不进后端/URL/日志。
  • [ ] 客户端:所有状态用有限状态机,重连有退避有上限,鉴权错误不重试。
  • [ ] 部署:HTTPS + WSS + 收紧的 CSP,同源提供前端。

项目地址:https://github.com/walterfan/webrtc-to-freeswitch

你在打通浏览器到 FreeSWITCH 的媒体面时,踩过哪些"信令通就是没声音"的坑?欢迎留言——candidate ACL 和 TURN 这摊水,值得单开一篇。