TCP打洞
在处于NAT之后的两台主机之间建立p2p TCP连接比建立相应的UDP要稍微复杂,但在协议层次,TCP打洞非常类似与UDP打洞。然而TCP协议本身比较复杂,因此支持的NAT比较少。然而,在NAT支持TCP打洞的情况下,TCP打洞像UPD打洞那样快并且可靠。穿透“行为良好”的NAT的TCP p2p连接事实上比UDP连接更健壮,因为,TCP协议的状态机给路径上的NAT提供了一种决定特定TCP连接确切生存周期的标准方式,而维持UDP连接的时间是不确定的。
套接字和TCP端口重用
对于想实现TCP打洞的应用程序来说,实际面临主要的挑战不是协议问题,而是API的问题。因为标准的Berkeley sockets API是围绕C/S编程而设计的。这个API通过connect()允许一个TCP流套接字初始化一个向外的连接,通过listen()和 accept()监听一个外入的连接,一个套接字不能既用来监听又用来初始化向外的连接。更进一步讲, TCP套接字通常与本地主机上的TCP端口一一对应:一个套接字绑定到本地主机机上的某个端口后,另一个套接字就不能再绑定到该端口。
然而TCP打洞要成功,需要一个本地的TCP端口既可以监听外入的连接,同时又可以发起多个向外的连接。幸运的是,所有主流的操作系统都支持一个特殊的socket选项,通常叫做SO_REUSEADDR,它运行应用程序绑定多个设置了该选项的套接字到同一端口。BSD系统引入了SO_REUSEPORT选项来控制端口重用,从而把端口重用和地址重用相分离。在这样的系统中,两个选项都需要被设置。
打开对等TCP流
假定Client A想要同Client B建立一个TCP连接。我们假设Client A和 Client B都与一个双方都知道的集结服务器S保持着一个活动的TCP连接。这个服务器记录了每一个注册客户端的公有和私有地址,这类似于UDP打洞的情况。在协议层,TCP打洞几乎和UDP一样:
- Client A利用与S的连接请求S帮助它连接到B
- S把B的公有和私有地址告诉A,同时把A的公有和私有地址告诉B。
- A和B都通过相同的本地TCP端口向S返回给它们的地址(公有和私有地址都发)发起一个向外的连接尝试,同时在它们各自的本地端口上监听外入的连接。
- A和B等待向外的连接尝试成功,或者一个外入的连接出现。如果由于”connection reset”或”host unreachable”这样的网络错误引起一个向外的连接尝试失败,主机就简单的延迟一段时间(比如1秒)之后再尝试连接,直到出现一个应用程序定义的最大超时。
- 当一个TCP连接建立之后,主机通过验证彼此以确定它们连接到了目的主机。如果验证失败,客户端就关闭连接等待另一个连接。一旦找到一个验证成功的TCP流连接,这一过程就终止。
不像UDP,这里每个客户端只需要维持一个与S的连接,而不管同时有多少个对端。TCP方案中,每一个客户端应用程序必须管理多个绑定到同一个本地端口的sockets。如下图所示。
每个客户端需要一个流套接字来与S连接,一个套接字来监听来自对端的外入连接,至少两个另外的流套接字用来发起到对端公有地址和私有地址的向外的TCP连接。如下图所示,考虑到通常的情况都是,A和B位于不同的NAT之后,A和B发起的到对端私有地址的连接尝
试可能失败或连接到错误的主机。类似于UDP的情况,TCP应用程序验证它们的端到端会话也是很重要的,因为可能发生这样的情况:连接到一个本地网络中与想要连接的远程主机具有同一个私有地址的主机上。
这些客户端发起的到对端公网地址的连接尝试,促使它们各自的NAT开启一个新的“洞”,这个“洞”允许A和B可以直接进行TCP通信。如果它们之间的NAT是“行为良好”,就会自动在它们直接形成一条p2p TCP流。如果A发到B的第一个SYN包在B发到A的第一个SYN包到底B的NAT之前到达该NAT,那么B的NAT会把A的SYN包当做未授权的外入连接尝试而把该SYN包丢弃。随后B的第一个SYN包应该能顺利通过,因为A的NAT把它当做A的第一个SYN包已经发起的向外连接的一部分。
应用程序观察到的行为
客户端应用程序在它们的sockets上观察到的行为依赖于时机和TCP实现。假定A外发到B的公共端的第一个SYN包被B的NAT丢弃,但是随后的B发送到A的公共端的第一个SYN包在A重新发送SYN包之前穿过网络到达了A,依赖于涉及到的操作系统,将发生下述行为之一:
- A的TCP实现通知会话终端,这个外入的SYN匹配A起先发起的向外会话。因此A的协议栈就把这个新会话与该socket联系在一起,A的本地应用程序使用connect()连接B的公共端。应用程序的异步调用connect()成功,监听socket什么也没有发生。
然而收到的SYN包没有包含先前A外发的SYN包的ACK,A的TCP回应B的公共端一个SYN-ACK包,其中SYN部分只是初始外发SYN的重复,即使用同样的SYN序列号。一旦B的TCP收到A的SYN-ACK,它用它自己的ACK回应A的SYN,之后两端之间的TCP会话就建立起来了。
- 作为另一种选择,与上一种情况不同,A的TCP实现可能通知应用程序,在那个端口上有一个活动监听套接字正在等待连接尝试。因为B的SYN包看起来像一个外入的连接尝试,所以A的TCP创建一个新的流套接字并把它和这个新的TCP会话结合在一起,并通过应用程序的在它的监听端口的下一次accept调用返回给应用程序。A的TCP随后回应B一个上述的SYN-ACK,随后的TCP连接创建处理就和通常的C/S一样。
因为先前A到B的向外connect()尝试所使用的源和目的地址结合正在被另一个socket使用,即刚刚通过accept()返回给应用程序的socket,所以A异步调用的connect()在某个时间肯定会失败,通常是返回一个“address in use”错误。然而已经有一个可以工作的p2p 流socket用于与B通信,所以可以简单的忽略该失败。
上述的第一行为通常出现在BSD系统中,第二种行为通常出现在Linux和Windows系统中。
TCP同时开放
假定我们解决了打洞过程中的各种各样连接尝试的时机问题,以便来自两个客户端的外发SYN包穿过了它们各自的本地NAT,在到达远程对端的NAT之前在每个NAT上打开了向外的TCP会话。在这种“幸运”的情况下,NAT没有阻挡这两个初始SYN包,这两个SYN包在两个客户端各自的NAT之间的线路上交叉通过。在这种情况下,客户端观察到了被称为同步TCP开放的事件:每一个对端在等待SYN-ACK的时候收到了一个“raw”SYN。每一个对端的TCP回应对方一个SYN-ACK,其中的SYN部分是初始外发SYN的重复,ACK是对各自收到的SYN的确认。
在这种情况下,各自的应用程序观察到的行为也依赖于TCP的实现,如上一部分所述。如果两个客户端都实现了上述的第二种行为,可能是这样:所有由应用程序异步调用的connect()最终都失败了,但是运行在每个客户端的应用程序通过accept()收到了一个新的、可以工作得p2p TCP 流套接字 – 好像TCP流魔术般的在线路上创建了它自身,而终端只是被动的接受这个流。应用程序不关心最终的流是通过connect()还是accept()收到的,这个过程在任何正确实现了标准TCP状态机的TCP实现上产生了一个可以工作的流。