跳到主要内容

9.4 连接跟踪条目(Connection Tracking Entries)

上一节我们聊了 nf_conntrack_tuple——那张「单程机票」。现在问题来了:内核拿到了这张机票,实际上要去哪里找对应的「乘客记录」?那个用来存放连接状态、被称为 struct nf_conn 的结构体,到底长什么样?

这才是连接跟踪的心脏。

连接跟踪条目的长相:struct nf_conn

这个结构体比较大,我们把它拆开看。你可以在 include/net/netfilter/nf_conntrack.h 里找到它的定义。

struct nf_conn {
/* Usage count in here is 1 for hash table/destruct timer, 1 per skb,
plus 1 for any connection(s) we are `master' for */
struct nf_conntrack ct_general;

spinlock_t lock;

/* XXX should I move this to the tail ? - Y.K */
/* These are my tuples; original and reply */
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];

/* Have we seen traffic both ways yet? (bitset) */
unsigned long status;

/* If we were expected by an expectation, this will be it */
struct nf_conn *master;

/* Timer function; drops refcnt when it goes off. */
struct timer_list timeout;

. . .

/* Extensions */
struct nf_ct_ext *ext;
#ifdef CONFIG_NET_NS
struct net *ct_net;
#endif

/* Storage reserved for other modules, must be the last member */
union nf_conntrack_proto proto;
};

这里有几个字段非常关键,值得我们停下来细看。

1. tuplehash[IP_CT_DIR_MAX]:双向的指纹

还记得上一节那个「单程机票」的比喻吗?如果你只握着去程的票,你是回不来的。一个完整的连接包含两个方向:原始方向和回复方向。

这个数组就存着这两个方向的 tuple:

  • tuplehash[0](或 IP_CT_DIR_ORIGINAL):原始方向的流(比如你发给服务器的 SYN 包)。
  • tuplehash[1](或 IP_CT_DIR_REPLY):回复方向的流(服务器回给你的 SYN+ACK)。

这两个元素被插入到哈希表中。无论包是从哪个方向来的,内核都能算出哈希值,找到这个 nf_conn 结构体。

2. status:连接的状态机

这是一个位图。连接刚被创建时,它可能处于 IP_CT_NEW 状态;一旦双方沟通顺畅(比如 TCP 握手完成,或者 UDP 收到了回包),它就会变成 IP_CT_ESTABLISHED

你可以在 include/uapi/linux/netfilter/nf_conntrack_common.h 里看到 ip_conntrack_info 枚举,那里定义了所有可能的状态。

3. master:主从关系

这个指针指向「主连接」。什么意思?比如 FTP 协议,它在著名的 21 端口上跑控制连接,但数据传输会另开端口。内核知道 21 号端口的连接是「老大」,那个动态开出来的数据连接就是「小弟」。master 就是让小弟能找到老大的指针。

这通常由 init_conntrack() 方法设置,当发现这个包匹配了一个「预期连接」时就会挂上。

4. timeout:倒计时炸弹

连接不是永久存在的。每一条 nf_conn 都挂着一个定时器。如果一段时间没看到流量,定时器到期,连接就会被销毁,内存被回收。

  • 对于 UDP:如果一直没回包,超时时间短;如果双方有来有回,超时时间长。
  • 当你调用 __nf_conntrack_alloc() 分配这个对象时,定时器就会被设成 death_by_timeout(),听起来就很硬核。

5. extproto:扩展性

  • ext:指向扩展区域。有些模块需要在连接跟踪里存点私货(比如时间戳、计费信息),不用动主结构体,挂在这就行。
  • proto:这是一个联合体,专门留给协议层(TCP/UDP/ICMP)自己存私有数据的。放在最后面是为了不浪费内存空间。

连接跟踪的入口:nf_conntrack_in()

结构体看完了,现在来看内核是怎么用它的。当一个数据包进入 Netfilter 框架时,如果连接跟踪功能开启,最终会调用到 nf_conntrack_in()

这个函数可以说是整个 conntrack 的中枢神经。

unsigned int nf_conntrack_in(struct net *net, u_int8_t pf, unsigned int hooknum,
struct sk_buff *skb)
{
struct nf_conn *ct, *tmpl = NULL;
enum ip_conntrack_info ctinfo;
struct nf_conntrack_l3proto *l3proto;
struct nf_conntrack_l4proto *l4proto;
unsigned int *timeouts;
unsigned int dataoff;
u_int8_t protonum;
int set_reply = 0;
int ret;

第一步:是不是老熟人?

首先看这个包是不是已经被处理过了(比如在 loopback 设备上绕了一圈,或者被标记为 untracked)。

if (skb->nfct) {
/* Previously seen (loopback or untracked)? Ignore. */
tmpl = (struct nf_conn *)skb->nfct;
if (!nf_ct_is_template(tmpl)) {
NF_CT_STAT_INC_ATOMIC(net, ignore);
return NF_ACCEPT;
}
skb->nfct = NULL;
}

如果是模板连接,它会暂时被保存下来,后面处理完可能还要挂回去(这在 TCP 的某些特殊处理中会用到)。

第二步:确认身份(L3 和 L4 协议)

接下来,内核先确认你是哪一层(L3)的协议。对于 IPv4,就是找 nf_conntrack_l3proto_ipv4

l3proto = __nf_ct_l3proto_find(pf);

然后,最重要的来了:抠出四层协议号。因为只有知道你是 TCP 还是 UDP,才能找到对应的处理逻辑。

ret = l3proto->get_l4proto(skb, skb_network_offset(skb),
&dataoff, &protonum);
if (ret <= 0) {
. . .
ret = -ret;
goto out;
}

l4proto = __nf_ct_l4proto_find(pf, protonum);

这里 ipv4_get_l4proto() 会把 IP 头扒开,告诉你 Protocol 字段是多少(6 是 TCP,17 是 UDP)。拿到 protonum 后,立马找到对应的 L4 协议对象 l4proto

第三步:查错(Error Checking)

还没完,L4 协议可能有自己的脾气。在真正开始跟踪之前,协议模块会先检查一下这个包有没有畸形。

  • udp_error():检查校验和、包长度对不对。
  • tcp_error():检查标志位是不是合法、序列号是不是瞎编的。
if (l4proto->error != NULL) {
ret = l4proto->error(net, tmpl, skb, dataoff, &ctinfo,
pf, hooknum);
if (ret <= 0) {
NF_CT_STAT_INC_ATOMIC(net, error);
NF_CT_STAT_INC_ATOMIC(net, invalid);
ret = -ret;
goto out;
}
/* ICMP[v6] protocol trackers may assign one conntrack. */
if (skb->nfct)
goto out;
}

如果这一步报错,包会被直接丢弃,统计计数器 invalid 加一。

第四步:核心操作——查找或创建

这是最关键的一步。resolve_normal_ct() 做了以下几件事:

  1. 算哈希:调用 hash_conntrack_raw(),根据这个包的五元组算出一个哈希值。
  2. 查表:拿着哈希值去哈希表里找 __nf_conntrack_find_get(),看有没有现成的记录。
  3. 创建:如果没找到,说明这是一个新连接。调用 init_conntrack() 分配一个新的 nf_conn 结构体。
  4. 挂入未确认列表注意,这时候这个新连接还不敢直接进哈希表,而是先被扔进「未确认列表」。

为什么有个「未确认列表」?因为包可能还要经过后面的 NAT 或者过滤模块,如果中途被 DROP 了,那这个连接条目就不应该存在。只有等包真正通过了所有检查,才会调用 __nf_conntrack_confirm() 把它移到正式的哈希表里。这个列表是挂在网络命名空间 netns_ct 里的。

ct = resolve_normal_ct(net, tmpl, skb, dataoff, pf, protonum,
l3proto, l4proto, &set_reply, &ctinfo);
if (!ct) {
/* Not valid part of a connection */
NF_CT_STAT_INC_ATOMIC(net, invalid);
ret = NF_ACCEPT;
goto out;
}
if (IS_ERR(ct)) {
/* Too stressed to deal. */
NF_CT_STAT_INC_ATOMIC(net, drop);
ret = NF_DROP;
goto out;
}

如果 IS_ERR(ct) 为真,说明内核太忙了(比如哈希表满了),直接 NF_DROP

同时,resolve_normal_ct() 还会把找到(或新建)的 nf_conn 指针挂到 SKB 的 nfct 成员上,并把连接状态(比如 IP_CT_NEW)塞进 nfctinfo。这样后面别的模块看一眼 SKB 就知道这连接是谁、处于什么状态。

第五步:处理协议状态与超时

拿到了连接对象,现在要更新它的状态。

首先是超时策略。对于 UDP,如果是单向的(没见过回包),超时可能是 30 秒;如果是双向的,可能是 180 秒。nf_ct_timeout_lookup() 就是要决定这个数字。

/* Decide what timeout policy we want to apply to this flow. */
timeouts = nf_ct_timeout_lookup(net, ct, l4proto);

然后,调用具体的协议处理函数。比如 UDP 会调用 udp_packet(),TCP 会调用 tcp_packet()

ret = l4proto->packet(ct, skb, dataoff, ctinfo, pf, hooknum, timeouts);
if (ret <= 0) {
/* Invalid: inverse of the return code tells
* the netfilter core what to do */
pr_debug("nf_conntrack_in: Can't track with proto module\n");
nf_conntrack_put(skb->nfct);
skb->nfct = NULL;
NF_CT_STAT_INC_ATOMIC(net, invalid);
if (ret == -NF_DROP)
NF_CT_STAT_INC_ATOMIC(net, drop);
ret = -ret;
goto out;
}

udp_packet() 里,会调用 nf_ct_refresh_acct() 刷新定时器。如果这是第一次看到回包,它会把这个连接标记为「已回复」。

第六步:事件触发与清理

如果 set_reply 标记被设置了(说明这是反向的第一个包),还需要触发一个事件,告诉别的子系统「嘿,这条连接现在通了」。

if (set_reply && !test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
nf_conntrack_event_cache(IPCT_REPLY, ct);

最后,处理一下之前的模板 tmpl,把引用计数放回去。

out:
if (tmpl) {
/* Special case: we have to repeat this hook, assign the
* template again to this packet. We assume that this packet
* has no conntrack assigned. This is used by nf_ct_tcp. */
if (ret == NF_REPEAT)
skb->nfct = (struct nf_conntrack *)tmpl;
else
nf_ct_put(tmpl);
}

return ret;
}

最后一块拼图:确认(Confirmation)

我们前面说新建的连接会先扔进「未确认列表」。那它是怎么变成正式员工的呢?

这发生在 ipv4_confirm() 函数里。它挂载在 NF_INET_POST_ROUTINGNF_INET_LOCAL_IN 钩子上。

当包一路畅通无阻,快走出协议栈(或者即将交给本机进程)时,ipv4_confirm() 会调用 __nf_conntrack_confirm()。这个函数会做两件事:

  1. 把连接从未确认列表里摘下来。
  2. 正式插入到全局的哈希表中。

此时,这条连接才算真正「上岗」了。如果包在中间被任何规则 DROP 了,这个 confirm 函数就不会被调用,那个未确认的条目最终会被销毁——这就保证了哈希表里存的都是真正活着的连接。