P2P技术与NAT穿越
P2P
即点对点通信,或称为对等联网,与传统的服务器客户端模式有着明显的区别,在即时通讯方案中应用广泛(比如IM应用中的实时音视频通信、实时文件传输甚至文字聊天等)
NAT
技术和P2P
技术作为经典的两项网络技术,在现在的网络上有着广泛的应用,P2P
主机位于NAT
网关后面的情况屡见不鲜。NAT
技术虽然在一定程度上解决了IPv4
地址短缺的问题,在构建防火墙、保证网络安全方面都发挥了一定的作用,却破坏了端到端的网络通信。NAT
阻碍主机进行P2P
通信的主要原因是NAT
不允许外网主机主动访问内网主机,但是P2P
技术却要求通信双方都能主动发起访问,所以要在NAT
网络环境中进行有效的P2P
通信,就必须采用新的解决方案
P2P
作为一项实用的技术,有很大的优化空间,并且相对于网络设备,基于P2P
的应用程序在实现上更为灵活。所以为了兼容NAT
,基于P2P
的应用程序在开发的时候大多会根据自身特点加入一些穿越NAT
的功能以解决上述问题。以下着重介绍几种常见的P2P
穿越NAT
方案
反向链接
一种特殊的
P2P
场景(通信双方中只有一方位于NAT
设备之后)
此种情况是所有P2P
场景中最简单的,它使用一种被称为反向链接技术
来解决这个问题。大致的原理如下所述
客户端A
位于NAT
之后,它通过TCP
端口1234
连接到服务器的TCP
端口1235
上,NAT
设备为这个连接重新分配了TCP
端口62000
客户端B
也通过TCP
端口1234
连接到服务器端口1235
上。A
和B
从服务器处获知的对方的外网地址二元组138.76.29.7:1234
和155.99.25.11:62000
,它们在各自的本地端口上进行侦听
由于B
拥有外网IP地址,所以A
要发起与B
的通信,可以直接通过TCP
连接到B
但如果B
尝试通过TCP
连接到A
进行P2P
通信,则会失败,原因是A
位于NAT
设备后,虽然B
发出的TCP SYN
请求能够到达NAT
设备的端口62000
,但NAT
设备会拒绝这个连接请求
要想与A
通信,B
不是直接向A
发起连接,而是通过服务器给A
转发一个连接请求,反过来请求A
连接到B
(即进行反向链接),A
在收到从服务器转发过来的请求以后,会主动向B
发起一个TCP
的连接请求,这样在NAT
设备上就会建立起关于这个连接的相关表项,使A
和B
之间能够正常通信,从而建立起它们之间的TCP
连接
客户端A——>本地IP:10.0.0.1,本地端口:1234,外网IP:155.99.25.11,外网端口:62000
客户端B——>外网IP:138.76.29.7,外网端口:1234
基于UDP协议的P2P打洞技术
UDP
打洞技术是通过中间服务器的协助在各自的NAT
网关上建立相关的表项,使P2P
连接的双方发送的报文能够直接穿透对方的NAT
网关,从而实现P2P
客户端互连。
如果两台位于NAT
设备后面的P2P
客户端希望在自己的NAT
网关上打个洞,那么他们需要一个协助者——集中服务器
,并且还需要一种用于打洞的Session
建立机制
什么是集中服务器?
集中服务器
本质上是一台被设置在公网上的服务器,建立P2P
的双方都可以直接访问到这台服务器。位于NAT
网关后面的客户端A
和B
都可以与一台已知的集中服务器
建立连接,并通过这台集中服务器
了解对方的信息并中转各自的信息。
同时集中服务器
的另一个重要作用在于判断某个客户端是否在NAT
网关之后
具体的方法是:一个客户端在集中服务器
上登陆的时候,服务器记录下该客户端的两对地址二元组信息IP地址:UDP端口
,一对是该客户端与集中服务器
进行通信的自身的IP地址和端口号,另一对是集中服务器
记录下的由服务器“观察”到的该客户端实际与自己通信所使用的IP地址和端口号。
我们可以把前一对地址二元组看作是客户端的内网IP地址和端口号,把后一对地址二元组看作是客户端的内网IP地址和端口号经过NAT
转换后的外网IP地址和端口号。集中服务器
可以从客户端的登陆消息中得到该客户端的内网相关信息,还可以通过登陆消息的IP头和UDP头得到该客户端的外网相关信息。如果该客户端不是位于NAT
设备后面,那么采用上述方法得到的两对地址二元组信息是完全相同的
P2P的Session建立原理
假定客户端A
要发起对客户端B
的直接连接,具体的“打洞”过程如下:
A
最初不知道如何向客户端B
发起连接,于是A
向集中服务器发送消息,请求集中服务器帮助建立与客户端B
的UDP
连接集中服务器
将含有B
的外网和内网的地址二元组发给A
,同时,集中服务器将包含有A
的外网和内网的地址二元组信息的消息也发给B
。这样一来,A
与B
就都知道对方外网和内网的地址二元组信息了- 当
A
收到由集中服务器
发来的包含B
的外网和内网的地址二元组信息后,A
开始向B
的地址二元组发送UDP
数据包,并且A
会自动锁定第一个给出响应的B的地址二元组。
同理,当B
收到由集中服务器
发来的A
的外网和内网地址二元组信息后,也会开始向A
的外网和内网的地址二元组发送UDP
数据包,并且自动锁定第一个得到A
回应的地址二元组。由于A
与B
互相向对方发送UDP
数据包的操作是异步的,所以A
和B
发送数据包的时间先后并没有时序要求
具体情境分析
- 第一种是最简单的一种情景,两个客户端都位于同一个
NAT
设备后面,即位于同一内网中 - 第二种是最普遍的一种情景,两个客户端分别位于不同的
NAT
设备后面,分属不同的内网 - 第三种是客户端位于两层
NAT
设备之后,通常最上层的NAT
是由网络提供商提供的,第二层NAT
是家用的NAT
路由器之类的设备提供的
典型P2P情景1:两客户端位于同一NAT设备后面
客户端A
和B
分别与集中服务器
建立UDP
连接,经过NAT
转换后,A
的公网端口被映射为62000
,B
的公网端口映射为62005
当A
向集中服务器
发出消息请求与B
进行连接,集中服务器
将B
的外网地址二元组以及内网地址二元组发给A
,同时把A
的外网以及内网的地址二元组信息发给B
A
和B
发往对方公网地址二元组信息的UDP
数据包不一定会被对方收到,这取决于当前的NAT
设备是否支持不同端口之间的UDP
数据包能否到达(即Hairpin
转换特性),无论如何A
与B
发往对方内网的地址二元组信息的UDP
数据包是一定可以到达的,内网数据包不需要路由,且速度更快。A
与B
推荐采用内网的地址二元组信息进行常规的P2P
通信
假定NAT
设备支持Hairpin
转换,P2P
双方也应忽略与内网地址二元组的连接,如果A
和B
采用外网的地址二元组做为P2P
通信的连接,这势必会造成数据包无谓地经过NAT
设备,这是一种对资源的浪费。就目前的网络情况而言,应用程序在“打洞”的时候,最好还是把外网和内网的地址二元组都尝试一下。如果都能成功,优先以内网地址进行连接
- 什么是
Hairpin
技术?
Hairpin
技术又被称为Hairpin NAT
、Loopback NAT
或Hairpin Translation
。Hairpin
技术需要NAT
网关支持,它能够让两台位于同一台NAT
网关后面的主机,通过对方的公网地址和端口相互访问,NAT
网关会根据一系列规则,将对内部主机发往其NAT
公网IP
地址的报文进行转换,并从私网接口发送给目标主机。目前有很多NAT
设备不支持该技术,这种情况下,NAT
网关在一些特定场合下将会阻断P2P
穿越NAT
的行为,打洞的尝试是无法成功的。好在现在已经有越来越多的NAT
设备商开始加入到对该转换的支持中来
典型P2P情景2:两客户端位于不同的NAT设备后面
客户端A
与B
经由各自的NAT
设备与集中服务器
建立UDP
连接,A
与B
的本地端口号均为4321
,集中服务器
的公网端口号为1234
。在向外的会话中,A
的外网IP被映射为155.99.25.11
,外网端口为62000
;B
的外网IP被映射为138.76.29.7
,外网端口为31000
客户端A——>本地:10.0.0.1:4321,外网:155.99.25.11:62000
客户端B——>本地:10.1.1.3:4321,外网:138.76.29.7:31000
在A
向服务器
发送的登陆消息中,包含有A
的内网地址二元组信息,即10.0.0.1:4321
;服务器会记录下A
的内网地址二元组信息,同时会把自己观察到的A
的外网地址二元组信息记录下来。
同理,服务器也会记录下B
的内网地址二元组信息和由服务器
观察到的客户端B
的外网地址二元组信息。无论A
与B
二者中的任何一方向服务器发送P2P
连接请求,服务器都会将其记录下来的上述的外网和内网地址二元组发送给A
或B
A
和B
分属不同的内网,它们的内网地址在外网中是没有路由的,所以发往各自内网地址的UDP
数据包会发送到错误的主机或者根本不存在的主机上。
当A
的第一个消息发往B
的外网地址,该消息途经A
的NAT
设备,并在该设备上生成一个会话表项,该会话的源地址二元组信息是10.0.0.1:4321
,和A
与服务器
建立连接的时候NAT
生成的源地址二元组信息一样,但它的目的地址是B
的外网地址。
在A
的NAT
设备支持保留A
的内网地址二元组信息的情况下,所有来自A
的源地址二元组信息为10.0.0.1:4321
的数据包都沿用A
与集中服务器
事先建立起来的会话,这些数据包的外网地址二元组信息均被映射为155.99.25.11:62000
A
向B
的外网地址发送消息的过程就是“打洞”的过程,从A
的内网的角度来看应为从10.0.0.1:4321
发往138.76.29.7:31000
,从A
在其NAT
设备上建立的会话来看,是从155.99.25.11:62000
发到138.76.29.7:31000
。
如果A
发给B
的外网地址二元组的消息包在B
向A
发送消息包之前到达B
的NAT
设备,B
的NAT
设备会认为A
发过来的消息是未经授权的外网消息,并丢弃该数据包
B
发往A
的消息包也会在B
的NAT
设备上建立一个10.1.1.3:4321
,155.99.25.11:62000
的会话(通常也会沿用B
与集中服务器
连接时建立的会话,只是该会话现在不仅接受由服务器发给B
的消息,还可以接受从A
的NAT
设备155.99.25.11:62000
发来的消息)
一旦A
与B
都向对方的NAT
设备在外网上的地址二元组发送了数据包,就打开了A
与B
之间的“洞”,A
与B
向对方的外网地址发送数据,等效为向对方的客户端直接发送UDP
数据包了。一旦应用程序确认已经可以通过往对方的外网地址发送数据包的方式让数据包到达NAT
后面的目的应用程序,程序会自动停止继续发送用于“打洞”的数据包,转而开始真正的P2P
数据传输
典型P2P情景3:两客户端位于两层(或多层)NAT设备之后
此种情景最典型的部署情况就像这样:最上层的NAT
设备通常是由网络提供商(ISP
)提供,下层NAT
设备是家用路由器
假定NAT C
是由ISP
提供的NAT
设备,NAT C
提供将多个用户节点映射到有限的几个公网IP的服务,NAT A
和NAT B
作为NAT C
的内网节点将把用户的内部网络接入NAT C
的内网,用户的内部网络就可以经由NAT C
访问公网了。
从这种拓扑结构上来看,只有服务器
与NAT C
是真正拥有公网可路由IP地址的设备,而NAT A
和NAT B
所使用的公网IP地址,实际上是由ISP
服务提供商设定的(相对于NAT C
而言)内网地址(我们将这种由ISP
提供的内网地址称之为“伪”公网地址)。
同理,隶属于NAT A
与NAT B
的客户端,它们处于NAT A
,NAT B
的内网,以此类推,客户端可以放到到多层NAT
设备后面。客户端A
和客户端B
发起对服务器S
的连接的时候,就会依次在NAT A
和NAT B
上建立向外的Session
,而NAT A
、NAT B
要联入公网的时候,会在NAT C
上再建立向外的Session
现在假定客户端A
和B
希望通过UDP
“打洞”完成两个客户端的P2P
直连。最优化的路由策略是客户端A
向客户端B
的“伪公网”IP上发送数据包,即ISP
服务提供商指定的内网IP,NAT B
的“伪”公网地址二元组,10.0.1.2:55000
。
由于从服务器的角度只能观察到真正的公网地址,也就是NAT A
,NAT B
在NAT C
建立session
的真正的公网地址155.99.25.11:62000
以及155.99.25.11:62005
,非常不幸的是客户端A
与客户端B
是无法通过服务器知道这些“伪”公网的地址,
而且即使客户端A和B通过某种手段可以得到NAT A
和NAT B
的“伪”公网地址,我们仍然不建议采用上述的“最优化”的打洞方式,这是因为这些地址是由ISP
服务提供商提供的或许会存在与客户端本身所在的内网地址重复的可能性(例如:NAT A
的内网的IP地址域恰好与NAT A
在NAT C
的“伪”公网IP地址域重复,这样就会导致打洞数据包无法发出的问题)
客户端A——>本地:10.0.0.1:4321;伪外网:10.0.1.2:45000(NAT A);外网:155.99.25.11:62000(NAT S)
客户端B——>本地:10.1.1.3:4321;伪外网:10.0.1.2:55000(NAT B);外网:155.99.25.11:62005(NAT S)
因此客户端别无选择,只能使用由公网服务器观察到的A
,B
的公网地址二元组进行“打洞”操作,用于“打洞”的数据包将由NAT C
进行转发
当客户端A
向客户端B
的公网地址二元组155.99.25.11:62005
发送UDP
数据包的时候,NAT A
首先把数据包的源地址二元组由A
的内网地址二元组10.0.0.1:4321
转换为“伪”公网地址二元组10.0.1.1:45000
,
现在数据包到了NAT C
,NAT C
应该可以识别出来该数据包是要发往自身转换过的公网地址二元组,如果NAT C
可以给出“合理”响应的话,NAT C
将把该数据包的源地址二元组改为155.99.25.11:62000
,目的地址二元组改为10.0.1.2:55000
,即NAT B
的“伪”公网地址二元组,NAT B
最后会将收到的数据包发往客户端B
。同样,由B
发往A
的数据包也会经过类似的过程。目前也有很多NAT
设备不支持类似这样的“Hairpin
转换”,但是已经有越来越多的NAT
设备商开始加入对该转换的支持中来
一个需要考虑的现实问题:UDP在空闲状态下的超时
当然,从应用的角度上来说,在完成打洞过程的同时,还有一些技术问题需要解决,如UDP
在空闲状态下的超时问题。由于UDP
转换协议提供的“洞”不是绝对可靠的,多数NAT
设备内部都有一个UDP
转换的空闲状态计时器,
如果在一段时间内没有UDP
数据通信,NAT
设备会关掉由“打洞”过程打出来的“洞”。如果P2P
应用程序希望“洞”的存活时间不受NAT
网关的限制,就最好在穿越NAT
以后设定一个穿越的有效期
对于有效期目前没有标准值,它与NAT
设备内部的配置有关,某些设备上最短的只有20秒左右。在这个有效期内,即使没有P2P
数据包需要传输,应用程序为了维持该“洞”可以正常工作,也必须向对方发送“打洞”心跳包。
这个心跳包是需要双方应用程序都发送的,只有一方发送不会维持另一方的Session
正常工作。除了频繁发送“打洞”心跳包以外,还有一个方法就是在当前的“洞”超时之前,P2P
客户端双方重新“打洞”,丢弃原有的“洞”,这也不失为一个有效的方法
基于TCP协议的P2P打洞技术
建立穿越
NAT
设备的P2P
的TCP
连接只比UDP
复杂一点点,TCP
协议的“打洞”从协议层来看是与UDP
的“打洞”过程非常相似的。
尽管如此,基于TCP
协议的打洞至今为止还没有被很好的理解,这也造成了的对其提供支持的NAT
设备不是很多。在NAT设
备支持的前提下,基于TCP
的“打洞”技术实际上与基于UDP
的“打洞”技术一样快捷、可靠。
实际上,只要NAT设备支持的话,基于TCP
的P2P
技术的健壮性将比基于UDP
技术的更强一些,因为TCP
协议的状态机给出了一种标准的方法来精确的获取某个TCP session
的生命期,而UDP
协议则无法做到这一点
套接字和TCP端口的重用
实现基于TCP
协议的P2P
打洞过程中,最主要的问题不是来自于TCP
协议,而是来自于应用程序的API
接口。
这是由于标准的伯克利(Berkeley
)套接字的API
是围绕着构建客户端/服务器程序而设计的,API
允许TCP
流套接字通过调用connect()
函数来建立向外的连接,或者通过listen()
和accept()
函数接受来自外部的连接,
但是,API
不提供类似UDP
那样的,同一个端口既可以向外连接,又能够接受来自外部的连接。而且更糟的是,TCP
的套接字通常仅允许建立1对1
的响应,即应用程序在将一个套接字绑定到本地的一个端口以后,任何试图将第二个套接字绑定到该端口的操作都会失败
为了让TCP
“打洞”能够顺利工作,我们需要使用一个本地的TCP
端口来监听来自外部的TCP
连接,同时建立多个向外的TCP
连接。
幸运的是,所有的主流操作系统都能够支持特殊的TCP
套接字参数,通常叫做SO_REUSEADDR
,该参数允许应用程序将多个套接字绑定到本地的一个地址二元组(只要所有要绑定的套接字都设置了SO_REUSEADDR
参数即可)。BSD
系统引入了SO_REUSEPORT
参数,该参数用于区分端口重用还是地址重用,在这样的系统里面,上述所有的参数必须都设置才行
打开P2P的TCP流
假定客户端A
希望建立与B
的TCP
连接。我们像通常一样假定A
和B
已经与公网上的已知服务器建立了TCP
连接。服务器记录下来每个接入的客户端的公网和内网的地址二元组,如同为UDP
服务的时候一样
从协议层来看,TCP
“打洞”与UDP
“打洞”是几乎完全相同的过程:
- 客户端
A
使用其与服务器的连接向服务器发送请求,要求服务器协助其连接客户端B
- 服务器将
B
的公网和内网的TCP
地址的二元组信息返回给A
,同时,服务器将A
的公网和内网的地址二元组也发送给B
- 客户端
A
和B
使用连接服务器的端口异步地发起向对方的公网、内网地址二元组的TCP
连接,同时监听各自的本地TCP
端口是否有外部的连接联入 A
和B
开始等待向外的连接是否成功,检查是否有新连接联入。如果向外的连接由于某种网络错误而失败,如:“连接被重置”或者“节点无法访问”,客户端只需要延迟一小段时间(例如延迟一秒钟),然后重新发起连接即可,延迟的时间和重复连接的次数可以由应用程序编写者来确定TCP
连接建立起来以后,客户端之间应该开始鉴权操作,确保目前联入的连接就是所希望的连接。如果鉴权失败,客户端将关闭连接,并且继续等待新的连接联入。客户端通常采用“先入为主”的策略,只接受第一个通过鉴权操作的客户端,然后将进入P2P
通信过程不再继续等待是否有新的连接联入
与UDP
不同的是,因为使用UDP
协议的每个客户端只需要一个套接字即可完成与服务器的通信,而TCP
客户端必须处理多个套接字绑定到同一个本地TCP
端口的问题。
现在来看实际中常见的一种情景,A
与B
分别位于不同的NAT
设备后面,向外的连接代表A
和B
向对方的内网地址二元组发起的连接,这些连接或许会失败或者无法连接到对方。
如同使用UDP
协议进行“打洞”操作遇到的问题一样,TCP
的“打洞”操作也会遇到内网的IP与“伪”公网IP重复造成连接失败或者错误连接之类的问题
客户端向彼此公网地址二元组发起连接的操作,会使得各自的NAT
设备打开新的“洞”允许A
与B
的TCP
数据通过。如果NAT
设备支持TCP
“打洞”操作的话,一个在客户端之间的基于TCP
协议的流通道就会自动建立起来。
如果A
向B
发送的第一个SYN
包发到了B
的NAT
设备,而B
在此前没有向A
发送SYN
包,B
的NAT
设备会丢弃这个包,这会引起A
的“连接失败”或“无法连接”问题。
而此时,由于A
已经向B
发送过SYN
包,B
发往A
的SYN
包将被看作是由A
发往B
的包的回应的一部分,所以B
发往A
的SYN
包会顺利地通过A
的NAT
设备,到达A
,从而建立起A
与B
的P2P
连接
从应用程序的角度来看TCP“打洞”
假定A
首先向B
发出SYN
包,该包发往B
的公网地址二元组,并且被B
的NAT
设备丢弃,但是B
发往A
的公网地址二元组的SYN
包则通过A
的NAT
到达了A
,
然后,会发生以下的两种结果中的一种,具体是哪一种取决于操作系统对TCP
协议的实现:
1、A
的TCP
实现会发现收到的SYN
包就是其发起连接并希望联入的B
的SYN
包,通俗一点来说就是“说曹操,曹操到”的意思,本来A
要去找B
,结果B
自己找上门来了。A
的TCP
协议栈因此会把B
作为A
向B
发起连接connect
的一部分,并认为连接已经成功。程序A
调用的异步connect()
函数将成功返回,A
的listen()
等待从外部联入的函数将没有任何反映。
此时,B
联入A
的操作在A
程序的内部被理解为A
联入B
连接成功,并且A
开始使用这个连接与B
开始P2P
通信
由于收到的SYN
包中不包含A
需要的ACK
数据,因此,A
的TCP
将用SYN-ACK
包回应B
的公网地址二元组,并且将使用先前A
发向B
的SYN
包一样的序列号。
一旦B
的TCP
收到由A
发来的SYN-ACK
包,则把自己的ACK
包发给A
,然后两端建立起TCP
连接。
简单的说,第一种,就是即使A
发往B
的SYN
包被B
的NAT
丢弃了,但是由于B
发往A
的包到达了A
。结果是,A
认为自己连接成功了,B
也认为自己连接成功了,不管是谁成功了,总之连接是已经建立起来了
2、
另外一种结果是,A
的TCP
实现没有像(1)中所讲的那么“智能”,它没有发现现在联入的B
就是自己希望联入的。就好比在机场接人,明明遇到了自己想要接的人却不认识,误认为是其他的人,安排别人给接走了,后来才知道是自己错过了机会,但是无论如何,人已经接到了任务已经完成了。
然后,A
通过常规的listen()
函数和accept()
函数得到与B
的连接,而由A
发起的向B
的公网地址二元组的连接会以失败告终。尽管A
向B
的连接失败,A
仍然得到了B
发起的向A
的连接,等效于A
与B
之间已经联通,不管中间过程如何,A
与B
已经连接起来了,结果是A
和B
的基于TCP
协议的P2P
连接已经建立起来了
第一种结果适用于基于BSD
的操作系统对于TCP
的实现,而第二种结果更加普遍一些,多数Linux
和Windows
系统都会按照第二种结果来处理