9.8 NAT 钩子回调与连接跟踪钩子回调的共舞
上一节我们把 nf_nat_ipv4_ops 这个钩子数组注册进了内核,这就像在数据包必经的高速公路上设了卡。但如果你仔细看这些关卡,你会发现一件有意思的事:有些关卡上,既有连接跟踪(conntrack)的人在查身份证,也有 NAT 的人在改地址。他们挤在同一个 Hook 点上,按什么顺序出来工作,这是个甚至能决定生死的细节。
Hook 点上的优先级游戏
以 NF_INET_PRE_ROUTING 这个点为例,这是所有入站数据包到达的第一个检查站。
在这个点上,注册了两个关键回调:
- 连接跟踪回调:
ipv4_conntrack_in(),优先级是NF_IP_PRI_CONNTRACK(-200)。 - NAT 回调:
nf_nat_ipv4_in(),优先级是NF_IP_PRI_NAT_DST(-100)。
在 Netfilter 的规则里,优先级数值越小,越早执行。这意味着 -200 的连接跟踪会排在 -100 的 NAT 前面。
为什么顺序这么重要?因为 NAT 严重依赖连接跟踪。
请看 nf_nat_ipv4_fn() 的代码——它是 NAT 回调的核心逻辑之一(在 PRE_ROUTING 阶段,nf_nat_ipv4_in 会直接调用它):
static unsigned int nf_nat_ipv4_fn(unsigned int hooknum,
struct sk_buff *skb,
const struct net_device *in,
const struct net_device *out,
int (*okfn)(struct sk_buff *))
{
struct nf_conn *ct;
. . .
/* Don't try to NAT if this packet is not conntracked */
if (nf_ct_is_untracked(ct))
return NF_ACCEPT;
. . .
}
(net/ipv4/netfilter/iptable_nat.c)
注意
nf_nat_ipv4_fn()这个方法不仅在这里被调用,也是后面我们要讲的 NAT POST_ROUTING 回调nf_nat_ipv4_out()的核心实现。
这行代码是关键:if (nf_ct_is_untracked(ct))。
NAT 的本质是查表改写,而那张表就是连接跟踪表。如果连接跟踪还没对这个包建立认知(找不到 ct),NAT 就没法进行地址转换,只能放行(NF_ACCEPT)。如果顺序反了,NAT 先跑,它去查连接跟踪表,结果一无所获,这个 DNAT 规则就失效了。
再看 NF_INET_POST_ROUTING 这个点,情况更复杂一点。这里注册了三个回调:
- NAT 回调:
nf_nat_ipv4_out(),优先级NF_IP_PRI_NAT_SRC(100)。 - Helper 回调:
ipv4_helper(),优先级NF_IP_PRI_CONNTRACK_HELPER(300)。 - 确认回调:
ipv4_confirm(),优先级NF_IP_PRI_CONNTRACK_CONFIRM(INT_MAX,最大值)。
按照从小到大的顺序,执行顺序是: NAT (100) → Helper (300) → Confirm (INT_MAX)
这也很有道理:
- NAT 先改地址:把源地址改成公网 IP(SNAT)。
- Helper 再看数据:有些协议(如 FTP)会在数据包里夹杂 IP 地址信息,Helper 需要在 NAT 改完之后,根据新地址去修改 payload 里的内容。
- Confirm 最后确认:一切尘埃落定后,把这条连接跟踪条目正式确认到哈希表里。
实战推演:一个 DNAT 包的旅程
光看代码比较抽象,我们来推演一个真实的场景。这是一个经典的 DNAT(目标地址转换)配置。
场景设定: 中间这台机器(AMD 服务器)运行着 Linux,充当网关。我们要把发给它自己的 UDP 9999 端口的数据,转发给左边的笔记本电脑(192.168.1.8)。
规则:
iptables -t nat -A PREROUTING -j DNAT -p udp --dport 9999 --to-destination 192.168.1.8
(规则:凡是进来的 UDP 包,目的端口是 9999 的,把目的 IP 改为 192.168.1.8)
数据包流向:
右边的 Desktop 发送 UDP 包给 192.168.1.9:9999。
我们可以通过一张图(Figure 9-4 的概念)来看这个包在内核里的完整生命周期:
-
PREROUTING Hook (Netfilter 入口):
- ** ipv4_conntrack_in() (优先级 -200)**:内核先认脸。「这是谁?没见过,创建一个新连接条目。」
- nf_nat_ipv4_in() -> nf_nat_ipv4_fn() (优先级 -100):
- NAT 去查刚才建立的连接跟踪条目。
- 发现规则是 DNAT,于是把 IP 头部的 目的 IP 从 192.168.1.9 改为 192.168.1.8。
- 同时更新连接跟踪条目里的 tuple 信息,记录下「这个包原本是要去 1.9 的,但现在被改去 1.8 了」。
-
路由决策:
- 内核看路由表:目的 IP 是 192.168.1.8,不在本机,在左边的 LAN 里。于是决定 FORWARD(转发)。
-
FORWARD Hook:
- 这里主要是过滤(filter 表),假设没有配置规则,直接通过。
-
POST_ROUTING Hook (离开本机前):
- nf_nat_ipv4_out() (优先级 100):这是 SNAT 的点,但在本例中是 DNAT 转发,这一步可能会做些校验或处理(如果是 MASQUERADE 就在这里改源地址)。
- ipv4_helper() (优先级 300):UDP 通常不需要 helper,如果是 FTP 这里会大动干戈。
- ipv4_confirm() (INT_MAX):
- 关键一步。刚才在
PREROUTING里创建的连接跟踪条目一直处于「未确认」状态。 - 到这里,内核调用
__nf_conntrack_confirm(),把这个条目正式插入到全局的哈希表中。从此,这个连接的后续回包就能被正确识别了。
- 关键一步。刚才在
通过这个流程你应该能明白:连接跟踪是 NAT 的眼睛,而 Hook 优先级保证了眼睛先于手脚工作。
深入底层:如何不动声色地改写头部
上面讲了流程,现在我们剥开表皮,看看内核物理上是怎么修改数据包的。
NAT 的实现核心在 net/netfilter/nf_nat_core.c。为了同时支持 IPv4 和 IPv6,内核抽象了两个关键结构:
nf_nat_l3proto:处理三层(IP 层)协议逻辑。nf_nat_l4proto:处理四层(TCP/UDP 等)协议逻辑。
这两个结构里都有一个关键的函数指针:manip_pkt()(Manipulate Packet,操作数据包)。它是真正拿着手术刀修改 IP 头和 TCP/UDP 头的地方。
我们来看一个 TCP 协议的 manip_pkt() 实现,代码在 net/netfilter/nf_nat_proto_tcp.c:
static bool tcp_manip_pkt(struct sk_buff *skb,
const struct nf_nat_l3proto *l3proto,
unsigned int iphdroff, unsigned int hdroff,
const struct nf_conntrack_tuple *tuple,
enum nf_nat_manip_type maniptype)
{
struct tcphdr *hdr;
__be16 *portptr, newport, oldport;
/* TCP 连接跟踪能保证至少有 8 字节的头部 */
int hdrsize = 8;
/* 如果这是个 ICMP 错误包里包含的 TCP 片段,可能头部不全。
* 但这里我们先按正常包处理,如果是完整包就修正 hdrsize */
if (skb->len >= hdroff + sizeof(struct tcphdr))
hdrsize = sizeof(struct tcphdr);
/* 动刀前的准备:确保这块内存可写。
* 如果是只读的(比如被 clone 过的),内核会在这里做 COW,
* 这是一个昂贵的操作。 */
if (!skb_make_writable(skb, hdroff + hdrsize))
return false;
hdr = (struct tcphdr *)(skb->data + hdroff);
接下来是确定要改哪个端口。maniptype 告诉我们这是源地址 NAT(SNAT)还是目的地址 NAT(DNAT):
/* 根据 maniptype 决定修改源端口还是目的端口 */
if (maniptype == NF_NAT_MANIP_SRC) {
/* 修改源端口:从 tuple 的 src 里取新端口 */
newport = tuple->src.u.tcp.port;
portptr = &hdr->source;
} else {
/* 修改目的端口:从 tuple 的 dst 里取新端口 */
newport = tuple->dst.u.tcp.port;
portptr = &hdr->dest;
}
端口修改看似简单,就一行赋值,但它的副作用是校验和失效。TCP 头部有校验和,IP 头也有校验和(虽然这里只显示了 TCP 的逻辑,IP 的由 l3proto 处理)。我们必须重新计算。
为了计算校验和,我们需要保留旧端口:
oldport = *portptr;
*portptr = newport;
if (hdrsize < sizeof(*hdr))
return true;
/* 重新计算校验和:
* l3proto->csum_update 会处理 IP 层相关的伪头部校验和更新
* inet_proto_csum_replace2 专门处理 TCP/UDP 的校验和字段更新
* 这里用了 "2" 是因为替换的是 2 字节 (16 bit) 的端口 */
l3proto->csum_update(skb, iphdroff, &hdr->check, tuple, maniptype);
inet_proto_csum_replace2(&hdr->check, skb, oldport, newport, 0);
return true;
}
这三行代码——oldport 保存、*portptr 赋值、csum_replace2 调用——就是 NAT 技术的微观具象化。
在这一节里,我们看到了内核如何通过精心编排的 Hook 优先级,让连接跟踪和 NAT 像齿轮一样咬合,以及 manip_pkt 如何精确地在比特流中完成偷天换日。这一切发生的时候,用户进程还在 recv() 里睡眠,完全不知道刚才它的数据包已经被 Kernel 偷梁换柱了。