4 关于TCP打洞技术
建立穿越NAT设备的p2p的TCP连接只比UDP复杂一点点,TCP协议的“打洞”从协议层来看是与UDP
的“打洞”过程非常相似的。尽管如此,基于TCP协议的打洞至今为止还没有被很好的理解,这也
造成了对其提供支持的NAT设备不是很多。在NAT设备支持的前提下,基于TCP的“打洞”技术实际上
与基于UDP的“打洞”技术一样快捷、可靠。实际上,只要NAT设备支持的话,基于TCP的p2p技术
的健壮性将比基于UDP的技术的更强一些,因为TCP协议的状态机给出了一种标准的方法来精确的
获取某个TCP session的生命期,而UDP协议则无法做到这一点。
4.1 套接字和TCP端口的重用
实现基于TCP协议的p2p“打洞”过程中,最主要的问题不是来自于TCP协议,而是来自于来自于应用
程序的API接口。这是由于标准的伯克利(Berkeley)套接字的API是围绕着构建客户端/服务器程序
而设计的,API允许TCP流套接字通过调用connect()函数来建立向外的连接,或者通过listen()和
accept函数接受来自外部的连接,但是,API不提供类似UDP那样的,同一个端口既可以向外连接,
又能够接受来自外部的连接。而且更糟的是,TCP的套接字通常仅允许建立1对1的响应,即应用程
序在将一个套接字绑定到本地的一个端口以后,任何试图将第二个套接字绑定到该端口的操作都会
失败。
为了让TCP“打洞”能够顺利工作,我们需要使用一个本地的TCP端口来监听来自外部的TCP连接,同时
建立多个向外的TCP连接。幸运的是,所有的主流操作系统都能够支持特殊的TCP套接字参数,通常
叫做“SO_REUSEADDR”,该参数允许应用程序将多个套接字绑定到本地的一个endpoint(只要所有要
绑定的套接字都设置了SO_REUSEADDR参数即可)。BSD系统引入了SO_REUSEPORT参数,该参数用于区分
端口重用还是地址重用,在这样的系统里面,上述所有的参数必须都设置才行。
4.2 打开p2p的TCP流
假定客户端A希望建立与B的TCP连接。我们像通常一样假定A和B已经与公网上的已知服务器S建立了TCP
连接。服务器记录下来每个联入的客户端的公网和内网的endpoints,如同为UDP服务的时候一样。
从协议层来看,TCP“打洞”与UDP“打洞”是几乎完全相同的过程。
1、客户端A使用其与服务器S的连接向服务器发送请求,要求服务器S协助其连接客户端B。
2、S将B的公网和内网的TCP endpoint返回给A,同时,S将A的公网和内网的endpoint发送给B。
3、客户端A和B使用连接S的端口异步地发起向对方的公网、内网endpoint的TCP连接,同时监听
各自的本地TCP端口是否有外部的连接联入。
4、A和B开始等待向外的连接是否成功,检查是否有新连接联入。如果向外的连接由于某种网络
错误而失败,如:“连接被重置”或者“节点无法访问”,客户端只需要延迟一小段时间(例如
延迟一秒钟),然后重新发起连接即可,延迟的时间和重复连接的次数可以由应用程序编写者
来确定。
5、TCP连接建立起来以后,客户端之间应该开始鉴权操作,确保目前联入的连接就是所希望的
连接。如果鉴权失败,客户端将关闭连接,并且继续等待新的连接联入。客户端通常采用
“先入为主”的策略,只接受第一个通过鉴权操作的客户端,然后将进入p2p通信过程不再继续
等待是否有新的连接联入。
500)this.width=500;" border=0>
(图 7)
与UDP不同的是,使用UDP协议的每个客户端只需要一个套接字即可完成与服务器S通信,
并同时与多个p2p客户端通信的任务,而TCP客户端必须处理多个套接字绑定到同一个本地
TCP端口的问题,如图7所示。
现在来看更加实际的一种情景,A与B分别位于不同的NAT设备后面,如图5所示,并且假定图中
的端口号是TCP协议的端口号,而不是UDP的端口号。图中向外的连接代表A和B向对方的内网
endpoint发起的连接,这些连接或许会失败或者无法连接到对方。如同使用UDP协议进行“打洞”
操作遇到的问题一样,TCP的“打洞”操作也会遇到内网的IP与“伪”公网IP重复造成连接失败或者
错误连接之类的问题。
客户端向彼此公网endpoint发起连接的操作,会使得各自的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连接。
4.3 从应用程序的角度来看TCP“打洞”
从应用程序的角度来看,在进行TCP“打洞”的时候都发生了什么呢?假定A首先向B发出SYN包,
该包发往B的公网endpoint,并且被B的NAT设备丢弃,但是B发往A的公网endpoint的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的公网endpoint,
并且将使用先前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的公网endpoint的连接会以失败告终。尽管A向B的连接失败,A仍然得到了B发起的向A的连接,
等效于A与B之间已经联通,不管中间过程如何,A与B已经连接起来了,结果是A和B的基于TCP协议
的p2p连接已经建立起来了。
第一种结果适用于基于BSD的操作系统对于TCP的实现,而第二种结果更加普遍一些,多数linux和
windows系统都会按照第二种结果来处理。