Z'zyc

我是一名iOS开发者,我是一名非计算机专业出生的程序员。

你可以通过 1748439277@qq.com 联系我

Z'zyc

我是一名iOS开发者,我是一名非计算机专业出生的程序员。

如果学习很幸苦,那就尝试无知的代价

如何使两台离线设备通过热点实现webrtc通话能力
2024-11-14 技术分享 阅 118

背景

这一次的难题来自智能设备的同学,由于耗费时间比较长(近60h),所以有一定分享的意义,如果你有幸遇到类似的问题,希望这篇文章能够帮到你。

智能设备的项目要求,对执法记录仪的画面实时传输到手机上显示,由于一开始没有执法记录仪的设备,我们只能通过两台测试机来模拟这个场景。如果只是相互传输画面,WebRTC的能力足以满足需求,但在这里有个前置条件,执法记录和设备端设备均不可联网,且为离线状态,也就是没有互联网。

在基于没有联网的设备前提下,我们提供了几个设计思路:

  1. 蓝牙连接+音视频传输
  2. 热点连接+音视频传输
  3. 热点连接+webrtc传输

在第1、2点中,涉及到“音视频流传输”技术,一定会用到音视频编码、解码等问题,这里没有一定的音视频开发经验,可能很难做到,所以第3点,就是我们研究的方向。

确定了技术方向,我们很快完成了 webrtc 的流程编码工作,并开始验证工作,结果是失败的。我们发现两台设备在wifi连接的情况下,能够实现音视频通话的效果,然而,使用热点相互连接之后,却不行,无论怎样配置修改都没有画面。

因为这项工作已经持续了一段时间,我们也给了一个解决方案,可以购买一个小型的移动随身路由设备,让两台设备连接到随身路由设备上完成通信,当然也有类似于像 Hi3861 芯片的小型处理器可以实现这样的效果。(大概每台成本30~50块钱差不多)

img

原以为这项工作可以告一段落,但客户,仍然坚持要求以两台设备直连接为最终目标,所以,我们的同事开始了“热点连接+音视频传输”研究,而且很不幸,卡在视频编码/解码上,此时,还没涉及到音频开发,感觉这一条路也很难走的通。

基于上面提到的种种难题,也就引出了,本文带来的解决方案,模拟Turn协议,“骗取”WebRTC流量,实现基于本地中继服务的WebRTC流转发。

问题分析

看来,音视频流转发这条路应该是走不通了,所以,我们回来重新分析,如何解决这个问题。这时,我想到一个问题:如何在保证 WebRTC 原来数据不变的情况下,进行UDP离线转发,这样既保证了 WebRTC功能的完整性,又能进行离线点对点通信?

带着这样的问题,我查阅了相关资料,我找到一项关于 WebRTC 的说明。实际上,WebRTC 并不能确保任何情况下都能正常进行实时通讯,在如今的路由器设备种类过多的情况下,很多电子设备都存在于NAT设备后,然而,跨 NAT 间的设备难以通信,进而无法实现 WebRTC,为了确保在 NAT 设备下的电子设备间能有效的进行 WebRTC通讯,WebRTC提供了 ICE 候选能力的配置,也就是 STUN/TURN 协议来辅助WebRTC 在复杂 NAT 场景下的有效通讯。

到这里我有点熟悉了,如果对 STUN/TURN 不太理解的同学,可以阅读我的这篇文章: WebRTC实时通讯技术研究

简单说明下,STUN 是 NAT 内网穿透协议,用于交换两台设备间在 NAT 设备下对公网访问之后的临时端口和公网IP,之后利用通讯维持端口不灭,来保证设备间交换数据,完成通讯。而 TURN 是 STUN 的升级版,TURN协议是两台设备间通过 STUN 交换后,如果没能实现通讯,那会在服务端为其创建中继服务,之后的数据会在中继服务中对两台设备间交换转发流量,对,也就是后续的流量走了云端转发实现通讯。

所以,到这里我们就有了一个思路,能否模拟 TURN协议,获取到 WebRTC 中的流量,再通过 UDP 本地转发给另外一台设备,实现 WebRTC 通讯,没错,这是可行的。

截获流量

TURN 协议的默认端口 3478 ,所以最简单的操作,就是编写一个本地的 Socket 服务,监听 3478 端口,然后在 WebRTC ICE 配置上配置 TURN 协议的IP。

public PeerConnection createPeerConnection(PeerConnection.Observer observer) {
     ArrayList<PeerConnection.IceServer> iceServers = new ArrayList<>();
     iceServers.add(
        new PeerConnection.IceServer("turn:192.168.43.1", "demo", "123456")
     );
     PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
     return factory.createPeerConnection(rtcConfig, observer);
}

很顺利,我截获了 TURN 协议的相关流量,如下:

reqst  000100002112a44263754d6935426e4e4f575933
reqst  000300082112a4424e5a3252644b6d39546553580019000411000000
reqst  000300682112a4423868526b4849615779754c5500190004110000000006000464656d6f0014001561746c616e7469732d736f6674776172652e6e6574000000001500203432396539363330346639303062396532623365313566393134646335326565000800149530199a90863c1d259af2fd5a042eab0

这些字节,肯定看不出是什么,如果有协议开发经验的同学,这些字节都有着一定的含义。TURN 协议遵循了RFC5766 协议标准,这种标准一般都有公开的文档和说明,地址如下:

  1. RFC5766协议原文地址
  2. RFC5766 三方翻译中文地址

TURN 协议的协议数据结构如下:

img

我们举个例子,原始字节数据例如:

000100002112a44263754d6935426e4e4f575933

经过我们拆解分析就是:

  1. 0001 : 消息类型
  2. 0000: 消息长度
  3. 2112a442:固定值 MagicCookie
  4. 63754d6935426e4e4f575933:TranscationID 事物ID

因为这条消息的数据长度为0,所以也就是没有消息数据,接下来我们看个有数据的消息:


000300682112a4423868526b4849615779754c5500190004110000000006000464656d6f0014001561746c616e7469732d736f6674776172652e6e6574000000001500203432396539363330346639303062396532623365313566393134646335326565000800149530199a90863c1d259af2fd5a042eab0

  1. 0003 : 消息类型
  2. 0068: 消息长度
  3. 2112a442:固定值 MagicCookie
  4. 3868526b4849615779754c55:TranscationID 事物ID
  5. 数据体解析1:(0019) 数据类型、(0004) 数据长度、(11000000)数据;
  6. 数据体解析2:(0060) 数据类型、(0004) 数据长度、(64656d6f)数据;
  7. 数据体解析3:(0014) 数据类型、(0015) 数据长度、(61746c616e7469732d736f6674776172652e6e6574)数据、(000000)偏移量3字节;
  8. … 以此类推

到这里,我们基本就推算出了,各个消息数据字节的含义,然后我们来看下“消息类型”和“数据类型”代表的含义。

消息类型的含义如下:

  1. 0x0001 :绑定公共IP和端口号
  2. 0x0003:分配中继服务的端口号
  3. 0x0004:刷新中继服务的有效时长
  4. 0x0008:对指定的中继服务进行授权
  5. 0x0009:对指定的中继服务进行管道编号绑定
  6. 0x0016:实际转发的流量数据

数据类型的含义如下,:

  1. 0x0006:用户名
  2. 0x0008:消息签名
  3. 0x0009:错误码
  4. 0x0014:Realm 作用域
  5. 0x0015:Nonce 随机字符串
  6. 0x8022:服务器名称
  7. 0x000C:管道编号
  8. 0x000D:有效时长
  9. 0x0012:peer点的IP地址和端口号
  10. 0x0013:数据体
  11. 0x0016: 中继服务地址

由于参数较多,这里我们提到了几个需要用到的参数。

如何响应

既然,我们得倒来自 WebRTC TURN 协议的请求数据,那我应该如何响应呢,在官方的文档中,并没有切实提到响应数据的结构(主要是看不到响应数据长什么样子),所以这一块比较难分析。

到这里,我们得借助“参照物”程序,有两种方式:

  1. 利用 tcpdump+coturn 抓去 3478 的流量包,就获取响应数据的结构;
  2. 利用 node-turn 模块进行代码调试,获取数据结构;

由于 coturn 是 c++ 程序,调试不方便,所以我选择了后者,实现了一个简单的 nodejs 程序部署在云端。

const Turn = require('node-turn');

// 创建 TURN 服务器,设置为无认证
const server = new Turn({
  authMech: 'long-term', // 不需要认证
  enableTCP: true,    // 启用TCP支持
  listeningPort: 3478,   // TURN 服务器监听的端口
  listeningIps: ['0.0.0.0'], // 监听的 IP 地址
  relayIps: ['127.0.0.1'], // 中继的 IP 地址
});

server.addUser("demo", "123456")

// 启动 TURN 服务器
server.start(() => {
  console.log('TURN server started on port 3478 without authentication');
});

node-turn 是 nodejs turn 协议的三方模块,通过简单几行代码就可以运行 TURN 服务,同时 JS 动态语法的特性,调试起来非常方便,所以我很容易就获取到了响应的数据。

reqst  000100002112a44263754d6935426e4e4f575933
resp 0101000c2112a44263754d6935426e4e4f57593300200008000115f7fb169357

reqst  000300082112a4424e5a3252644b6d39546553580019000411000000
error resp 011300542112a4424e5a3252644b6d39546553580014001561746c616e7469732d736f6674776172652e6e65740000000015002034323965393633303466393030623965326233653135663931346463353265650009001000000401556e617574686f72697a6564

reqst  000300682112a4423868526b4849615779754c5500190004110000000006000464656d6f0014001561746c616e7469732d736f6674776172652e6e6574000000001500203432396539363330346639303062396532623365313566393134646335326565000800149530199a90863c1d259af2fd5a042eab0c2d4ee3
resp 010300482112a4423868526b4849615779754c55001600080001329a5e12a443000d00040000025800200008000115f7fb169357802200096e6f64652d7475726e00000000080014d7d90553df0a39596c0673c746a92f2a95f0d828

......

经过对比分析得出,响应数据的结构和请求消息结构是一样的,消息类型的响应值不一样:

  1. 0x0101 : 代表针对 0x0001 的成功响应
  2. 0x0103 : 代表针对 0x0003 的成功响应
  3. 0x0113 : 代表针对 0x0003 的失败响应

整体的消息结构一致,响应的数据体内容不一样,例如:

在原响应文中:0101000c2112a44263754d6935426e4e4f57593300200008000115f7fb169357

分析得处:

  1. 0x0101 : 成功
  2. 0x000C:数据体长度
  3. 0x2112a442 : 固定值 magicCookie
  4. 63754d6935426e4e4f575933:TranscationID 事物ID
  5. 0x0020:公网 IP + 端口
  6. 0x0008:该条数据的长度
  7. 0x0001:代表IPV4
  8. 0x15f7 : 端口号
  9. 0xfb、0x16、0x93、0x57 :代表 IP地址

这是首个请求的响应,其他响应的数据也是类似,复杂的是不同的数据类型需要不同的解析。

执行流程

好,到这里,结合 node-turn 的源码,我们基本掌握了 Turn 协议的数据解析和组装,接下来,我们需要对 Turn 协议的发送流程进行拼装,这里我们需要回到 [RFC5766协议原文](https://datatracker.ietf.org/doc/html/rfc5766) 中,这里有一些流程图,结合 node-turn 的流程基本是正确的。

img

  • 第1步,每个 Peer 都会发送 0x0001 请求,来知道自己的公网 IP 地址+端口号;
  • 第2步,每个Peer 会发送不携带身份信息的 0x0003 请求;
  • 第3步,每个 Peer 会发送带身份信息的 0x0003 请求,分配中继服务地址;
  • 第4步,每个 Peer 会发送 0x0008 的请求,对指定的对等方的中继服务地址进行授权;
  • 第 5 步,每个 Peer 会发送 0x0016 的请求,对指定的对等方发送数据传输初始化的基本信息;
  • 第6 步,每个 Peer 会发送 0x0009 的请求,绑定两个 Peer 的 ChannelNumber,后续会用 ChannelNumbner 进行通讯;
  • 第 7 步,数据方Peer 会发送 ChannelNumber 流数据转发给指定的接收方;
  • 最后,由于链接存在有效期,在接收到 0x0004 请求的时候,需要刷新 lifetime的值,保持会话永续;

按照的上面步骤,就可以一步一步套出 WebRTC 的流量数据,虽然流程是这样,单我们仍然有几个问题:

  1. 为什么有两个 0x0003? 经过测试,第一次 0x0003 的请求,不携带身份信息,这次访问是针对 STUN 的逻辑路径,如果返回成功信息,后续将不会触发中继流量,所以这里一定要返回失败并且信息是未授权401,这样才可以触发后续的中继服务逻辑。

  2. 如何知道两个 Peer 是对等方?实际上,在第二次 0x0003 携带身份请求中,我们需要返回分配的中继 IP 地址+端口号,每个 Peer 都有一个自己的中继 IP +端口号,类似于标记当前 Peer 在服务器中的 ID,在 node-turn 中可是真正意义上的初始化了一个 socket 服务:

    img

    在客户端我们就没必要这么做,所有的IP都可以是默认的 127.0.0.1,端口号取 49152 ~ 65535 中间的不重号即可,为什么是最大 65535, 因为端口号占两个字节,最大值是 0xFFFF 就是 65535,响应成功之后。客户端后续会发送 0x0008 的请求,用户绑定当前推送方对指定中继端口号进行授权,端口号包含在数据体中,这里端口号就是我们从 49152 ~ 65535 的端口号,所以我们需要在 0x0003 分配端口号的时候,用 HashMap 把对应端口号指定的客户端IP+端口存储下来。

  3. 对第2点的补充,后续我发现 0x16 的数据,本身也会携带这条数据向谁发的端口号,这样就可以绑定了Peer对等方的关系。

  4. 在 0x0009 请求之后响应成功之后,接收到 4000 开头的数据? 0x0009 是绑定对等方之间的渠道号ChannelNumber,响应成功之后,后续流量数据会使用 ChannelNumber 直接转发,所以我们需要在 0x0009 的逻辑中,将 ChannelNumber 以及对应的两个 Peer 对等方存储下来,在后续接受到 4000 的数据之后,直接从 HashMap 中找到对等方直接转发流量即可。

一个隐含的问题

实际上,完成上述操作后,仍然没有画面,我花了很长时间找原因,我发现作为热点站点方的设备,没有和连接方的设备产生握手,图示例如下:

img

我检查了很久,udp 信令服务之间可以正常通信,但 Turn 协议服务,始终只有 192.168.43.9 的请求数据和一条不明所以的 127.0.0.1 + 端口号的请求并且只发送了一次(一开始没有注意到🐶),并没有来自 192.168.43.1 的数据,我想了很多原因,任何一个原因都有可能导致我前面的工作前功尽弃。后来,我将注意力转移到了 127.0.0.1 的请求上,反复测试后发现,这确实来自站点设备,站点模式下的设备,在发送 WebRTC的请求时,数据会以 127.0.0.1 的请求发出,而实际我在 ICE 配置的时候仍然以 192.168.43.1 配置,我想到了类似于跨域的问题。

也就是站点设备,配置了 192.168.43.1 的 Turn 协议,然后以 127.0.0.1 的源头发送给 Turn 服务,所以他的请求IP肯定是 192.168.43.1 ,但他的源头是 127.0.0.1,熟悉跨域问题的同学肯定知道, Host 和 Origin 不匹配的时候,就会造成跨域并抛错误,所以,我的解决方案将 Turn 配置成 127.0.0.1 后,重新操作,握手成功!!,果然发送成功,画面也有了!!

img

我仍然不确实是什么问题,但确实成功了,我由此联想到了热点模式两台设备无法直接进行 webrtc 的问题,是不是也是类似问题导致的。

实机效果

最后附一张实机效果图

img