跳到主要内容

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),必须抢在所有防火墙规则之前跑:

  1. ipv4_conntrack_in:挂在 NF_INET_PRE_ROUTING
  2. ipv4_conntrack_local:挂在 NF_INET_LOCAL_OUT

为什么是这两个点?

  • PRE_ROUTING:所有从外面进来的包,不管是要转发给本地还是发给别人,都会经过这里。如果不在这里截获,转发包的状态就会丢。
  • LOCAL_OUT:本地发出的包。如果不在这里截获,本机发出的数据包就只有“去”没有“回”,连接跟踪表就只有半条命。

至于剩下的 ipv4_helperipv4_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 里到底藏了什么?这就是我们接下来要挖的东西。