理解 NAT 和 NAT 行为、类型

本文为关于 einat-ebpf 的系列文章第(一)章。

本文希望你有如 iptables/nftables masquerading 的 NAT/NAPT 配置经验,并理解根据目的地址的网络包路由。

推荐配合阅读 How NAT traversal works译文

NAT 到底做了什么

NAT 即 Network Address Translation,按字面意思理解即对网络包的地址进行转换。 而当我们提到 NAT 时,通常它也指 NAPT 即 Network Address and Port Translation,即也对网络包的端口进行转换,本文的 NAT 也默认指 NAPT。故为了理解 NAT 我们首先应该知道网络包是什么样子的。

网络包的地址存在 IPv4/IPv6(网络层,Layer 3)的包首结构中,而端口则存在 TCP/UDP(传输层,Layer 4)的包首结构中,后者在网络包中紧接前者。以 IPv4 UDP 包为例子:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- IPv4 包首
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|      Fragment Offset    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |         Header Checksum       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Source Address                          | IPv4 源地址
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Destination Address                        | IPv4 目的地址
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options                    |    Padding    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- UDP 包首
|        UDP Source Port        |      UDP Desination Port      | UDP 源和目的端口
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           UDP Length          |          UDP Checksum         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- 下一层数据
|                          data octets ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- ...

NAT 最基础的操作即将源地址或目的地址及源端口或目的端口或全部改为其它的地址及端口。

我们将网络包的路由信息抽象为 (Src Addr, Dest Addr, Src Port, Dest Port),则可描述以下转换,其中等于号 = 表示为未改变。

SNAT

转换源地址和源端口
(Src Addr, Dest Addr, Src Port, Dest Port)
--> (Src Addr', =, Src Port', =)

DNAT

转换目的地址和目的端口
(Src Addr, Dest Addr, Src Port, Dest Port)
--> (=, Dest Addr', =, Dest Port')

包路由

网络包是通过目的地址来路由的,为了从目的处接收返回包故而在网络包中存入了源地址的信息,以此允许双向的数据路由、通信。

对于一个主机上源地址和端口对 (Addr X, Port x),其发出的包均为 (Src Addr X, _, Src Port x, _),且在此源允许接收的包均为 (_, Dest Addr X, _, Dest Port x)。 注意发出包的源地址和端口与接收包的目的地址和端口是相同的。而路由器仅需要根据目标地址转发包到对应的网卡即可。

但如果路由器对内部网络某主机发出包做了 SNAT,则也需要对接收包做反向的 DNAT 转换使目的地址和端口与发出包的源地址和端口相同,以使接收包可以路由回源主机。

对发出包源地址和端口做如下 SNAT
(Src Addr X, _, Src Port, _)
--> (Src Addr X', =, Src Port x', =)

对接收包的目的地址和源端口做如下 DNAT
(_, Dest Addr X', _, Dest Port x')
--> (=, Dest Addr X, =, Dest Port x)

其中的 (X, x) <--> (X', x') 双向转换即对源地址和端口 NAT 的映射规则。这达到的效果就是不论 (X, x) 是否可以被外部所达,只要 (X', x') 可以被外部所达就可以通过 NAT 的转换完成双向通信。

比如 (192.168.1.2, 12345) --> (233.252.0.200, 54321) 的映射规则,192.168.1.2 是 NAT 主机可达但外部不可达的内部网络地址,而 233.252.0.200 是 NAT 主机的某一或唯一外部地址。

静态 NAT

静态 NAT 即无状态 NAT。 对于只需要某些内部地址或端口可以与外部进行双向数据连通的场景下,可以配置静态 NAT 规则实现一对一的映射,只要 NAT 主机有足够的外部地址或端口满足一对一的映射即可。

在 Linux 下可使用 iptables/nftables 的 snatdnatredirect 操作 实现近似无状态 NAT 或使用 set 重写网络包地址或端口来手动配置映射规则。对于仅对地址做 NAT 映射转换则可使用策略路由 ip rulenat 操作.

动态 NAT

动态 NAT 即有状态 NAT。 对于 NAT 主机只有少量或单个外部地址场景下,要满足多个内部主机从任意内部地址或端口与外部进行双向通信,则使用静态的一对一的映射规则显然是不充分的。 若是动态的分配映射规则,只要内部主机同时使用(此处的“使用”由超时时间或连接状态决定)连接数量不超过 NAT 主机容许的映射规则容量就可以允许所有内部主机与外部进行双向通信。

假定 NAT 主机只有一个外部地址,则其对 TCP 或 UDP 协议有 65535(2^16 去掉未定义的 0)个端口可以分配给映射规则。 从简单出发,对每一个发出包源地址和端口 (X, x),如果不存在相应的映射规则,则从 165535 中选定一个未被分配的端口 x' 分配给一个新的映射规则。则最多会有 65535 个映射规则唯一对应某源地址和端口,如下表所示。 这种端口分配行为即是 Endpoint-Independent Mapping(EIM) 的 NAT 映射行为,并且是另称为 Full Cone NAT 或俗称 NAT1 的必要条件。

XxX’x’
192.168.1.2100233.252.0.2005
192.168.2.3100233.252.0.2006
192.168.1.2200233.252.0.2007
192.168.1.2300233.252.0.200300
……………………

但是当所有外部源端口都被事先占用, 比如所有外部源端口 233.252.0.200:1-65535 已经分配至 192.168.1.2:1-65535,如何为新的内部地址和端口(如 192.168.2.3:100)分配映射规则呢? 最简单的做法是将旧的或最少使用的映射规则删去并释放外部源端口以分配给新的映射规则,但这有可能中断还在使用中的连接。 而更进一步,NAT 可以利用传输层连接不是用永久的特性仅为当前活动的连接分配映射规则,从而使外部端口可以得到更大限度的利用。这就需要 NAT 对连接进行跟踪。

UDP、ICMP 连接跟踪

虽然在 UDP 和 ICMP 协议中不存在稳定且有状态的连接概念,但对于 Client-server 应用和 P2P 应用还是存在近似连接的概念的, 比如 ICMP Ping 及 μTP、QUIC over UDP 等在 UDP 之上构建的的有连接协议。

但也因为 UDP 和 ICMP 没有内含连接开启或关闭信息,NAT 无法知道这种近似连接是否还在使用, 所以 NAT 只能通过为连接赋予超时时间来认定连接是否在使用,并根据有包的传输来刷新、延长超时时间。 对于在短时间内交互的 Client-server 应用来说这种超时方式完全没有问题, 而且当今的 UDP 应用都是在有 NAT 的前提下工作的并且存在 keep-alive 机制,故在使用中的连接可以正确的被 NAT 认定。

RFC 4787 中规定 UDP 映射规则超时时间最小为 2 分钟并推荐为 5 分钟。

对于 UDP 连接跟踪,有如下跟踪索引信息

(Src Addr X, Dest Addr, Src Port x, Dest Port)
--> (Src Addr X', =, Src Port x', =)

对于 ICMP 连接跟踪,有如下跟踪索引信息

(Src Addr X, Dest Addr, Query ID x)
--> (Src Addr X', =, Query ID x')

并且取决于 NAT 映射行为,可以有多个或一个连接跟踪记录唯一对应某映射规则,如对于 EIM 的映射行为会有 (X, x) --> (X', x') 的映射对应一个或多个来自相同源地址端口的连接跟踪记录。 且因为连接的超时时间仅通过有包的传输刷新并单调延长,对于 UDP 和 ICMP 来说其映射规则的超时时间即为所有对应连接的最迟超时时间。当所有连接均超时时,相应的映射规则不再被使用故可以被释放。

TCP 连接跟踪

与 UDP 连接跟踪相似,对于 TCP 有如下连接跟踪索引信息

(Src Addr X, Dest Addr, Src Port x, Dest Port)
--> (Src Addr X', =, Src Port x', =)

而由于 TCP 是有明确的连接状态的,相比 UDP 而言 NAT 可以根据 TCP 包首中的 SYNRSTFIN 等状态信息更精确的推测当前连接的开闭状态。 其中的状态转换如 RFC 7857 (TCP Session Tracking) 中图表所示。 由于 TCP 连接相关的包存在丢包的风险,连接跟踪的状态不一定与内部和远程主机的状态同步,所以对于 TCP 连接跟踪会有额外的超时时间以确保内部和远程主机及 NAT 状态同步。

此外 NAT 对于 TCP 的连接跟踪应该允许“同时开启”,即不管内部主机初始是以被动监听还是主动发起建立连接的,相应的连接跟踪记录和映射规则都应该允许被建立。 这从而允许内部主机使用 natmapNatter 等工具在 Full Cone NAT 下创建 TCP 端口映射。

小结

到此即为对于 TCP、UDP、ICMP 的有状态 NAPT 基本实现的描述。

NAT 行为

在上文中,我们预想了最透明的映射规则创建策略(EIM)并且不考虑对网络包进行防火墙过滤。在 NAT 的不同实现中映射规则的创建策略不尽相同,且存在对于入站连接的防火墙策略。 但大致可以按照 RFC 4787 根据映射行为过滤行为各分为三大类。

映射行为

EIM

EIM 即 Endpoint-Independent Mapping。

在上文定义的映射规则 (X, x) <--> (X', x') 中,无论对于什么目的地址端口,只要出口包的源地址和端口是 (X, x),则根据此映射规则将网络包的源地址和端口改写为 (X', x')

对于接收包则同理做反向转换,即无论对于什么源地址端口,只要接收包的目的地址和端口是 (X', x'),则根据此映射规则将网络包的源目的地址和端口改写为 (X, x)

出站方向
(Src Addr X, Src Port x)
--> (Src Addr X', Src Port x')
入站方向
(Dest Addr X', Dest Port x')
--> (Dest Addr X, Dest Port x)

ADM

ADM 即 Address-Dependent Mapping。

在 EIM 映射规则之上,除了根据源地址和端口映射外部端口和地址,再加上目的地址来唯一对应某外部地址和端口对。

出站方向
(Src Addr X, Src Port x, Dest Addr Y)
--> (Src Addr X', Src Port x', =)
入站方向
(Dest Addr X', Dest Port x', Src Addr Y)
--> (Dest Addr X, Dest Port x, =)

无论对于什么目的端口,只要出口包的源地址和端口及目的地址是 (X, x, Y),则根据此映射规则将网络包的源地址和端口改写为 (X', x')。对于接收包则同理做反向转换。

APDM

APDM 即 Address and Port-Dependent Mapping。

在 EIM 映射规则之上,除了根据源地址和端口映射外部端口和地址,再加上目的地址和目的端口来唯一对应某外部地址和端口对。

出站方向
(Src Addr X, Src port x, Dest Addr Y, Dest Port y)
--> (Src Addr X', Src Port x', =, =)
入站方向
(Dest Addr X', Dest Port x', Src Addr Y, Src port y)
--> (Dest Addr X, Dest Port x, =, =)

而此时映射规则索引和连接跟踪索引相同,所以对于 APDM 来说映射规则和连接跟踪记录可以合并,Linux Netfilter 的 conntrack 记录正是如此。

故对于 APDM 仅当有和创建映射规则的相同连接存在时,才根据此映射规则或连接将网络包的源地址和端口改写为 (X', x')。对于接收包则同理做反向转换。

过滤行为

在 EIM 中若不做任何包过滤,NAT 允许任意目的是 (X', x') 的接收包通过并将目的改写为内部源,而不管外部来源是什么。 而在 ADM 中若不做任何包过滤,NAT 允许任意目的和来源地址是 (X', x', Y) 的接收包通过并将目的改写为内部源,而不管外部来源端口是什么。

网络管理员出于安全考虑,希望对接收包的外部来源做一定限制,比如只允许来自和创建映射规则的初始发出包的目的地址(和端口)相同的接收包。 因此为了实现这一点,NAT 需要在创建和更新映射规则时额外记录发出包的目的地址和端口。

而根据过滤策略的不同,可以将过滤行为分成有意义的三大类。

EIF

EIF 即 Endpoint-Independent Filtering。NAT 允许任意目的地址和端口(X', x') 的接收包通过并将目的改写为内部源 (X, x),而不管外部来源地址或端口是什么。

对于 EIM 映射规则来说这就是默认的行为,因为它不记录也不根据外部来源地址或端口进行索引。

EIM(EIF) 入站方向
(Dest Addr X', Dest Port x')
--> (Dest Addr X, Dest Port x)

对于 ADM 和 ADPM 来说,由于在索引时就限定了来源地址或端口,且映射后的内部源地址 (X, x) 不一定相同,所以在它们之上无法实现完备的 EIF。

ADM 入站方向
(Dest Addr X', Dest Port x', Src Addr Y)
--> (Dest Addr X, Dest Port x, =)
ADPM 入站方向
(Dest Addr X', Dest Port x', Src Addr Y, Src port y)
--> (Dest Addr X, Dest Port x, =, =)

ADF

ADF 即 Address-Dependent Filtering。NAT 允许任意目的地址、端口和来源地址(X', x', Y) 的接收包通过并将目的改写为内部源,而不管外部来源端口是什么。

对于 EIM 来说这需要在映射规则索引之上额外记录所有发出包的外部目的地址,并在入站方向匹配映射规则索引后对外部来源地址进行匹配过滤。

EIM + ADF 入站方向
(Dest Addr X', Dest Port x')
--> (Dest Addr X, Dest Port x) + { Src Addr Y1, Src Addr Y2, ... }

对于 ADM 映射规则来说这是默认行为,因为它不记录也不根据外部来源端口进行索引。

ADM(ADF)入站方向
(Dest Addr X', Dest Port x', Src Addr Y)
--> (Dest Addr X, Dest Port x, =)

对于 ADPM 来说,由于在索引时就限定了来源端口,且映射后的内部源地址 (X, x) 不一定相同,所以在它们之上无法实现完备的 ADF。

APDF

APDF 即 Address and Port-Dependent Filtering。NAT 仅允许来源和目的发出包的目的和来源对称匹配的接收包通过,并将目的改写为内部源。即只允许连接所对应的回应包通过 NAT。

对于 EIM 来说这需要在映射规则索引之上额外记录所有发出包的外部目的地址和端口对,并在入站方向匹配映射规则索引后对外部来源地址和端口对进行匹配过滤。

EIM + APDF 入站方向
(Dest Addr X', Dest Port x')
--> (Dest Addr X, Dest Port x)
    + { (Src Addr Y1, Src Port y1), (Src Addr Y2, Src Port y2), ... }

对于 ADM 映射规则这需要在映射规则索引之上额外记录所有发出包的外部目的端口,并在入站方向匹配映射规则索引后对外部来源端口进行匹配过滤。

ADM + APDF 入站方向
(Dest Addr X', Dest Port x', Src Addr Y)
--> (Dest Addr X, Dest Port x, =) + { Src Port y1, Src Port y2, ... }

对于 ADPM 映射规则来说这是默认行为,因为它只匹配和创建映射规则的连接所对应的接收包,而无需额外过滤。所以 ADPM 也可以叫为对称(Symmetric)NAT。

小结

某过滤行为只能在更同等或更宽松的映射行为上实现,或者说某映射行为上只能应用同等或更严格的过滤行为。 即 EIM 可以应用所有过滤行为,ADM 只能应用 ADF 和 ADPF,ADPM 只能应用 ADPF。

在这三种过滤行为外是否存在其他过滤行为?答案是肯定的,比如仅按照发出包的目的端口而非地址进行过滤,但是这种行为在实际应用中意义不大。对于映射行为也是同理。

注意在这里定义的 NAT 行为对于跟踪的所有连接都符合对应 NAT 行为的定义,某些 NAT 实现可能在低负载的情况下部分连接分组可能会符合相比总体 NAT 行为更宽松的行为。 比如 Linux Netfilter masquerading 在部分场景下是具有 EIM 映射行为的,但是它的映射规则仍然是符合 APDM 定义的所以可以构造某些映射规则使其失去 EIM 特性, 所以严格来讲 Linux Netfilter masquerading 仍然是具有 APDM 映射行为。

NAT 类型对应关系

EIF
ADF
ADPF
EIM
Full Cone / NAT1
Restricted Cone / NAT2
Port Restricted Cone / NAT3
ADM
不存在
ADM + ADF
ADM + ADPF
ADPM
不存在
不存在
Symmetric / NAT4

所以这里定义了共 6 种严格符合的有实际意义的 NAT 类型。

关于不同 NAT 类型所开启的应用场景我在这里不再赘述,但简单地说只要确保本机所在网络有 EIM 行为则可以利用 STUN 服务稳定地和任意远程网络内主机“打洞”建立双向连接。 而若是确保本机所在网络有 EIM + EIF 行为则可以利用动态 NAT 一节中所提到的连接跟踪超时时间可通过传输包刷新的机理维持映射规则所对应的外部端口持续打开, 从而允许被动地接收任何来源的传入连接,而无需远端主机上程序配合“打洞”,见 natmapNatter

嵌套 NAT 后 NAT 行为

对于映射行为来说,映射的外部源地址、端口首先由内部源地址、端口限定,再依次加上目的地址和目的端口限定映射的外部端口。 对于有同样映射行为的多级嵌套 NAT 来说,每一级的外部端口都由外部源地址、端口和目的地址、端口中的相同组合条件限定,因此即使经过多级 NAT 其映射行为也不会改变。 而对于有不同映射行为的多级嵌套 NAT 来说,其中的某一级有相比其他 NAT 更多的映射端口限定条件,因此总的来看 NAT 映射行为是其中有最多限定条件即最严格 NAT 的映射行为。

同理对于过滤行为来说也有类似的逻辑,多级嵌套 NAT 中能够通过 NAT 过滤防火墙的网络包由其中有最严格过滤行为的 NAT 决定,而之后的网络无法接收到被过滤丢弃的包。

因此对于嵌套 NAT,其最内层网络的 NAT 行为由层级之中最严格的 NAT 映射行为和层级之中最严格的 NAT 过滤行为决定,而最终对应的 NAT 类型可查上“NAT 类型对应关系”中一表得出。 我称这为 NAT 行为的“木桶原理”。

NAT 迷思

端口转发或 DMZ 可以实现 Full Cone NAT?

在一般语境下,当我们提及 NAT 时,其一般指有状态的动态的 NAT 系统,且其中的端口映射通常并不固定。对于动态 Full Cone NAT, 其内部网络下有任何地址的网络主机都无需网络管理员手动额外配置既有对所有内部源端口的 Full Cone NAT 行为。

而与动态 NAT 相反,端口转发或 DMZ 需要网络管理源手动配置且只能特定某些内网主机的某些端口。 虽然端口转发是一种静态 NAT 并且因为映射端口固定(EIM)且没有防火墙过滤(EIF)而符合 Full Cone 的 NAT 行为, 但相比于完整的 NAT 系统而言只是静态地将外部流量无条件地转发到特定的内网主机,而没有动态 NAT 的灵活性。

因此对于使用端口转发或 DMZ 将公网或 Full Cone(CG)NAT 网络下分配的 IP 地址端口转发到内网主机使后者有 Full Cone 行为的方案, 不能简单地称为“实现/添加 Full Cone NAT”,毕竟并没有 NAT 系统的实体被实现。 我认为更合适的说法是“通过端口转发或配置 DMZ 主机为特定内网主机端口添加 Full Cone(EIM + EIF)的 NAT 行为”。

Full Cone NAT 下需要打洞?

如上文说所,Full Cone(EIM + EIF)NAT 下的内部主机可以维持某外部端口打开,而后只需通过 STUN 检测外部地址和端口并告知远端应用即可, 而远端应用可直接对此外部地址和端口建立 TCP/UDP/ICMP 通信。所以在 Full Cone NAT 下为了建立双向连接而双向配合“打洞”是不必要的。

但是因为 Full Cone NAT 拥有最透明的 EIM 行为,在它之下可以充分地执行双向配合“打洞”的 P2P 协议。

Full Cone NAT 下根据目的地址分流?

设想你的路由器上的默认出口 extern0 有公网 IPv4 地址,或有 Full Cone NAT 网络下地址并配置了 einat-ebpf 实现 Full Cone NAT 级联, 并且还有一个 Wireguard 接口 wg0 或其他 VPN 接口提供优质的海外连接并且在服务端也配置了 Full Cone NAT。

并且你通过策略路由和 nftables 规则将所有的目的地址在海外的流量都路由到了 VPN 接口。

现在在内网主机中有一个国外开发的 P2P 游戏应用,你创建了一个游戏房间因此它打开了本机某个 UDP 端口并且请求设立在海外的某个服务器,你的 VPN 为此连接建立了 Full Cone NAT, 并且该游戏的海外服务器知道了你的本地端口映射的代理服务端地址并汇报给了其他用户,而其他用户不管是在海外还是在海内都只能通过你的代理服务端地址连接你的游戏房间。海内用户反而会有额外的延迟,因此分流加速的效果无法达到。

在根据目的地址分流的场景下,普通应用只会预想一个本地地址端口对应于一个外部地址端口,而不会特意去处理多个外部地址的情况。

上述行为也同理应用于传输层代理软件的内部分流。

TCP 的非对称 NAT 不容易实现?

UDP 协议不存在连接,因此没有被动监听连接和主动连接的抽象,并且在协议栈中绑定端口后可以任意收发任何来源或目的的数据包。 所以对于 SOCKS、V2raysing-box 等传输层代理协议或软件来说,可以在远端网络协议栈打开端口并唯一映射本地端口从而直接转发封装数据包,这就已经满足 EIM + EIF 的 NAT 行为。

然而 TCP 协议存在连接和状态,并且协议栈默认只允许唯一以监听模式或主动连接模式绑定端口,不过应用可以通过为 TCP socket 指定 SO_REUSEPORT 来为相同源端口的不同连接应用监听或主动连接模式。 这对于通过网络协议栈的传输层代理来说也需要在本地端口和远端端口同步不同连接的不同连接模式,而由于网络栈的实现和配置不同,必然无法稳定的维持连接状态的同步, 因此传输层代理软件在 TCP 协议栈上实现 TCP 的同时允许传入和传出连接的端口转发(即 EIM + EIF NAT)意义不大。

传输层代理软件普遍支持对单一模式(唯一以监听模式或主动连接模式工作)的 TCP 端口上所有连接的转发。

因此要实现 TCP 代理的非对称 NAT,还是需要在网络层转发网络包,即网络层 VPN。 目前你可通过在 VPN 远端服务器的默认出口配置 einat-ebpf 替代 Netfilter masquerade 实现对 TCP 的 Full Cone NAT 转发。