实现简易webrtc 网关

本文实现一个简易的单向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连接进行传输的。

使用到的库

交互流程

客户端和服务器在同一个网段内,程序不支持指定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


    pc chrome测试h264
  • Firefox号称已经支持了h264,实际协商的时候createanswer 返回的sdp里面还是vp8,ice会失败
  • 代码行数统计
image.png

参考

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容