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/config、GET /health/live、GET /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/credential 是 ICE 传输层凭据,和 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 直接忽略的坑。
五、状态机:让非法状态根本表达不出来
"来电时正在通话怎么办"这种问题,如果用几个布尔变量 isRinging、isActive 去凑,迟早会撞上"既在响铃又在通话"这种非法组合。项目的做法是用两组有限状态机,合法迁移写死在表里:
注册状态:disconnected → connecting → registering → registered → reconnecting → failed
通话状态:idle → outgoing-dialing/incoming-ringing → outgoing-ringing → active → terminating → ended
每个状态允许哪些事件,直接是一张表,非法迁移会直接抛异常:
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-acl、ext-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 exec 跑 fs_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-acl 或 ext-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 这摊水,值得单开一篇。