sipML5

1 术语

缩写 定义 术语
RTC real-time communication 实时通信
SIP session initiation protocol 会话初始协议
SMS short message service 短信服务
ICE Internet communication engine 互联网通讯引擎
DTLS datagram transport layer security 数据报传输层安全
SRTP secure real-time transport protocol 安全实时传输协议
WSS secure websocket -
MCU multi conference unit -
NGN next generation network 下一代网络

2 简介

  • sipML5 是开源的 HTML5 SIP 客户端
  • 媒体栈依赖于 WebRTC,建议使用 Chrome 和 Firefox Nightly 测试
    • Safari,Firefox,Opera 和 IE 需要安装 webrtc-everywhere 扩展
    • 主要功能包括生成 SDP、采集本机摄像头和麦克风的音视频数据、传输媒体数据、处理接收到的音视频数据等
  • SIP 和 SDP 栈由 JavaScript 实现,网络传输使用 WebSocket
    • SIP 协议栈:生成和解析 SIP 信令
    • SDP 协议栈:生成和解析 SDP 信令

3 sipML5 源码分析

  • 主要包括 3 个模块
    • media:主要是会话管理和媒体处理(包括 SDP 的生成和修改)
    • SDP:实现 SDP 协议栈,包括 SDP 的修改和解析函数
    • SIP:实现 SIP 协议栈(包括 SIP 的修改和解析函数)和与多媒体网关进行信令交互(WebSocket)
  • 一般而言,需要进行功能扩充和修改的地方包括:
    • 界面入口:call.htm
    • 媒体处理模块:tmedia_session_jsep.js
    • WebSocket 发送和接收的数据:tsip_transport.js

3.1 注册

  • 在生成 SIP 头文件时进行修改和添加
  • 如果收到的 SIP 协议格式不支持,将收到的进行修改
  • 将发出的 SIP 协议进行修改

3.1.1 建立 WebSocket 连接

3.1.2 生成 SIP 头

// call.htm: function sipRegister()
// create SIP stack
oSipStack = new SIPml.Stack({
    realm: txtRealm.value,
    impi: txtPrivateIdentity.value,
    impu: txtPublicIdentity.value,
    password: txtPassword.value,
    display_name: txtDisplayName.value,
    websocket_proxy_url: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.websocket_server_url') : null),
    outbound_proxy_url: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.sip_outboundproxy_url') : null),
    ice_servers: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.ice_servers') : null),
    enable_rtcweb_breaker: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.enable_rtcweb_breaker') == "true" : false),
    events_listener: { events: '*', listener: onSipEventStack },
    enable_early_ims: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.disable_early_ims') != "true" : true), // Must be true unless you're using a real IMS network
    enable_media_stream_cache: (window.localStorage ? window.localStorage.getItem('org.doubango.expert.enable_media_caching') == "true" : false),
    bandwidth: (window.localStorage ? tsk_string_to_object(window.localStorage.getItem('org.doubango.expert.bandwidth')) : null), // could be redefined a session-level
    video_size: (window.localStorage ? tsk_string_to_object(window.localStorage.getItem('org.doubango.expert.video_size')) : null), // could be redefined a session-level
    sip_headers: [
            { name: 'User-Agent', value: 'IM-client/OMA1.0 sipML5-v1.2016.03.04' },
            { name: 'Organization', value: 'Doubango Telecom' }
    ]
}

3.1.3 发送注册的 SIP 信息(用 session 管理)

// src/tinySIP/src/transports/tsip_transport.js
tsip_transport.prototype.send = function (s_branch, o_message, s_dest_ip, i_dest_port) {
    var o_data = null;
    if (o_message.is_request() && (!o_message.is_ack() || (o_message.is_ack() && !o_message.o_hdr_firstVia)) && !o_message.is_cancel()) {
        this.message_addvia(s_branch, o_message); /* should be done before tsip_transport_o_message_update() which could use the Via header */
        this.message_update_aor(o_message); /* AoR */
        this.message_update(o_message); /* IPSec, SigComp, ... */
    }
    else if (o_message.is_response()) {
        /* AoR for responses which have a contact header (e.g. 183/200 INVITE) */
        if (o_message.o_hdr_Contact) {
            this.message_update_aor(o_message);
        }
        if (o_message.o_hdr_firstVia.i_rport == 0) {
            o_message.o_hdr_firstVia.i_rport = o_message.o_hdr_firstVia.i_port;
        }
    }

    o_data = o_message.toString();

    tsk_utils_log_info("SEND: " + o_data);

    return this.__send(o_data, o_data.length);
}

3.1.4 收到 200 OK

// src/tinySIP/src/transports/tsip_transport.js
function __tsip_transport_ws_onmessage(evt) {
    tsk_utils_log_info("__tsip_transport_ws_onmessage");

    var o_ragel_state = tsk_ragel_state_create();
    if(typeof(evt.data) == 'string'){
        tsk_ragel_state_init_str(o_ragel_state, evt.data);
    }
    else{
        tsk_ragel_state_init_ai(o_ragel_state, evt.data);
    }
    var o_message = tsip_message.prototype.Parse(o_ragel_state, true);

    if (o_message) {
        tsk_utils_log_info("recv=" + o_message);
        o_message.o_socket = this;
        return this.o_transport.get_layer().handle_incoming_message(o_message);
    }
    else {
        tsk_utils_log_error("Failed to parse message: " + evt.data);
        return -1;
    }
}

3.2 呼叫

  • 下面关联文件主要是 src/tinyMEDIA/src/tmedia_session_jsep.js

3.2.1 呼叫过程中的 SDP 与流

sequenceDiagram
participant A as ClientA
participant stu as STUN Server
participant sig as Signal Server
participant B as ClientB
A ->> sig: 1. connect
B ->> sig: 2. connect
Note right of A: 3. Create PeerConnection
Note right of A: 4. Add Streams
Note right of A: 5. CreateOffer
A ->> sig: 6. Send Offer SDP
sig ->> B: 7. Relay Offer SDP
Note right of B: 8. CreateAnswer
B ->> sig: 9. Send Answer SDP
sig ->> A: 10. Relay Answer SDP
A ->> stu: 11. Ask my IP
stu ->> A: 12. OnIceCandidate
A ->> sig: 13. Send candidate
sig ->> B: 14. Relay candidate
Note right of B: 15. AddIceCandidate
B ->> stu: 16. Ask my IP
stu ->> B: 17. OnIceCandidate
B ->> sig: 18. Send candidate
sig ->> A: 19. Relay candidate
A --> B: 20. P2P channel
Note right of B: 21. OnAddStream
  • Add Streams:添加本地流,对应函数onGetUserMediaSuccess
    • 如果本地不推流,则注释掉 This.o_pc.addStream(o_stream);
  • __get_lo:生成 SDP,在onGetUserMediaSuccess调用This.o_pc.createOffer会发送 SDP
  • __set_ro:获得远端 SDP 后在此函数处理
  • subscribe_stream_events:接收远端发送的流,在此函数中调用this.o_pc.onaddstream

3.2.2 双向(默认)

  • 音频(默认):直接发送带音频的 SDP
  • 音视频(默认):直接发送带音视频的 SDP
  • 视频:验证可以扩充视频功能

3.2.3 单向(可扩充)

  • 音频:web 端对流只接收不发送,SDP 修改成 sendonly
  • 音视频:web 端对流只接收不发送,SDP 修改成 sendonly
  • 视频:验证可以扩充视频功能

4 API

// initialize the engine, start the stack and make video call from bob to alice
SIPml.init(
    function(e){
        var stack =  new SIPml.Stack({realm: 'example.org', impi: 'bob', impu: 'sip:bob@example.org', password: 'mysecret',
            events_listener: { events: 'started', listener: function(e){
                    var callSession = stack.newSession('call-audiovideo', {
                            video_local: document.getElementById('video-local'),
                            video_remote: document.getElementById('video-remote'),
                            audio_remote: document.getElementById('audio-remote')
                        }
                    );
                    callSession.call('alice');
                }
            }
        });
        stack.start();
    }
);

4.1 类图

类图

4.2 初始化引擎

// initialize the media and signaling engines
var readyCallback = function(e){
    createSipStack(); // see next section
};
var errorCallback = function(e){
    console.error('Failed to initialize the engine: ' + e.message);
}
SIPml.init(readyCallback, errorCallback);

4.3 创建 SIP 栈

// created before any attempt to make/receive calls, send messages or manage presence
var sipStack;
var eventsListener = function(e){
    if(e.type == 'started'){
        login();
    }
    else if(e.type == 'i_new_message'){ // incoming new SIP MESSAGE (SMS-like)
        acceptMessage(e);
    }
    else if(e.type == 'i_new_call'){ // incoming audio/video call
        acceptCall(e);
    }
}

function createSipStack(){
    sipStack = new SIPml.Stack({
            realm: 'example.org', // mandatory: domain name
            impi: 'bob', // mandatory: authorization name (IMS Private Identity)
            impu: 'sip:bob@example.org', // mandatory: valid SIP Uri (IMS Public Identity)
            password: 'mysecret', // optional
            display_name: 'Bob legend', // optional
            websocket_proxy_url: 'wss://sipml5.org:10062', // optional
            outbound_proxy_url: 'udp://example.org:5060', // optional
            enable_rtcweb_breaker: false, // optional
            events_listener: { events: '*', listener: eventsListener }, // optional: '*' means all events
            sip_headers: [ // optional
                { name: 'User-Agent', value: 'IM-client/OMA1.0 sipML5-v1.0.0.0' },
                { name: 'Organization', value: 'Doubango Telecom' }
            ]
        }
    );
}
sipStack.start(); // asynchronous function

4.4 注册/登录

var registerSession;
var eventsListener = function(e){
    console.info('session event = ' + e.type);
    if(e.type == 'connected' && e.session == registerSession){
        makeCall();
        sendMessage();
        publishPresence();
        subscribePresence('johndoe'); // watch johndoe's presence status change
    }
}
var login = function(){
    registerSession = sipStack.newSession('register', {
        events_listener: { events: '*', listener: eventsListener } // optional: '*' means all events
    });
    registerSession.register();
}

4.5 创建/接收音频/视频会话

var callSession;
var eventsListener = function(e){
    console.info('session event = ' + e.type);
}
var makeCall = function(){
    callSession = sipStack.newSession('call-audiovideo', {
        video_local: document.getElementById('video-local'),
        video_remote: document.getElementById('video-remote'),
        audio_remote: document.getElementById('audio-remote'),
        events_listener: { events: '*', listener: eventsListener } // optional: '*' means all events
    });
    callSession.call('johndoe');
}

// to accept incoming audio/video call
var acceptCall = function(e){
    e.newSession.accept(); // e.newSession.reject() to reject the call
}

4.6 共享屏幕/桌面

  • 类比上述创建视频会话,区别在于会话类型(call-screenshare而不是call-audiovideo)
  • 屏幕/桌面共享会话不包括音频流,所以在需要增加 SDP 的m类型来发送音频
  • 关于浏览器设置

4.7 发送/接收 SIP MESSAGE(类似 SMS)

var messageSession;
var eventsListener = function(e){
    console.info('session event = ' + e.type);
}
var sendMessage = function(){
    messageSession = sipStack.newSession('message', {
        events_listener: { events: '*', listener: eventsListener } // optional: '*' means all events
    });
    messageSession.send('johndoe', 'Pêche à la moule', 'text/plain;charset=utf-8');
}

// To accept incoming SIP MESSAGE
var acceptMessage = function(e){
    e.newSession.accept(); // e.newSession.reject(); to reject the message
    console.info('SMS-content = ' + e.getContentString() + ' and SMS-content-type = ' + e.getContentType());
}

4.8 发布存在状态

var publishSession;
var eventsListener = function(e){
    console.info('session event = ' + e.type);
}
var publishPresence = function(){
    publishSession = sipStack.newSession('publish', {
        events_listener: { events: '*', listener: eventsListener } // optional: '*' means all events
    });
    var contentType = 'application/pidf+xml';
    var content = '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n' +
                    '<presence xmlns=\"urn:ietf:params:xml:ns:pidf\"\n' +
                        ' xmlns:im=\"urn:ietf:params:xml:ns:pidf:im\"' +
                        ' entity=\"sip:bob@example.com\">\n' +
                        '<tuple id=\"s8794\">\n' +
                        '<status>\n'+
                        '   <basic>open</basic>\n' +
                        '   <im:im>away</im:im>\n' +
                        '</status>\n' +
                        '<contact priority=\"0.8\">tel:+33600000000</contact>\n' +
                        '<note  xml:lang=\"fr\">Bonjour de Paris :)</note>\n' +
                        '</tuple>\n' +
                    '</presence>';

    // send the PUBLISH request
    publishSession.publish(content, contentType,{
        expires: 200,
        sip_caps: [
            { name: '+g.oma.sip-im' },
            { name: '+sip.ice' },
            { name: 'language', value: '\"en,fr\"' }
        ],
        sip_headers: [
            { name: 'Event', value: 'presence' },
            { name: 'Organization', value: 'Doubango Telecom' }
        ]
    });
}

4.9 订阅存在状态

var subscribeSession;
var eventsListener = function(e){
    console.info('session event = ' + e.type);
    if(e.type == 'i_notify'){
        console.info('NOTIFY content = ' + e.getContentString());
        console.info('NOTIFY content-type = ' + e.getContentType());

        if (e.getContentType() == 'application/pidf+xml') {
            if (window.DOMParser) {
                var parser = new DOMParser();
                var xmlDoc = parser ? parser.parseFromString(e.getContentString(), "text/xml") : null;
                var presenceNode = xmlDoc ? xmlDoc.getElementsByTagName ("presence")[0] : null;
                if(presenceNode){
                    var entityUri = presenceNode.getAttribute ("entity");
                    var tupleNode = presenceNode.getElementsByTagName ("tuple")[0];
                    if(entityUri && tupleNode){
                        var statusNode = tupleNode.getElementsByTagName ("status")[0];
                        if(statusNode){
                            var basicNode = statusNode.getElementsByTagName ("basic")[0];
                            if(basicNode){
                                console.info('Presence notification: Uri = ' + entityUri + ' status = ' + basicNode.textContent);
                            }
                        }
                    }
                }
            }
        }
    }
}
var subscribePresence = function(to){
    subscribeSession = sipStack.newSession('subscribe', {
            expires: 200,
            events_listener: { events: '*', listener: eventsListener },
            sip_headers: [
                { name: 'Event', value: 'presence' }, // only notify for 'presence' events
                { name: 'Accept', value: 'application/pidf+xml' } // supported content types (COMMA-sparated)
            ],
            sip_caps: [
                { name: '+g.oma.sip-im', value: null },
                { name: '+audio', value: null },
                { name: 'language', value: '\"en,fr\"' }
            ]
        }
    );
    // start watching for entity's presence status (You may track event type 'connected' to be sure that the request has been accepted by the server)
    subscribeSession.subscribe(to);
}

5 浏览器、webrtc2sip 与服务器的信令交互流程

5.1 注册流程

  • 浏览器和 webrtc2sip 基于 HTTP 协议进行一次握手,建立 WebSocket 通道
  • 浏览器和 webrtc2sip 通过 WebSocket 通道进行 SIP 消息交互
  • webrtc2sip 和 MCU 通过 UDP 进行 SIP 信息交互

    sequenceDiagram
    participant wb as Web Browser
    participant w2s as webrtc2sip
    participant MCU
    wb ->> w2s: 1. WebSocket 请求 (HTTP)
    w2s ->> wb: 2. WebSocket 回复 (HTTP)
    wb --> w2s: 建立 WebSocket 通道
    wb ->> w2s: 3. Register (WS)
    w2s ->> MCU: 4. Register (UDP)
    MCU ->> w2s: 5. 100 Trying (UDP)
    w2s ->> wb: 6. 100 Trying (WS)
    MCU ->> w2s: 7. 401 Unauthorized (UDP)
    w2s ->> wb: 8. 401 Unauthorized (WS)
    wb ->> w2s: 9. Register (WS)
    w2s ->> MCU: 10. Register (UDP)
    MCU ->> w2s: 11. 100 Trying (UDP)
    w2s ->> wb: 12. 100 Trying (WS)
    MCU ->> w2s: 13. 200 OK (UDP)
    w2s ->> wb: 14. 200 OK (WS)
    

5.2 主叫流程

sequenceDiagram
  participant wb as Web Browser
  participant w2s as webrtc2sip
  participant MCU
  wb --> w2s: 建立 WebSocket 通道
  wb ->> w2s: 1. INVITE (WS)
  w2s ->> MCU: 2. INVITE (UDP)
  MCU ->> w2s: 3. 100 Trying (UDP)
  w2s ->> wb: 4. 100 Trying (WS)
  w2s ->> wb: 5. 180 Ringing (WS)
  MCU ->> w2s: 6. 200 OK (UDP)
  w2s ->> wb: 7. 200 OK (WS)
  wb ->> w2s: 8. ACK (WS)
  w2s ->> MCU: 9. ACK (UDP)

5.3 被叫流程

sequenceDiagram
  participant wb as Web Browser
  participant w2s as webrtc2sip
  participant MCU
  wb --> w2s: 建立 WebSocket 通道
  MCU ->> w2s: 1. INVITE (UDP)
  w2s ->> wb: 2. INVITE (WS)
  wb ->> w2s: 3. 100 Trying (WS)
  w2s ->> MCU: 4. 100 Trying (UDP)
  wb ->> w2s: 5. 180 Ringing (WS)
  w2s ->> MCU: 6. 180 Ringing (UDP)
  wb ->> w2s: 7. 200 OK (WS)
  w2s ->> MCU: 8. 200 OK (UDP)
  MCU ->> w2s: 9. ACK (UDP)
  w2s ->> wb: 10. ACK (WS)

5.4 终端挂断流程

sequenceDiagram
  participant wb as Web Browser
  participant w2s as webrtc2sip
  participant MCU
  wb --> w2s: 建立 WebSocket 通道
  wb ->> w2s: 1. BYE (WS)
  w2s ->> MCU: 2. BYE (UDP)
  MCU ->> w2s: 3. 100 Trying (UDP)
  MCU ->> w2s: 4. 200 OK (UDP)
  w2s ->> wb: 5. 200 OK (WS)

5.5 MCU 挂断流程

sequenceDiagram
  participant wb as Web Browser
  participant w2s as webrtc2sip
  participant MCU
  wb --> w2s: 建立 WebSocket 通道
  MCU ->> w2s: 1. BYE (UDP)
  w2s ->> wb: 2. BYE (WS)
  wb ->> w2s: 3. 200 OK (WS)
  w2s ->> MCU: 4. 200 OK (UDP)

5.6 注销流程

类比注册流程,但是 Contact 头域的 expires 设置为 0

6 问题

6.1 安全机制

  • sipML5 基于 WebRTC 和 WebSocket,所以需要浏览器支持二者
  • sipML5 在进行呼叫业务需要借助 WebRTC 访问本地摄像头,所以涉及到安全机制
    • 一般需要 https 部署访问
    • 如果没有 https,只能用 localhost 呼叫业务,或者用 Firefox 浏览器呼叫业务(Firefox 解除了 https 的安全机制)。但是 Firefox 不同版本对于 sipML5 的支持可能存在一些问题

6.2 信令兼容

  • SDP 的描述需要兼容
    • webrtc2sip 中,INVITE 携带的 SDP 的 m 字段必须与 200 OK 所携带的 SDP 的 m 字段一一对应,否则建立会话但不会处理流
  • webrtc2sip 与 SIP 网关的 SIP 信令存在差异,可在二者之间添加 SIP 代理
  • webrtc2sip 中,如果 SDP 的端口对应 sendrecv,根据 RFC 规范,对于 SIP 客户端发过来的 RTP 和 RTCP 流会进行端口重设,之后 webrtc2sip将流推送到重新设置的端口,即把流推送到 SIP 客户端的发送端口,可注释重设端口的代码
  • 播放音频没有声音:可能是音频采样率的原因,在代码中重设音频采样率
  • 会话建立后,SIP 客户端可能 20s 后才显示画面:会话没完全建立时,I 帧已经发生但是未与 SIP 客户端成功建立会话导致 I 帧丢失,要在编码单元设置 I 帧间隔

7 源码发布

  • 环境:ubuntu16.04

7.1 安装 java

# 在终端输入 java, 如果未安装会提示可选择的安装包
sudo apt-get install openjdk-8-jre-headless

7.2 发布

# 执行工程中的脚本文件 release.sh
./release.sh

8 参考

相关