WebRTC源码研究(23)WebRTC网络传输基本知识

WebRTC源码研究(23)WebRTC网络传输基本知识

1. NAT

在计算机科学中,NAT穿越(NAT traversal)涉及TCP/IP网络中的一个常见问题,即在处于使用了NAT设备的私有TCP/IP网络中的主机之间创建连接的问题。

会遇到这个问题的通常是那些客户端网络交互应用程序的开发人员,尤其是在对等网络和VoIP领域中。IPsec VPN客户普遍使用NAT-T来达到使ESP包通过NAT的目的。

尽管有许多穿越NAT的技术,但没有一项是完美的,这是因为NAT的行为是非标准化的。这些技术中的大多数都要求有一个公共服务器,而且这个服务器使用的是一个众所周知的、从全球任何地方都能访问得到的IP地址。一些方法仅在创建连接时需要使用这个服务器,而其它的方法则通过这个服务器中继所有的数据——这就引入了带宽开销的问题。

两种常用的NAT穿越技术是:UDP路由验证STUN。除此之外,还有TURNICEALG,以及SBC

1.1 NAT 简介

  • 什么NAT ?

网络地址转换(英语:Network Address Translation,缩写:NAT;又称网络掩蔽、IP掩蔽)在计算机网络中是一种在IP数据包通过路由器或防火墙时重写来源IP地址或目的IP地址的技术。这种技术被普遍使用在有多台主机但只通过一个公有IP地址访问互联网的私有网络中。它是一个方便且得到了广泛应用的技术。当然,NAT也让主机之间的通信变得复杂,导致了通信效率的降低。

我们以传统的邮件作为例子,给大家说明NAT是什么?比如A和B两个人要发信,那B告诉A它在某个楼的某层时,这个时候A 可以给B发消息或者信件吗?这肯定不行,因为它并不知道一个具体的地址是多少?你必须告诉它具体哪个省哪个市哪个区哪个小区哪号楼哪层时,只有这种公共的地址,也就是大家都认识的地址,邮局才能帮你把这封信送达。你说哪号楼哪层这个只有你小区内的人才知道。那这个就和我们的网络是相关的。
那对于网络上的主机,你必须要有个公网的地址,那相互之间才能进行通讯,如果告诉它一个私网(内网)的地址,那它根本 找不到你。那对于我们现实中大部分主机都是在网关之后的,他们之间都是有自己的内网IP地址,并不知道自己的外网是多少。那怎么办呢?实际是有一个映射,在网关上有个NAT功能,它可以使你的内网地址变成外网地址。所以他就是一个资源组,映射之后就将你的内网IP端口映射成外网IP端口。那有了外网的IP端口之后,其他的主机就可以通过内网的IP地址与你通讯了。这就是NAT

什么NAT
首先是NAT,这张图就表现的非常清楚,这就是一个地址映射,左边的分别 是内网的几台机子,通过内网的IP他们之间是可以相互通信的,但是与互联网之间是不通的,如何访问互联网的资源呢,就必须通过NAT,将我们的内网地址转换成外网地址。

由于每台主机都要映射不同的端口,NAT产生的原因有两种:

  • 第一种是IPv4的地址不够,解决IPv4地址不够有两种方案,其中最好的是使用IPv6,IPv6的地址池更多,基本上每台主机都有自己的IP地址。还有一种就是进行NAT穿越,就是内网数万台主机都有自己的IP地址,但是映射到外网只有一个IP地址或者几个IP地址,它通过端口好来区分每一台主机,那就形成了一对几百或者以对几万,大大减少了公网IP地址的使用。

  • 第二个是处于网络安全的考虑。

  • NAT 产生的历史原因:

  1. 主要原因是IP地址不够用:
    1990年代中期,NAT是作为一种解决IPv4地址短缺以避免保留IP地址困难的方案而流行起来的。网络地址转换在很多国家广泛使用。所以NAT就成了家庭和小型办公室网络连接上的路由器的一个标准特征,因为对他们来说,申请独立的IP地址的代价要高于所带来的效益。
  2. 在一个典型的配置中,一个本地网络使用一个专有网络的指定子网(比如192.168.x.x或10.x.x.x)和连在这个网络上的一个路由器。这个路由器占有这个网络地址空间的一个专有地址(比如192.168.0.1),同时它还通过一个或多个因特网服务提供商提供的公有的IP地址(叫做“过载”NAT)连接到因特网上。当信息由本地网络向因特网传递时,源地址从专有地址转换为公用地址。由路由器跟踪每个连接上的基本数据,主要是目的地址和端口。当有回复返回路由器时,它通过输出阶段记录的连接跟踪数据来决定该转发给内部网的哪个主机;如果有多个公用地址可用,当数据包返回时,TCP或UDP客户机的端口号可以用来分解数据包。对于因特网上的通信,路由器本身充当源和目的。
  3. 流行在网络上的一种看法认为,IPv6的广泛采用将使得NAT不再需要,因为NAT只是一个处理IPv4的地址空间不足的方法。
  • NAT 的缺点:
  1. 在一个具有NAT功能的路由器下的主机并没有创建真正的IP地址,并且不能参与一些因特网协议。一些需要初始化从外部网络创建的TCP连接和无状态协议(比如UDP)无法实现。除非NAT路由器管理者预先设置了规则,否则送来的数据包将不能到达正确的目的地址。一些协议有时可以在应用层网关(见下)的辅助下,在参与NAT的主机之间容纳一个NAT的实例,比如FTP。NAT也会使安全协议变的复杂,比如IPsec。
  2. 端对端连接是被IAB委员会(Internet Architecture Board)支持的核心因特网协议之一,因此有些人据此认为NAT是对公用因特网的一个破坏。一些因特网服务提供商(ISP)只向他们的客户提供本地IP地址,所以他们必须通过NAT来访问ISP网络以外的服务,并且这些公司能不能算的上真正的提供了因特网服务的话题也被谈起。
  3. NAT除了带来方便和代价之外,对全双工连接支持的缺少在一些情况下可以看作是一个有好处的特征而不是一个限制。在一定程度上,NAT依赖于本地网络上的一台机器来初始化和路由器另一边的主机的任何连接,它可以阻止外部网络上的主机的恶意活动。这样就可以阻止网络蠕虫病毒来提高本地系统的可靠性,阻挡恶意浏览来提高本地系统的私密性。很多具有NAT功能的防火墙都是使用这种功能来提供核心保护的。另外,它也为UDP的跨局域网的传输提供了方便。

1.2 NAT 网络地址转换

  • 基本网络地址转换(Basic NAT)

这一种也可称作NAT或“静态NAT”,在RFC 2663中提供了信息。它在技术上比较简单,仅支持地址转换,不支持端口映射。Basic NAT要求对每一个当前连接都要对应一个公网IP地址,因此要维护一个公网的地址池。宽带(broadband)路由器通常使用这种方式来允许一台指定的设备去管理所有的外部链接,甚至当路由器本身只有一个可用外部IP时也如此,这台路由器有时也被标记为DMZ主机。由于改变了IP源地址,在重新封装数据包时候必须重新计算校验和,网络层以上的只要涉及到IP地址的头部校验和都要重新计算。

Basic NAT要维护一个无端口号NAT表,结构如下。

无端口号NAT表

  • 网络地址端口转换(NAPT)

这种方式支持端口的映射,并允许多台主机共享一个公网IP地址。
支持端口转换的NAT又可以分为两类:源地址转换和目的地址转换。前一种情形下发起连接的计算机的IP地址将会被重写,使得内网主机发出的数据包能够到达外网主机。后一种情况下被连接计算机的IP地址将被重写,使得外网主机发出的数据包能够到达内网主机。实际上,以上两种方式通常会一起被使用以支持双向通信。

NAPT维护一个带有IP以及端口号的NAT表,结构如下:

带有IP以及端口号的NAT表

1.3 受到NAT影响的应用程序

一些高层协议(比如FTPQuakeSIP)在IP包的有效数据内发送网络层(第三层)信息。比如,主动模式的FTP使用单独的端口分别来控制命令传输和数据传输。当请求一个文件传输时,主机在发送请求的同时也通知对方自己想要在哪个端口接受数据。但是,如果主机是在一个简单的NAT防火墙后发送的请求,那么由于端口的映射,将会使对方接收到的信息无效。

一个应用层网关(Application Layer Gateway,ALG)可以修正这个问题。运行在NAT防火墙设备上的ALG软件模块可以更新任何由地址转换而导致无效的信息。显然,ALG需要明白它所要修正的上层协议,所以每个有这种问题的协议都需要有一个单独的ALG。

但是,除FTP外的大多数传统的客户机-服务器协议不需要发送网络层(第三层)信息,也就不需要ALG。

这个问题的另一个可能的解决方法是使用像STUN这样的技术,但是这只针对创建在UDP上的高层协议,并且需要它内建这种技术。这种技术对于对称NAT也是无效的。还有一种可能的方案是UPnP,但它需要和NAT设备配合起来使用。

1.4 不同类型的NAT

1.4.1 完全圆锥型NAT

  • 完全圆锥型NAT(Full cone NAT),即一对一(one-to-one)NAT
    一旦一个内部地址(iAddr:port)映射到外部地址(eAddr:port),所有发自iAddr:port的包都经由eAddr:port向外发送。任意外部主机都能通过给eAddr:port发包到达iAddr:port(注:port不需要一样)

更通俗一点讲:什么是完全锥型?

就是当内网中的某一台主机经过NAT映射形成一个外网的IP地址和端口,也就是外网所有的主机,只要知道这个地址都可以向这个地址发送数据,基本上就是没有什么限制,这就是安全性比较低的一种类型,也就是谁都能来。
完全圆锥型NAT

1.4.2 受限圆锥型NAT

受限圆锥型NAT(Address-Restricted cone NAT):

  • 内部客户端必须首先发送数据包到对方(IP=X.X.X.X),然后才能接收来自X.X.X.X的数据包。在限制方面,唯一的要求是数据包是来自X.X.X.X。
  • 内部地址(iAddr:port1)映射到外部地址(eAddr:port2),所有发自iAddr:port1的包都经由eAddr:port2向外发送。外部主机(hostAddr:any)能通过给eAddr:port2发包到达iAddr:port1。(注:any指外部主机源端口不受限制,但是目的端口必须是port2。只有外部主机数据包的目的IP 为 内部客户端的所映射的外部ip,且目的端口为port2时数据包才被放行。)

更通俗一点讲:
什么是受限圆锥型NAT?

受限圆锥型NAT 又称为地址限制锥型NAT:
也就是大家觉得完全锥型安全性问题太大了,那就做一些限制,也就是请求出去的时候会记录一下出去的IP地址,那么当你回来的时候只有这台地址的主机才能给我回消息,对于公网上的其他地址来说,我一检查IP地址不对,就给PASS掉。这种就是地址限制型。只要我没向你发送过请求,你直接向我发数据,这是不允许的。
受限圆锥型NAT

1.4.3 端口受限圆锥型NAT

端口受限圆锥型NAT(Port-Restricted cone NAT):
类似受限制锥形NAT(Restricted cone NAT),但是还有端口限制。

  • 一旦一个内部地址(iAddr:port1)映射到外部地址(eAddr:port2),所有发自iAddr:port1的包都经由eAddr:port2向外发送。
  • 在受限圆锥型NAT基础上增加了外部主机源端口必须是固定的。

更通俗一点讲:
什么是端口受限圆锥型NAT?

端口限制型就是在IP地址的限制基础上又增加了对端口的限制,也就是我发送信息的时候会给主机的某个应用的某个端口发送数据,那么你回来的时候 ,只有这个端口回来的数据才接受,对于同一台主机其他端口发送过来的数据都拒绝接收。更何况 是其他的主机 了,所以它的限制更加严格。
端口受限圆锥型NAT

1.4.4 对称NAT

对称NAT(Symmetric NAT):

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

更通俗一点讲:
什么是对称NAT?

当我进行NAT转换的时候,内网的主机出外网的时候形成的映射,并不是形成一个IP地址和端口,它会形成多个,对于访问不同的主机,它会形成不同的IP地址和端口,这就更加严格,想知道IP地址都很困难,比如说你通过这个地址请求A,那么A告诉B通过这个IP地址是可以访问,那B实际上是访问不通的。内网的主机与第三个主机连接的时候,它会新建一个IP地址 和端口,这个就更加复杂,这个对NAT穿越就提出了更高的要求,对于对称型的NAT基本上都是不能穿越的。
对称NAT

1.5 NAT 的用途

  • 负载均衡:目的地址转换NAT可以重定向一些服务器的连接到其他随机选定的服务器。
  • 失效终结:目的地址转换NAT可以用来提供高可靠性的服务。如果一个系统有一台通过路由器访问的关键服务器,一旦路由器检测到该服务器宕机,它可以使用目的地址转换NAT透明的把连接转移到一个备份服务器上。
  • 透明代理:NAT可以把连接到因特网的HTTP连接重定向到一个指定的HTTP代理服务器以缓存数据和过滤请求。一些因特网服务提供商就使用这种技术来减少带宽的使用而不用让他们的客户配置他们的浏览器支持代理连接。

2. STUN

  • 什么是STUN?

有了NAT之后,可以将内网地址转成公网地址,那两个公网之间是不是就可以通讯了呢?那中间还是缺了一步的,他们虽然 都存在这个世界上,但是彼此并不认识,怎么办呢?那必须要有一个第三方的服务做一个介绍,这个就是STUN服务。
STUN服务说白了就是做一个中介,把各自的公网信息进行一下交换,让他们彼此进行认识,这个STUN服务也非常简单。

看看维基百科的解释:

STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。该协议由RFC 5389定义。

2.1 STUN 穿越NAT方案

一旦客户端得知了Internet端的UDP端口,通信就可以开始了。如果NAT完全圆锥型的,那么双方中的任何一方都可以发起通信。如果NAT受限圆锥型端口受限圆锥型,双方必须一起开始传输。

需要注意的是,要使用STUN RFC中描述的技术并不一定需要使用STUN协议——还可以另外设计一个协议并把相同的功能集成到运行该协议的服务器上。

SIP之类的协议是使用UDP分组在Internet上传输音频和/或视频数据的。不幸的是,由于通信的两个末端往往位于NAT之后,因此用传统的方法是无法创建连接的。这也就是STUN发挥作用的地方。

STUN是一个客户机-服务器协议。一个VoIP电话或软件包可能会包括一个STUN客户端。这个客户端会向STUN服务器发送请求,之后,服务器就会向STUN客户端报告NAT路由器的公网IP地址以及NAT为允许传入流量传回内网而开通的端口。

以上的响应同时还使得STUN客户端能够确定正在使用的NAT类型——因为不同的NAT类型处理传入的UDP分组的方式是不同的。四种主要类型中有三种是可以使用的:完全圆锥型NAT受限圆锥型NAT端口受限圆锥型NAT——但大型公司网络中经常采用的对称型NAT(又称为双向NAT)则不能使用。

2.3 STUN 穿越NAT算法

STUN使用下列的算法(取自RFC 3489)来发现NAT gateways以及防火墙(firewalls):

STUN 穿越NAT算法

3. TURN协议

经过介绍认识之后,A和B这两台主机就可以建立连接了,连接一旦建立完毕就可以传输数据,那光有STUN服务他们之间是不是就一定能够创建成功这个连接呢?其实不一定,在美国有一项数据表示在进行P2P穿越的时候,有70%是可以穿越成功的,但是实际上在国内来说就很难达到这个70%的成功率,50%可能都到不了。那在现实过程中,我又要实现浏览器之间的传输,那当P2P连接不成功的情况下,如何保证音视频还能互通呢?

这就引入了TURN服务,TURN 就是在云端架设一个服务器,这个服务器就负责之间双方流媒体数据的转发,让他们进入到同一个房间里之后呢,这个TURN就会给房间里的所有人进行转发,那么对 端就能收到了,A 发送信息通过TURN到了B,同样的B发送信息通过TURN发送给A。这样就在P2P连接不成功的情况下 ,它有了一条路线可以进行音视频的传输。这就是TURN服务。

3.1 TURN协议简介

TURN(全名Traversal Using Relay NAT),是一种数据传输协议(data-transfer protocol)。允许在TCPUDP的连在线跨越NAT或防火墙。

TURN是一个client-server协议。TURNNAT穿透方法与STUN类似,都是通过获取应用层中的公有地址达到NAT穿透。但实现TURN client的终端必须在通信开始前与TURN server进行交互,并要求TURN server产生"relay port",也就是relayed-transport-address。这时TURN server会创建peer,即远程端点(remote endpoints),开始进行中继(relay)的动作,TURN client利用relay port将数据发送至peer,再由peer转传到另一方的TURN client

TURNSTUN/RFC5389的一个拓展,主要添加了Relay功能。TURN协议是建立在UDP协议之上的一个应用层协议。如果一台主机处于NAT后面,那么在一定条件下(NAT穿透失败)两台主机无法之间进行通讯。在这种条件下,那么使用中继服务提供通讯是有必要的。TURN协议允许一台主机使用中继服务与对端进行报文传输。TURN协议也是ICE(交互式连接建立)协议的组成部分,也可以单独使用。如果TURN使用于ICE协议中,relay地址会作为一个候选,由ICE在多个候选中进行评估,选取最合适的通讯地址。一般来说中继的优先级都是最低的。

TURN和其他中继协议的不同之处在于,它允许客户端使用同一个中继地址(relay address)与多个不同的peer进行通信。如图下图所示:

TURN映射IP

3.2 TURN协议工作原理

Turn协议的工作原理主要有三个阶段,也称三大机制:

  • 分配(Allocation),
  • 转发(Relay)
  • 信道(Channel)。

3.2.1 分配机制

客户端想要使用中继功能,需要在中继服务器上申请一个中继地址。客户端发送分配请求(Allocate request)到服务器,服务器为用户开启一个relay端口然后返回分配成功响应,并包含了分配的地址。

分配机制

如上图所示,流程大致如下:

  1. 客户端A向STUN Port发送Allocate请求(图中绿色部分)。
  2. STUN服务器接收到客户端A的Allocate请求,服务器一看是Allocate请求,则根据relay端口分配策略为A分配一个端口。
  3. 服务器发送response成功响应。在该response中包含XOR-RELAYED-ADDRESS属性。该属性值就是A的relay端口。
  4. 客户端接收到response后,就知道了自己的relay地址。该relay地址是个公网地址,可以看作是客户端A在公网上的一个代理,任何想要联系A的客户端,只要将数据发送到A的relay地址就可以了。

3.2.2 转发机制

任何想要联系客户端A的人,只要知道客户端A的relay地址就可以了。

clientpeer之间有两种方法通过中继服务器交换数据。第一种是使用relay,第二种使用channel。两种方法都通过某种方式告知服务器哪个peer应该接收数据,以及服务器告知client数据来自哪个peer

Relay Mechanism使用了SendData指令(Indication)。其中Send指令用来把数据从client发送到server,而Data指令用来把数据从server发送到client

Relay Mechanism

如上图所示是B主动给A发消息:“Hello”,A回应“Hi”的过程:

  1. 序号1、2、3、4、5为B的发送请求(蓝色箭头方向);
  2. 序号6、7、8、9、10为A的回应,原路返回(绿色箭头方向)。
  3. 1、2阶段时,发送的是裸的UDP数据。
  4. 第3阶段是:从A的relay端口收到数据,添加STUN头后,最后从STUN Port 发出的过程。
  5. 在4、5过程中,是被STUN协议包装过的“Hello”,称之为Data indication。为了能够让客户端A知道这个包是哪个客户端发来的,所以,STUN 协议对“Hello”进行了重新的包装,最主要的就是添加了一个XOR-PEER-ADDRESS属性。
  6. 6、7阶段为被STUN协议包装过的“Hi”,称之为Send indication。为了能够让A的relay port知道最终发往哪个客户端,因此也为“Hi”添加了STUN头,也是添加了XOR-PEER-ADDRESS属性。
  7. 第8阶段是:从STUN Port 接收到带STUN 头的数据,去掉STUN头,最后从A的relay端口发出的过程。
    h) 9、10是裸的UDP数据。
3.2.2.1 Relay端口消息转发

任何想要联系客户端A的人,只要知道客户端A的relay地址就可以了。

  • A的Relay端口接受其他客户端的消息:
    A的Relay端口接受其他客户端的消息
    如上图所示:因为客户端A位于NAT后,所以其他客户端无法和A建立直接的通信。但是客户端A在STUN服务器上申请了一个端口(上图中:A的relay端口),其他客户端想要和A通信,那么只需要将信息发送到“A的relay端口”,STUN服务器会将从relay端口接收到的信息通过STUN Port发送给A。

  • A的响应消息原路返回:
    A应答其他客户端发来的消息的时候,是通过原路返回的。

A的响应消息原路返回

3.2.3 信道机制

对于一些应用程序,比如VOIP,在Send/Data Indication中多加的36字节格式信息会加重客户端和服务端之间的带宽压力。为改善这种情况,TURN提供了第二种方法来让clientpeer交互数据.该方法使用另一种数据包格式,即ChannelData message,信道数据报文。

ChannelData message不使用STUN头部,而使用一个4字节的头部,包含了一个称之为信道号的值(channel number),每一个使用中的信道号都与一个特定的peer绑定,即作为对等端地址的一个记号。

要将一个信道与对等端绑定,客户端首先发送一个信道绑定请求(ChannelBind Request)到服务器,并且指定一个未绑定的信道号以及对等端的地址信息。

Channel Mechanism

如上图所示,中继服务器将数据封装成channel message发送给peer。其实就是将3.2.2 转发机制 中的4/5/6/7的indication换成channel message
在音视频的传输应用中,使用信道机制会大大减少包头长度,节省带宽占用,提高传输效率。

3.2.4 Refresh请求

STUN服务器给客户端A分配的relay地址都具有一定的有效时长,可能是30秒或者1分钟或者几十分钟。客户端如果需要STUN服务器一直为它开启这个端口,就需要定时的向STUN服务器发送请求,该请求用刷新relay端口的剩余时间。

在标准的TURN(RFC 5766)协议中,客户端A向STUN服务器发送Allocate请求,STUN服务器在响应消息中添加了一个“LifeTime”的属性,该属性表示relay的存活时间。 客户端需要在relay的存活时间内周期性的调用REFRESH请求,服务端接收到REFRESH请求后,刷新剩余时间;当REFRESH请求中的lifetime属性为0时,说明是客户端主动要求关闭relay地址。

3.2.5 STUN端口的保活

由于与STUN服务器通信使用的是UDP,所以为了保持一个长连接,需要客户端周期性的向STUN服务器的STUN Port发送心跳包。
周期性心跳包的目的就是,使得NAT设备对客户端A的反射地址(Server Reflexive Address)一直有效。使得从STUN Port发送的数据能通过A的反射地址到达A。此处不理解的可以查阅“NAT 类型的分类以及NAT的作用”。
此处解释了,因为客户端A没有和relay Port保活,又由于NAT的特性,数据直接通过relay port转发给A时,NAT直接就丢弃了,所以A是收不到的。所以数据必须经过STUN服务器的STUN Port发送。

4. ICE

什么是ICE?

ICE就是将上面介绍的NATTURN等服务打包一起做一个最优的选择,那它首先尝试进行P2PP2P在你的主机上有可能有双网卡或者是多个端口,当其中有一个端口或者某一个网卡不通的时候,它可以换 其他 的,
如果两条都是通 的时候,它选择一条更高效的,也就是说哪个网卡性能更好它会使用哪个。那当P2P不通的时候它又会选择TURN服务中转,TURN也不一定能通,尤其是中国,很有可能被拦掉,那怎么办呢?那有可能选择了多个节点,有可能是在上海一个节点,在日本东京一个节点,当上海的节点不通的时候还可以 选择东京的节点,ICE就是将这些所有的可能性都罗列好,会在这其中找到一条最优的路径,将数据传送过去。

4.1 ICE工作原理

4.1.1 ICE 简介

ICE的全称为Interactive Connectivity Establishment,即交互式连接建立.初学者可能会将其与网络编程的ICE 弄混,其实那是不一样的东西,在网络编程中,如C++的ICE库,都是指Internate Communications Engine, 是一种用于分布式程序设计的网络通信中间件.我们这里说的只是交互式连接建立.

ICE是一个用于在offer/answer模式下的NAT传输协议,主要用于UDP下多媒体会话的建立,其使用了STUN协议以及TURN 协议,同时也能被其他实现了offer/answer模型的的其他程序所使用,比如SIP(Session Initiation Protocol).

使用offer/answer模型(RFC3264)的协议通常很难在NAT之间穿透,因为其目的一般是建立多媒体数据流,而且在报文中还 携带了数据的源IP和端口信息,这在通过NAT时是有问题的.RFC3264还尝试在客户端之间建立直接的通路,因此中间就缺少 了应用层的封装.这样设计是为了减少媒体数据延迟,减少丢包率以及减少程序部署的负担.然而这一切都很难通过NAT而完成. 有很多解决方案可以使得这些协议运行于NAT环境之中,包括应用层网关(ALGs),Classic STUN以及Realm Specific IP+SDP 协同工作等方法.不幸的是,这些技术都是在某些网络拓扑下工作很好,而在另一些环境下表现又很差,因此我们需要一个单一的, 可自由定制的解决方案,以便能在所有环境中都能较好工作.

4.1.2 ICE工作流程

一个典型的ICE工作环境如下,有两个端点L和R,都运行在各自的NAT之后(他们自己也许并不知道),NAT的类型和性质也是未知的. L和R通过交换SDP信息在彼此之间建立多媒体会话,通常交换通过一个SIP服务器完成:
ICE工作流程
ICE的基本思路是,每个终端都有一系列传输地址(包括传输协议,IP地址和端口)的候选,可以用来和其他端点进行通信. 其中可能包括:

  • 直接和网络接口联系的传输地址(host address)
  • 经过NAT转换的传输地址,即反射地址(server reflective address)
  • TURN服务器分配的中继地址(relay address)

虽然潜在要求任意一个L的候选地址都能用来和R的候选地址进行通信.但是实际中发现有许多组合是无法工作的.举例来说, 如果L和R都在NAT之后而且不处于同一内网,他们的直接地址就无法进行通信.ICE的目的就是为了发现哪一对候选地址的 组合可以工作,并且通过系统的方法对所有组合进行测试(用一种精心挑选的顺序).

为了执行ICE,客户端必须要识别出其所有的地址候选,ICE中定义了三种候选类型,有些是从物理地址或者逻辑网络接口继承 而来,其他则是从STUN或者TURN服务器发现的.很自然,一个可用的地址为和本地网络接口直接联系的地址,通常是内网地址, 称为HOST CANDIDATE,如果客户端有多个网络接口,比如既连接了WiFi又插着网线,那么就可能有多个内网地址候选.

其次,客户端通过STUN或者TURN来获得更多的候选传输地址,即SERVER REFLEXIVE CANDIDATESRELAYED CANDIDATES, 如果TURN服务器是标准化的,那么两种地址都可以通过TURN服务器获得.当L获得所有的自己的候选地址之后,会将其 按优先级排序,然后通过signaling通道发送到R.候选地址被存储在SDP offer报文的属性部分.当R接收到offer之后, 就会进行同样的获选地址收集过程,并返回给L.

这一步骤之后,两个对等端都拥有了若干自己和对方的候选地址,并将其配对,组成CANDIDATE PAIRS.为了查看哪对组合 可以工作,每个终端都进行一系列的检查.每个检查都是一次STUN request/response传输,将request从候选地址对的本地 地址发送到远端地址. 连接性检查的基本原则很简单:

  • 以一定的优先级将候选地址对进行排序.
  • 以该优先级顺序发送checks请求
  • 从其他终端接收到checks的确认信息

两端连接性测试,结果是一个4次握手过程:

4次握手过程

值的一提的是,STUN request的发送和接收地址都是接下来进多媒体传输(如RTPRTCP)的地址和端口,所以, 客户端实际上是将STUN协议与RTP/RTCP协议在数据包中进行复用(而不是在端口上复用).

由于STUN Binding request用来进行连接性测试,因此STUN Binding response中会包含终端的实际地址, 如果这个地址和之前学习的所有地址都不匹配,发送方就会生成一个新的candidate,称为PEER REFLEXIVE CANDIDATE, 和其他candidate一样,也要通过ICE的检查测试.

4.1.3 连接性检查(Connectivity Checks)

所有的ICE实现都要求与STUN(RFC5389)兼容,并且废弃Classic STUN(RFC3489).ICE的完整实现既生成checks(作为STUN client), 也接收checks(作为STUN server),而lite实现则只负责接收checks.这里只介绍完整实现情况下的检查过程.

  1. 为中继候选地址生成许可(Permissions).

  2. 从本地候选往远端候选发送Binding Request.

在Binding请求中通常需要包含一些特殊的属性,以在ICE进行连接性检查的时候提供必要信息.

  1. PRIORITY 和 USE-CANDIDATE
    终端必须在其request中包含PRIORITY属性,指明其优先级,优先级由公式计算而得. 如果有需要也可以给出特别指定的候选(即USE-CANDIDATE属性).
  2. ICE-CONTROLLED和ICE-CONTROLLING
    在每次会话中,每个终端都有一个身份,有两种身份,即受控方(controlled role)和主控方(controlling role). 主控方负责选择最终用来通讯的候选地址对,受控方被告知哪个候选地址对用来进行哪次媒体流传输, 并且不生成更新过的offer来提示此次告知.发起ICE处理进程(即生成offer)的一方必须是主控方,而另一方则是受控方. 如果终端是受控方,那么在request中就必须加上ICE-CONTROLLED属性,同样,如果终端是主控方,就需要ICE-CONTROLLING属性.
  3. 生成Credential
    作为连接性检查的Binding Request必须使用STUN的短期身份验证.验证的用户名被格式化为一系列username段 的联结,包含了发送请求的所有对等端的用户名,以冒号隔开;密码就是对等端的密码.
  1. 处理Response.

当收到Binding Response时,终端会将其与Binding Request相联系,通常通过事务ID.随后将会将此事务ID与 候选地址对进行绑定.

  1. 失败响应
    如果STUN传输返回487(Role Conflict)错误响应,终端首先会检查其是否包含了ICE-CONTROLLED或ICE-CONTROLLING 属性.如果有ICE-CONTROLLED,终端必须切换为controlling role;如果请求包含ICE-CONTROLLING属性, 则必须切换为controlled role.切换好之后,终端必须使产生487错误的候选地址对进入检查队列中, 并将此地址对的状态设置为Waiting.
  2. 成功响应,一次连接检查在满足下列所有情况时候就被认为成功:
    STUN传输产生一个Success Response
    response的源IP和端口等于Binding Request的目的IP和端口
    response的目的IP和端口等于Binding Request的源IP和端口

终端收到成功响应之后,先检查其mapped address是否与本地记录的地址对有匹配,如果没有则生成一个新的候选地址. 即对等端的反射地址.如果有匹配,则终端会构造一个可用候选地址对(valid pair).通常很可能地址对不存在于任何 检查列表中,检索检查列表中没有被服务器反射的本地地址,这些地址把它们的本地候选转换成服务器反射地址的基地址, 并把冗余的地址去除掉.

4.2 SDP

ICE信息的描述格式通常采用标准的SDP,其全称为Session Description Protocol,会话描述协议. SDP只是一种信息格式的描述标准,不属于传输协议,但是可以被其他传输协议用来交换必要的信息,如SIPRTSP等.

4.2.1 SDP信息

一个SDP会话描述包含如下部分:

  • 会话名称和会话目的
  • 会话的激活时间
  • 构成会话的媒体(media)
  • 为了接收该媒体所需要的信息(如地址,端口,格式等)

因为在中途参与会话也许会受限制,所以可能会需要一些额外的信息:

  • 会话使用的的带宽信息
  • 会话拥有者的联系信息

一般来说,SDP必须包含充分的信息使得应用程序能够加入会话,并且可以提供任何非参与者使用时需要知道的资源 状况,后者在当SDP同时用于多个会话声明协议时尤其有用.

4.2.2 SDP格式

SDP是基于文本的协议,使用ISO 10646字符集和UTF-8编码.SDP字段名称和属性名称只使用UTF-8的一个子集US-ASCII, 因此不能存在中文.虽然理论上文本字段和属性字段支持全集,但最好还是不要在其中使用中文.

SDP会话描述包含了多行如下类型的文本:

<type>=<value>

其中type是大小写敏感的,其中一些行是必须要有的,有些是可选的,所有元素都必须以固定顺序给出.固定的顺序极大改善了 错误检测,同时使得处理端设计更加简单.如下所示,其中可选的元素标记为* :

会话描述:
     v=  (protocol version)
     o=  (originator and session identifier)
     s=  (session name)
     i=* (session information)
     u=* (URI of description)
     e=* (email address)
     p=* (phone number)
     c=* (connection information -- not required if included in
          all media)
     b=* (zero or more bandwidth information lines)
     One or more time descriptions ("t=" and "r=" lines; see below)
     z=* (time zone adjustments)
     k=* (encryption key)
     a=* (zero or more session attribute lines)
     Zero or more media descriptions

时间信息描述:
     t=  (time the session is active)
     r=* (zero or more repeat times)

多媒体信息描述(如果有的话):
     m=  (media name and transport address)
     i=* (media title)
     c=* (connection information -- optional if included at
          session level)
     b=* (zero or more bandwidth information lines)
     k=* (encryption key)
     a=* (zero or more media attribute lines)

所有元素的type都为小写,并且不提供拓展.但是我们可以用a(attribute)字段来提供额外的信息.一个SDP描述的例子如下:

 v=0
  o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
  s=SDP Seminar
  i=A Seminar on the session description protocol
  u=http://www.example.com/seminars/sdp.pdf
  e=j.doe@example.com (Jane Doe)
  c=IN IP4 224.2.17.12/127
  t=2873397496 2873404696
  a=recvonly
  m=audio 49170 RTP/AVP 0
  m=video 51372 RTP/AVP 99
  a=rtpmap:99 h263-1998/90000

具体字段的type/value描述和格式可以去参考RFC4566.

4.2.3 Offer/Answer模型

SDP用来描述多播主干网络的会话信息,但是并没有具体的交互操作细节是如何实现的,因此RFC3264 定义了一种基于SDPoffer/answer模型.在该模型中,会话参与者的其中一方生成一个SDP报文构成offer, 其中包含了一组offerer希望使用的多媒体流和编解码方法,以及offerer用来接收改数据的IP地址和端口信息. offer传输到会话的另一端(称为answer),由answer生成一个answer,即用来响应对应offerSDP报文. answer中包含不同offer对应的多媒体流,并指明该流是否可以接受.

RFC3264只介绍了交换数据过程,而没有定义传递offer/answer报文的方法,后者在RFC3261/SIP 即会话初始化协议中描述.值得一提的是,offer/answer模型也经常被SIP作为一种基本方法使用. offer/answer模型在SDP报文的基础上进行了一些定义,工作过程不在此描述,需要了解细节的朋友可以参考RFC3261.

5. 打洞

5.1 UDP打洞

通过UDP路由验证实现NAT穿越是一种在处于使用了NAT的私有网络中的Internet主机之间建立双向UDP连接的方法。由于NAT的行为是非标准化的,因此它并不能应用于所有类型的NAT。

其基本思想是这样的:让位于NAT后的两台主机都与处于公共地址空间的、众所周知的第三台服务器相连,然后,一旦NAT设备建立好UDP状态信息就转为直接通信,并寄希望于NAT设备会在分组其实是从另外一个主机传送过来的情况下仍然保持当前状态。

这项技术需要一个圆锥型NAT设备才能够正常工作。对称型NAT不能使用这项技术。

这项技术在P2P软件和VoIP电话领域被广泛采用。它是Skype用以绕过防火墙和NAT设备的技术之一。

相同的技术有时还被用于TCP连接——尽管远没有UDP成功。

5.1.1 UDP打洞算法

假设有两台分别处于各自的私有网络中的主机:A1和A2。N1和N2是两个网络的NAT设备,分别拥有IP地址P1和P2。 S是一个双方共知的、从任何地方都能访问得到的IP地址的公共服务器。

  • 步骤一:A1和A2分别和S建立UDP连接、NAT设备N1和N2创建UDP转换状态并分配临时的外部端口号

  • 步骤二:S检查UDP包,看A1和A2的端口是否是正在被使用的(否则的话N1和N2应该是应用了端口随机分配,这会让路由验证变得更麻烦)

  • 步骤三:如果端口不是随机化的,那么A1和A2各自选择端口X和Y,并告知S。S会让A1发送UDP包到P2:Y,让A2发送UDP包到P1:X。

  • 步骤四:A1和A2通过转换好的IP地址和端口直接联系到对方的NAT设备。

如果用UDP传输文件之类的东西,必须自己在上面再封装一层可靠的传输协议。可以参考reliable-udp这个规范(搜索一下RELIABLE UDP PROTOCOL就有)

UDP打洞了为了在两台局域网的机器建立好UDP连接,这个过程需要一台公网服务器支持,建立好以后就不再需要公网服务器了,过程大致如下:

  1. 双方都通过UDP与服务器通讯后,网关默认就是做了一个外网IP和端口号 与你内网IP与端口号的映射,这个无需设置的,服务器也不需要知道客户的真正内网IP
  2. 用户A先通过服务器知道用户B的外网地址与端口
  3. 用户A向用户B的外网地址与端口发送消息,
  4. 在这一次发送中,用户B的网关会拒收这条消息,因为它的映射中并没有这条规则。
  5. 但是用户A的网关就会增加了一条允许规则,允许接收从B发送过来的消息
  6. 服务器要求用户B发送一个消息到用户A的外网IP与端口号
  7. 用户B发送一条消息,这时用户A就可以接收到B的消息,而且网关B也增加了允许规则
  8. 之后,由于网关A与网关B都增加了允许规则,所以A与B都可以向对方的外网IP和端口号发送消息

5.2 TCP打洞

假设我们有两台处于不同内网的两台机器A和B和一台众所周知外网IP的服务器S,而机器A中运行着通讯的服务端程序B运行着通讯的客户端程序,那么

  1. A连接S,S记录A的外网IP与通讯的端口
  2. B连接S
  3. S将A与此通讯的端口号返回给A
  4. S将A与此连接的IP与端口号返回给B
  5. A在程序中将服务绑定并侦听在从S返回的端口
  6. B使用从S返回的IP与端口连接A

这样A与B就成功连接了,这里需要注意的一点就是两个socket在同一个端口绑定的问题,socket提供了setsockopt函数,其中参数SO_REUSEADDR可以解决这个问题

下面是c语言代码示例

S中的程序 如下:

 #include <stdio.h>
 #include <sys/socket.h>
 #include <sys/types.h>
 #include <stdlib.h>
 #include <string.h>
 #include <errno.h>
 #include <arpa/inet.h>
 #include <netinet/in.h>
 
 typedef struct sockaddr SA;
 typedef struct sockaddr_in SA_IN;
 
 typedef struct{
  struct in_addr ip;
  int port;
 }IP; //记录ip与端口
 
 int main(int argc,char **argv)
 {
  SA_IN server,addr;
  int sockfd;
  IP ip;
  char s;
  socklen_t addrlen=sizeof(SA_IN);
  sockfd=socket(AF_INET,SOCK_STREAM,0);
 
  if(sockfd == -1){
   perror("socket");
   return -1;
  }
 
  bzero(&server,sizeof(SA_IN));
  server.sin_port=htons(8888);
  server.sin_family=AF_INET;
  server.sin_addr.s_addr=INADDR_ANY;
 
  if(bind(sockfd,(SA *)&server,sizeof(SA_IN)) == -1){
   perror("bind");
   return -1;
  }
 
  if(listen(sockfd,20) == -1){
   perror("listen");
   return -1;
  }
 
  while(1){
   int newfd[2];
   newfd[0]=accept(sockfd,(SA *)&addr,&addrlen);
   //接收两个心跳包
   recv(newfd[0],&s,sizeof(char),0);
   memcpy(&ip.ip,&addr.sin_addr,sizeof(struct in_addr));
   ip.port=addr.sin_port;
   printf("%s\t%d OK\n",inet_ntoa(ip.ip),ntohs(ip.port));
   newfd[1]=accept(sockfd,(SA *)&addr,&addrlen);
   printf("%s\t%d OK\n",
   inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));
   send(newfd[0],&ip,sizeof(IP),0);
 
   send(newfd[1],&ip,sizeof(IP),0);
   close(newfd[0]);
   close(newfd[1]);
  }
  return 0;
 }

A中的程序 :

 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/socket.h>
 #include <sys/types.h>
 #include <arpa/inet.h>
 
 #define SER "xxx.xxx.xxx.xxx"
 #define PORT 8888
 
 typedef struct{
  struct in_addr ip;
  int port;
 }IP; //ip与端口
 
 typedef struct sockaddr SA;
 typedef struct sockaddr_in SA_IN;
 
 //回射服务
 void echo_ser(int sockfd){
  char buf[1024];
  while(1){
   bzero(buf,sizeof(buf));
   //接收B发来的数据
   recv(sockfd,buf,sizeof(buf)-1,0);
   printf("%s",buf);
   //向B发送数据
   send(sockfd,buf,strlen(buf),0);
   buf[strlen(buf)-1]='\0';
   if(strcmp(buf,"exit") == 0)
    break;
  }
 }
 
 int main(int argc,char **argv){
  int sockfd,sockfd2;
  SA_IN server,addr;
  IP ip;
  socklen_t addrlen=sizeof(SA_IN);
  char s='a';
  int flags=1;
  sockfd=socket(AF_INET,SOCK_STREAM,0);
  bzero(&server,sizeof(SA_IN));
 
  server.sin_family=AF_INET;
  server.sin_addr.s_addr=inet_addr(SER);
  server.sin_port=htons(PORT);
 
  if(setsockopt(sockfd,SOL_SOCKET,SO_REUSEADDR,&flags,sizeof(int)) == -1) perror("setsockopt sockfd");
  connect(sockfd,(SA *)&server,sizeof(SA_IN));
  send(sockfd,&s,sizeof(char),0);
  recv(sockfd,&ip,sizeof(IP),0);
  close(sockfd);
  sockfd2=socket(AF_INET,SOCK_STREAM,0);

  if(sockfd2 == -1)perror("sockfd2");
  if(setsockopt(sockfd2,SOL_SOCKET,SO_REUSEADDR,&flags,sizeof(int)) == -1)perror("setsockopt sockfd2");
  server.sin_addr.s_addr=INADDR_ANY;
  server.sin_port=ip.port;
  if(bind(sockfd2,(SA *)&server,sizeof(SA_IN)) == -1)perror("bind sockfd");
  if(listen(sockfd2,20) == -1) perror("listen");
  echo_ser(accept(sockfd2,(SA *)&addr,&addrlen));
  close(sockfd2);
  return 0;
 }

B中的程序:

 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/socket.h>
 #include <sys/types.h>
 #include <arpa/inet.h>
 
 #define SER "xxx.xxx.xxx.xxx"
 #define PORT 8888

 typedef struct{
  struct in_addr ip;
  int port;
 }IP; //ip与端口
 
 typedef struct sockaddr SA;
 typedef struct sockaddr_in SA_IN;
 
 void echo_cli(int sockfd){
  char buf[1024];
  while(1){
   bzero(buf,sizeof(buf));
   printf(">");
   fflush(stdout);
   fgets(buf,sizeof(buf)-1,stdin);
   send(sockfd,buf,strlen(buf),0);
   bzero(buf,sizeof(buf));
   recv(sockfd,buf,sizeof(buf)-1,0);
   printf("%s",buf);
   buf[strlen(buf)-1]='\0';
   if(strcmp(buf,"exit") == 0) break;
  }
 }
 
 int main(int argc,char **argv){
 
  int sockfd,sockfd2;
  SA_IN server,addr;
  IP ip;
  socklen_t addrlen=sizeof(SA_IN);
  sockfd=socket(AF_INET,SOCK_STREAM,0);
  bzero(&server,sizeof(SA_IN));
  server.sin_family=AF_INET;
  server.sin_addr.s_addr=inet_addr(SER);
  server.sin_port=htons(PORT);
 
  connect(sockfd,(SA *)&server,sizeof(SA_IN));
  recv(sockfd,&ip,sizeof(IP),0);
  close(sockfd);

  sockfd2=socket(AF_INET,SOCK_STREAM,0);
  server.sin_addr=ip.ip;
  server.sin_port=ip.port;
 
  while(connect(sockfd2,(SA *)&server,sizeof(SA_IN)) == -1)
   perror("connect");
  echo_cli(sockfd2);

  close(sockfd2);
  return 0;
 }

5.3 ICMP打洞

6. WebRTC协议栈

WebRTC协议栈

参考: 维基百科
声网:https://juejin.im/post/6844903875598614536
https://zhuanlan.zhihu.com/p/26857913
https://cloud.tencent.com/developer/article/1470343

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: WebRTC(Web实时通信)是一个开源项目,用于实现浏览器之间的实时通信。它提供了一系列的API,可以在Web浏览器中实现音频、视频和数据的共享。我们可以通过CSDN网站找到WebRTC的源代码。 在CSDN网站上,可以通过搜索引擎或直接访问相关的代码仓库,例如GitHub,来获取WebRTC的源代码。在代码仓库中,我们可以找到所有的源代码文件和相关的文档,以便我们深入了解和研究WebRTC的实现。 WebRTC的源代码主要由C++和JavaScript编写。C++部分包含了底层音视频处理的功能,例如编码和解码、网络传输和流媒体处理等。JavaScript部分则负责在浏览器中调用和使用WebRTC的API,并处理与用户界面的交互。 WebRTC的源代码非常庞大且涉及复杂的技术,包括音视频编解码、网络传输、数据通信等。因此,理解和使用WebRTC的源代码需要具备一定的编程和网络知识。 通过研究WebRTC的源代码,我们可以深入了解实时通信技术的内部机制,并根据实际需求进行定制和优化。同时,我们也可以利用WebRTC的源代码作为学习和参考,开发自己的实时通信应用程序。 总之,通过在CSDN上获取WebRTC的源代码,我们可以深入了解WebRTC的实现,并利用它构建强大的实时通信应用程序。但是,请注意,理解和使用WebRTC的源代码需要一定的学习和实践。 ### 回答2: WebRTC是一个开源项目,用于实现浏览器之间的实时音视频通信。该项目的源码可以在GitHub上找到,并且在CSDN社区也有相关的文章和教程。 在CSDN上搜索"Webrtc源码"可以找到很多相关的资源。这些资源包括了Webrtc的基本概念解释、架构设计、源码解析以及开发应用的示例等内容。通过阅读这些文章,可以了解Webrtc的整体架构、通信流程以及关键模块的工作原理。 在学习Webrtc源码的过程中,建议先理解WebRTC的基本概念和术语,如信令服务器、ICE协议、SDP等。然后,可以重点关注核心模块,如音视频采集、媒体传输、音视频编解码等。此外,还可以深入研究网络传输协议、媒体处理等相关技术。 CSDN上的相关文章和教程能够提供对Webrtc源码解析和开发指导,同时也是讨论和交流的平台。在学习过程中,可以参考这些文章,并结合源码进行实际的操作和实践。 总而言之,Webrtc源码可以通过CSDN找到相关资源,并通过阅读相应的文章和教程对源码进行理解和学习,借助这些资源和社区的支持,我们可以更好地掌握Webrtc的实现原理和开发应用。 ### 回答3: WebRTC(Web实时通信)是一个开放源代码项目,旨在提供实时音视频通信的能力。关于WebRTC源码,可以在CSDN等技术社区找到相关资源。 在CSDN上,可以找到很多关于WebRTC源码的学习资料和教程。首先,可以通过搜索关键词“WebRTC源码”来获取一些源码解析文章,这些文章会帮助我们深入了解WebRTC的实现原理和基本架构。 此外,CSDN上也有一些专门讨论WebRTC的论坛和社区,这些社区中的会员可以分享彼此的学习经验、提供问题解答等。在这些社区中,我们可以找到许多WebRTC开发者和爱好者,他们可以提供有关源码的深入分析和讨论。 在CSDN还可以找到一些WebRTC相关的开源项目,例如一些基于WebRTC的音频、视频通信的实现。这些开源项目往往提供了完整的源代码和文档,可以作为参考和学习的资料。 总之,CSDN是一个很好的资源平台,提供了关于WebRTC源码的丰富信息。通过CSDN,我们可以找到相关的源码解析文章、讨论社区和开源项目,这些资源都能帮助我们更好地理解和学习WebRTC源码
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值