9.9 NAT Hook 回调与连接跟踪扩展
上一节我们看了 manip_pkt 这个「外科医生」是如何拿着手术刀修改数据包 IP 和端口的。但问题来了:这个函数是谁调用的?它在什么时候进场?更重要的是,它怎么知道该把这个数据包改成什么样——比如,它是该改源地址(SNAT)还是改目的地址(DNAT)?
答案在 Netfilter 的 NAT Hook 回调里。
对于 IPv4 来说,实现 NAT 的核心代码在 net/ipv4/netfilter/iptable_nat.c(IPv6 对应的是 ip6table_nat.c)。这个模块注册了四个回调函数,对应我们之前见过的那些 Netfilter 挂载点。
9.9.1 四个回调入口
让我们先看清这张「排班表」。表 9-1 列出了 IPv4 和 IPv6 NAT 模块注册的 Hook 回调函数。
表 9-1 IPv4 和 IPv6 NAT 回调函数
| Netfilter Hook | IPv4 回调函数 | IPv6 回调函数 |
|---|---|---|
NF_INET_PRE_ROUTING | nf_nat_ipv4_fn | nf_nat_ipv6_fn |
NF_INET_POST_ROUTING | nf_nat_ipv4_out | nf_nat_ipv6_out |
NF_INET_LOCAL_OUT | nf_nat_ipv4_local_fn | nf_nat_ipv6_local_fn |
NF_INET_LOCAL_IN | nf_nat_ipv4_fn | nf_nat_ipv6_fn |
这里有一个值得玩味的设计细节。
你会发现 nf_nat_ipv4_fn 这个名字出现了两次。这四个回调函数里,虽然名字叫法各异,但 nf_nat_ipv4_fn 显然是核心中的核心——其他三个函数(nf_nat_ipv4_in, nf_nat_ipv4_out, nf_nat_ipv4_local_fn)最终都会把皮球踢给它。换句话说,nf_nat_ipv4_fn 是 NAT 引擎的「大脑」,其他几个只是分发器。
9.9.2 核心处理函数:nf_nat_ipv4_fn()
现在让我们切开这个大脑看看。这个函数的逻辑并不短,但每一步都有它的道理。我们先看前半段:
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;
enum ip_conntrack_info ctinfo;
struct nf_conn_nat *nat;
/* maniptype == SRC for postrouting. */
enum nf_nat_manip_type maniptype = HOOK2MANIP(hooknum);
第一件事是定义变量,并通过 HOOK2MANIP 宏把当前的 Hook 编号转换成操作类型(maniptype)。因为在 Netfilter 的设计里,POST_ROUTING 这个点通常做 SNAT(修改源地址),这个宏就是做这种映射的。
接下来的这个断言非常关键,它揭示了内核设计的另一个隐形约定:
/* 我们在这里永远看不到分片包:conntrack 会在 pre-routing
* 和 local-out 做重组,而 nf_nat_out 会保护 post-routing。
*/
NF_CT_ASSERT(!ip_is_fragment(ip_hdr(skb)));
这意味着,当数据包走到 NAT 这一层时,它一定是完整的。这是谁干的好事?是连接跟踪层。它早在数据包进入 PRE_ROUTING 或 LOCAL_OUT 时,就帮你把分片拼好了。所以你在 NAT 里完全不用操心分片带来的麻烦,这真是个解脱。
接下来的代码开始处理最核心的对象——连接跟踪条目:
ct = nf_ct_get(skb, &ctinfo);
/* 无法跟踪?不是因为压力大,否则 conntrack 早就把它丢了。
* 因此这是用户的责任:要么用包过滤规则把它丢掉,
* 要么给那个协议实现 conntrack/NAT。;) --RR
*/
if (!ct)
return NF_ACCEPT;
/* 如果这个包没有被 conntrack 跟踪,就不要尝试做 NAT */
if (nf_ct_is_untracked(ct))
return NF_ACCEPT;
这里我们尝试从 skb 中获取 nf_conn 对象。如果拿不到,或者这个包被标记为「无需跟踪」,我们直接放行(NF_ACCEPT)。
为什么要直接放过?这是一种防御性编程。如果 NAT 模块无法理解这个包(或者 conntrack 根本没管它),贸然修改地址只会把事情搞砸。与其改错,不如不改。原注释里的那个 ;) 是 Alan Cox(Linux 内元老)的玩笑,意思是「这是你的锅,内核不管了」。
但如果拿到了 ct,真正的挑战才开始——我们需要给这个连接分配 NAT 扩展空间。
nat = nfct_nat(ct);
if (!nat) {
/* NAT 模块加载晚了。 */
if (nf_ct_is_confirmed(ct))
return NF_ACCEPT;
nat = nf_ct_ext_add(ct, NF_CT_EXT_NAT, GFP_ATOMIC);
if (nat == NULL) {
pr_debug("failed to add NAT extension\n");
return NF_ACCEPT;
}
}
这里的逻辑有点绕。我们试图获取这个连接条目关联的 NAT 扩展(nf_conn_nat)。如果拿不到,可能是因为 NAT 模块刚加载,而连接已经存在了。
这时候有个分岔路口:
- 如果连接已经被「确认」过了(
nf_ct_is_confirmed为真),说明连接的状态已经稳定,这时候再去给它挂一个 NAT 扩展太危险了,于是直接放过。 - 如果连接还没确认(比如这是第一个包),我们就调用
nf_ct_ext_add动态给它加一个 NAT 扩展。
注意这里的 GFP_ATOMIC 标志——我们在中断上下文里,不能睡眠。
9.9.3 根据连接状态做决策
拿到了 ct 和 nat 之后,代码进入了一个巨大的 switch 语句,根据当前数据包在连接中的状态(ctinfo)来决定怎么处理。
第一种情况是「关联包」:
switch (ctinfo) {
case IP_CT_RELATED:
case IP_CT_RELATED_REPLY:
if (ip_hdr(skb)->protocol == IPPROTO_ICMP) {
if (!nf_nat_icmp_reply_translation(skb, ct, ctinfo,
hooknum))
return NF_DROP;
else
return NF_ACCEPT;
}
/* Fall thru... (只有 ICMP 会是 IP_CT_IS_REPLY) */
IP_CT_RELATED 通常意味着这是一个 ICMP 错误报文(比如「端口不可达」),或者是 FTP 这种协议的数据连接。如果是 ICMP 协议,内核会调用专门的 nf_nat_icmp_reply_translation 来处理——因为 ICMP 报文里嵌入了原数据包的 IP 头,改起来比较麻烦,需要特殊照顾。
如果不是 ICMP,代码会穿透下去,进入 IP_CT_NEW 的逻辑。这也就是我们最常见的「新连接」场景:
case IP_CT_NEW:
/* 以前见过吗?这可能发生在环回、重传
* 或本地包的情况。
*/
if (!nf_nat_initialized(ct, maniptype)) {
unsigned int ret;
ret = nf_nat_rule_find(skb, hooknum, in, out, ct);
if (ret != NF_ACCEPT)
return ret;
这是 NAT 规则真正生效的地方。nf_nat_initialized 检查我们是否已经为这个连接设置好了该方向(源或目标)的 NAT 规则。如果没有,我们就调用 nf_nat_rule_find。
这个函数会去跑 iptables 的 NAT 表(ipt_do_table)。如果在表里找到了匹配的规则(比如 SNAT --to-source 192.168.1.2),它就会把修改动作记录到 nat 结构体里,并初始化必要的反向映射逻辑。如果没有匹配到规则(返回 NF_ACCEPT),那就什么都不做,原样放行。
但如果我们已经为这个连接设置过 NAT 了,比如这是新连接的第二个包,或者是一个重传包:
} else {
pr_debug("Already setup manip %s for ct %p\n",
maniptype == NF_NAT_MANIP_SRC ? "SRC" : "DST",
ct);
if (nf_nat_oif_changed(hooknum, ctinfo, nat, out))
goto oif_changed;
}
break;
这时候就不需要再查表了(效率很高)。我们要做的只是检查一下出接口是不是变了(比如路由策略变了,包突然改从另一张网卡出去了)。如果没变,皆大欢喜;如果变了,那这个 NAT 会话可能就无效了,得跳到 oif_changed 标签处把它杀掉。
最后一种是常态:
default:
/* ESTABLISHED */
NF_CT_ASSERT(ctinfo == IP_CT_ESTABLISHED ||
ctinfo == IP_CT_ESTABLISHED_REPLY);
if (nf_nat_oif_changed(hooknum, ctinfo, nat, out))
goto oif_changed;
}
对于 ESTABLISHED 的连接,逻辑非常简单:检查接口变化,然后直接放行。因为规则早就定好了,映射关系也存下来了,我们只需要执行就好。
函数的最后两行是真正的「动手」环节:
return nf_nat_packet(ct, ctinfo, hooknum, skb);
oif_changed:
nf_ct_kill_acct(ct, ctinfo, skb);
return NF_DROP;
nf_nat_packet 就是我们在上一节里分析过的函数,它会根据 nat 结构里记录的映射信息,调用 manip_pkt 真正去改写 IP 头和端口。
而如果到了 oif_changed,说明环境变了(比如网卡down了或者路由切了),这个 NAT 会话已经不可信了,内核会毫不留情地调用 nf_ct_kill_acct 杀掉这个连接,并丢弃当前数据包。
9.9.4 连接跟踪扩展(Connection Tracking Extensions)
你可能会问:既然每个连接都要存这么多状态(IP 映射、端口映射),那如果一个网络里大部分连接都不需要做 NAT,是不是浪费了很多内存?
这是个好问题。在内核 2.6.23 之前,这确实是个问题。但在那之后,Linux 引入了「连接跟踪扩展」机制。
这个机制的核心思想是:按需分配。
如果你不加载 NAT 模块,那么 conntrack 层就绝对不会分配那块用来存 NAT 映射信息的内存。如果你想给连接打标签(比如用 iptables -m connlabel),只要你没加载标签模块,它也不会占那份内存。
扩展的注册与挂载
每个想要扩展 conntrack 功能的模块,都需要定义一个 nf_ct_ext_type 结构体,并调用 nf_ct_extend_register() 把它注册到内核的大本营里。反注册则是 nf_ct_extend_unregister()。
代码里还提到了一个细节:每个扩展模块都应该提供一个函数,用来把自己的扩展挂载到 nf_conn 对象上。这个函数通常会在 init_conntrack()(初始化连接跟踪条目时)里被调用。
比如:
- 时间戳扩展模块提供
nf_ct_tstamp_ext_add() - 标签扩展模块提供
nf_ct_labels_ext_add()
这一套扩展的基础设施实现在 net/netfilter/nf_conntrack_extend.c 里。
截至目前(本书写作时),内核里已经内置了以下几种扩展模块(都位于 net/netfilter/ 目录下):
- nf_conntrack_timestamp.c: 记录连接 seen 的时间戳,或者用于调试。
- nf_conntrack_timeout.c: 允许针对不同协议或连接设置不同的超时时间。
- nf_conntrack_acct.c: 连接计费(Accounting),统计每个连接的流量字节数和包数。
- nf_conntrack_ecache.c: 事件缓存,用于向用户空间发送连接状态变化的事件(配合
conntrackd等工具)。 - nf_conntrack_labels.c: 给连接打标签(像是一个位图),用于复杂的策略匹配。
- nf_conntrack_helper.c: 这就是前面提到的 Helper,比如 FTP、SIP 这种需要「特事特办」的协议支持。
我们可以把这些扩展看作是给警察局(conntrack)里的档案袋(nf_conn)贴附件。默认的档案袋里只夹着一张嫌疑人基本信息(tuple)。如果你需要监控他的电话(timestamp)或者算他的账(acct),再往里塞对应的小纸条。如果案子不需要,就别塞,省得档案柜(内存)爆炸。