本文实现一个简易的单向webrtc网关,使用chrome浏览器浏览服务器上的h264视频文件。
代码地址 https://github.com/wangdxh/Desert-Eagle/tree/master/webrtcgateway
网关服务器使用c++开发,通过webrtc底层协议和chrome浏览器进行交互。
webrtc交互需要一些udp网络和ice协议基础,参考资料:
web性能权威指南 第3章UDP的构成和第18章webrtc
ice协议rfc5245
大致交互流程
webrtc使用sdp除了描述媒体类型,还有一些额外的字段来描述ice的连接候选项。
- chrome浏览器首先获取服务器提供的offer sdp,收到sdp之后,创建应答sdp和ice 候选项发送到服务器。
- 双方都收到sdp之后会首先进行ice连接(即一条udp链路)。
- 连接建立之后,发起dtls交互,得到远端和本地的srtp的key(分别用于解密远端到来的srtp和加密本地即将发出去的rtp数据包)。
- 然后就可以接收和发送rtp,rtcp数据了,发送之前要进行srtp加密,然后通过ice的连接发送出去。
- dtls 和 srtp 的数据包都是通过ice的udp连接进行传输的。
使用到的库
- libnice 用于进行ice候选项生成和连接的建立
github地址:https://github.com/libnice/libnice - openssl 用于dtls的交互,完成srtp key的协商
- srtp 用于srtp的加密和解密
github地址:https://github.com/cisco/libsrtp - websocketpp 用于网页和服务器进行sdp的交换,使用websocket来完成
github地址:https://github.com/zaphoyd/websocketpp - glib libnice使用到了glib
github地址:https://github.com/GNOME/glib
交互流程
客户端和服务器在同一个网段内,程序不支持指定stun服务器,主机单网卡,所以ice候选项只有一个主机类型的候选项。
sdp内容
server发给浏览器的offer sdp如下:
v=0
o=- 1495799811084970 1495799811084970 IN IP4 10.10.10.11
s=Streaming Test
t=0 0
a=group:BUNDLE video
a=msid-semantic: WMS janus
m=video 1 RTP/SAVPF 96
c=IN IP4 10.10.10.11
a=mid:video
a=sendonly
a=rtcp-mux
a=ice-ufrag:j9UX
a=ice-pwd:J5bBevBdPbtH5oYhxy0cMJ
a=ice-options:trickle
a=fingerprint:sha-256 23:83:58:1C:2C:BB:E3:A2:2C:19:00:0C:AD:CD:99:EF:28:F7:F6:A8:99:3E:FF:97:48:C4:BF:DA:1D:71:83:8B
a=setup:actpass
a=connection:new
a=rtpmap:96 H264/90000
a=ssrc:12345678 cname:janusvideo
a=ssrc:12345678 msid:janus janusv0
a=ssrc:12345678 mslabel:janus
a=ssrc:12345678 label:janusv0
a=candidate:1 1 udp 2013266431 10.10.10.11 55194 typ host
sdp里面除了媒体描述的信息之外新增了几个选项:
- a=rtcp-mux
表示rtp和rtcp使用同一个端口进行发送和接收 - a=mid:video
标识这一路媒体的id名称,用于a=group - a=group:BUNDLE video
现在只有一路视频流,当音视频都有的时候 a=group:BUNDLE video audio 标识音频和视频流复用同一个端口进行发送和接收,通过ssrc进行区分不同的流。所以当既有rtcp-mux,又有a=group把多路流打包到一起的时候,只要创建一个主机类型候选项即可。 - a=ice-ufrag 和 a=ice-pwd
用于ice进行stun协商时进行对端认证。 - a=fingerprint
当双方进行dtls协商交互srtp的加密key时,对对端进行验证。 - a=candidate
ice的候选项 通知对端本地的ice连接的候选项,本文只使用了本地地址的主机候选项,没有使用stun服务器。 - a=ice-options:trickle
通知对端支持trickle,即sdp里面描述媒体信息和ice候选项的信息可以分开传输。
浏览器返回的answer sdp和trickle 候选项如下:
v=0
o=- 8873346408483571164 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE video
a=msid-semantic: WMS
m=video 9 RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:USWn
a=ice-pwd:A/BfLTfs/lGlOE1QMmNi+YPU
a=fingerprint:sha-256 15:71:96:BA:29:2F:BB:FF:1A:F6:3C:07:0B:9B:9C:2B:BF:37:7D:D8:D8:5B:36:9F:9F:57:08:31:82:43:88:D7
a=setup:active
a=mid:video
a=recvonly
a=rtcp-mux
a=rtpmap:96 H264/90000
a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
candidate:2153010912 1 udp 2113937151 10.10.10.11 55195 typ host generation 0 ufrag USWn network-cost 500063890C
更详细的信息请参考rfc5245,ice协议的rfc描述的非常清楚易懂。
网页发起请求
webrtchtml文件夹下面有个index.html 页面,点击call时
- 创建 RTCPeerConnection
- 发起websocket连接向服务器请求sdp,收到sdp之后RTCPeerConnection调用setLocalDescription,将描述设置为本地描述。
- RTCPeerConnection调用createAnswer创建answer sdp将创建的sdp通过websocket返回给服务端。
- RTCPeerConnection的onaddstream回调,通知检测到远端的流之后,将其设置为video标签的源。
- RTCPeerConnection的onicecandidate回调,通知有本地的ice回调上来了,收到之后将ice候选项通过websocket发送到服务器。
- 服务器和浏览器都收到对方的sdp和候选项之后,就开始底层ice的连接建立和码流传输。
js代码在js文件夹下面的main.js内。
服务器端
websocket交互的部分就不描述了,代码比较简洁。
ice初始化
libnice依赖于glib,所以初始化glib即可,libnice不需要进行初始化
g_networking_init();
gloop = g_main_loop_new(NULL, FALSE);
gloopthread = g_thread_new("loop thread", &loop_thread, gloop);
void* loop_thread(void *data)
{
GMainLoop* ploop = (GMainLoop*)data;
g_main_loop_run(ploop);
return 0;
}
ice建立
服务器收到websocket请求之后,会创建一个niceagent对象,
- 设置控制模式为true,按照ice的规范,主动发起offer的一端,应设置为控制一端。
- 从websocket连接得到本地的ip地址,将其ip增加到niceagent的地址列表中,这样生成候选项的列表只包含设置的ip地址,否则会根据本地所有的网卡信息去生成。
- 设置候选项采集完成,componet状态改变,ice selected pair完成 回调。
agent = nice_agent_new(g_main_loop_get_context (gloop), NICE_COMPATIBILITY_RFC5245);
g_object_set(agent, "controlling-mode", controlling, NULL);
NiceAddress addr_local;
nice_address_init (&addr_local);
nice_address_set_from_string (&addr_local, strhost.c_str());
nice_agent_add_local_address (agent, &addr_local);
g_signal_connect(agent, "candidate-gathering-done", G_CALLBACK(cb_candidate_gathering_done), this);
g_signal_connect(agent, "component-state-changed", G_CALLBACK(cb_component_state_changed), this);
g_signal_connect (agent, "new-selected-pair-full",G_CALLBACK (cb_new_selected_pair_full), this);
- 创建流和组件
通过nice_agent_add_stream添加组件,流就是视频流或者音频流,nice_agent_add_stream的返回值是从1开始的,添加流的时候,需要指定这条流的组件数componetnum,组件也就是这条流要使用的udp端口的个数,以视频流为例,如果rtp和rtcp的端口是分开的,那么创建的组件数就是2,rtp的组件id是1,rtcp的组件id是2(这是乌龟的屁股—规定 参考rtc5245);如果rtp和rtcpmux,那么只需要创建一个组件就可以了,组件id就是1。
当视频和音频group bundle,并且rtcp-mux时,只需要一个stream,一个组件就可以了。 - 采集流的候选项
首先要nice_agent_attach_recv,attach之后才会把流和组件对象和glib的网络层绑定起来,attach的回调函数用来回调收到的网络数据。
nice_agent_gather_candidates 开启收集流的候选项,收集完成之后,会通过之前的回调 “candidate-gathering-done” 返回。
guint stream_id = nice_agent_add_stream(agent, componentnum);
nice_agent_attach_recv(agent, streamid, componetid, g_main_loop_get_context (gloop), cb_nice_recv, this);
nice_agent_gather_candidates(agent, streamid)
- 候选项采集完成
流的候选项采集完成之后通过,前面设置的回调函数cb_candidate_gathering_done返回:
根据回调中的流id,获取ice协商时的用户名和密码; 获取收集到的候选项,这里我们只有一个候选项,从NiceCandidate中可以得到候选项的foundation,优先级,ip地址,端口,候选项类别(这里都是主机类型)等。
将相关信息组建成sdp,就是服务器端的offer sdp,通过websocket发送给客户端。
nice_agent_get_local_credentials(agent, stream_id,&local_ufrag, &local_password)
cands = nice_agent_get_local_candidates(agent, stream_id, 1);
NiceCandidate *c = (NiceCandidate *)g_slist_nth(cands, 0)->data;
- 浏览器返回sdp 和 候选项
收到sdp和候选项后,解析出ice协商用户名和密码,候选项的信息,设置到相应的流中。这里流和组件都只有1个,值都是1。
nice_agent_set_remote_credentials(agent, 1, ufrag, pwd)
nice_agent_set_remote_candidates(agent, 1, 1, plist)
- ice 连接状态和候选项
设置了候选项和ice认证信息之后,niceagent会自动去和客户端协商ice连接建立,连接状态的改变和采用的是哪一组候选项会通过回调函数通知。
"component-state-changed" 回调函数中只打印了状态的变化信息,状态变化为connecting,connected,ready。
"new-selected-pair-full" 回调通知底层的ice选择了哪一对候选项(一个本地候选项,一个远端候选项)。 - ice连接完成
当组件的状态变为ready的时候,ice的连接状态完成。接着浏览器端会向服务器端主动发起dtls连接,dtls的数据包和码流包一样都是通过ice建立的udp通道来完成的。
流和通道的远端数据的接收是通过nice_agent_attach_recv设置的回调函数cb_nice_recv来回调的。
ice 通过回调cb_nice_recv收到对端的数据之后,要区分数据包是dtls包,还是rtp,rtcp的包,区分完成之后,将不同的数据包分流到对应的业务处理中。
bool is_dtls(char *buf)
{
return ((*buf >= 20) && (*buf <= 64));
}
bool is_rtp(char *buf)
{
rtp_header *header = (rtp_header *)buf;
return ((header->type < 64) || (header->type >= 96));
}
bool is_rtcp(char *buf)
{
rtp_header *header = (rtp_header *)buf;
return ((header->type >= 64) && (header->type < 96));
}
nice_开头的接口参考libnice api页面
dtls协商
- dtls的实现在类dtls_srtp中,服务端是作为dtls server,由客户端来发起dtls连接,从niceagent中接收到的dtls数据包,都输入到dtls_srtp类中。
- dtls 协商主要将 从niceagent上收上来的数据输入到ssl中,再将ssl的输出通过niceagent发送到对端。
- 然后检测ssl的初始化是否完成,初始化完成之后,可以通过SSL_get_peer_certificate获取到对端的fingerprint,这里可以比较一下是否和客户端返回的answer sdp里面的fingerprint相同,相同将连接状态置为DTLS_STATE_CONNECTED
- ssl初始化完成,SSL_export_keying_material 导出srtp的keying material,成功之后dtls的协商功能就结束了。
SSL_new(ssl_ctx);
SSL_set_accept_state(ssl);
SSL_do_handshake(ssl);
......
SSL_is_init_finished(ssl)
SSL_get_peer_certificate
......
unsigned char material[SRTP_MASTER_LENGTH*2];
unsigned char *local_key, *local_salt, *remote_key, *remote_salt;
SSL_export_keying_material(ssl, material, SRTP_MASTER_LENGTH*2, "EXTRACTOR-dtls_srtp", 19, NULL, 0, 0)
remote_key = material;
local_key = remote_key + SRTP_MASTER_KEY_LENGTH;
remote_salt = local_key + SRTP_MASTER_KEY_LENGTH;
local_salt = remote_salt + SRTP_MASTER_SALT_LENGTH;
srtp发送码流
dtls 协商完成后会得到远端和本地的srtp的key和salt,用于创建srtp对象,远端的key和salt创建的srtp对象命名为srtp_in,所有进来的srtp数据需要通过改对象进行解密,解密为rtp;发送的srtp对象命名为srtp_out,所以需要发送的rtp,需要加密为srtp。
srtp_policy_t remote_policy
remote_policy.ssrc.type = ssrc_any_inbound;
memcpy(remote_policy.key, remote_key, SRTP_MASTER_KEY_LENGTH);
memcpy(remote_policy.key + SRTP_MASTER_KEY_LENGTH, remote_salt, SRTP_MASTER_SALT_LENGTH);
srtp_t srtp_in;
err_status_t res = srtp_create(&srtp_in, &remote_policy);
- 当dtls协商建立完成之后,chrome浏览器会向服务器端发送RR的rtcp数据包,服务器端收到这个数据包之后,会独立创建一个发送线程读取h264文件进行发送。(在dtls连接建立,srtp创建成功之后,就可以创建发送线程了)
- 拆封h264帧为rtp的过程和rtsp中tcp传输rtp基本一致,不过没有4字节的额外头信息而已。
- 通过srtp_out,进行加密之后,使用niceagent发送到对端
int res = srtp_protect(dtls_->srtp_out, buf, &protectedlen);
int sent = nice_agent_send(agent, streamid, componentid, protectedlen, buf);
rtcp处理
本demo暂时没有实现rtcp的处理,只是演示一下webrtc的基本码流协商传输流程。
测试
- webrtchtml 文件夹下面有个bat脚本,startpythonweb.bat会开启python web服务
- bin目录下 启动webrtcgateway程序,依赖的dll都在这里
结果
-
在PC端的chrome浏览器上面可以看到h264图像,但是手机端好像不支持h264
- Firefox号称已经支持了h264,实际协商的时候createanswer 返回的sdp里面还是vp8,ice会失败
- 代码行数统计
参考
- 本文参考janus webrtc网关实现
github地址:https://github.com/meetecho/janus-gateway - 已经很久不需要画类图了,janus这个结构,,,,,,