ch09_3
9.3 连接跟踪初始化:把“状态”注入网络栈
上一节我们留了个尾巴,讲了怎么把自己写的函数挂到 Netfilter 的检查站上。但光有钩子没用,得有人去干活。
这一节,我们来看 Linux 内核里最庞大、最复杂的那组钩子——连接跟踪——是怎么把自己铺满整个网络栈的。它是 NAT 的基石,也是防火墙理解“状态”的前提。
注册:从数组到检查站
连接跟踪需要的钩子不止一个。为了能在不同路径上截获数据包,它定义了一组 nf_hook_ops,全部塞在 ipv4_conntrack_ops 数组里:
static struct nf_hook_ops ipv4_conntrack_ops[] __read_mostly = {
{
.hook = ipv4_conntrack_in,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_PRE_ROUTING,
.priority = NF_IP_PRI_CONNTRACK,
},
{
.hook = ipv4_conntrack_local,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_OUT,
.priority = NF_IP_PRI_CONNTRACK,
},
{
.hook = ipv4_helper,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_POST_ROUTING,
.priority = NF_IP_PRI_CONNTRACK_HELPER,
},
{
.hook = ipv4_confirm,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_POST_ROUTING,
.priority = NF_IP_PRI_CONNTRACK_CONFIRM,
},
{
.hook = ipv4_helper,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_IN,
.priority = NF_IP_PRI_CONNTRACK_HELPER,
},
{
.hook = ipv4_confirm,
.owner = THIS_MODULE,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_IN,
.priority = NF_IP_PRI_CONNTRACK_CONFIRM,
},
};
这里面有两个角色是绝对的主角,它们的优先级极高(数值极低,为 -200),必须抢在所有防火墙规则之前跑:
ipv4_conntrack_in:挂在NF_INET_PRE_ROUTING。ipv4_conntrack_local:挂在NF_INET_LOCAL_OUT。
为什么是这两个点?
PRE_ROUTING:所有从外面进来的包,不管是要转发给本地还是发给别人,都会经过这里。如果不在这里截获,转发包的状态就会丢。LOCAL_OUT:本地发出的包。如果不在这里截获,本机发出的数据包就只有“去”没有“回”,连接跟踪表就只有半条命。
至于剩下的 ipv4_helper 和 ipv4_confirm,它们的优先级比较低,负责处理一些复杂的边缘情况(比如 FTP 的数据连接协商)和最终确认,我们稍后再说。
入口:nf_conntrack_in()
不管包是从 ipv4_conntrack_in 还是 ipv4_conntrack_local 进来的,最终殊途同归,都会调用一个核心函数:nf_conntrack_in()。
这个函数位于连接跟踪的核心层,跟 IPv4 还是 IPv6 无关,它是协议无关的。它的签名大概长这样:
unsigned int nf_conntrack_in(struct net *net,
u_int8_t pf,
unsigned int hooknum,
struct sk_buff *skb);
- pf:协议族,告诉它是
PF_INET(IPv4) 还是PF_INET6(IPv6)。 - hooknum:包是从哪个钩子进来的(
PRE_ROUTING还是LOCAL_OUT)。
它的任务很简单:看着这个包,问自己——“我以前见过这家伙吗?”
- 如果见过,找到对应的连接条目(
struct nf_conn),更新状态。 - 如果没见过,创建一个新的条目,把它塞进哈希表里。
这就是连接跟踪的本质:无状态的网络栈变成了有状态的会话表。
⚠️ 性能预警
只要你在编译内核时选上了
CONFIG_NF_CONNTRACK,这套钩子就会挂上去——哪怕你一条 iptables 规则都没写。这是有代价的。每一个包都要过一遍连接跟踪,查哈希表,计算哈希值。如果你的设备是一个纯粹的转发节点,完全不需要状态,也不想跑 NAT,那么这个开销就是白扔的。在这种场景下,考虑把连接跟踪编译成模块,然后干脆别加载它,能省下不少 CPU。
注册动作:nf_register_hooks()
定义好数组之后,总得告诉内核吧?这是在 nf_conntrack_l3proto_ipv4_init() 里完成的:
static int __init nf_conntrack_l3proto_ipv4_init(void)
{
int ret;
...
ret = nf_register_hooks(ipv4_conntrack_ops,
ARRAY_SIZE(ipv4_conntrack_ops));
...
}
一个 nf_register_hooks 调用,把 ipv4_conntrack_ops 数组里的 6 个钩子一次性原子性地注册进去。
视觉化:钩子在哪里
为了防止你在代码迷宫里迷路,我们停下来画张图。下图 9-1 展示了这些回调函数在网络栈中的具体位置。你可以把它想象成一张高速公路网的收费站点位图。
Figure 9-1. Connection Tracking hooks (IPv4)
(此处展示原图逻辑:数据包从 Netdev 进来,经过 ipv4_conntrack_in (PRE_ROUTING),然后分岔去转发或本地。本地发出的包经过 ipv4_conntrack_local (LOCAL_OUT)。最后在 POST_ROUTING 和 LOCAL_IN 处理 Helper 和 Confirm 逻辑。)
(注:为了不让图变成一团乱麻,我省略了 IPsec、分片、组播这些复杂场景,也没画本地发包时 ip_queue_xmit 这种细节。现在的图是主干道。)
识别身份:nf_conntrack_tuple
连接跟踪怎么知道两个包属于同一条连接?
它不能只看 IP,因为 NAT 会改 IP;也不能只看端口,因为端口会复用。它需要一个能唯一标识“单向流”的东西——这就是 nf_conntrack_tuple。
struct nf_conntrack_tuple {
struct nf_conntrack_man src;
/* These are the parts of the tuple which are fixed. */
struct {
union nf_inet_addr u3;
union {
/* Add other protocols here. */
__be16 all;
struct {
__be16 port;
} tcp;
struct {
__be16 port;
} udp;
struct {
u_int8_t type, code;
} icmp;
struct {
__be16 port;
} dccp;
struct {
__be16 port;
} sctp;
struct {
__be16 key;
} gre;
} u;
/* The protocol. */
u_int8_t protonum;
/* The direction (for tuplehash) */
u_int8_t dir;
} dst;
};
这个结构体有点像一张**“单程机票”**。它只描述流的一个方向:
- src(源):包含源 IP 和源端口。
- dst(目的):包含目的 IP、目的端口、协议号(TCP/UDP/ICMP 等)。
注意看那个 union。因为 TCP 看的是端口,ICMP 看的是 Type 和 Code,GRE 看的是 Key,所以这里用了个联合体来适应不同的 L4 协议。每种协议都有自己的连接跟踪模块(比如 nf_conntrack_proto_tcp.c),它们知道怎么从包里抠出这些信息填进去。
你可以把 tuple 理解为连接追踪的指纹。
只要两个包的 tuple 一样(或者互为源/目的颠倒),内核就认为它们属于同一条连接。这个 tuple 后面会用来查哈希表,速度必须极快。
到这里,我们已经把钩子挂好了,也知道怎么用 tuple 来识别包了。但有条连接被记录下来之后,它具体长什么样?那个神秘的 struct nf_conn 里到底藏了什么?这就是我们接下来要挖的东西。