WebRTC-NAT打洞策略

1、什么是NAT?

NAT(Network Address Translation)指的是网络地址转换,常部署在一个组织的网络出口位置。

网络分为“私网”和“公网”两个部分,NAT网关设置在私网到公网的路由出口位置,私网与公网间的双向数据必须都要经过NAT网关。位于NAT后的是私网IP地址,分配给NAT的是公网IP地址。

NAT通过将私网IP地址映射为公网IP地址,实现私网设备访问外部互联网的能力。组织内部的大量设备,通过NAT就可以共享一个公网IP地址,解决了IPv4地址不足的问题。同时NAT也起到隐藏内部设备,安全防护的作用。

上图中有两个组织,每个组织的NAT分配一个公网IP,分别是 1.2.3.4 以及 1.2.3.5 。每个组织私网设备通过NAT将内网地址转换为公网地址,然后加入互联网。通过NAT每个私网设备就不必都需要分配公网IP了,就像图中,一个组织配一个公网IP即可。

NAT问题

虽然NAT解决了IPv4地址耗尽的问题,但是也存在一些问题。

首先我们先说下不存在NAT时设备间点对点(P2P)的通信情况:

  • 位于同一个私网内,可以直接通过内网IP地址通信(例如192.168开头IP地址)
  • 通信双方都有独立的公网IP地址,可以直接通过公网IP地址通信

但是NAT的存在就不能这样处理了,增加了点对点通信的复杂度。如下图所示:

左边私网地址为 192.168.1.100 的设备要跟右边组织内的设备进行通信,由于右边组织多台设备共享一个公网IP,所以不能直接通过公网IP地址端口号进行通信,数据发过去了,根本不知道送到哪台设备,这样两个组织内的设备就不具有点对点通信的能力。既然这样,数据要怎么穿过NAT到达私网内,NAT网关要如何转发数据到指定设备呢?

NAT穿透技术

现实生活中,大多数设备都位于NAT后。比如连着同一个基站的移动设备,同一个小区的宽带用户等。NAT的存在使得设备间不能直接进行点对点通信。有时候为了流量节省,以及安全等原因考虑,我们希望不同NAT后的设备也能进行点对点通信,不需要经过第三方的数据转发。

为了进行设备间的点对点通信,我们需要使用相关技术检测设备间是否有点对点通信的可能性,以及如何进行点对点通信。这些相关技术就是NAT穿透(NAT traversal)。NAT穿透是为了解决使用了NAT后的私有网络中设备之间建立连接的问题

目前常见的NAT穿越技术、方法主要有:

应用层网关、中间件技术、打洞技术(Hole Punching)、服务器中转(Relay)技术

没有一种完美的NAT穿透,常常是多种技术互相配合,最常见的一种方案是打洞配合中转,例如后面说到的ICE方案。

NAT打洞工作在传输层,最为常见。下面说下基本原理。

NAT网关维护着一张关联表,进行公网/私网地址端口的转换,结构如下所示:

私网IP

公网IP

192.168.1.100:5566

1.2.3.4:9200

192.168.1.101:80

1.2.3.4:9201

192.168.1.102:4465

1.2.3.4:9202

如下图,左边组织的公网IP为 1.2.3.4 的NAT网关收到发到 1.2.3.4:1234 的数据,假如关联表中还未存在映射关系,NAT对外部发来的数据包直接丢掉。

所以网络访问只能先由私网侧发起,公网无法主动访问私网主机,既然这样,就由私网侧主动点。如下图:

私网地址 10.0.0.100 的设备发送数据包到公网。NAT网关关联表中创建了该设备 私网地址:端口 公网地址:端口 的映射,即上图中的 10.0.0.100:1234 1.2.3.4:1234 。这相当于在NAT上打了一个洞,其它人就可以通过这个“洞”把数据传进来,这就是为什么叫打洞技术了。

接下来,通过某种方式将打好的洞信息:NAT映射后的公网地址+端口告诉要通信那方,要通信那方就向该洞:1.2.3.4:1234 发数据。NAT收到发到 1.2.3.4:1234 的数据,NAT网关在关联表中找到映射,然后转发数据到对应私网设备 10.0.0.100:1234

这里我们总结下打洞技术:

  • 首先位于NAT后的peer1节点需要向外发送数据包,以便让NAT建立起私网 endpoint1(ip1:port1) 和公网 endpoint2(ip2:port2) 的映射关系;
  • 然后通过某种方式将映射后的公网endpoint2通知给对端节点peer2;
  • 最后peer2往收到的公网endpoint2发送数据包,然后该数据包又会被NAT转发给私网的peer1;

但又些情况下,打洞会失败,此时只能通过部署在公网的第三方服务器进行数据转发,间接实现通信。

NAT类型

NAT 有 3 中类型:

静态NAT、动态NAT、端口复用NAPT

静态NAT

内部本地地址一对一转换成内部全局地址,相当内部本地的每一台PC都绑定了一个全局地址。一般用于在内网中对外提供服务的服务器。

动态NAT

在内部本地地址转换的时候,在地址池中选择一个空闲的,没有正在被使用的地址,来进行转换,一般选择的是在地址池定义中排在前面的地址,当数据传输或者访问完成时就会放回地址池中,以供内部本地的其他主机使用,但是,如果这个地址正在被使用的时候,是不能被另外的主机拿来进行地址转换的。

端口复用NAPT

面对私网内部数量庞大的主机,如果NAT只进行IP地址的简单替换,就会产生一个问题:当有多个内部主机去访问同一个服务器时,从返回的信息不足以区分响应应该转发到哪个内部主机。

此时,需要 NAT 设备根据传输层信息或其他上层协议去区分不同的会话,并且可能要对上层协议的标识进行转换,比如 TCP 或 UDP 端口号。这样 NAT 网关就可以将不同的内部连接访问映射到同一公网IP的不同传输层端口,通过这种方式实现公网IP的复用和解复用。这种方式也被称为端口转换PAT、NAPT或IP伪装,但更多时候直接被称为NAT,因为它是最典型的一种应用模式。

如下图所示,以私网 10.0.0.10 和 10.0.0.11 的主机与外网 163.221.120.9 的主机进行通信为例讲解 NAPT 的工作机制:

2、聊聊这个NAT

默认行为(策略)

大部分NAT默认有以下行为:

  • 允许所有出向连接
  • 禁止所有入向连接

如何区分入向和出向?

连接(connection)和方向(direction)都是协议设计者头脑中的概念,到了物理传输层,每个连接都是双向的,允许所有的宝物双向传输。那NAT是如何区分哪些是入向包、哪些是出向包的呢?

这就要回到“有状态”(stateful)这三个字了:有状态NAT会记录它看到的每个包,当收到下一个包时,会利用这些信息(状态)来判断应该做什么。

对UDP来说,规则很简单:如果NAT之前看到过一个出向包(outbound),就会允许相应的入向包(inbound)通过,以下图为例

笔记本电脑中自带了一个NAT,当该NAT看到从这台机器出去的 2.2.2.2:1234 -> 5.5.5.5:5678 包时,就会记录一下:5.5.5.5:5678 -> 2.2.2.2:1234 入向包应该放行。这里的逻辑是:我们信任的世界想主动与 5.5.5.5:5678 通信,因此应该放行(allow)其回报路径。

某些非常宽松的NAT只要看到有从 2.2.2.2:1234 出去的包,就会允许所有从外部进入 2.2.2.2:1234 的流量。这种NAT对我们的NAT穿透来说非常友好,但已经越来越少见了。

NAT朝向与穿透方案

NAT朝向相同

场景特点:服务端IP可直接访问

在NAT穿透场景中,以上默认规则对UDP流量的影响不大——只要路径上所有NAT的“朝向”是一样的。一般来说,从内网访问公网上的某个服务器都属于这种情况。

唯一的要求是:连接必须是由NAT后面的机器发起的,这是因为在它主动和别人通信之前,没人能主动和它通信,如下图所示:

穿透方案:客户端直连服务端

但上图是假设了通信双方中,其中一端(服务端)是能直接访问到的。在VPN场景中,这就形成了所谓的hub-and-spoke拓扑:中心的hub没有任何NAT策略,谁都能访问到;NAT后面的spokes连接到hub。如下图所示:

NAT朝向不同的方向

场景特点:服务端IP不可直接访问

但如果两个“客户端”相连,以上方式就不行了,此时两边的NAT相向而立,如下图所示:

根据前面的讨论,这种情况意味着:两边要同时发起连接请求,但也意味着两边都无法发起有效请求,因为对方先发起请求才能在它的NAT上打开一条缝让我们进去!如何破解这个问题呢?一种方式是让用户重新配置一边或两边的NAT,打开一个端口,允许对方的流量进来。

这显然对用户不友好,在像Tailscale这样的mesh网络中的扩展性也不好,在mesh网络中,我们假设对端会以一定的粒度在公网上移动。

此外,在很多情况下用户也没有NAT的控制权限:例如在咖啡馆或机场中,连接的路由器是不受你控制的(否则你可能就有麻烦了)。

因此,我们需要寻找一种不用重新配置NAT的方式。

穿透方案:两边同时主动建连,在本地NAT为对方打开一个洞

解决的思路

还是先重新审视前面提到的有状态NAT规则。对于UDP,其规则(逻辑)是:包必须先出去才能进来。

注意,这里除了要满足包的IP和端口要匹配这一条件之外,并没有要求包必须是相关的(related)。换句话说,只要某些包带着正确的来源和目的地址出去了,任何看起来像是响应的包都会被NAT放进来——即使对端根本没收到你发出去的包。

因此,要穿透这些有状态NAT,我们只需要共享一些信息:让两端提前知道对方使用的<ip:port>,手动静态配置是一种方式,但显然扩展性不好。于是有了一种全新的服务,以灵活、安全的方式来同步<ip:port>信息。

有了对方的<ip:port>信息之后,两端开始给对方发送UDP包。

在这个过程中,我们预料到某些包将会被丢弃。因此,双方必须要接受某些包会丢失的事实,因此如果是重要信息,你必须自己准备好重传。

来看一下具体建连(穿透)过程,如图所示:

首先,2.2.2.2的客户端通过某种方式(可能是从服务端获取到的)得到的对端IP地址,向其发出第一包:2.2.2.2:1234 -> 7.7.7.7:5678,穿过NAT进入到公网。

对方的NAT会将这个包拦截掉,因为它没有 7.7.7.7:5678 -> 2.2.2.2:1234 的流量记录。但另一方面,此时2.2.2.2端右侧的NAT已经记录了出向连接,因此会允许 7.7.7.7:5678 -> 2.2.2.2:1234 的应答包进来。

接着,7.7.7.7的服务端通过某种方式(可能是从服务端获取到的)得到了客户端的请求,以及该客户端的外网地址<ip:port>,便将第一个 7.7.7.7:5678 -> 2.2.2.2:1234 穿过它自己的NAT到达公网。

到达2.2.2.2端右侧的NAT时,NAT认为这是刚才出向包的应答包,因此就放行它进入了。此外,右侧的NAT此时也记录了 2.2.2.2:1234 -> 7.7.7.7:5678 的包应该放行。

此时,2.2.2.2端收到服务器发来的包之后,发送一个包作为应答。这个包穿过右侧的NAT和服务端侧的NAT(因为这是对服务端发送的包的应答包),最终达到服务端。

成功!这样我们就建立了一个穿透两个相向NAT的双向通信连接。而初看之下,这项任务似乎是不可能完成的。

穿透NAT的思考

穿透NAT并非永远这么轻松,有时会受一些第三方系统的间接影响,需要仔细处理。那穿透NAT需要注意什么呢?重要的一点是:通信双方必须几乎同时发起通信,这样才能在路径上的NAT打开一条缝,而且两端还都是活着的。

双向主动建连:旁路信道

如何实现“同时”呢?一种方式是两端不断重试,但显然这种方式很浪费资源。假如双方都知道何时开始建连就好了。

这听上去是鸡生蛋蛋生鸡的问题了:双方想要通信,必须先提前通个信。

但实际上,我们可以通过旁路信道“SideChannel”来达到这个目的,并且这个旁路信道并不需要很实时,它可以有几秒钟的延迟甚至只需要传送几KB的信息,因此即使是一个配置非常低的虚拟机,也能为几千台机器提供这样的旁路通信服务。

一个例子是WebRTC,它需要你提供一个自己的“信令通道”(SignallingChannel),并将其配置到WebRTCAPI。

Signalling Channel(信令通道)

  • 一种资源,使应用程序可以通过交换信令消息来发现、建立、控制和终止对等连接;
  • 信令消息是两个应用程序相互交换以建立对等连接的元数据。该元数据包括本地媒体信息,例如媒体编解码器和编解码器参数,以及两个应用程序可能相互连接以进行实时流传输的可能的网络候选路径;
  • 信令通道通常由信令服务器提供;

非活跃连接被NAT清理

  1. 要么定期向对方发包来keepalive;
  2. 要么有某种带外方式来按需重建连接;

有状态NAT内存通常比较有限,因此会定期清理不活跃的连接(UDP常见的是30s)因此要保持连接alive的话需要定期通信,否则就会被NAT关闭,为避免这个问题,我们:

问题都解决了?不,挑战刚刚开始

对于NAT穿透来说,我们并不需要关心路径上有几堵墙,只要它们是有状态NAT且允许出向连接,这种同时发包机制就能穿透任意多层NAT。这一点对我们来说非常友好,因为只需要实现一个逻辑,然后能适用于任何地方了。对吗?

其实,不完全对。这个机制有效的前提是:我们能提前知道对方的<ip:port>。

3、STUN穿越

基本原理

STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序),是基于UDP的完整的穿透NAT的解决方案,属于我们前面说到的打洞技术。由STUN服务器(STUN Server)组成和STUN客户端(STUN Client)组成,默认端口号是3478

STUN原理

STUN基于一个简单的观察:从一个会被NAT的客户端访问公网服务器时,服务器看到的是NAT设备的公网<ip:port>地址,而非该客户端的局域网<ip:port>地址。

也就是说,服务器能告诉客户端它看到的客户端的<ip:port>是什么。因此,只要将这个信息以某种方式告诉通信对端(peer),后者就知道该和哪个地址建连了!这样就又简化为前面的NAT穿透问题了。

本质上这就是STUN协议的工作原理,如下图所示:

  • 笔记本向STUN服务器发送一个请求:“从你的角度看,我的地址什么?”
  • STUN服务器返回一个响应:“我看到你的UDP包是从这个地址来的:<ip:port>”

为什么NAT穿透逻辑和主协议要共享同一个socket?

理解了STUN原理,也就能理解为什么说,如果要实现自己的NAT穿透逻辑和主协议,就必须让二者共享同一个socket:

  • 每个socket在NAT设备上都对应一个映射关系(私网地址->公网地址)
  • STUN服务器只是辅助穿透的基础设施(让客户端提前打洞)比如客户端
  • 客户端与STUN服务器通信之后,在NAT及NAT设备上打开了一个连接,允许入向包进来(前提是NAT的限制是放开的,目的地址是符合过滤要求的)
  • 因此,接下来只要将这个地址告诉我们的通信对端(peer),让它往这个地址发包,就能实现穿透了
  • 需要这个洞长期存在,客户端就要不断地向STUN发送keepalive,刷新映射表

STUN不能穿透所有NAT设备

有了STUN,我们的穿透目的似乎已经实现了:

每台机器都通过STUN来获取自己的私网socket对应的公网<ip:port>,然后把这个信息告诉对端,然后两端同时发起穿透NAT的尝试,后面NAT就穿透了,对吗?

答案是:看情况,某些情况下确实如此,但有些情况下却不行,具体看“类型嗅探”。

  • 对于大部分家用路由器场景,这种方式是没问题的;
  • 但对于一些企业级NAT网关来说,这种方式无法奏效,因为企业级NAT内部限制很多;

重新审视STUN的前提

再次审视前面关于STUN的假设:当STUN服务器告诉客户端在公网看来它的地址是 2.2.2.2:4242 时,那所有目的地址是 2.2.2.2:4242 的包就都能穿透防火墙到达该客户端。

这也正是问题所在:这一点并不总是成立。

某些NAT设备的行为与我们假设的一致,它们的有状态防火墙组件只要看到有客户端自己发起的出向包,就会允许相应的入向包进入;因此只要利用STUN功能,再加上两端同时发起防火墙穿透,就能把连接打通;

另外一些NAT设备就要困难很多了,它会针对每个目的地址来生成一条相应的映射关系。在这样的设备上,如果我们用相同的socket来分别发送数据包到 5.5.5.5:1234 和 7.7.7.7:2345,我们就会得到2.2.2.2上的两个不同的端口,每个目的地址对应一个。如果反向包的端口用的不对,包就无法通过防火墙。如下图所示:

报文结构

STUN消息头

所有的STUN消息都包含20个字节的消息头,包括16位的消息类型,16位的消息长度和128位的事务ID。

  • 消息类型:STUN Message Type(14bit)

0x0001

Binding Request

捆绑请求

0x0101

Binding Response

捆绑响应

0x0111

Binding Error Resonse

捆绑错误响应

0x0002

Shared Secret Request

共享私密请求

0x0102

Shared Secret Response

共享私密响应

0x01112

Shared Secret Error Response

共享私密错误响应

  • 消息长度:Message Length(16bit)

消息长度,是消息大小的字节数,但不包括20字节的头部

  • 事务ID:Transaction ID(128bit)

128位的标识符,用于随机请求和响应,请求与其相应的所有响应具有相同的标识符

STUN属性

消息头之后是0或多个属性,每个属性进行TLV编码,包括16位的属性类型、16位的属性长度和变长属性值。

  • 属性类型:Type(16bit)

任何属性类型都有可能在一个STUN报文中出现超过一次。除非特殊指定,否则其出现的顺序是有意义的:即只有第一次出现的属性会被接收端解析,而其余的将被忽略。为了以后版本的拓展和改进,属性区域被分为两个部分。
Type值在0x0000-0x7FFF之间的属性被指定为强制理解,意思是STUN终端必须要理解此属性,否则将返回错误信息;而0x8000-0xFFFF之间的属性为选择性理解,即如果STUN终端不识别此属性则将其忽略。目前STUN的属性类型由IANA维护。

  • 属性长度:Length(16bit)

必须包含Value部分需要补齐的长度,以字节为单位。由于STUN属性以32bit边界对齐,因此属性内容不足4字节的都会以padding bit进行补齐。padding bit会被忽略,但可以是任何值。

  • 属性内容:Value(32bit)

4、STUN类型嗅探

由于存在不同的NAT部署方式,所以产生了不同类型的NAT。

通过STUN客户端与STUN服务器之间的报文交互,STUN服务器可以发现NAT设备的存在,并获取NAT设备分配给STUN客户端的IP地址和端口号,在STUN客户端之间建立一条数据通道。STUN客户端之间建立好数据通道之后,客户端之间可以相互访问。

如果NAT是完全圆锥型的,那么双方中的任何一方都可以发起通信。

如果NAT是受限圆锥型或端口受限圆锥型,双方必须一起开始传输。

如果有一方位于对称NAT后,就无法打洞成功。

完全锥型(Full Cone NAT)

一旦一个内部地址 iaddr:port 映射到外部地址 eaddr:port,所有发自 iaddr:port 的包都经由 eaddr:port 向外发送。任意外部主机都能通过给 eaddr:port 发包到达 iaddr:port(注:port不需要一样),那他就是一个完全圆锥型的NAT。

STUN报文交互过程:

地址收集:

  • STUN客户端(10.1.1.1:40000)向STUN服务端(1.1.1.1:3478)发送“Binding Request”消息,然后服务端返回一个“Binding Response”消息,并成功接收;
  • 然后,STUN客户端比较源地址和MAPPED-ADDRESS里两个字段,假如他们不匹配,那么STUN客户端就知道之间存在一个NAT服务;

Binding Request 消息:IP头里源地址信息为 10.1.1.1:40000

Binding Response 消息:MAPPED-ADDRESS 属性里写着 5.5.5.1:40000

嗅探阶段(1):

  • 接下来,STUN客户端向服务器发送一个“Binding Request”消息,携带CHANGE-REQUEST属性;

CHANGE-REQUEST 属性中的 Change IP 和 Change Port 标志都设置为1

  • 当STUN服务器接收到消息时,它使用一组备用地址(2.2.2.2:3479)而不是接收到的数据包的主地址(1.1.1.1:3478)作为“Binding Response”消息的源信息,接下来,它将消息发送回STUN客户端;
  • 如果收到这个响应,STUN客户端就知道它在一个全锥形NAT之后;

对称型(Symmetric NAT)

每一个来自相同内部IP与端口,到一个特定目的地地址和端口的请求,都映射到一个独特的外部IP地址和端口。同一内部IP与端口发到不同的目的地和端口的信息包,都使用不同的映射,只有曾经收到过内部主机数据的外部主机,才能够把数据包发回。

STUN报文交互过程:

地址收集:

和之前一样,通过源地址和MAPPED-ADDRESS比较得知,中间存在一个NAT;

嗅探阶段(1):

  • 接下来,STUN客户端向服务器发送一个“Binding Request”消息,携带CHANGE-REQUEST属性,但是他没有接收到“Binding Response”;
  • 那么,这个STUN客户端就知道,那个NAT并不是一个全圆锥型NAT,然后,它开始了一次测试1+,将探测1进行改装;

地址再收集:

  • STUN客户端(10.1.1.1:40000)向STUN服务端(2.2.2.2:3479)发送“Binding Request”消息,STUN服务端返回一个“Binding Response”消息,并成功收到反馈;
  • 然后,STUN客户端比较MAPPED-ADDRESS-1和MAPPED-ADDRESS-2里两个字段,他们不匹配,那么STUN客户端就知道这是一个对称NAT;

Binding Response 消息:MAPPED-ADDRESS-1 属性里写着 5.5.5.1:40000

Binding Response 消息:MAPPED-ADDRESS-2 属性里写着 5.5.5.1:50000

地址受限锥型(Restricted Cone NAT)

STUN报文交互过程:

地址收集:

和之前对称型一样,通过源地址和MAPPED-ADDRESS比较得知,中间存在一个NAT;

嗅探阶段(1):

和之前对称型一样,STUN客户端知道那个NAT并不是一个全圆锥型NAT;

地址再收集:

  • STUN客户端向STUN服务端发送“Binding Request”消息,就和对称型那样,STUN服务端返回一个“Binding Response”消息,并成功接收;
  • 然后,STUN客户端比较MAPPED-ADDRESS-1和MAPPED-ADDRESS-2里两个字段,他们是相同的,那么STUN客户端就知道这不是一个对称NAT;

Binding Response 消息:MAPPED-ADDRESS-1 属性里写着 5.5.5.1:40000

Binding Response 消息:MAPPED-ADDRESS-2 属性里写着 5.5.5.1:40000

嗅探阶段(2):

  • STUN客户端向STUN服务端(1.1.1.1:3478)发送一个“Binding Request”消息,携带CHANGE-REQUEST属性,并成功收到反馈;

CHANGE-REQUEST 属性中的 Change Port 标志设置为1

  • 然后,STUN服务器在收到信息之后向STUN客户端返回一个“Binding Response”消息,其中按要求修改了发送源的端口(3478 -> 3479)如果STUN客户端收到此消息,那么STUN客户端就知道它位于受限锥形NAT之后;

也就是说,具有改变过端口(1.1.1.1:3479)的数据包被允许接受,因此,客户端位于受限锥形NAT之后。

端口受限锥型(Port Restricted Cone NAT)

STUN报文交互过程:

地址收集:

和之前受限锥型一样,通过源地址和MAPPED-ADDRESS比较得知,中间存在一个NAT;

嗅探阶段(1):

和之前受限锥型一样,STUN客户端知道那个NAT并不是一个全圆锥型NAT;

地址再收集:

和之前受限锥型一样,STUN客户端知道这不是一个对称NAT;

嗅探阶段(2):

  • STUN客户端向STUN服务端发送一个同样的“Binding Request”消息,携带CHANGE-REQUEST属性,但STUN客户端没有能够收到反馈;

CHANGE-REQUEST 属性中的 Change Port 标志设置为1

得到结论

检测到需要穿越NAT的类型后,根据NAT类型特性,实施穿透策略。我们可以得出以下结论:

序号

ClientA.NAT

ClientB.NAT

能否穿透

1

完全锥型

完全锥型

2

地址限制

3

端口限制

4

对称型

5

地址限制

完全锥型

同2

6

地址限制

7

端口限制

8

对称型

9

端口限制

完全锥型

同3

10

地址限制

同7

11

端口限制

12

对称型

不能

13

对称型

完全锥型

同4

14

地址限制

同8

15

端口限制

同12

16

对称型

不能

5、STUN打洞过程

工作环境均为三台设备:Client.A、Client.B、Server。

首先,Client.A 和 Client.B 向 Server 发送自己内网IP和端口,Server 分别记为 <ipA1:portA1> 和 <ipB1:portB1>,同时 Server 记下 Client.A、Client.B 实际与自己通信所使用的外网IP地址和端口号(即对应NAT映射表项外网IP地址和端口),分别记为 <ipA2:portA2> 和 <ipB2:portB2>。

双端只有一方在NAT后(TCP)

如下图所示,Client.A 位于NAT内网,而 Client.B 是具有公网IP的主机:

如果是Client.A需要连接Client.B,那么Client.A直接连Client.B就可以了。如果是Client.B需要连接Client.A,那么Client.B直接连Client.A一般是连接不上的,但是我们可以反过来让Client.A主动去连Client.B

当通信的双方中只有一方位于NAT之后时,它们可以利用反向链接技术来进行P2P通信,TCP是双工通信,所以只需要其中一方完成连接即可。

具体的穿越过程如下:

  • Client.B 请求 Server 帮助建立与 Client.A 的TCP连接;
  • Server 发现 Client.B 不在NAT之后,将 Client.B 的 {<ipB1:portB1>, <ipB2:portB2>} 发给 Client.A,要求 Client.A 主动发起连接;
  • 当 Client.A 收到 {<ipB1:portB1>, <ipB2:portB2>} 后,也会开始向 <ipB1:portB1> 和 <ipB2:portB2> 依次发起TCP连接。当连接成功后,后面的地址便不再发起连接;
  • TCP连接建立后,Client.A 和 Client.B 均可以向对端发出TCP包,期间,Client.A会不断地向Client.B发送keepalive用以保证维持打洞;

双端在都在NAT后(UDP)

如下图,Client.A 和 Client.B 位于同一个NAT后面,这个时候 Client.A 和 Client.B 位于同一个局域网。

具体的穿越过程如下:

  • Server 将 Client.A 的 {<ipA1:portA1>, <ipA2:portA2>} 发给 Client.B,同时,Server 又将 Client.B 的 {<ipB1:portB1>, <ipB2:portB2>} 也发给 Client.A;
  • 当 Client.B 收到 {<ipA1:portA1>, <ipA2:portA2>} 后,开始向 <ipA1:portA1> 和 <ipA2:portA2> 分别发送UDP数据包,并且 Client.B 会自动锁定第一个给出响应的IP地址和端口号。同理,当 Client.A 收到{<ipB1:portB1>, <ipB2:portB2>} 后,也会开始向 <ipB1:portB1> 和 <ipB2:portB2> 分别发送UDP数据包,并且自动锁定第一个得到 Client.B 回应的IP地址和端口号;
  • 通信成功后,Client.A 和 Client.B 均可以向对端发出UDP包,期间,Client.B 和 Client.B 均会不断地向对端发送一个 keepalive(padding packet)用以保证维持打洞;

由于 Client.A 与 Client.B 互相向对方发送 UDP 数据包的操作是异步的,所以 Client.A 和 Client.B 发送数据包的时间先后并没有时序要求。

6、STUN不可用怎么办?

如果已经试过了前面介绍的所有方式仍然不能穿透,我们该怎么办呢?

  • 实际上,确实有很多NAT实现在这种情况下都会选择放弃,向用户报一个“无法连接”之类的错误;
  • 但对我们来说,这么快就放弃显然是不可接受的

中继连接

保底解决方式是:创建一个中继连接(relay)实现双方的无障碍地通信。但是,中继方式性能不是很差吗?这要看具体情况。

  • 如果能直连,那显然没必要用中继方式;
  • 但如果无法直连,而中继路径又非常接近双方直连的真实路径,并且带宽足够大,那中继方式并不会明显降低通信质量。延迟肯定会增加一点,带宽会占用一些,但相比完全连接不上,还是更能让用户接受的;

不过要注意:我们只有在无法直连时才会选择中继方式。实际场景中:

  • 对于大部分网络,我们都能通过前面介绍的方式实现直连;
  • 剩下的长尾用中继方式来解决,并不算一个很糟的方式;

此外,某些网络会阻止NAT穿透,其影响比之前讨论的限制级NAT大多了。例如,我们观察到UCBerkeleyguestWiFi禁止除DNS流量之外的所有outboundUDP流量。不管用什么NAT黑科技,都无法绕过这个拦截。因此我们终归还是需要一些可靠的fallback机制。

多种中继协议

有多种中继实现方式:

  • TURN(TraversalUsingRelaysaroundNAT)

用户先去公网上的TURN服务器认证,成功后后者会告诉你:“我已经为你分配了<ip:port>,接下来将为你中继流量”,然后将这个<ip:port>地址告诉对方,让它去连接这个地址,接下去就是非常简单的客户端/服务器通信模型了

  • DERP(DetouredEncryptedRoutingProtocol)

它是一个通用目的包中继协议,运行在HTTP之上,而大部分网络都是允许HTTP通信的。它根据目的公钥(destination’spublickey)来中继加密的流量(encryptedpayloads)

前面也简单提到过,TURN/DERP既是我们在NAT穿透失败时的保底通信方式,也是在其他一些场景下帮助我们完成NAT穿透的旁路信道。换句话说,它既是我们的保底方式,也是有更好的穿透链路时,帮助我们进行连接升级(upgradetoapeer-to-peerconnection)的基础设施。

有了“中继”这种保底方式之后,我们穿透的成功率大大增加了:

  • 90%的情况下,你都能实现直连穿透
  • 剩下的10%里,用中继方式能穿透一些(some)

这已经算是一个“足够好”的穿透实现了。

7、WebRTC打洞

表示双方去外部连接 Server 时对外的位置:

候选者地址交换

A想和B进行P2P连接

用户A在向信令服务建立连接请求前,会先去 STUN 收集本地用户A的候选地址,这个地址就是用来给別人可以连到我的地址。

在 WebRTC 收集地址时,分别对应三种类型的 ICE 候选者(Candidate)地址依次收集:

1、主机候选者(host)

表示网卡自己的 IP 地址及端口,通过设备网卡获取,优先级最高。在 WebRTC 底层首先会尝试本地局域网内建立连接。

2、反射候选者(srflx)

表示经过 NAT 之后的外网 IP 地址和端口,由 ICE 映射服务器获取,根据服务器的返回情况,来综合判断并知道自身在公网中的地址。其优先级低于主机候选者,当 WebRTC 尝试本地连接不通时,会尝试通过反射候选者获得的 IP 地址和端口进行连接。

3、中继候选者(relay)

表示的是中继(TURN)服务器的转发 IP 地址与端口,由 ICE 中继服务器提供。优先级最低,前两个都不行则会按该种方式。

三种类型的Candiate优先级依次是 host > srflx > relay,按这种优先级进行连通测试。

基本流程

这里描述的是 Trickle ICE 过程,并且省略了通话发起与接受的信令部分,流程如下:

1)Client.A 通过信令服务转发将A的SDP通过Offer送到 Client.B,Client.B 做完本地处理以后,通过信令服务转发将B的SDP通过Answer送到 Client.A

2)Client.A、Client.B 同时向STUN发送 Binding request 请求自身的外网地址,并从STUN回包的“MAPPED-ADDRESS”中得到各自的外网地址;

3)Client.A、Client.B 收集完内外网 ICE Candidate,并通过信令服务发送给对方;

4)双方开始做NAT穿越,互相给对方的ICE Candidate发送STUN Binding Request;

5)NAT穿越成功,A、B之间的P2P连接建立,进入媒体互通阶段;

在这个过程中,我们看到了有三个核心的部分:

  • SDP协商
  • ICE Candidate交换
  • Stun Binding Req/Res的连通性检查;

在了解了标准WebRTC的建连流程以后,我们来看看WebRTC客户端如何与网关建连:

首先,我们网关的Media Server拥有公网IP,因此Server就不需要通过Stun Server收集自身的公网IP。

WebRTC客户端先与网关Signal Server协商SDP,包括ICE Candidate,Media Server分配IP和端口作为网关的ice candidate发送给客户端。因为网关是公网IP,所以客户端向这个IP发送STUN Binding Request会被服务器收到, 并回复Response。接着客户端与网关媒体服务器进行DTLS握手与秘钥协商,在此基础上进一步进行SRTP的音视频通信。至此,WebRTC客户端与网关服务器建连成功。

WebRTC网关服务器媒体架构:

最简的服务器端端口方案是我们可以为每个客户端分配一个端口,服务器上使用这个端口区分每个用户,例如A、B、C、D四个人在WebRTC网关服务器上分别对应UDP端口10001~10004。这种方案逻辑上很简单,很多开源的服务器都采用这个方案,如janus。另外一个原因是使用了libnice库在服务器上来和客户端做ice建连,类似的做法都是采用多端口的架构。

那么多端口有什么不足呢?

1)很多的网络出口防火墙对能够通过的UDP端口是有限制的;

2)对于服务端来说开辟这么多端口,安全性本身也有一定的问题,特别是运维同学,更是拒绝;

3)开辟这么多的端口在Server端上,端口的开销和性能均有一定的影响。那能否用单端口?使用单端口前,核心要解决的一个问题是:如何区分每一个RTP/RTCP包是属于哪一个WebRTC客户端?

为了解决这个问题,我们需要使用一些小技巧。首先,有几个基础知识点我们先了解一下。如下:

# ice-ufrag、ice-pwd分别为ICE协商用到的认证信息
a=ice-ufrag:58142170598604946
a=ice-pwd:71696ad0528c4adb02bb40e1

# DTLS协商过程的指纹信息
a=fingerprint:sha-256 7F:98:08:AC:17:6A:34:DB:CF:3B:EC:93:ED:57:3F:5A:9E:1F:4A:F3:DB:D5:BF:66:EE:17:58:E0:57:EC:1B:19
# 当前的客户端在DTLS协商过程中,既可以作为客户端,也可以作为服务端
a=setup:actpass

# 当前媒体行的标识符(在a=group:BUNDLE 0 1 这行里用到,这里0表示audio)
a=mid:0

1)SDP Offer和Answer里配置的ice-ufrag字段里面内容,原来是用来作为STUN数据包的鉴权的,因此STUN Binding Request里面的USERNAME字段就是由上Offer和Answer的ice-ufrag内容拼接而成;

2)发送STUN Binding Request的客户端本地UDP fd,与ICE建连成功后发送媒体数据的UDP fd是同一个,也就是说Server上看到的ip port是同一个。

有了上面的背景知识,你肯定已经大致有一个方案了。我们来看看实现细节是怎么样的:

1)在服务器给Web端的SDP Answer中设置 ice-ufrag为roomid/userid,其中RoomID和UserID是通话业务层分配的内容,用于区分每对通话以及参与者。接着做Ice Candidate协商,Web端开始做连通性检测,也就是STUN binding request里的USERNAME为SDP local和remote的ice-ufrag指定内容;

2)服务器收到stun binding request的客户端ip和端口,并正常回stun binding response;

3)记录客户端地址与用户的信息的映射关系;

4)服务器收到一个RTP/RTCP媒体数据包,通过包的源ip和端口,查询映射表就可以识别这个包属于哪个用户;

WebRTC客户端使用PeerConnection来表示不同的媒体连接,接下来我们将介绍如何选择PeerConnection的方案。

8、源码解析

收集Host候选地址

一旦创建了 AsyncPacketSocket 对象,有了本地 IP 和端口,host 类型的 candidate 也就已经就绪了,而 AsyncPacketSocket 对象在 AllocationSequence::Init 里就已经创建好了,所以可以直接发出 host candidate。

AllocationSequence::Init
              ↓
AllocationSequence::OnMessage
              ↓
AllocationSequence::CreateUDPPorts
              ↓
BasicPortAllocatorSession::AddAllocatedPort
              ↓
UDPPort::PrepareAddress
              ↓
UDPPort::OnLocalAddressReady
              ↓
      Port::AddAddress
              ↓ sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady

收集Srflx候选地址

BindingRequest

UDPPort::OnLocalAddressReady
            ↓
UDPPort::MaybePrepareStunCandidate
            ↓
UDPPort::SendStunBindingRequest
            ↓
StunRequestManager::SendDelayed
            ↓ message
StunRequest::OnMessage
            ↓ sig slot (SignalSendPacket)
UDPPort::OnSendPacket
            ↓
AsyncUDPSocket::SendTo
            ↓
PhysicalSocket::SendTo
            ↓
      socket sendto

如下所示,UDPPort::OnLocalAddressReady 首先调用 MaybeSetDefaultLocalAddress,然后调用 AddAddress 添加 host 类型 candidate,接下来调用 MaybePrepareStunCandidate 准备收集 STUN 类型候选者(服务器反射地址候选者)

void UDPPort::OnLocalAddressReady(rtc::AsyncPacketSocket* socket, const rtc::SocketAddress& address) {
    // When adapter enumeration is disabled and binding to the any address, the
    // default local address will be issued as a candidate instead if
    // `emit_local_for_anyaddress` is true. This is to allow connectivity for
    // applications which absolutely requires a HOST candidate.
    rtc::SocketAddress addr = address;

    // If MaybeSetDefaultLocalAddress fails, we keep the "any" IP so that at
    // least the port is listening.
    MaybeSetDefaultLocalAddress(&addr);

    AddAddress(addr, addr, rtc::SocketAddress(), UDP_PROTOCOL_NAME, "", "", LOCAL_PORT_TYPE, ICE_TYPE_PREFERENCE_HOST, 0, "", false);
    
    MaybePrepareStunCandidate();
}

如果配置了 STUN 服务器地址则进入执行 SendStunBindingRequests 的分支。

void UDPPort::MaybePrepareStunCandidate() {
  // Sending binding request to the STUN server if address is available to
  // prepare STUN candidate.
  if (!server_addresses_.empty()) {
    SendStunBindingRequests();
  } else {
    // Port is done allocating candidates.
    MaybeSetPortCompleteOrError();
  }
}
void UDPPort::SendStunBindingRequests() {
  // We will keep pinging the stun server to make sure our NAT pin-hole stays
  // open until the deadline (specified in SendStunBindingRequest).
  RTC_DCHECK(request_manager_.empty());

  for (ServerAddresses::const_iterator it = server_addresses_.begin();
       it != server_addresses_.end();) {
    // sending a STUN binding request may cause the current SocketAddress to be
    // erased from the set, invalidating the loop iterator before it is
    // incremented (even if the SocketAddress itself still exists). So make a
    // copy of the loop iterator, which may be safely invalidated.
    ServerAddresses::const_iterator addr = it++;
    SendStunBindingRequest(*addr);
  }
}

如果配置的 STUN 服务器地址是域名则执行 ResolveStunAddress,等待域名解析成 IP 后再往 STUN 服务器发送 StunBindingRequest,否则进入 else if 分支,通过 StunRequestManager::Send 发送 Binding Request。

void UDPPort::SendStunBindingRequest(const rtc::SocketAddress& stun_addr) {
  if (stun_addr.IsUnresolvedIP()) {
    ResolveStunAddress(stun_addr);

  } else if (socket_->GetState() == rtc::AsyncPacketSocket::STATE_BOUND) {
    // Check if `server_addr_` is compatible with the port's ip.
    if (IsCompatibleAddress(stun_addr)) {
      request_manager_.Send(
          new StunBindingRequest(this, stun_addr, rtc::TimeMillis()));
    } else {
      // Since we can't send stun messages to the server, we should mark this
      // port ready.
      const char* reason = "STUN server address is incompatible.";
      RTC_LOG(LS_WARNING) << reason;
      OnStunBindingOrResolveRequestFailed(stun_addr, SERVER_NOT_REACHABLE_ERROR,
                                          reason);
    }
  }
}

StunRequestManager::Send 会调用 StunRequestManager::SendDelayed,该函数内部会执行 StunRequest::Construct 构造 Message,然后将消息事件塞入网络线程处理。

void StunRequestManager::Send(StunRequest* request) {
  SendDelayed(request, 0);
}

void StunRequestManager::SendDelayed(StunRequest* request, int delay) {
  RTC_DCHECK_RUN_ON(thread_);
  RTC_DCHECK_EQ(this, request->manager());
  auto [iter, was_inserted] =
      requests_.emplace(request->id(), absl::WrapUnique(request));
  RTC_DCHECK(was_inserted);
  request->Send(webrtc::TimeDelta::Millis(delay));
}
void UDPPort::OnSendPacket(const void* data, size_t size, StunRequest* req) {
  StunBindingRequest* sreq = static_cast<StunBindingRequest*>(req);
  rtc::PacketOptions options(StunDscpValue());
  options.info_signaled_after_sent.packet_type = rtc::PacketType::kStunMessage;
  CopyPortInformationToPacketInfo(&options.info_signaled_after_sent);
  if (socket_->SendTo(data, size, sreq->server_addr(), options) < 0) {
    RTC_LOG_ERR_EX(LS_ERROR, socket_->GetError())
        << "UDP send of " << size << " bytes to host "
        << sreq->server_addr().ToSensitiveString() << " ("
        << sreq->server_addr().ToResolvedSensitiveString()
        << ") failed with error " << error_;
  }
  stats_.stun_binding_requests_sent++;
}

BindingResponse

PhysicalSocketServer::WaitSelect
                ↓
SocketDispatcher::OnEvent
                ↓ sig slot (SignalReadEvent)
AsyncUDPSocket::OnReadEvent
                ↓ sig slot (SignalReadPacket)
AllocationSequence::OnReadPacket
                ↓
UDPPort::HandleIncomingPacket
                ↓
StunRequestManager::CheckResponse
                ↓
StunBindingRequest::OnResponse
                ↓
UDPPort::OnStunBindingRequestSucceeded
                ↓
        Port::AddAddress
                ↓ sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady

当 UDP 收到网络数据包后会调用 UDPPort::OnReadPacket,如果对端地址是我们配置过的 STUN 服务器地址,说明这可能是 STUN 服务器往本端发送的 STUN 报文,则进入StunRequestManager::CheckResponse 处理。

void UDPPort::OnReadPacket(rtc::AsyncPacketSocket* socket,
                           const char* data,
                           size_t size,
                           const rtc::SocketAddress& remote_addr,
                           const int64_t& packet_time_us) {
  RTC_DCHECK(socket == socket_);
  RTC_DCHECK(!remote_addr.IsUnresolvedIP());

  // Look for a response from the STUN server.
  // Even if the response doesn't match one of our outstanding requests, we
  // will eat it because it might be a response to a retransmitted packet, and
  // we already cleared the request when we got the first response.
  if (server_addresses_.find(remote_addr) != server_addresses_.end()) {
    request_manager_.CheckResponse(data, size);
    return;
  }

  if (Connection* conn = GetConnection(remote_addr)) {
    conn->OnReadPacket(data, size, packet_time_us);
  } else {
    Port::OnReadPacket(data, size, remote_addr, PROTO_UDP);
  }
}

StunRequestManager::CheckResponse 先检查 UDP 数据大小,如果小于 20 个字节说明不可能是 STUN 报文,直接 return false,接下来再按 STUN 报文格式读取 TransactionId,如果本端没有发送过这个 TransactionId 的请求则 return false,接下来构造 StunMessage 进入 StunRequestManager::CheckResponse(StunMessage* msg) 继续处理。

bool StunRequestManager::CheckResponse(const char* data, size_t size) {
  RTC_DCHECK_RUN_ON(thread_);
  // Check the appropriate bytes of the stream to see if they match the
  // transaction ID of a response we are expecting.

  if (size < 20)
    return false;

  std::string id;
  id.append(data + kStunTransactionIdOffset, kStunTransactionIdLength);

  RequestMap::iterator iter = requests_.find(id);
  if (iter == requests_.end())
    return false;

  // Parse the STUN message and continue processing as usual.

  rtc::ByteBufferReader buf(data, size);
  std::unique_ptr<StunMessage> response(iter->second->msg_->CreateNew());
  if (!response->Read(&buf)) {
    RTC_LOG(LS_WARNING) << "Failed to read STUN response "
                        << rtc::hex_encode(id);
    return false;
  }

  return CheckResponse(response.get());
}

StunRequestManager::CheckResponse 检查 STUN 消息类型,如果是成功响应则执行 StunRequest::OnResponse(StunRequest 是个基类,对于 StunBindingRequest 会调用 StunBindingRequest::OnResponse),如果是错误响应则执行 StunRequest::OnErrorResponse。

bool StunRequestManager::CheckResponse(StunMessage* msg) {
  RTC_DCHECK_RUN_ON(thread_);
  RequestMap::iterator iter = requests_.find(msg->transaction_id());
  if (iter == requests_.end())
    return false;

  StunRequest* request = iter->second.get();

  // Now that we know the request, we can see if the response is
  // integrity-protected or not.
  // For some tests, the message integrity is not set in the request.
  // Complain, and then don't check.
  bool skip_integrity_checking =
      (request->msg()->integrity() == StunMessage::IntegrityStatus::kNotSet);
  if (skip_integrity_checking) {
    // This indicates lazy test writing (not adding integrity attribute).
    // Complain, but only in debug mode (while developing).
    RTC_DLOG(LS_ERROR)
        << "CheckResponse called on a passwordless request. Fix test!";
  } else {
    if (msg->integrity() == StunMessage::IntegrityStatus::kNotSet) {
      // Checking status for the first time. Normal.
      msg->ValidateMessageIntegrity(request->msg()->password());
    } else if (msg->integrity() == StunMessage::IntegrityStatus::kIntegrityOk &&
               msg->password() == request->msg()->password()) {
      // Status is already checked, with the same password. This is the case
      // we would want to see happen.
    } else if (msg->integrity() ==
               StunMessage::IntegrityStatus::kIntegrityBad) {
      // This indicates that the original check had the wrong password.
      // Bad design, needs revisiting.
      // TODO(crbug.com/1177125): Fix this.
      msg->RevalidateMessageIntegrity(request->msg()->password());
    } else {
      RTC_CHECK_NOTREACHED();
    }
  }

  bool success = true;

  if (!msg->GetNonComprehendedAttributes().empty()) {
    // If a response contains unknown comprehension-required attributes, it's
    // simply discarded and the transaction is considered failed. See RFC5389
    // sections 7.3.3 and 7.3.4.
    RTC_LOG(LS_ERROR) << ": Discarding response due to unknown "
                         "comprehension-required attribute.";
    success = false;
  } else if (msg->type() == GetStunSuccessResponseType(request->type())) {
    if (!msg->IntegrityOk() && !skip_integrity_checking) {
      return false;
    }
    request->OnResponse(msg);
  } else if (msg->type() == GetStunErrorResponseType(request->type())) {
    request->OnErrorResponse(msg);
  } else {
    RTC_LOG(LS_ERROR) << "Received response with wrong type: " << msg->type()
                      << " (expecting "
                      << GetStunSuccessResponseType(request->type()) << ")";
    return false;
  }

  requests_.erase(iter);
  return success;
}

StunBindingRequest::OnReponse 检查 STUN Message 的 STUN_ATTR_MAPPED_ADDRESS 属性,取出后获取 ipaddr 和 port,并回调 UDPPort::OnStunBindingRequestSucceeded。

class StunBindingRequest : public StunRequest {
 public:
  StunBindingRequest(UDPPort* port,
                     const rtc::SocketAddress& addr,
                     int64_t start_time)
      : StunRequest(port->request_manager(),
                    std::make_unique<StunMessage>(STUN_BINDING_REQUEST)),
        port_(port),
        server_addr_(addr),
        start_time_(start_time) {}

  const rtc::SocketAddress& server_addr() const { return server_addr_; }

  void OnResponse(StunMessage* response) override {
    const StunAddressAttribute* addr_attr =
        response->GetAddress(STUN_ATTR_MAPPED_ADDRESS);
    if (!addr_attr) {
      RTC_LOG(LS_ERROR) << "Binding response missing mapped address.";
    } else if (addr_attr->family() != STUN_ADDRESS_IPV4 &&
               addr_attr->family() != STUN_ADDRESS_IPV6) {
      RTC_LOG(LS_ERROR) << "Binding address has bad family";
    } else {
      rtc::SocketAddress addr(addr_attr->ipaddr(), addr_attr->port());
      port_->OnStunBindingRequestSucceeded(this->Elapsed(), server_addr_, addr);
    }

    // The keep-alive requests will be stopped after its lifetime has passed.
    if (WithinLifetime(rtc::TimeMillis())) {
      port_->request_manager_.SendDelayed(
          new StunBindingRequest(port_, server_addr_, start_time_),
          port_->stun_keepalive_delay());
    }
  }
}

UDPPort::OnStunBindingRequestSucceeded 通过 AddAddress 添加 stun_reflected_addr 为 Server Reflexive Address 候选者,至此服务器反射候选者就收集完成了。

void UDPPort::OnStunBindingRequestSucceeded(
    int rtt_ms,
    const rtc::SocketAddress& stun_server_addr,
    const rtc::SocketAddress& stun_reflected_addr) {
  RTC_DCHECK(stats_.stun_binding_responses_received <
             stats_.stun_binding_requests_sent);
  stats_.stun_binding_responses_received++;
  stats_.stun_binding_rtt_ms_total += rtt_ms;
  stats_.stun_binding_rtt_ms_squared_total += rtt_ms * rtt_ms;
  if (bind_request_succeeded_servers_.find(stun_server_addr) !=
      bind_request_succeeded_servers_.end()) {
    return;
  }
  bind_request_succeeded_servers_.insert(stun_server_addr);
  // If socket is shared and `stun_reflected_addr` is equal to local socket
  // address, or if the same address has been added by another STUN server,
  // then discarding the stun address.
  // For STUN, related address is the local socket address.
  if ((!SharedSocket() || stun_reflected_addr != socket_->GetLocalAddress()) &&
      !HasCandidateWithAddress(stun_reflected_addr)) {
    rtc::SocketAddress related_address = socket_->GetLocalAddress();
    // If we can't stamp the related address correctly, empty it to avoid leak.
    if (!MaybeSetDefaultLocalAddress(&related_address)) {
      related_address =
          rtc::EmptySocketAddressWithFamily(related_address.family());
    }

    rtc::StringBuilder url;
    url << "stun:" << stun_server_addr.hostname() << ":"
        << stun_server_addr.port();
    AddAddress(stun_reflected_addr, socket_->GetLocalAddress(), related_address,
               UDP_PROTOCOL_NAME, "", "", STUN_PORT_TYPE,
               ICE_TYPE_PREFERENCE_SRFLX, 0, url.str(), false);
  }
  MaybeSetPortCompleteOrError();
}

收集Reply候选地址

AllocationSequence::OnMessage
              ↓
AllocationSequence::CreateRelayPorts
              ↓
BasicPortAllocatorSession::AddAllocatedPort
              ↓
TurnPort::PrepareAddress
              ↓
TurnPort::SendRequest
              ↓ message
StunRequest::OnMessage
              ↓ 发送请求、接收响应
StunRequestManager::CheckResponse
              ↓
TurnAllocateRequest::OnErrorResponse
              ↓
TurnAllocateRequest::OnAuthChallenge
              ↓
TurnPort::SendRequest
              ↓ 发送请求、接收响应
StunRequestManager::CheckResponse
              ↓
TurnAllocateRequest::OnResponse
              ↓
TurnPort::OnAllocateSuccess
              ↓
        Port::AddAddress
              ↓ sig slot (SignalCandidateReady)
BasicPortAllocatorSession::OnCandidateReady

OnCandidateReady(本地就绪)

OnCandidateReady 里会调用两个重要的函数(通过 sig slot):P2PTransportChannel::OnPortReady 和 P2PTransportChannel::OnCandidatesReady。

在 OnPortReady 里,P2PTransportChannel 会把 port 存入 ports_ 数组,供后续收到 remote candidate 后建立 Connection 用。此外,这里也会立即尝试用这个 port 和每个远端 candidate 建立 Connection。

在 OnCandidatesReady 里,P2PTransportChannel 会把 candidate 一路回调给 APP 层:

P2PTransportChannel::OnCandidatesReady
				↓
JsepTransportController::OnTransportCandidateGathered_n
				↓
PeerConnection::OnTransportControllerCandidatesGathered
				↓
PeerConnection::OnIceCandidate
				↓
PeerConnectionObserver::OnIceCandidate

这里我们看到,port 和 candidate 都送到了 P2PTransportChannel 这里,因为它就是对 ICE 逻辑的封装,接下来我们很快会看到,remote candidate 也是交给了 P2PTransportChannel。

AddIceCandidate(远端就绪)

收到远端的 candidate 后,我们调用 PeerConnection::AddIceCandidate 接口进行设置,其内部调用栈为:

PeerConnection::AddIceCandidate
              	↓
JsepTransportController::AddRemoteCandidates
              	↓
JsepTransport::AddRemoteCandidates
              	↓
P2PTransportChannel::AddRemoteCandidate
              	↓
            ..........

CreateConnections 会遍历本地所有的 port(在 OnCandidateReady 中保存),尝试与这个远端 candicate 建立连接。本文中我们只分析了 UDP 和 TURN 两种 port,所以会调用到 UDPPort::CreateConnection 和 TurnPort::CreateConnection 创建 Connection。

创建了 Connection 之后,怎么做连通性检查呢?这就是 ICE 协议定义的内容了。

连通性检查

WebRTC 有三处会触发连通性检查:

  • 收集到本地 Candidate,触发 OnCandidateReady
  • 接收到 Remote Candidate,触发 PeerConnection::AddIceCandidate
  • 接收到对端 Stun ping request

这三种情况下都会触发 P2PTransportChannel::SortConnectionsAndUpdateState 进行连通队列排序,这个方法里主要会做三件事:

// 连接排序与其状态更新
void P2PTransportChannel::SortConnectionsAndUpdateState(
  IceControllerEvent reason_to_sort) {
  RTC_DCHECK_RUN_ON(network_thread_);
    
  // 确保连接状态是最新的,因为这会影响它们的排序方式
  UpdateConnectionStates();
    
  // 在此之后的任何更改都需要重新排序
  sort_dirty_ = false;

  // 排序,如果可以选择新的 connection
  MaybeSwitchSelectedConnection(
    reason_to_sort, ice_controller_->SortAndSwitchConnection(reason_to_sort));

  // 修剪
  if (ice_role_ == ICEROLE_CONTROLLING || 
    (selected_connection_ && selected_connection_->nominated())) {
      PruneConnections();
  }

  // 检查所有连接是否超时
  bool all_connections_timedout = true;
  for (const Connection* conn : connections()) {
    if (conn->write_state() != Connection::STATE_WRITE_TIMEOUT) {
      all_connections_timedout = false;
      break;
    }
  }
  // 现在用我们目前掌握的信息更新通道的可写状态
  if (all_connections_timedout) {
    HandleAllTimedOut();
  }
  // 更新通道状态
  UpdateState();

  // PING测试连通性
  MaybeStartPinging();
}

在 OnCandidateReady 里我们会用刚分配好的 Port 与每个 remote candidate 建立 Connection,此外,如果收到了对方的 STUN ping request,那就会立即创建一个 Connection,再加上添加 remote candidate 的情况,这三种情况下,创建完 Connection 之后,P2PTransportChannel 都会立即执行 SortConnectionsAndUpdateState 函数,其中首先会对 Connection 进行排序,此外也会尝试开始 ping Connection。

STUN ping request 其实就是 STUN binding request,所以它的发送、response 的接收,前面都已经分析过了,这里只展示不同的部分:

P2PTransportChannel::AddRemoteCandidate
				↓
P2PTransportChannel::CreateConnections
				↓
P2PTransportChannel::SortConnectionsAndUpdateState
				↓
P2PTransportChannel::MaybeStartPinging
				↓
P2PTransportChannel::PingConnection
				↓
StunRequestManager::SendDelayed
				↓ 发送请求、接收响应
Connection::OnConnectionRequestResponse
				↓
Connection::set_write_state
				↓ sig slot (SignalStateChange)
P2PTransportChannel::OnConnectionStateChange
				↓

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

毕加索解锁

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值