开发 NAT 上的 P2P 协议

作者:Girish Venkatachalam

网络地址转换器 (NAT) 是每位软件工程师都听说过的东西,更不用说网络专业人士了。NAT 已经变得像网络术语中的思科路由器一样无处不在。

从根本上说,NAT 设备允许多台机器使用单个全球唯一的 IP 地址与互联网通信,有效地解决了稀缺的 IPv4 地址空间问题。虽然不是一个长期的解决方案,正如 1994 年最初设想的那样,但无论好坏,即使 IPv6 地址变得普遍,NAT 技术也将继续存在。部分原因是 IPv6 必须与 IPv4 共存,而实现这一目标的方法之一是使用 NAT 技术。

本文与其说是描述 NAT 的工作原理。Geoff Huston 已经就此主题撰写了一篇出色的文章(请参阅在线资源)。它非常全面,尽管互联网上也有大量其他资源可用。

本文讨论了解决 P2P 协议 NAT 问题的可能方案。

NAT 有什么问题?

NAT 破坏互联网的程度超过了它带来的好处。我在这里可能听起来很苛刻,但问问任何点对点应用程序开发人员,尤其是 VoIP 人员,他们会告诉你原因。

例如,您永远无法在 NAT 设备后面进行 Web 托管。至少,没有足够的调整就不行。不仅如此,您无法通过 NAT 设备运行任何服务,例如 FTP 或 rsync 或任何公共服务。这可以通过获得全球唯一的 IP 地址并配置 NAT 设备以绕过来自该特定 IP 的流量来解决。

但是,NAT IP 地址特别棘手的问题是您无法访问 NAT 后面的机器,仅仅是因为您甚至不知道中间存在 NAT。总的来说,NAT 被设计为透明的,并且它仍然如此。即使您知道存在 NAT 设备,NAT 也只会在私有 IP/TCP 或 UDP 端口号与 NAT 的公共 IP/TCP 或 UDP 端口号之间存在映射时,才让流量到达相应的私有 IP。并且,此映射仅在流量从私有 IP 发起到达互联网时创建,反之则不然。

更复杂的是,NAT 只是丢弃所有来自互联网到私有主机的未经请求的流量。尽管这种功能可以说通过默默无闻增加了一定程度的安全性,但至少从互联网的未来角度来看,它产生的问题多于它解决的问题。

至少 50% 最常用的网络应用程序使用点对点技术。常见的例子包括即时消息协议、VoIP 应用程序(如 Skype)和 BitTorrent 下载加速器。事实上,随着时间的推移,点对点流量只会增加,因为互联网除了传统的客户端/服务器范例之外,还有更多东西可以提供。

根据定义,点对点技术是一种网状网络,而不是客户端/服务器模型中的星型网络。在点对点网络中,所有节点同时充当客户端和服务器。这已经导致了编程复杂性,点对点节点还必须以某种方式处理中间有问题的 NAT 设备。

更让 P2P 应用程序开发人员感到困难的是,没有标准化的 NAT 行为。不同的 NAT 设备的行为有所不同。但是,一线希望是,当今存在的大部分 NAT 设备仍然表现得足够明智,至少可以让点对点 UDP 流量通过。

通过 NAT 设备发送 TCP 流量也取得了成功,尽管您可能没有 UDP 那么幸运。在本文中,我们纯粹关注 UDP,因为 TCP NAT 遍历仍然相当棘手。UDP NAT 遍历在所有 NAT 设备上也不是完全可靠的,但是现在情况非常令人鼓舞,并且随着 NAT 供应商意识到需要支持 P2P 协议,情况将继续变得更好。

顺便说一句,语音流量最好由 UDP 处理,因此这很适合我们。现在我们对我们试图解决的问题有了相当好的了解,让我们开始讨论解决方案。

解决方案的剖析

NAT 难题的关键在于,为了使 NAT 网关后面的机器与公共互联网交互,NAT 设备必然必须允许入站流量——即,对来自 NAT 设备后面的请求的回复。换句话说,NAT 设备允许流量通过到达 NAT 设备后面的特定主机,前提是流量确实是对 NAT 设备发送的请求的回复。现在,如上所述,NAT 设备的操作差异很大,它们允许来自其他主机和端口号的回复通过,这取决于它们自己对回复含义的理解。

如果我们理解了这一点,我们的工作就很简单了——我们不需要直接连接到 NAT 后面的主机,而是需要以某种方式模拟目标主机发起与我们的连接,然后我们连接到它,就好像我们正在响应请求一样。换句话说,我们对目标主机的连接请求应该看起来像是对 NAT 设备的回复。

事实证明,使用一种现在广为人知的 UDP 打洞技术,这种技术很容易实现。与名称暗示的相反,这不会留下明显的安全漏洞或任何类似的东西;这仅仅是解决点对点协议 NAT 问题的完全合理且有效的方法。

简而言之,UDP 打洞所做的事情已经解释过了。现在,如果只是这样,生活就太简单了,您就不会阅读这篇文章了。事实证明,前进的道路上有很多障碍,但没有一个过于复杂。

首先是如何让私有主机发起流量,以便我们可以将我们的连接请求伪装成回复发送给它。更糟糕的是,NAT 设备还有一个空闲计时器,通常约为 60 秒,这样一旦请求发起并且在 60 秒内没有回复,它们就会停止等待回复。因此,私有主机发起流量是不够的,而且我们必须快速行动——我们必须在 NAT 设备删除与私有主机的“关联”之前发送“回复”,这会使我们的连接尝试受挫。

现在,回复显然必须来自请求发送到的原始机器。如果我们不在另一个 NAT 设备后面,这很适合我们。因此,如果我们想与私有 IP 通信,我们让私有 IP 向我们发送一个数据包,我们将我们的连接请求作为对其的回复发送。但是,当我们想与私有 IP 通信时,我们如何通知私有 IP 向我们发送数据包呢?

如果两个点对点主机都位于不同的 NAT 设备后面,是否有可能相互通信?幸运的是,这是可能的。

事实证明,NAT 设备在某种程度上是宽容的,并且在解释它们认为是对请求的回复时,它们的宽容程度有所不同。NAT 行为有不同的种类

  1. 全锥型 NAT

  2. 限制锥型 NAT

  3. 端口限制型 NAT

  4. 对称型 NAT

我不会在此处详细介绍这些的细节和定义,因为在其他地方有大量资源解释它们。对称型 NAT 是 P2P 应用程序最强大的敌人。但是,凭借一定的聪明才智,我们可以合理地“猜测”对称型 NAT 的行为并加以处理——好吧,不是所有的对称型 NAT,但许多对称型 NAT 都可以被驯服以允许 P2P 协议。

首先,我们如何告诉私有 IP 我们在特定时刻有兴趣连接到它?

UDP 打洞技术的实现细节

这个问题可以通过加入问题来解决,而不是正面硬碰硬。为了实现跨 NAT 的点对点流量,我们必须稍微修改我们的 P2P 网状模型,使其成为传统星型模型和现代网状模型的混合体。

因此,我们引入了汇合服务器或中介服务器的概念,它监听全球可路由的 IP 地址。几乎所有点对点协议传统上都依赖于某些超级节点,换句话说,在 P2P 中,所有节点都是平等的,但有些节点更平等。在任何 P2P 协议中,一些节点总是充当关键角色。如果您听说过 BitTorrent 跟踪器,您就会明白我的意思。

汇合概念在 P2P 世界中并不新鲜,星型模型在 P2P 中也没有完全被摒弃。

回到我们最初的 NAT 问题,私有 IP 显然可以通过 NAT 设备浏览互联网,因此它们可以通过端口 80 或通过 TCP 上的代理 HTTP 端口进行 HTTP 通信。因此,私有 IP 几乎总是可以打开与全球 IP 地址的 TCP 连接。我们利用这一事实使私有 IP 通过 TCP 连接到中介或汇合服务器。

我们的解决方案依赖于所有 P2P 节点都不断与汇合服务器保持联系,通过持久的 TCP 连接监听全球 IP 地址。请记住,P2P 节点同时是客户端和服务器,因此它们可以发起连接,也可以同时服务连接请求。

正是通过此 TCP 连接,我们通知特定的 P2P 节点另一个节点想要与它通信。然后,目标节点发送一个请求,之后对等方将连接请求作为对该请求的响应发送。

由于 NAT 设备后面的私有机器没有可路由的 IP 地址,因此我们从 NAT 设备外部访问它们的唯一方法是通过 NAT 设备为机器与外部世界通信维护的映射。对于从私有 IP 发起的每个连接,都会在 NAT 设备上分配一个唯一的端口。为了与私有 IP 通信,我们必须将我们的数据包发送到为私有 IP 与外部世界的连接分配的特定端口。现在,我们知道 UDP 世界中没有连接的概念,因此 NAT 假设如果 UDP 请求在约 60 秒内没有收到回复,则认为连接不存在并关闭。

因此,现在我们有另一个问题——确定在 NAT 的公共接口上为私有 IP 连接分配的端口。这可以通过检查到达任何全球 IP 的 UDP 数据报的源地址来推断出来。

到目前为止一切顺利。如果我们不在 NAT 后面,我们可以使用前面提到的技术来启动与私有 IP 的通信,使用汇合服务器。

但是,现实告诉我们,P2P 对等方更有可能位于 NAT 后面。因此,此解决方案是不够的。我们希望从 NAT 设备后面自己发起 P2P 连接。因此,现在我们有两个 NAT 设备,每个 P2P 节点后面一个。

现在真正的乐趣开始了。首先,让我们根据这个问题的新变化重新定义我们的目标,并逐步解决它。我们现在想做的是使用汇合服务器并通知目标 P2P 节点向我们发送请求,但我们位于 NAT 后面。

因此,为了让任何外部方与我们通信,我们应该在 NAT 公共接口上有一个全球 IP/端口组合。首先,我们必须为自己创建一个。只有这样,我们才能接收来自 NAT 网络外部的通信请求。

我们可以通过向全球 IP 发送数据包来为我们创建映射。然后,全球 IP 可以通过检查来自地址来找出我们的映射。但是,我们如何将此地址告知我们的 P2P 节点?为此,我们可以使用与汇合机器的 TCP 连接。但是,只有我们向其发送数据包的全球 IP 才知道我们的关联,那么我们如何找出这一点呢?这很简单。全球 IP 可以将该信息作为数据包有效负载中的回复发送给我们。

假设我们以某种方式获得了公共 IP、端口对并找出了它,我们告诉中介我们正在该公共 IP/端口对上监听,并请求 P2P 目标节点向我们发起请求。随后,我们可以作为对该消息的回复连接到它。

但是,然后我们无法接收来自 P2P 目标节点的数据包,因为 NAT 没有期望来自该全球 IP 的回复。事实上,一些表现出全锥型行为的 NAT 允许数据包来自任何 IP,但大多数 NAT 不允许——又回到了原点。

考虑一下:如果 NAT 后面的两个 P2P 节点向彼此的公共 IP/端口发送数据包,则来自每一方的第一个数据包将被丢弃,因为它是未经请求的。但是随后的数据包会被放行,因为 NAT 认为数据包是对我们原始请求的回复。瞧,漏洞被打了,UDP 流量可以直接在 P2P 节点之间传递。

不幸的是,NAT 在为不同目标 IP 分配公共端口的行为方面也有所不同。幸运的是,大多数 NAT 设备不会更改对不同目标 IP 的请求之间的公共端口,因此我们可以安全地假设这一点。

因此,首先我们向两个不同的 IP 发送某些探测或发现数据包,并找出 NAT 的行为。如果发现它是一致的,我们的方法就会奏效。在不太可能遇到的对称型 NAT 行为会改变请求之间的端口的情况下,我们可以找出端口号变化的增量。并且,使用此增量,我们可以猜测为特定请求分配的端口。

我们如此特别关注这一点的原因是,发送到 NAT 后面的 P2P 目标的第一个数据包会被 NAT 丢弃。因此,我们所能做的就是猜测。然而,在实践中,它效果相当好。这就是为什么 P2P 节点保持通信的源端口和目标端口相同很重要。

一旦执行了此打洞过程,两个 P2P 节点就可以在没有汇合机器的帮助下相互通信。因此,汇合机器仅用于通知 P2P 节点有关传入连接的信息,并告知每个通信对等方有关彼此的公共地址的信息。随后,通信直接发生,无需汇合服务器的干预。

现在我们必须运用一些独创性,并在数据包中引入适当的标头,以告知对等方它发送的是否是针对 P2P 客户端的回复,或者它发送的是否是针对 P2P 服务器的请求。一旦我们能够区分两者,我们就设置好了。我们还需要区分打洞流量和常规流量,因为打洞流量需要被弹回,而常规流量需要被处理。

当然,如果我们停止发送和接收,两端的 NAT 设备上的关联将过期。因此,我们可以发送保持活动流量或重新运行打洞技术。您可以根据自己的需要选择合适的技巧。

如果两个 P2P 节点位于同一 NAT 设备后面,则此技术将不起作用。因此,我们还必须弄清楚是否可以使用私有 IP 地址本身直接通信。因此,我们的打洞必须尝试私有接口以及对等方的公共接口。并且,我们的私有网络可能具有与对等方的私有 IP 相同的私有 IP。因此,我们必须防止获得虚假响应。

还可能发生的是,与我们位于同一私有网络中的另一个 P2P 节点具有与我们想要在另一个私有网络中与之通信的 P2P 节点相同的私有 IP。然后,我们必须对对等方的身份进行额外的验证,以确保我们确实在与感兴趣的节点通信。

在不太可能遇到的两端都运行着脑残 NAT 设备的情况下,此技术显然会失败,因为我们应该能够预测分配给我们的公共地址。在这种情况下,唯一的办法是让汇合服务器充当流量的 relay。因此,点对点流量会通过,但它不再是点对点,汇合机器充当服务器。如果您遇到这种情况,您需要考虑也实现这一点。

现在,了解真相,实现上述目标的 C 代码

由于篇幅过长,本文的列表位于 Linux Journal FTP 站点,地址为 ftp.linuxjournal.com/pub/lj/listings/issue148/9004.tgz。我省略了不必要的细节和胶水代码,而纯粹专注于 UDP 打洞的非平凡方面。

如果您需要有关实现自己的打洞库的更多信息,您可以始终参考上述设计约束并设计适当的解决方案。

请注意,我有意识地忽略了 rfcs 和 NAT 发现技术,例如 STUN 和 ICE 等框架。UDP 打洞已经很复杂了,如果我们不增加任何真正的价值就使其更加臃肿,我们将一无所获。因此,就目前而言,该技术的效果与其他 NAT 遍历机制一样好甚至更好。

首先,看一下汇合代码(列表 1)。请注意,我们使用 select() 来服务多个套接字。我们也可以在 *BSD 上使用 kqueue(),或者更好的是,使用 libevent 抽象(请参阅资源)。但是,我坚持使用 select(),因为性能对我们来说并不那么重要。我们仅在建立点对点连接时才与中介服务器通信,否则不通信。

打洞实现方式在列表 2 中给出,P2P 客户端在列表 3 中给出。

使用此方法,您应该能够开发自己的点对点协议。您可以轻松地开发自己的即时消息协议以及一些 GUI 代码。您可以直接使用 nc 或代码传输文件。您可以开发某些应用程序,例如通过麦克风和扬声器传输语音。换句话说,您可以使用此方法开发一个业余 VoIP 应用程序。

存在多种可能性。如果您对数据的安全到达感到偏执,可以在 UDP 之上添加一些可靠性。

对我的这项工作帮助极大的一个非常有用的工具是网络瑞士军刀 netcat。

您可以通过使用这个简单的命令来查看打洞的实际效果。在每一端,键入

      
      $ nc -u -p 17000 <peer public IP> 17000
      
      

只有对等方公共 IP 不同,如果运气好的话,您可以开始通信,因为大多数 NAT 设备都尝试分配与公共端口相同的私有端口。

如果您想测试 TCP 打洞,请尝试此操作

         $nc -l -p 17000
      

在一端,以及此操作

      
      $nc -p 17000 <peer public IP> 17000
      
      
在另一端。
未来工作

您可以设置几个汇合服务器,而不是只有一个,以实现故障转移和地理分布。但是,如果您位于两层 NAT 后面,有时这可能不起作用。您还可以监听多个虚拟和真实接口,并尝试在所有接口上进行打洞。您可以在类似的线路上添加 TCP 打洞并首先尝试,然后再尝试 UDP 打洞。

本文的资源: /article/9072

Girish Venkatachalam 喜欢玩开源操作系统,例如 OpenBSD、FreeBSD 和 Debian GNU/Linux。他不在黑客攻击时也喜欢骑自行车。可以通过 girish1729@gmail.com 与他联系。

加载 Disqus 评论