手记

Linux基于mark的策略路由以及nf_conntrack RELATED

谈到什么是意义,话题总显得很大,近日每晚都和老城里的朋友聊老城的文化,老城的老房子,老城的叫卖声,老城的方言…进行了很多的思考,也挺充实。至于技术方面,也有跟朋友以及前同事聊过,这些都是意义。又到了周末,早早起来写一篇技术总结,至于老城的话题,我会在朋友圈零零散散地写。

本文关键词:Linux策略路由,nf_conntrack,socket,路由缓存

再谈“哪里来的回哪里”

当人们部署双线服务器时,比如一根线接电信,一根线接联通,人们当然不希望同一个流的流量被跨运营商路由,一个自然而然的需求就是哪里来的回哪里,为了实现这个需求,一般采用的技术是策略路由(Policy Routing),基于Linux,我们可以通过下面的范式来实现:

iptables -t mangle -A PREROUTING -j CONNMARK --restore-mark
iptables -t mangle -A PREROUTING -m mark ! --mark 0 -j ACCEPT
iptables -t mangle -A PREROUTING -i XXX .... -j MARK --set-mark X
iptables -t mangle -A PREROUTING -m mark ! --mark 0 -j CONNMARK --save-mark
iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
ip rule add fwmark X table X
ip route add $net/$mask via $gw dev $device table X

这样,我们就可以通过不同的标签来识别不同线路的流量,从而通过策略路由配置多个不同的默认网关。
  以上这些在Linux运维圈子里几乎成了一个典型的认知,但是仔细看上述的范式,好像少了点什么…

从一个错误的分析说起

如果我通过电信的线路去telnet一个在服务器上并不存在的端口,服务器会产生一条destination port unreachable的ICMP错误信息,然而这条ICMP信息严格来讲并不属于这个telnet引发的TCP流,因此貌似范式中的以下规则并不会起作用:

iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark

因此,后面的策略路由便不会被命中,最终这条ICMP报错信息并不一定会通过电信的线路返回,这完全不符合我们的预期,怎么办?
  事情将在此反转。
  如果你亲自试一下,并且加入下面的规则调试:

iptables -A OUTPUT -m mark ! --mark 0x0 -j LOG

发现ICMP消息也被打上了标签,也就是说,我们上述的担心并不真的存在!难道上面的逻辑分析哪里不对吗?
  在知识构成有缺失的时候,逻辑分析非常不靠谱,此时实际动手试一下更显的真实!我之所以强调这句话,是因为这是我2013年时面对上述问题最终解决后的总结,它对我十分有用。
  上面看似严谨的逻辑分析之所以是错的,是因为我当时根本就不知道nf_conntrack实现的细节,更不晓得什么是RELATED状态…这个ICMP错误消息虽然不属于telnet引发的TCP流,但它跟该TCP流却是RELATED的,而RELATED的流会继承原始流的conntrack结构体表项,这就是问题的根本,如果缺失了这个细节,就会带来错误的判断。
  在这里分析RELATED实现的细节会显得喧宾夺主而不合时宜,我会在本文的附录中给出详细的解释。
  在初步理解了RELATED状态的流域引发它们的原始流之间的关系后,我接下来给出另一个范式。

另一个范式-把ICMP错误信息引流到固定的地方

不要回答,但要审计!“不与陌生人说话”是信息安全领域的最高要求之一,读过《三体-黑暗森林》的应该有所体会,保持沉默永远是最安全的。
  如果有人伪造源IP地址发动了针对你的服务器的攻击,你难道真的要给这个伪造的源回复一条ICMP报错消息吗?不,这么做相当于你给陌生人说话了,伪造源的攻击者正等着收到你的ICMP信息呢,至少他可以通过IP头的TTL字段知道你离他真正有多远吧…因此最好不要回答!
  然而,我们却可以把这个ICMP报错消息发送到我们自己特定的审计服务器里,便于审计服务进行离线的模式分析,所以说这个ICMP消息某种意义上还是有用的。实现这个需求的方案依然是策略路由,和哪里来的回哪里范式不同的是,这个新的范式只需要标记由于错误的IP报文引发的ICMP报错消息报文即可,并不需要标记原始的数据包,我先把范式列如下:

# 连接的第一个包记录mark到conntrack
iptables -t mangle -A PREROUTING -m state --state NEW -j MARK --set-mark X
# 短路规则
iptables -t mangle -A PREROUTING -m mark --mark 0 -j ACCEPT
iptables -t mangle -A PREROUTING -j CONNMARK --save-mark 
# 待将mark保存在conntrack中的任务完成后,解除mark,不影响原始流
iptables -t mangle -A PREROUTING -j MARK --set-mark 0
# 如果是ICMP related包,就先restore标签,这里会恢复mark标签到skb
iptables -t mangle -A OUTPUT -p icmp -j CONNMARK --restore-mark

# 配置策略路由
ip rule add fwmark X table X
# 审计服务器的路由
ip route add $net/$mask via $gw dev $device table X

嗯,以上的范式可以完美做到将发生的ICMP错误消息路由给审计服务器,值得注意的是,在部署审计服务器的时候,最好将其部署在独立的区域,即通过服务器的某个网口仅能够到达审计服务器。
  虽然,我们完成了任务,然而,当需求满足了的时候,便要考虑性能优化了。好吧,又一次,我们遇到了conntrack,不可避免地,有人要问,“如果不用conntrack,如何满足上述两个范式中满足的需求,我真是受够了conntrack了,能不用它就不用它”,这里先给出答案,完全可以!诚然,说conntrack不好的人可能并不是真的知道conntrack到底差在哪里,更别说它为什么差劲了,大多数情况,这些人都是听别人说的,因此这并不能作为不用conntrack的理由,这就好比说别人说奥迪烧机油不要买,我听了之后就彻底看扁这个品牌了,不,不是这样,在说一个东西不好之前,你首先要了解它。

conntrack的毛病到底在哪儿

我可以客观的说,conntrack完全没毛病,说它有性能问题完全是胡扯,因为既然你想到了用conntrack帮你解决的问题,那就说明你还没有到拼性能的时候,如果真到了拼性能的地步,别说conntrack,连整个内核协议栈都需要被绕过去,君不见诸多DPI(深度包解析)平台,有哪个是基于传统的Linux内核协议栈的,一般的思路难道不是众核平台结合DPDK吗?
  我个人认识到以上这一点是经历了一个比较久的过程的,起初我也尝试着用conntrack来完成一个诸如短路,信息分析和记录这种事,后来我发现使用nf_queue来将skb上推到用户态,在用户态处理后再注入会更方便也更稳定,这个时候conntrack又退回到它本来的位置了,在接下来的优化中,我发现几个锁的开销是绕不开的,在一番深思熟虑之后,我为conntrack加入了percpu的cache,性能得到了大幅提升,然而此时的瓶颈成了路由查表或者socket查表…总之,如果追求极致的性能,Linux内核协议栈可能是无法胜任的,在2014年初的时候,我接触到了Tilera平台,跟DPDK一样的原理,只是没有后者通用,基于这些平台的解决方案无一例外地都是绕过传统的内核协议栈,直接从网卡中把数据包拉到用户态,充分利用核数越来越多的CPU,这是一种新的模式,它解决了传统协议栈无法解决的在多核平台上锁的问题。做个比喻,DPDK的方式不仅仅是一辆代步的质量好的轿车,而是一辆不以代步为目的的专用跑车,可能它很便宜,但它仍然是跑车。
  简简单单说conntrack性能差,就跟说宝马3系比奥迪A4L要好一样无聊。插曲在这里说正合适。
  近期老婆要换车,主要在宝马3系/4系和奥迪A4L/A5之间选择,大多数人千篇一律地说什么宝马操控上要比奥迪好什么什么的,我相信除了极少数人,绝大多数人都是听别人说的,后来老婆的一个懂车的朋友推荐买奥迪,并说了句实话,“前驱或四驱或更稳一些,应对路况的突变或者雨雪天会更好,至于操控?!你以为你开赛车玩漂移啊?!”,是的,也就30万,40万的车,还没到拼百公里加速的层次,就是个普通的质量好一些的代步工具而已,这个价位的车子主要功能就是代步,而不是品玩,简单点说,它们都是差不多的,对手车之间差别不可能太大,人人也都不是傻子,安全舒适好看要比比拼那些相差毫厘的参数更重要。所以买哪个还看自己的品牌认同感,以及看哪个顺眼了。我不懂车,还没我老婆懂,关于换车的事,我就不掺和了,但我懂Linux协议栈和内嵌的nf_conntrack,下文中我就说说conntrack如果有问题,那么它的问题到底在哪里。
  和内核协议栈其它的部分一样,影响conntrack机制性能的罪魁祸首,简单点说就是该机制的实现中内置的1把全局自旋锁(请注意,自旋锁问题并不是conntrack机制独有的!但凡多核平台,这就是个恶魔),即nf_conntrack_lock,该自旋锁在3个地方会被lock,我分别说。

1. conntrack表项初始化时
当一个数据包进入协议栈并且没有关联任何conntrack表项时,会为其初始化一个conntrack表项结构体,虽然它还没有必要被立即confirm,然而还是会被置入一个链表,整个过程大致如下:

init_conntrack()
{
    spin_lock_bh(&nf_conntrack_lock);

    // 这个expect机制我会在附录里详述,这里仅仅了解到所有expect流都在一个全局链表中即可
    exp = nf_ct_find_expectation(net, zone, tuple);
    if (exp) {
        __set_bit(IPS_EXPECTED_BIT, &ct->status);
        ct->master = exp->master;
    }
    // 为了管理方便,所有刚刚初始化的conntrack结构体都要置入一个叫做unconfirmed的链表中
    hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
                &net->ct.unconfirmed);
    spin_unlock_bh(&nf_conntrack_lock);
}

2. conntrack表项被confirm时
记住,unconfirmed链表仅仅是为了让一个conntrack结构体可被追溯,而不至于脱离管理而游离,当它最终被confirm的时候,就意味着它要加入全局conntrack链表了,此时便可以将它从unconfirmed链表中安全摘除了。在confirm例程中,大致的逻辑如下:

__nf_conntrack_confirm()
{
    spin_lock_bh(&nf_conntrack_lock);

    // 从unconfirmed链表摘除
    hlist_nulls_del_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode);
    // 加入全局的confirm链表
    __nf_conntrack_hash_insert(ct, hash, repl_hash);

    spin_unlock_bh(&nf_conntrack_lock);
}

3. conntrack销毁删除的时候
这个不多说,非常显然。
—————————————
以上几处需要lock/unlock自旋锁的地方意味着什么呢?
  首先只有conntrack结构体在初始化创建或者confirm的时候,才会lock/unlock这把自旋锁,如果说当前系统中的连接都是既有的且稳定的时候,这把自旋锁根本就不会被触动,因此执行流便根本不会落入它所带来的串行化瓶颈区域,这就意味着这种情况下conntrack机制的瓶颈是不存在的,至少说是不严重的,懂了吗?
  那么什么时候使用conntrack才会影响性能?
  很简单,有大量连接新建或删除的时候,比如说遭遇了DDoS攻击的时候,比如说大量短链接的时候,比如说同时大量TCP timewait连接的时候…
对于另外的情况,也是有很多方案可化解的,比如汗牛充栋的解决TCP timewait的方案,比如HTTP协议将短链接聚合成长连接的方案(多个HTTP请求重用单独的TCP连接)…只要我们避免了这类情况,就不必担心conntrack带来的性能损耗,然而,理想归理想,你永远也不能预料什么时候会有一个什么样的数据包到达你的服务器,就像你永远不能预料什么时候有什么人会敲你家的门一样,所以说,这个全局自旋锁的开销平均下来是非常可观的。Netfilter开发社区的猛士当然知道这个问题,当广大使用者正在纠结于conntrack全局锁如何在应用层面避开的时候,社区的内核开发者们早就在机制层面给予了优化,这是一件多么幸运的事情。
  我们来看看优化的细节,告诉大家,Linux 3.10内核还没有这个优化,但是发现4.3往后的内核就有了(我没有具体确认从哪个版本开始引入了这个优化,手头上有一个4.3版本的内核,确认了一下,这个优化已经被引入),所以还是那句话,尽量升级你的内核到最高版本吧。
  优化的本质在于自旋锁的细粒度拆分,仔细想想,unconfirmed链表需要全局锁吗?它只是为了确保conntrack结构体不要脱缰,因此只需要本地保存即可,没必要搞成全局的。说再多不如看代码。所以说新的优化版本逻辑如下:

init_conntrack()
{
    if (net->ct.expect_count) { // 这里加了一个条件,确保只有在确实需要查询expect链表的时候才会锁定额外的全局自旋锁,这是另一个优化
        // 注意,这里仍然有个全局锁,但是却不是每次都必进的分支,因为新增了expect_count这个条件变量
        spin_lock(&nf_conntrack_expect_lock);
        if (exp) {
            __set_bit(IPS_EXPECTED_BIT, &ct->status);
            ct->master = exp->master;
        }       
        spin_unlock(&nf_conntrack_expect_lock);
    }
    // 注意,以下的inline函数将全局的spinlock分解成了局部的percpu spinlock
    {
        ct->cpu = smp_processor_id();
        pcpu = per_cpu_ptr(nf_ct_net(ct)->ct.pcpu_lists, ct->cpu);
        // 仅仅锁定本地的自旋锁
        spin_lock(&pcpu->lock);
        // 仅仅将conntrack加入到本地的unconfirmed链表中
        hlist_nulls_add_head(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
                 &pcpu->unconfirmed);
        spin_unlock(&pcpu->lock);
    }
}

可以看到,unconfirmed链表成了percpu本地链表了,因此自旋锁的锁定粒度大大减小了,这并不影响其它的执行逻辑,比如当需要dump所有的unconfirmed表项时,完全可以先锁定全局的自旋锁再获取,要知道,全局自旋锁是可以囊含本地自旋锁的。好了,我们再看下优化后的confirm例程:

__nf_conntrack_confirm()
{
    {
        // 从conntrack的cpu字段获取当初它加入的本地cpu(conntrack结构体中保留cpu字段是个创举)
        pcpu = per_cpu_ptr(nf_ct_net(ct)->ct.pcpu_lists, ct->cpu);
        spin_lock(&pcpu->lock);
        // 从conntrack当初加入的cpu的本地unconfirmed链表删除
        hlist_nulls_del_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode);
        spin_unlock(&pcpu->lock);
    }
    {
        // 全局链表的自旋锁也分解成了orig以及reply两个方向的锁
        spin_lock(nf_conntrack_locks_orig);
        spin_lock(nf_conntrack_locks_reply);
        __nf_conntrack_hash_insert(ct, hash, repl_hash);
        spin_unlock(nf_conntrack_locks_reply);
        spin_unlock(nf_conntrack_locks_orig);
    }
}

通过以上的事实以及针对这些事实的论述,会发现影响conntrack性能的就是自旋锁,然而随着自旋锁在不断的细粒度化分解,这些问题将越来越不是问题,我相信未来追究会解决所有关于conntrack的性能问题的。
  也许你会说,除了链表插入,删除的自旋锁开销,难道不得不做的查询行为没有开销吗?哈哈,开销肯定是有的,但是你难道就不会变通一下吗?查什么不是查,就算你避开了查conntrack链表,难道你能避开查路由表或者socket链表吗?所以说,干嘛不把conntrack当成一个cache呢?把路由表以及socket表的查询结果都保存在conntrack表项中,这样就可以只查conntrack链表了,顺带取出的还有路由(转发时)以及socket(本地接收时),这个优化我亲自实现过,效果非常好,并且conntrack是所有包括路由表和socket链表在内第一个被查询的,所有这样的优化非常容易实现,没有实际操作过的人就不要人云亦云地诟病conntrack了,先试试我的方案再说!Together with L4_early_demux!
  看到conntrack在持续优化,我就欣慰了,因为这样我就可以将下文的方案作为另一种稍微好的做法,而不至于作为不得已的退避手段了。好了,接下来让我们看一下如何不使用conntrack来实现‘哪里来哪里去’

不使用conntrack实现“哪里来哪里去”范式

接着上一节的最后论调继续说。conntrack连接跟踪记录了一个五元组,而对于一个服务器而言,一个socket在全部意义上就扮演了conntrack的角色,因此在服务器上,即那些非转发设备上,可以让socket替代conntrack表项来保存一些必要的信息。在这些信息中,对于策略路由而言最关键的信息就是mark!
  昔日,我们用iptables为匹配的skb打上mark,然后将mark保存在conntrack结构体中,此后对于reply方向的包就可以将conntrack的mark给restore到skb中,对于服务器而言,我们完全可以把匹配的skb的mark保存在socket中,更加轻松的是,一旦socket有了mark,只要是本socket发出的skb,就会被自动打上相应的socket的mark,根本连iptables的restore-mark规则都不需要!
  这简直是如鱼得水,全程没有使用conntrack,让某些诟病conntrack的吃瓜群众放了心。
  在具体实现上,上一节同样说过,查什么不是查,只要保证查一次即可,既然在early demux以及L4 recv函数中都能查socket,那么我们自己写一个iptables的target,在该target中实现如下的逻辑岂不是妙哉:

sk = nf_tproxy_get_sock_v4(net, skb, hp, iph->protocol, iph->saddr, laddr, hp->source, lport, skb->dev, NFT_LOOKUP_LISTENER);
if (sk) {
    sk->sk_mark = mark_value;
}

请注意,mark并没有为skb来打,而是直接打给了socket,这是为了该socket往外发包时,这些数据包会自带该mark!这里的socket就完全扮演了conntrack的角色。
  如此一来,只要当进入的数据包匹配了iptables的规则,那么既定的mark就会被打入socket,然后该socket在发包的时候,会自动继承这个mark,从而去匹配特定于mark的策略路由表等。
  这是一种实现“哪里来哪里去”范式的良好手段,然而对于实现第二个范式,即为ICMP错误消息设定策略路由这种事就不好办了,因为ICMP消息的发送完全是内核来自动完成的,并不是通过某个socket来发送的。这个并没有好的手段来实现,因此必然要修改代码才能实现,我侧重于修改icmp_send函数:

void icmp_send(struct sk_buff *skb_in, int type, int code, __be32 info)
{
    ...
    // 这里在查路由表时并没有体现出mark的意义
    rt = icmp_route_lookup(net, &fl4, skb_in, iph, saddr, tos, type, code, &icmp_param);
    ...
}

因此我只需在icmp_route_lookup增加mark就好了,也比较简单,就是取skb_in的mark字段作为参数传入即可…
  本来我正准备改代码并编译呢,在此之前,我看了4.9/4.14的代码…所有这些都已经实现了:

void icmp_send(struct sk_buff *skb_in, int type, int code, __be32 info)
{
    ...
    mark = IP4_REPLY_MARK(net, skb_in->mark);
    sk->sk_mark = mark;
    rt = icmp_route_lookup(net, &fl4, skb_in, iph, saddr, tos, mark,
                   type, code, &icmp_param);
    ...
}

代码就不用注释了。关键在于IP4_REPLY_MARK宏:

#define IP4_REPLY_MARK(net, mark) \
    ((net)->ipv4.sysctl_fwmark_reflect ? (mark) : 0)

啊哈,抓住了大鱼!就是sysctl_fwmark_reflect,sysctl参数名是net.ipv4.fwmark_reflect,是不是在高版本中我只需要把net.ipv4.fwmark_reflect设置成1就OK了呢?答案显然是肯定的。
  因此,使用sysctl的fwmark_reflect参数来实现第二范式可以完美解决!那么第一范式呢?显然刚才我们就知道,第一范式可以通过重新编写一个target模块来解决,而不是用参数来解决,但是既然可以用参数解决ICMP消息的标记问题,是不是也有参数能满足第一范式的需求呢?即便没有,我觉得应该也不难,自己实现的话,最多也就一天吧,原因很简单,只需要把conntrack中转交mark的逻辑置于socket中即可。无比幸运的是,就连这也都不需要我自己来做了,系统针对TCP早就有了这样的支持机制,这就是net.ipv4.tcp_fwmark_accept所起的作用。

net.ipv4.tcp_fwmark_accept参数实现“哪里来哪里去”范式

我先给出范式的配置方法吧,列如下:

# 开启mark转交功能
sysctl -w net.ipv4.tcp_fwmark_accept=1
# 仅仅为新入的SYN包来进行标记,后续包无需标记,因为fwmark_accept会将mark赋值给socket
iptables -t mangle -A PREROUTING -i $dev1 -p tcp --syn -j MARK --set-mark $mark_dev1 
iptables -t mangle -A PREROUTING -i $dev2 -p tcp --syn -j MARK --set-mark $mark_dev2

# 配置策略路由
ip rule add fwmark $mark_dev1 tab dev1
ip rule add fwmark $mark_dev1 tab dev1

# 分别添加路由项
ip route add default via $gateway1 dev $dev1 tab dev1
ip route add default via $gateway2 dev $dev2 tab dev2

我打算本节就此打住,但是还是忍不住想再多说一点。
  tcp_fwmark_accept这个参数带来的功能是如何实现的呢?非常简单,TCP连接在初始化一个request socket的时候,会调用以下函数来为这个将来的客户端socket选择一个mark:

static inline u32 inet_request_mark(const struct sock *sk, struct sk_buff *skb)
{
    if (!sk->sk_mark && sock_net(sk)->ipv4.sysctl_tcp_fwmark_accept)
        return skb->mark;

    return sk->sk_mark;
}

可见,skb上的mark在tcp_fwmark_accept参数启用的情况下成为首选。

本节毕!

socket上的路由cache

很久以前,在我想到将路由查询的结果放在conntrack结构体中的同时,我就想到在作为服务器的场景下,把路由查询结果放在socket中了,但其实,Linux在实现四层协议时,天然地就支持一个路由cache。对于TCP而言,系统会将SYN-ACK的结果路由缓存到socket中,这是通过下面的代码实现的:

tcp_v4_syn_recv_sock()
{
    // 接上一节,在tcp_fwmark_accept开启时,req中便已经有了mark了,会影响接下来的路由查找
    dst = inet_csk_route_child_sock(sk, newsk, req);
    // 缓存路由到socket!
    sk_setup_caps(newsk, dst);
}

非常干净直接的一个逻辑,但是同样要考虑到的是,既然是缓存,就有什么时候过期删除的问题,TCP和IP很好的处理了这一点,在使用这个路由缓存的时候,会进行判断,我们来看下ip_queue_xmit函数:

ip_queue_xmit()
{
    rt = skb_rtable(skb);
    if (rt) // 这是已经路由好的数据包
        goto packet_routed;

    // 这里是重点,在使用socket的路由cache前,首先要check一下。
    rt = (struct rtable *)__sk_dst_check(sk, 0);
    if (!rt) {
}

有两种情况会导致socket的路由缓存失效:

1. 系统路由表发生了变动
  这是很好理解的,在实现上也很精妙。系统维护了一个全局计数器,每次路由表变动时都会递增,而每一个路由缓存都有一个ID字段,其值就等于当前的全局计数器的值,在使用路由缓存前发现缓存的ID和全局计数器不一致,便可将缓存废了,说明路由表发生了变化,要重新路由了。

2. TCP连接发生了超时重传
  一般情况下,除了尾部丢包和链路完全堵死,TCP在丢包时都会触发快速重传机制的,一旦发生超时重传,意味着发生了严重的事情,下层的IP层路由发生了变化只是原因之一,但是发生了严重事情后的行为需要更加保守,所以这个时候废除socket的路由缓存是一个好主意。

附录

附1:RELATED实现的细节

什么是RELATED流?
  所谓的RELATED流并不是自发产生的流,而是由别的流引起的流,典型地,我将RELATED流分为两类:

1. 可预期的带内流
这类的典型例子就是FTP协议,在一个连接中发起另一个连接,另外还有H.323协议,SIP协议等等,也属于这类。也就是说通过解析原始的数据包就能知道后面会有什么样的流通过,换句话说就是这些RELATED流是可预期的。

2. 不可预期的带外控制流
如果一个数据包在某跳发生了路由不可达事件,当前节点会向源端发送一个ICMP消息,鉴于IP是无连接无状态的,对于它已经经过的所有节点而言,这个ICMP完全是不可预期的,只能通过分析这个ICMP数据包的内容来判断它和哪个流相关联。
  以上两类都属于RELATED流,那么Linux是如何处理它们的呢?
  我们先看数据包进入conntrack处理逻辑后第一个要干的事,即nf_conntrack_in里面发生的事:

nf_conntrack_in()
{
    l4proto = __nf_ct_l4proto_find(pf, protonum);
    // 首先调用特定四层协议的error函数
    if (l4proto->error != NULL) {
        ret = l4proto->error(net, tmpl, skb, dataoff, pf, hooknum);
        if (ret <= 0) {
            return ret;
        }
    }
    // 然后再关联conntrack表项
    ret = resolve_normal_ct(net, tmpl, skb, dataoff, pf, protonum,
                l3proto, l4proto);
    ...
}

从error回调的调用来看,如果收到的数据包是一个ICMP包且ICMP协议有error回调的话,它是一定会被调用的。ICMP当然有error回调:

icmp_error()
{
    struct nf_conntrack_tuple innertuple...

    innertuple = ICMP包内容中解析出来的引发它的原始数据包的元组

    ctinfo = IP_CT_RELATED;
    h = nf_conntrack_find_get(net, zone, &innertuple);
    // 关联RELATED包到原始的conntrack表项
    nf_ct_set(skb, nf_ct_tuplehash_to_ctrack(h), ctinfo);

}

这意味着,对于ICMP错误消息而言,并不产生新的conntrack表项,而是重用引发它的原始conntrack表项,但这并不是RELATED的全部,对于可预期的RELATED流而言,事情远没有这么简单。
  假设来了一个新的流,并为其创建了一个新的conntrack表项,内核是怎么知道这个新的流是真正独立的新流还是由前面某个旧的流引发的预期中的流呢?
  为了实现上述的判断,系统需要把所有预期中的流全部保存到一个容器中,然后每到来一个新流都会去检索这个预期流容器,只要能在这个容器中被检索到,就为其打上RELATED标签,说明这个流并不是独立的。新流的检索逻辑如下:

init_conntrack()
{
    ... // 省略例行创建部分,这里只关注expect流
    if (net->ct.expect_count) {
        // 全局自旋锁
        spin_lock(&nf_conntrack_expect_lock);
        // 检索预期中的流表
        exp = nf_ct_find_expectation(net, zone, tuple);
        if (exp) {
            // 打上RELATED标签,过程省略
            ...
        }
        spin_unlock(&nf_conntrack_expect_lock);
    }
    ...
}

虽然在理论上,每到来一个新流都要去检索预期表,但是内核在这里做了一个大大的优化,如果你知道你的预期表是空的,你还要去检索它吗?虽然查一个空表也不费什么事儿,但问题的关键在于这个全局的自旋锁,在多核环境下,100个CPU同时锁一下再释放,玩呢?!所以说,这个优化在于,如果没有查表的必要,就不去查了。这个优化依托于这样一个事实,即内核在往预期流表中添加或者删除一个项时,它是知道这件事的,也就是说,内核知道预期表的当前项目数量,如果项目数量为0,那便可以避开全局自旋锁了!
  撸一下代码,就知道expect_count这个变量在nf_ct_expect_insert(当特定协议【比如FTP】的helper回调中发现了一个可以预期的流时,会创建一条预期流项,插入到一个expect流表中)中递增,这正是新建一个预期表项的时刻。
  这个优化太有深意了,几乎是一个通用的范式!
  言归正传,但也没几句话了,在预期的流和不可预期的流都被打上了RELATED标识后,就可以用以下的iptables规则识别了:

iptables -t mangle -A PREROUTING -m state --state RELATED ...

以上就是RELATED机制实现的全部了。

附2:local路由表可以添加删除啦

曾经,我抱怨过一个事实,那就是数据包到达,首先要无条件判断的就是这个包是不是发给本机的,如果是发给本机的,除非用iptables重新折腾它,便被本机无条件接收而根本就没有机会去查策略路由表。
  但是现在,事情起了变化,local表现在可以删除和重新添加了:

ip ru del pref 0 tab local

从而,路由表排序可以变成下面的样子:

10: from all fwmark 0x7b lookup 123
32765: from all lookup local
32766: from all lookup main
32767: from all lookup default

这不知给多少玩家带来了福音啊!

后记

通过世俗的方式,将宿命转化为连续,把偶然转化为意义!

本文原创作者:Bomb250

6人推荐
随时随地看视频
慕课网APP