9.7 网络地址转换 (NAT)
现在我们来到了 Netfilter 世界里最著名的那个功能——NAT(Network Address Translation)。
你肯定每天都在用它——只要你把手机连上家里的 Wi-Fi 上网,你就在用 NAT。它是现代互联网能够撑住不至于地址耗尽的「止血钳」,也是让一屋子设备能共用一个公网 IP 的「魔法盒子」。
但作为一个内核开发者,你不能只停留在「它能共享上网」这个层面。我们需要撕开这个盒子,看看它到底在网络栈的哪个位置伸手修改了数据包。
本节会分为两部分:先把 NAT 的两种常见模式(SNAT 和 DNAT)讲清楚,然后深入内核,看看它是如何把自己「挂」到我们之前讨论的那些 Hook 点上的。
NAT 是谁,来自哪里,要去何方
NAT 模块的主要工作,正如其名,是转换网络地址——具体来说,是改写 IP 头部里的源地址或目的地址,顺带改改 L4 的端口(TCP/UDP header)。
最常见的场景是这样的:你家里有个路由器(也就是我们常说的「光猫」或「网关」),后面挂着你的手机、笔记本、冰箱。这些设备都是私有 IP(比如 192.168.x.x)。当你用笔记本访问 Google 时,Google 的服务器肯定不能把回包发给 192.168.1.5——这是个私有地址,在公网上是无效的。
这时候,路由器上的 NAT 就站出来了。它会把你的数据包里的源 IP 改成路由器的公网 IP,同时在内存里记一笔:「192.168.1.5:12345 的包被我伪装成了 公网IP:54321」。当 Google 的回包回到路由器时,它再把这一笔账翻出来,把目的 IP 改回 192.168.1.5,然后把包扔给你的笔记本。
这就是 NAT 最核心的价值:让一群拥有「假身份证」(私有 IP)的设备,能通过一个拥有「真身份证」(公网 IP)的代理人去访问外面的世界。
Netfilter 子系统同时实现了 IPv4 和 IPv6 的 NAT。
虽然 NAT 的存在本身就是为了缓解 IPv4 地址短缺,这在 IPv6 世界里本不该是个问题——但历史惯性是很强大的。内核从 3.7 版本开始正式支持 IPv6 NAT(代码在 net/ipv6/netfilter/ip6table_nat.c)。它的实现很大程度上是照搬 IPv4 的,用户配置起来体验也差不多。除了共享上网,NAT 还常被用来做简单的负载均衡(通过配置 DNAT 把流量分发到不同的后端服务器)。
NAT 的配置细节千变万化,网上的文档能装满几个硬盘。但在内核眼里,其实只有两类基本操作:
- SNAT (Source NAT):改写源 IP。
- 这是你家里路由器做的事。
- 通常在数据包离开网络栈时做(
POST_ROUTING)。
- DNAT (Destination NAT):改写目的 IP。
- 这是端口转发做的事(比如把发到公网 IP:80 的包转发到你内网的 192.168.1.100:80)。
- 通常在数据包进入网络栈后、做路由决定前做(
PRE_ROUTING)。
你在 iptables 里用 -j 参数指定目标时,就是在这两者之间做选择:
# 把源地址改成 1.2.3.4(SNAT)
iptables -t nat -A POSTROUTING -o eth0 -j SNAT --to-source 1.2.3.4
# 把目的地址改成 10.0.0.1(DNAT)
iptables -t nat -A PREROUTING -i eth0 -j DNAT --to-destination 10.0.0.1
无论是 SNAT 还是 DNAT,底层的实现逻辑都在 net/netfilter/xt_nat.c 里。
注意:
iptables的 NAT 表和 filter 表不一样,它只在特定的几个 Hook 点上生效。下面我们就要看到这个表是怎么被初始化的。
NAT 的初始化:把钩子挂上去
还记得我们在上一节看到的 filter 表吗?NAT 表(nat table)本质上也是一个 xt_table 对象。它的注册方式也和 filter 表非常相似,由 ipt_register_table() 和 ipt_unregister_table() 负责。
但有一个关键的区别:NAT 表不关心 NF_INET_FORWARD。
想一想为什么?
因为如果是为了共享上网(SNAT),数据包到了转发节点(FORWARD),路由已经决定了它要去哪,这时候我们只关心它在离开前(POST_ROUTING)把源地址改掉。如果是为了端口转发(DNAT),我们要在路由决定前(PRE_ROUTING)就把目的地址改了,好让路由查表把它导向内网机器。
在 FORWARD 这个点,NAT 通常是没事可做的——决定已经做好了。
来看看 NAT 表的定义(代码在 net/ipv4/netfilter/iptable_nat.c):
static const struct xt_table nf_nat_ipv4_table = {
.name = "nat",
/* 只在以下四个 hook 点生效 */
.valid_hooks = (1 << NF_INET_PRE_ROUTING) | /* DNAT 在这里 */
(1 << NF_INET_POST_ROUTING) | /* SNAT 在这里 */
(1 << NF_INET_LOCAL_OUT) | /* 本机发出的包也能做 DNAT */
(1 << NF_INET_LOCAL_IN), /* 本机收到的包也能做 SNAT */
.me = THIS_MODULE,
.af = NFPROTO_IPV4,
};
这里你看得很清楚:它排除了 NF_INET_FORWARD。这和我们之前说的「NAT 修改的是包的起点或终点」的逻辑是完全吻合的。
这个 xt_table 对象会被注册到网络命名空间(struct net)里的 netns_ipv4 结构体中(那个 nat_table 指针)。我们在前面讨论 iptables 时提到过这个结构,现在 nat 表也住进去了。
注册 Hook 回调
光有表还不行,我们得告诉内核:「当数据包走到这些 Hook 点时,请调用我们的函数来处理」。
这需要定义一个 nf_hook_ops 数组,并把它们注册到 Netfilter 框架里。这个数组长得比 filter 表的要稍微丰富一点,因为 NAT 需要在不同的 Hook 点做不同的事情:
static struct nf_hook_ops nf_nat_ipv4_ops[] __read_mostly = {
/* Before packet filtering, change destination (DNAT) */
{
.hook = nf_nat_ipv4_in, /* 处理函数 */
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_PRE_ROUTING, /* 挂在 PRE_ROUTING 上 */
.priority = NF_IP_PRI_NAT_DST, /* 优先级:DNAT 必须在路由前做完 */
},
/* After packet filtering, change source (SNAT) */
{
.hook = nf_nat_ipv4_out,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_POST_ROUTING, /* 挂在 POST_ROUTING 上 */
.priority = NF_IP_PRI_NAT_SRC, /* 优先级:SNAT 在包即将离开时做 */
},
/* Before packet filtering, change destination (DNAT) */
{
.hook = nf_nat_ipv4_local_fn,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_OUT, /* 本机发出的包,也能做 DNAT */
.priority = NF_IP_PRI_NAT_DST,
},
/* After packet filtering, change source (SNAT) */
{
.hook = nf_nat_ipv4_fn, /* 本机收到的包,也能做 SNAT */
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_IN,
.priority = NF_IP_PRI_NAT_SRC,
},
};
注意看注释里的逻辑:
- 在
PRE_ROUTING和LOCAL_OUT上挂的回调,优先级是NF_IP_PRI_NAT_DST(目的地址转换)。这意味着只要包一到,还没开始做复杂的过滤判断,先把它的目的地改了再说——这是 DNAT 的典型特征。 - 在
POST_ROUTING和LOCAL_IN上挂的回调,优先级是NF_IP_PRI_NAT_SRC(源地址转换)。这意味着包已经被路由、过滤过了,马上要出站或者交给本机进程了,最后把它的源地址改一下——这是 SNAT 的特征。
最后的注册动作是在模块初始化函数 iptable_nat_init() 里完成的:
static int __init iptable_nat_init(void)
{
int err;
. . .
err = nf_register_hooks(nf_nat_ipv4_ops, ARRAY_SIZE(nf_nat_ipv4_ops));
if (err < 0)
goto err2;
return 0;
. . .
}
当这段代码运行之后,你的网络栈里就有了一双双「看不见的手」,在数据包经过这些关键路口时,悄悄修改着它们的地址。
但这仅仅是基础设施就位。下一节,我们会深入这些 hook 函数(比如 nf_nat_ipv4_fn)的内部,看看当一个数据包真的被它捕获时,内核是如何一步步修改 IP 头部、重新计算校验和,并更新连接跟踪条目的——毕竟,改了地址忘了更新 conntrack 记录,那就是一场灾难。