最近在调试基于web的sip开发库(有sip.js和jssip两家),原理都差不多,都是通过SIP RFC的websocket扩展(SIP over WebSocket RFC 7118,标准情况下是UDP、TCP或TLS传输方式),通过ws消息传递标准sip消息(目前主流VoIP平台,例如:Asterisk,Freeswitch,Kamillo等均有支持),然后通过浏览器webrtc进行音视频编解码和rtp通信。
需求的来源也很简单,目前类似PJSIP的C/C++的sip开发库虽然已经非常成熟,但native开发始终是个门槛,尤其是界面开发(之前曾经开发定制过一款单exe的mbphone sip软电话,可以说80%工作量都花在界面上了,还是在使用了duilib库的前提下),现在想找齐Windows、Linux、Mac三平台都简洁好用的SIP软电话都非常困难,更不用说开发集成了,尤其是现在WEB应用和移动应用普及的时代,开发全平台的软件成本太高。

webrtc的开源平台和客户端sdk开发解决方案似乎很多,github一拉都齐活,但仔细试下来,由于webrtc的开放性并没有定义应用层通信协议,所以许多开发者大多是另起炉灶采用私有应用层通信协议,甚至ws+http混用,与标准程控交换机IPPBX、呼叫中心、视频会议等现有设施并不兼容(一般是通过这些Webrtc平台额外提供的sip网关进行转接,商用的zoom、腾讯会议也是类似解决方案)。

如果需要开发标准的SIP软电话,就需要开发5个原生的客户端(例如采用PJSIP,支持windows、Android、iOS、Linux、MacOS全平台),对于中小公司和个人开发者几乎不现实,所以视线还是回到跨平台特别是web解决方案上来。

早几年,各家开源sip通信平台对于websocket的支持还不太成熟,例如freeswitch当时实现的webrtc软电话方案,是通过verto方案折中实现verto就是一个私有的应用层协议,它并不与sip协议兼容,而且文档也不完备,除了直接使用它提供的verto软电话,并不具备太多个性化定制能力。
好在freeswitch最新1.10版本上,sip的websocket支持已经可用了,而基于javscript的sip协议栈例如sip.jsJssip也非常成熟了,所以通过WEB实现标准的sip通信成为可能。


  • 开发服务器

使用docker快速搭建一个支持websocket的sip服务器:

1
2
#docker pull safarov/freeswitch
#docker run -d --name fs -v ~/fscfg:/etc/freeswitch --net=host safarov/freeswitch

我们这里使用host网络,避免映射太多端口的麻烦以及docker网络性能的瓶颈,因为sip通信需要海量的udp端口段。
我们这里还映射了容器内配置文件目录到主机fscfg目录下,方便修改配置文件。

  • ICE服务器(stun、turn)
1
#docker run -d --name ice --network=host coturn/coturn

由于webrtc强制ICE进行网络协商,在sip服务器上或另一台服务器上架设coturn服务器以支持ICE协商。

  • 开发服务器基本配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#cd ~/fscfg/
#nano vars.xml
<X-PRE-PROCESS cmd="set" data="default_password=mbstudio"/>
<X-PRE-PROCESS cmd="set" data="domain=172.21.2.210"/>
<X-PRE-PROCESS cmd="stun-set" data="external_rtp_ip=172.21.2.210"/>
<X-PRE-PROCESS cmd="stun-set" data="external_sip_ip=172.21.2.210"/>

#nano autoload_configs/switch.conf.xml
<!-- RTP port range -->
<param name="rtp-start-port" value="8000"/>
<param name="rtp-end-port" value="8100"/>

#nano autoload_configs/acl.conf.xml
<list name="wan.auto" default="allow">
<node type="allow" cidr="172.21.0.0/16"/> #fix coturn 488 error
</list>

#nano autoload_configs/event_socket.conf.xml
<param name="listen-ip" value="0.0.0.0"/> #fix fs_cli.c:1699 main() Error Connecting []
以上,分别修改了上述容器实例内置的1000-1019共20个测试账号的初始密码为mbstudio,测试账号在配置文件夹fscfg/directory/default/目录下,以10xx.xml格式保存。 由于是内网测试,不启用stun外网地址探测和nat穿透等功能,手工设置为主机ip地址。还修改了rtp端口范围(如果不使用host网络要手工映射端口的话)。
  • 启动服务器端调试

应用上述修改并打开调试开关(主要是打印sip消息方便调试)。
使用sip软电话或硬件sip电话机注册到服务器,例如账号1000,密码mbstudio,注册地址是172.21.2.210(端口5060)。
image
其中,transport可以手工指定sip通信协议使用tcp还是udp(默认)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#docker restart fs
#docker exec -ti fs fs_cli
fs>sofia global siptrace on #打开sip消息调试开关
+OK Global siptrace on
------------------------------------------------------------------------
...
SIP/2.0 200 OK
Via: SIP/2.0/TCP 172.21.2.202:65297;rport=65297;branch=z9hG4bKPj2e3d5eaf-5b9c-4e40-98f4-1f242c1fca62;alias
From: "1000" <sip:[email protected]>;tag=558a0010-8079-48ec-a1e4-47929619cd01
To: "1000" <sip:[email protected]>;tag=HXvtBgX2Fa94B
Call-ID: 24489094-f40d-47fd-87e8-1c30eba35fa0
CSeq: 4 REGISTER
Date: Thu, 26 Jun 2025 02:01:24 GMT
User-Agent: FreeSWITCH-mod_sofia/1.10.12-release-10222002881-a88d069d6f+git~20240802T210227Z~a88d069d6f~64bit
Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, MESSAGE, INFO, UPDATE, REGISTER, REFER, NOTIFY, PUBLISH, SUBSCRIBE
Supported: timer, path, replaces
Content-Length: 0
...

能看到上述200 OK消息表示服务器已经正常工作。

  • sip开发库准备

下载最新的JSSIP库:https://jssip.net/download/releases/jssip-3.10.0.js
发布时可用: https://jssip.net/download/releases/jssip-3.10.0.min.js 减少体积。

这里暂时不选择sip.js是因为其最新版本已经完全采用ts语言开发了,需要编译、发布才能使用。虽然也提供了UMD的js文件,但其文档和demo完全只讲ts,对新手非常不友好。
私以为,现在web应用开发不论规模就上ts,各种包依赖、编译、打包发布,丢掉了web开发的天生的源码开放性、简洁性和可读性,题外话了。

  • web sip软电话开发步骤

新建一个html页面,引入jssip库,配置sip服务器wss地址和用户名密码,并注册回调函数启动sip ua,关键代码如下:

  1. SIP账号注册在线
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
index.html:
<script src="./jssip-3.10.0.js"></script>

mbphone.js:
const server = {
domain: '172.21.2.210',
wsServers: 'wss://172.21.2.210:7443',
};

const user = {
disName: 'test 1000',
name: '1000',
authName: '1000',
authPwd: 'mbstudio',
regExpires: 180
}

var socket = new JsSIP.WebSocketInterface(server.wsServers);
var configuration = {
sockets : [ socket ],
display_name: user.disName,
uri : 'sip:'+user.name+'@'+server.domain,
contact_uri: 'sip:'+user.name+'@'+server.domain,
authorization_user: user.authName,
password : user.authPwd,
register_expires: user.regExpires,
user_agent: 'MBWebPhone 1.0'
};
//https://jssip.net/documentation/api/ua_configuration_parameters/#parameter_authorization_user

var myPhone = new JsSIP.UA(configuration);
myPhone.on('connected', function(e){
console.log('connected');
});
myPhone.on('disconnected', function(e){
console.log('disconnected');
});
myPhone.on('newRTCSession', function(e){
console.log('newRTCSession');
});
myPhone.on('newMessage', function(e){
console.log('newMessage');
});
myPhone.on('registered', function(e){
console.log('registered');
});
myPhone.on('unregistered', function(e){
console.log('unregistered');
});
myPhone.on('registrationFailed', function(e){
console.log('registrationFailed');
});

myPhone.start();

在浏览器中直接打开index.html,如果一切正常的话,console中会不报错并提示connected, registered。
并且sip服务器端也应该可以看到正常的注册消息200 OK,表示1000在线了。

注意:
fs的默认wss监听地址是7443,同时,由于是内网IP时址,没有正确的ssl证书,第一次运行时会直接报错websocket无法连接,需要手工在浏览器里输入 https://172.21.2.210:7443 在弹出的警告页面里手工允许访问,后续demo就可以正常连接了。

  1. 发起呼叫

代码中先写死被叫号码,例如1002(提前在电脑或手机上使用其它软件或硬件注册在线)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
index.html:
<div class="video">
<video id="remote-video" width="100%" muted="muted">
<p>Your browser doesn't support HTML5 video.</p>
</video>
<div class="video-local">
<video id="local-video" width="100%" muted="muted">
<p>Your browser doesn't support HTML5 video.</p>
</video>
</div>
</div>

mbphone.js:
var views = {
'selfView': document.getElementById('local-video'),
'remoteView': document.getElementById('remote-video')
};
var eventHandlers = {
'progress': function(data){
console.log("calling");
},
'failed': function(data){
console.log("call failed");
},
'confirmed': function(data){
console.log("call confirmed");
},
'ended': function(data){
console.log("call ended");
}
};
var options = {
'eventHandlers': eventHandlers,
'mediaConstraints': {'audio': true, 'video': true},
sessionTimersExpires: 3600 //过短也会呼叫失败
};

const callee = 1002;
myPhone.call('sip:'+callee+'@'+server.domain, options);

刷新页面,会弹出权限麦克风和摄像头权限窗口,允许后自动呼叫1002。
image

由于局域网没有stun和ice服务器,配置是清空的,呼叫时报错:
SIP/2.0 488 Not Acceptable Here

查看fs打印的日志,原因是jssip错误地在sdp中携带了类似:
a=candidate:1302296363 1 udp 2113937151 a807f338-fbf2-45a9-ba04-90a2cb5763dd.local 63851 typ host generation 0 network-cost 999
其中:a807f338-fbf2-45a9-ba04-90a2cb5763dd.local是非法地址(其实是chrome浏览器的隐私保护隐藏了本机IP,旧版本就是本地IP地址),导致mod_sofia.c:2588 CODEC NEGOTIATION ERROR.
switch_core_media.c:4197 Drop audio Candidate cid: 1 proto: udp type: host addr: a807f338-fbf2-45a9-ba04-90a2cb5763dd.local:63851 (not an IP address)
回复488消息中携带了:
Reason: Q.850;cause=88;text="INCOMPATIBLE_DESTINATION"

解决办法是加入stun或ice服务器,让jssip能够探测到本机的正常ip地址,局域网方案则是内网搭建coTurn服务器:
非常折腾但是没办法,这些问题类似webrtc强制https,wss,强制媒体dtls、srtp加密等等,rtp通信协商也是强制ICE,这些只能去适应webrtc开发者google的规范和定义,要跳过这些限制只能使用原生的C/C++开发客户端SIP软电话,就可以不受制约了。

1
2
3
4
5
6
7
var options = {
...
'pcConfig': {
'iceServers': [{urls: 'stun:172.21.2.210:3478'}]
},
...
};
  1. 调试jssip

浏览器console中输入:
JsSIP.debug.enable('JsSIP:*');
重新加载页面即可看到sip消息日志。
关闭调试则同样的:
JsSIP.debug.disable('JsSIP:*');

  1. 调试webrtc

chrome://webrtc-internals/
可以查看当前活动的rtc媒体通信,例如媒体收发端口,速率,编码等,调试单通、卡顿等SIP通信中常见问题。

提示:目前demo中,始终使用设备默认的麦克风、扬声器和摄像头建立呼叫,并且JSSIP文档中毫无相关介绍怎么修改。这是因为jssip的作者也是SIP over WebSocket RFC 7118的作者,学者一般比较有个性,认为这一块是属于浏览器的webrtc的API管的,jssip不掺和。
所以准备下一篇文章介绍下webrtc通信中怎么配合jssip实现枚举、选取音视频设备,音视频编解码启用、禁用及优先级设置,视频清晰度设置等功能。
介绍一本webrtc好书:https://webrtcforthecurious.com/zh/

  1. 进一步开发提示

如上,具备了一个sip软电话的基本功能,注册在线,自动发起呼叫。
进一步需要开发:(不定时更新在线demo及源码)

  • 将用户名、密码可配置,wss服务器地址配死或自动分发。
  • 监测注册事件,提示在线、离线状态和错误(例如:用户名、密码不对,网络中断离线自动重注册等)。
  • 通过手工输入号码呼叫,或者拉取通讯录电话号码点击号码,或者实现传统的电话数字拨号盘方式发起呼叫。
  • 监测呼叫事件,例如振铃、接通、无人应答、无法接通等状态,来电时提示接通或自动应答。
  • 呼叫或应答时,可选仅音频或视频通话。
  • 增加通话挂断、呼叫保持(hold)、静音等功能按钮。
  • 本地webrtc实现录音、录像保存功能。
  • 以及,开发一个友好美观的web功能界面或网页悬浮按钮通话插件。

在线demo:https://mbstudio.cn/mbwebphone-demo (原始版)
https://mbstudio.cn/mbwebphone (最新版)
提示:上述demo演示只有web部署,依然会在你的本地局域网172.21.2.210地址上去找freeswitch连接wss。
源码:https://github.com/meineson/MeConf

image
image
image

注意:如果是直接打开html文件方式进行开发调试,浏览器每次都会弹屏申请摄像头、麦克风权限。
如果以localhost或局域网IP方式,以http方式部署web服务开发调试,chrome系浏览器会提示不安全站点,并禁止打开摄像头、麦克风。
需要手动启用参数,例如:chrome://flags#unsafely-treat-insecure-origin-as-secure=http://172.21.2.203:4444,http://localhost:4444 才能使特定站点(你的局域网web服务地址,精确到端口)在http协议下能够取得多媒体设备权限。并且,此时你可以不使用wss而是ws了,例如修改mbphone.js中的wsServers: 'ws://172.21.2.210:5066' 使用freeswitch的ws端口进行sip websocket通信,为下一篇文章开发客户端程序埋下伏笔。
如果局域网内以https方式部署web服务开发调试,由于ssl证书一般都是自签发的浏览器不认,需要在浏览器手动忽略警告继续访问(包括web服务端口和wss两个端口)。