引子
可能大家都听说过 iptables 这个名字,可能也都知道这玩意儿可以在 Linux 中用于配置防火墙,可能也或多或少听说过和它相关 netfilter 这个东西。但是日常工作中大家可能直接用到它的场景并不很多,因此今儿咱们来简单了解一下 iptables 它在内核中大概的实现原理,并且最后尝试着写个简单的模块塞到内核里,以此来验证我们了解到的~
开局一张图
iptables 简介
当前来讲,上面那种图可能没了解过 iptables 的小伙伴可能看不懂,咱们慢慢往后就行了。首先我们简单介绍一下 iptables 是个啥。
iptables 是 Linux 中最常用的一种防火墙工具,除了防火墙它还可以用作 ip 转发以及简单的负载均衡等功能。基于 Linux 中的 netfilter 内核模块实现的,当前大家可以简单地认为 netfilter 就是在内核里的固定位置塞了一堆钩子,数据包会通过这些钩子。而 iptables 相当于这些钩子的 “前端”,也就是说,它给用户侧提供了一个机会,一个可以让用户自由决定 “当数据包通过这些个钩子时要发生哪些变化” 的机会。
iptables 简单试用
再来一张图
这里我再给大家总结一张图,图中描述了 iptables 这个命令的总体用法,看不懂没关系,我们马上来玩一下子~
防火墙功能
iptables -I INPUT -p icmp -j REJECT # 拒绝所有进入来的 icmp 协议
首先我这边有一台配有公网 ip 的云主机,ip 地址是:82.157.xxx.xxx。然后在上面执行以上命令:
然后我在我本地的电脑上去 ping 一下子,就会发现 icmp 报文确实凉了:
代理转发功能
# 将发往 3000 端口的转发到 13190
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.1 --dport 3000 -j DNAT --to-destination 127.0.0.1:13190
可以看到我先执行了上面的命令,然后通过 ts-node 开了一个简单的本地服务器并且监听 13190 端口,同时通过 lsof 命令确认一下 13190 端口确实开启而 3000 端口并没有人再监听:
接下来尝试去 curl 127.0.0.1:3000,一般来讲没有监听 3000 端口就会返回一个 refused 之类的东西,但是由于我们上面设置了 iptables 规则,所以发往 3000 端口的数据都被转发到 13190 这个端口上去了:
当然关于 iptables 还有很多更高级的用法我们就不挨个儿试了,最后我会给大家总结一下一些常用的命令。
试用了一下之后,我们可以去尝试简单地了解一下其实现原理,这就涉及到了在 Linux 2.4.x 版本以上被添加到内核的 netfilter 模块了。
netfilter 介绍
挂接点
Netfilter 中文 “网络过滤”,其实就是在数据包从进到网卡到送入用户态之间的几个固定位置中插入了几个挂接点,我们可以看下图,这是 Linux 5.x 版本中定义的几个挂接点,最后那个什么 NUMHOOKS 不用管它,对其他几个有个印象就行:
这几个挂接点正好对应了我们开局那张图上的几个框框。
我们知道了数据包从网卡到用户态中间会有几个挂节点,那么这几个挂节点分别在哪儿呢?
挂接点1:PRE_ROUTING
我们再来一张图:(图片来自于网络)
这张图的内容大家应该都很熟悉,就是我们常说的网络分层模型,这里我们不管它是分五层还是七层,我们只看下四层,也就是内核态的四层。
其中的网络层主要做的事情有一步叫做 “路由选择子系统”,简单来讲就是根据主机路由表进行路由选择的,这里我们回到“开局那张图”:
可以看到红框里的就是“路由选择”,而它前面的叫 “PREROUTING”,所以我们第一个挂载点就出来了,它的钩子被调用发生在数据包从“链路层”进到“网络层”触发“路由选择”之前,也就是说可以认为它发生在“二层”到“三层”之间,对应到内核源码的话大概在这个位置,这个 ip_rcv 函数就是链路层处理完后要经过的第一个网络层函数:
这里大家看到这个函数可能会对 NF_HOOK 这个东西感到陌生,现在可以简单认为它里面会调用“NF_INET_PRE_ROUTING”上的钩子就可以了。另外我说这个“ip_rcv”是网络层的函数,而这个“NF_HOOK”是在这个“ip_rcv”最后被调用的,大家可别认为是网络层的什么“路由选择”都执行完事儿才调用到它昂,真正的执行“路由选择”以及其他网络层重要事情的逻辑都在“NF_HOOK”最后那个参数“ip_rcv_finish”中去做的。
另外再插一句,如果是 ipv4 的报文就会走这个“ip_rcv”,对于 ipv6 会走类似的“ipv6_rcv”:
虽然对于调用的函数不一样,但是对于 netfilter 这个大框架来说,ipv4 和 ipv6 的处理流程都是差不多,这里我们主要以 ipv4 来往下看。
挂接点2:INPUT
对于第二个挂接点“NF_INET_LOCAL_IN”,我个人习惯叫他“INPUT”,叫法无所谓,大家知道是啥就行。
该挂载点发生在网络层“路由选择”之后,如果说路由选择子系统发现当前的 ip 是本机的 ip 的话,就会将 skb 这个数据缓冲结构体扔给“ip_local_deliver”这个内核函数:
而 NF_HOOK 最后那个参数“ip_local_deliver_finish”表示执行完这个挂载点的钩子之后要执行的回调:
简单看一下我们就可以知道从名字上来看我们也能知道,此时还没出“网络层”,所以简单来讲这第二个所谓的“INPUT”挂载点就在位于“三层”通往“四层传输层”之间被触发的。
P.S. 当然对于 ipv6 来讲也有一个类似的以 ip6 开头的函数,我们就不截图看了,后面对于其他挂载点来说也都是如此,ipv6 都会有个对应的处理函数,我们就不在每次都解释一遍了。
挂接点3:FORWARD
对于第三个挂载点“FORWARD”,从名字上来看就知道它是转发的时候被触发的,那什么时候应该转发呢?
我们在上面知道在网络走到三层的时候会进行“路由选择”是吧,上面那个“INPUT”点位是当路由选择子系统认为当前 ip 是属于本机的情况下会走到,那么反之当路由选择子系统认为过来的这个包的目标 ip 不是本机的时候就会选择转发,从而触发这个“ip_forward”函数:
这个函数由于太长了,我就直接删掉中间部分进行截图了。
所以现在我们知道这第三个挂载点位是当数据包在“第三层网络层”时,如果内核认为这个 ip 不属于本机的话就会走到该钩子点位,因此一定要说它在几层的话,大概就算是在“三层中间”吧。
挂接点4:OUTPUT
现在来到了第四个点位“OUTPUT”这里,顾名思义,“OUTPUT”一定是在数据包发送的时候被触发的,所以我们来看源码中下面这段截图:
截图中这个名为“__ip_queue_xmit”的函数正是数据包从“传输层”出来之后很快就会执行到的一个函数,它的里面先通过“ip_toute_output_ports”进行路由系统的选择,然后紧接着就执行了“ip_local_out”方法,这个里面又会执行“__ip_local_out”:
出现了!我们期待的“OUTPUT”出现了!你来或不来,他就在这里,不离不弃。所以简单来讲,这第四个“OUTPUT”的钩子被触发在数据包从“四层传输层”出来并执行完路由选择之后。
挂接点5:POSTROUTING
终于来到最后这个名为“POSTROUTING”的挂接点,它的位置在哪里呢?
我们之前说过,nf_hook 执行时传进去的最后一个参数就是当前钩子都执行完毕后的回调函数,对于“OUTPUT”钩子来讲它的回调是“dst_output”函数:
可以看到这个“dst_output”里执行了一个 output 函数,这个函数是谁呢?我们先来看一下下面这坨代码:
该函数有点长所以我把中间的过程都给干掉了,这个函数中可以看到第 1645 行有个 rt->dst.input = ip_local_deliver 函数,这个函数是不是有点眼熟,忘了的话可以往上翻一下,就在“挂接点2: INPUT”那里我们有说到,“ip_local_deliver 是数据包从外边进来的时候通过它要执行 INPUT 钩子的函数”。
然后你再看,这个“ip_local_deliver”函数上面大概第 1638 行的代码,是不是能看到一行“rt->dst.output = ip_output”这样的一行代码,“ip_local_deliver”赋值给了 dst.intput 属性,而这个 ip_output 赋值给了 dst.output 属性。
所以不知道你想到没有,这个“rt_dst_alloc”的作用就是当数据包进来时给数据包上放一个用来表示“intput”的“ip_local_deliver”函数,而当数据包出去时,则用来给数据包上安装一个表示“output”的“ip_output”函数。所以现在回到上面那个“dst_output”函数中:
“dst_output”函数中绝无仅有的这一行代码“skb_dst(skb)->output”里面执行的这个“output”正是这个“ip_output”:
很惊喜,我们期盼的最后一个点位“POSTROUTING”就在这个函数中!并且不知道你有没有发现,上一个“OUTPUT”点位和这个“POSTROUTING”钩子的执行位置离的非常的接近,甚至可以说中间是无缝衔接了,所以不知道你是不是也有这么一种疑问“这俩玩意儿离得这么近,那还要那个 OUTPUT 干啥?”。
这里我们来解释一下,其实从代码实现角度来看这个“OUTPUT”和“POSTROUTING”点位的执行离得确实近,但是却不能轻易干掉“OUTPUT”,为啥呢,我们还记得在上面的“挂接点3:FORWARD”中说的么,“forward 是当路由子系统发现目标 ip 不属于本机 ip 的时候做转发的”,它里面执行的函数我们再复习一遍,就是下面这个“ip_forward”:
我们重点来关注一下它最后那个“ip_forward_finish”回调函数,看看它里头做了什么:
看看最后那行的“dst_output”,眼熟不,是的他就是“挂接点4:OUTPUT”的钩子执行完毕后,最后的那个回调,同时也是“挂接点5: POSTROUTING”的钩子的上层函数。
所以简单点来讲,当数据包从外边进来并且内核发现目标 ip 不属于本机时会做 forward,做完 forward 之后也会触发这个“POSTROUTING”,而当一个数据包是从本机的上层协议栈直接发出的时候会先走“OUTPUT”,走完“OUTPUT”也会走“POSTROUTING”。
所以说到这儿,不知道你想明白“OUTPUT”和“POSTROUTING”的区别以及“OUTPUT”和“POSTROUTING”虽然离得近但是并不能被干掉的原因了么,是的,原因就是由于“POSTROUTING”不管是内核要转发包还是从上层协议栈往外发包都一定会被执行,所以对于想要只修改来自上层 UDP 或 TCP 协议的包来说,如果只有一个“POSTROUTING”挂载点位的话,那么所有的被“FORWARD”的包也会跟着受影响,因此“OUTPUT”这个钩子点决不能被干掉!
个人疑问
可能有很多朋友在网上见过 iptables 相关的图式,可能长这样(图片均来自于谷歌图片):
也可能长这样:
总之这些图和我在“开局一张图”中画的大同小异,但是有一点,就是有些图示中会在“OUTPUT”到“POSTROUTING”中间加上一个“路由判断”的过程。
我不确定他们是否是错的,也不能说我的“开局一张图”就一定是对的,只不过我个人在学习内核源码这部分内容的时候,确实是没有在“OUTPUT”到“POSTROUTING”这之间看到有做类似“路由选择”的操作。相关的源码我也在上面的文章中给大家截图了。当然了,也可能是因为我菜所以没找到相关部分,如果有朋友知道这中间到底做没做“路由选择”,如果做了是在哪里做的,希望能不吝告诉我一下,感激不尽~
简单总结
到这里,我们尝试从内核源码的角度简单了解了一下 netfilter 这个框架内部所定义的五个钩子,并且看了一下这五个钩子分别所在的位置,我们可以来简单总结一下:
-
“PREROUTING”钩子点位于数据包从“二层”刚一上去,还没到“三层”的时候被触发,发生在“三层”的路由选择之前。
-
“INPUT”钩子点则位于“三层”的路由选择之后,如果内核发现目标 ip 是本机地址的话,就要将数据包送往上“四层”,但是在真的进到“四层”之前,给了个机会去触发钩子,这个钩子就是“PREROUTING”。
-
“FORWAED”钩子则是和“PREROUTING”反着,如果内核认为目标 ip 不属于本机网卡的话,就要做“FORWAED”转发,因此它位于数据包进到“四层”之前,并且它会被直接扔出去不会进到本机的“四层”。
-
“OUTPUT”钩子位于数据包从“四层”下来之后,并且“三层”进行了路由选择之后被触发。
-
“POSTROUTING”钩子则是最终不管是转发包也好,本主机自己要往外发包也好,最终都一定会走到这个“POSTROUTING”钩子,它会发生在数据包进入到“二层”之前。
五链四表
简介
应该有很多小伙伴在学习 iptables 相关时都听过这个所谓的“五链四表”,那所谓五链四表究竟是什么东西呢?
首先我们来说“五链”。“五链”是什么?其实就是上篇文章中我们介绍的那个五个钩子点位,分别是“INPUT”、“PRE_ROUTING”、“FORWARD”、“OUTPUT”、“POST_ROUTING”。我们上篇文章中说在这五个点位上,分别有很多的钩子函数,而每个点位上的这堆钩子函数,拿根线儿,串一串儿,诶,这就是所谓的“五链”。所以目前简单理解的话,就是五个钩子点位上的钩子函数们都分别做成链表,这就是五链。
接下来我们说“四表”是啥。所谓四表就是 iptables 自己定义了四“套”,作用不一的,要分别放在五个点位的一些钩子函数。到这儿可能还是不太容易明白啥是“四表”,我们先往后继续简单介绍,然后结合源码来看就明白了。
那么“四表”都有哪四表呢?分别有:
-
-
- raw表: 决定是否要对数据包进行跟踪。分别位于“OUTPUT”和“PREROUTING”两条链上。
- mangle表: 用来给数据包做标记的。分别位“INPUT”、“OUTPUT”、“FORWARD”、“PREROUTING”、“POSTROUTING”等五条链儿上全都有。
- nat表: 做网络地址转换用的,可以修改数据包中的源、目标IP地址或端口。分别位于“INPUT”、“OUTPUT”、“PREROUTING”、 “POSTROUTING”等四条链儿上。
- filter表: 主要用作数据包过滤,可以用来决定是否要 Drop 掉某些包。分别位于“INPUT”、“FORWARD”、“OUTPUT”等三条链儿上。
-
其中 mangle 表和 raw 表相对来说并不是那么常用,其中 nat 表和 filter 表是大头儿,最常用,我们后面主要会结合他俩的源码来进行后面的了解。
接下来我们就尝试结合着源码去了解一下所谓的“四表”~
(P.S. 其实严格来讲应该是“五表”,Linux 比较高的版本中还有张名为“security”的表,不过它最不常用,我们也不做过多介绍,大家有个印象知道还有第五张表就行)
“表”是个啥
对于这几张“表”的源码都定义在 Linux 内核源码的“net/ipv4/netfilter”目录下,可以很容易地找到:
我们以其中最比较容易看的 iptable_nat 为例:
进去就可以看得到里头写死了一个包含有四个对象的数组。其中“hook”表示钩子函数,“pf”表示协议簇,除了 ipv4 还有 ipv6 的,“hooknum”表示该钩子要放到哪个点位上,也就是要插入到哪条链儿上,“priority”表示该钩子的优先级,数值越小,在对应的链儿上就越靠前,也就是会被越早执行。
现在有了这么个数组,我们就可以猜测它的作用,是不是就是要想办法告诉内核,要按照这条数组上写的内容来把对应的 hook 函数插入到 netfilter 的五个点位对应的位置。如此猜测的话,那后面一定会有类似于“register”这种注册类的函数,我们往后看:
噢哟果然有~那在其他的表中是否也有类似的操作呢,我们可以简单看下 filter 表中的源码:
哦哟也有!诶?但是你可能会发现这俩注册函数长得不太一样,而且如果你真的尝试去翻一翻 filter 表的源码文件的话,你会发现一个问题,那就是它里头根本没有像是上面 nat 表似的定义那一个大数组!那这是为啥呢?
其实吧,像 filter 表中那个“ipt_register_table”才是真正注册该表的函数,在 nat 表中也会执行这个函数:
该函数的第一个参数“net”表示要给哪个网络命名空间上添加表,网络命名空间的概念我们本篇文章不做过多介绍,对于 net 命名空间大家只要简单的记住一件事儿,就是:每当你收发一个数据包的时候,在数据包上都有一个 net 属性,net 属性就是一个类似命名空间的东西,它上面记录类似“当前数据包如果要查询路由的话要使用的路由表”,“当前数据包如果要触发 netfilter 的话要执行哪些钩子”,“当前数据包可以使用哪些网络设备”等等信息。不同的数据包可能会持有不同的 net 命名空间,自然每个数据包可能执行的 iptables 规则,或者要查询的路由表等等也就都有可能不一样。
所以每当用户在用户空间通过类似“ip netns add xxx”这种命令创建一个新的网络命名空间时,都会触发 iptables 的“ipt_register_table”的注册,都会往这个 net 属性上传入当前 iptables 的规则,当然刚创建的时候默认没有啥规则。
然后回到这个 “ipt_register_table” 函数,它的第二个参数 “nf_nat_ipv4_table” 就是描述了这张“表”的基本信息,我们来看下 filter 表和 nat 表的长啥样:
每张表都会有类似这么个基础信息,分别描述了“表名”,“要作用于哪条链儿上”也就是要作用于哪个钩子点位上,以及“协议簇”,“优先级”以及“初始化函数”等,其中的“初始化函数”当调用 “ipt_register_table” 后就会被执行
好,现在回到我们上面的问题,为啥 nat 表和 filter 表里的代码长得不太一样,nat 表的代码里直接定了一个大数组,而 filter 表中没有定义数组呢?其实不是 filter 表中没定义,而是 nat 表中写得太明白了,filter 表中也有个大数组,不过是通过函数动态生成的:
看看上面这个函数,该函数中执行了一个叫做 “xt_hook_ops_alloc” 的方法,它接收一个 “&packet_filter”,你可以往上翻一下,这个 packet_filter 就是刚才咱们说的每张表都有的描述了自己的基本信息的结构体。然后这第二个参数 “iptable_filter_hook” 就是这张表在每个点位要执行的钩子函数,这里只有一个,也就是说对于每个钩子点位,要执行的 filter 表的钩子函数都是一样的。
然后该函数返回的那个 “filter_ops”,实际上就是类似于 nat 表中直接写死的那个大数组一样的一个数据数据结构:
你看,他俩的数据类型果然都一样吧~
所以其实不管那张表,都会根据自己的一些基本信息比如名字,优先级等来创建出一个大数组,数组里的每个元素就分别表示了“要往哪个钩子点位的链表上,插入何种优先级的钩子函数”。
然后我们再回到上面那个注册函数 “ipt_register_table” 上,它的最后一个参数 “ipv4.iptable_filter”:
这个参数表示每个网络命名空下的不同协议的表项指针,简单来讲就是刚才上头咱不是说可能会有很多 netns 命名空间么,每个命名空间下都可能有不同的 iptables 规则,同时每个命名空间下也会分别有 ipv4 的 iptables 规则和 ipv6 的 iptables 规则。
所以到这里,如果用伪代码来简单描述一下最后产生的结果的话,那大概如下:
netns = {
netfilter: {
ipv4: {
// 以下不同点位对应的 hooks 们可能来自不同的地方注册
// 比如 iptable 的 nat 或者 filter, 都可能在同一个点位进行 hook 的注册
NF_INET_PRE_ROUTING: [来自 raw 表的 hook1, 来自 nat 表的 hook2, ......],
NF_INET_LOCAL_IN: [来自 mangle 表的 hook1, 来自 filter 表的 hook2, ......],
NF_BR_LOCAL_IN: [来自 xx 表的 hook1, 来自 yy 表的 hook2, ......],
......
},
ipv6: {
// 类似上面的 ipv4
}
}
}
也就是说,每张表的注册方法以及每张表的要插入的钩子函数以及挂载点位虽然各有些不太一样,但是最后的结果都是会在当前的这个网络命名空间下的 netfilter 规则中,在其不同的协议簇所对应的不同钩子点位的链表中,根据优先级插入自己定义好的钩子函数。
NF_HOOK
在上面我们简单了解了一下 iptables 中的表门是如何注册的,以及注册完之后大概长成什么样子我们也用伪代码来描述了一下。那么这个数据结构是如何被执行的呢?说到这里,不知道你还记不记得上一篇中咱们在介绍每个钩子点所在的位置的时候,我们发现每个位置的特征非常明显,那就是所有点位都是通过一个叫 NF_HOOK 的函数还执行的,比如:
我们来简单说一下这个函数主要做了什么。首先我们先来看下其源码:
我猜你可能不愿意看这种又丑又长的函数尽管我里头已经删了很多东西,或者觉得看着闹挺。所以其实不看也没事儿,我来给大家简单解释一下它做了啥。
首先,该函数一共 8 个参数,我们可以暂时先简单地将其函数签名抽象为以下伪代码:
NF_HOOK(aaa, bbb, net, sk, skb, indev, outdev, okfn);
里面做的事情如果总结成一句话的话,那就是:
找到在 net(网络命名空间) 的 aaa 协议(比如 ipv4)的 bbb 点位(比如 output),然后用 sk(socket), indev(放数据包进来的网络设备), outdev(要放数据包出去的网络设备), okfn(如果执行完 hook 数据包没被扔掉的话, 就掉用这个函数)做出一个如下的 state 结构体:
{
p->hook = hook;
p->pf = pf;
p->in = indev;
p->out = outdev;
p->sk = sk;
p->net = net;
p->okfn = okfn; // 表示过滤完之后如果 sk buffer 还没被扔, 就走这个回调
}
然后,遍历上面找到的点位对应的链表,比如上面我们那个伪代码中“netns.netfilter.ipv4.NF_INET_LOCAL_IN” 对应的那个链表,并用 skb(sk_buffer,数据包的各种信息都在它上面)以及刚上面构建出来的那个 state 结构体作为参数分别执行每个 hook。
看上面那个 “nf_hook” 函数最下面有个 “nf_hook_slow(skb, &state, hook_head, 0)”,诶对,就是它,就是它执行每个 hook 的,你看还接收了个 hook_head,这不就是每条链表的表头么:
该函数不算太长,主要做的事情也显而易见,就是上面咱们说的执行链表中的每一个 hook,然后根据 hook 的返回值来决定到底是 “ACCEPT” 该包还是要 “DROP” 掉该数据包。
简单总结
到这儿,咱们来简单总结一下吧:首先 netfilter 是 Linux 内核的一个网络防火墙框架,该框架在网络的“三层网络层”前后分别插入了五个点位,这五个点位的作用就是每个点位的不同协议栈都有一条链表,链表上都是一个个的钩子函数,钩子函数的返回值决定了该包到底是被丢弃还是被接收还是被转发走。而 iptables 正好是利用了 netfilter 这个框架,iptables 自己抽象出了四张表,每张表的钩子函数可能分别负责“过滤”或者“转换”或者“标记”等,所谓的“表”的真面目其实就是每张表在不同的钩子点位上注册一个自己的钩子函数,然后等待着数据包在内核中走到了 netfilter 对应的位置的时候被自动执行。
在内核中骚一把 netfilter
好,终于回到标题上说的要在内核里玩一下 netfilter 了,当然由于我个人水平有限,写不出什么高级的 c 语言代码,所以咱们就简单的利用netfilter 来看一下数据包的走向吧~
代码准备
首先我们先准备如下代码:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/ip.h>
#include <linux/netdevice.h>
#include <linux/skbuff.h>
#include <linux/if_arp.h>
#include <linux/if_ether.h>
#include <linux/netfilter_ipv4.h>
#include <linux/netfilter_arp.h>
#include <linux/in_route.h>
#include <net/ip.h>
int index = 0;
static unsigned int test_nf_pre_routing(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_pre_routing 111");
if (index == 0) {
printk("the first come in");
index = 1;
return NF_ACCEPT;
} else {
printk("not first come in, will reject");
return NF_DROP;
}
}
static unsigned int test_nf_local_input(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_local_input 222");
return NF_ACCEPT;
}
static unsigned int test_nf_forward(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_forward 333");
return NF_ACCEPT;
}
static unsigned int test_nf_local_output(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_local_output 444");
return NF_ACCEPT;
}
static unsigned int test_nf_local_output2(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_local_output 555");
return NF_ACCEPT;
}
static unsigned int test_nf_post_routing(void *priv, struct sk_buff *skb, const struct nf_hook_state *state) {
printk("this is test_nf_local_output 666");
return NF_ACCEPT;
}
static struct nf_hook_ops test_nf_ops[] = {
{
.hook = test_nf_pre_routing,
.pf = PF_INET,
.hooknum = NF_INET_PRE_ROUTING,
.priority = 100,
},
{
.hook = test_nf_local_input,
.pf = PF_INET,
.hooknum = NF_INET_LOCAL_IN,
.priority = 100,
},
{
.hook = test_nf_forward,
.pf = PF_INET,
.hooknum = NF_INET_FORWARD,
.priority = 100,
},
{
.hook = test_nf_local_output,
.pf = PF_INET,
.hooknum = NF_INET_LOCAL_OUT,
.priority = 200,
},
{
.hook = test_nf_local_output2,
.pf = PF_INET,
.hooknum = NF_INET_LOCAL_OUT,
.priority = 100,
},
{
.hook = test_nf_post_routing,
.pf = PF_INET,
.hooknum = NF_INET_POST_ROUTING,
.priority = 100,
},
};
static int __net_init test_netfilter_init(struct net *net) {
return nf_register_net_hooks(net, test_nf_ops, ARRAY_SIZE(test_nf_ops));
}
static void __net_exit test_netfilter_exit(struct net *net) {
nf_unregister_net_hooks(net, test_nf_ops, ARRAY_SIZE(test_nf_ops));
}
static struct pernet_operations test_netfilter_ops = {
.init = test_netfilter_init,
.exit = test_netfilter_exit,
};
static int __init test_module_init(void) {
return register_pernet_subsys(&test_netfilter_ops);
}
module_init(test_module_init);
static void __exit test_module_exit(void) {
unregister_pernet_subsys(&test_netfilter_ops);
}
module_exit(test_module_exit);
MODULE_LICENSE("GPL");
该代码非常简单,做的事情也很明了,咱们简单解释一下:
- 首先 “module_init” 用来注册内核模块,当在用户层执行了 “insmod xxx.ko” 命令之后就能执行到那个 “test_module_init”
- “test_module_init” 中只执行了一个 “register_pernet_subsys” 函数,该函数咱们在上面没有说明,其实在 iptables 注册各种自己的表的时候也会用到,它的作用就是给内核空间中添加一个网络协议子系统,这里可以简单地理解成一调用了它就会触发它参数中的 “init” 方法,也就是 “test_netfilter_init” 方法
- “nf_register_net_hooks”方法在 iptables 中也有用到,只不过埋的层级比较深,我就没有截图。大家还记得在 nat 表的代码中写死的那个有四个元素的大数组么,这个 “nf_register_net_hooks” 的作用其实就是遍历这个数组,然后用数组里每一项的优先级做排序,之后找到每一项对应的钩子点位的链表,把钩子插进去,没啥的。
- 之后的流程就不用介绍,简单来讲这段代码就是利用 netfilter 框架提供好的内核函数,往其五个点位上分别注册钩子函数,然后函数的内容就是打印一下函数名以及一个数字
- 其中有几个小地方咱们提一下:
-
- 第一个小地方是,对于 output 这个点位咱们注册了两个函数,一个优先级是 200,里面打印 “this is test_nf_local_output 444”,另一个优先级是 100,里面打印 “this is test_nf_local_output 555”。咱们之前说过,优先级越小的会被先执行,所以咱们的预期是 “555” 会早于 “444” 被打印:
-
- 第二个小地方是,代码的开头定义了一个 index 变量,然后再 pre_routing 中判断如果是第一次走到 pre_routing 的话就给它置为 1,然后返回 “NF_ACCEPT” 表示接收数据包,之后等第二次以及以后再进来就返回 “NF_DROP” 表示之后的包都被丢掉。所以这里我们的预期是,只有第一次数据包进来后会打印 “111” 和位于 “INPUT” 函数中的 “222”,但是从数据包第二次进来开始就只打印位于 “PRE_ROUTING” 中的 “111” 不打印 “222” 了,因为数据包已经在走完 “PRE_ROUTING” 之后被干掉了:
干!
好了代码准备完成,然后大家可以用任何的方式启动一台 Linux 系统,我这里是使用的这篇文章中的方法启动的一个基于 qemu 的最小 Linux 系统:
当准备好实验环境之后,我们编译上边的代码,这里我准备了一个 Makefile 文件:
obj-m:=test_netfilter.o
# 下面的这个路径要改成自己的 Linux 内核地址
KDIR:=/root/ding-os/lib/modules/5.14.15/build
PWD:=$(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) modules clean
obj-m 表示让这个模块生成一个可独立插入的 .ko 结尾的模块。(注:如果用 obj-y 的话,可以直接将这个模块编译到内
$(MAKE) -C $(KDIR) M=$(PWD) modules
这句话的意思是说,执行这个 makefile 的时候,先假装当前的工作目录是 $(KDIR) 这个目录,在这个目录下使用刚刚我们写的带有 module_init 代码的文件,执行 make modules 进行模块儿的编译。
然后我们可以执行 make 后会发现多出一大堆文件,其中我们只需要那个 .ko 结尾的文件,这个才是能往内核里插的东西:
之后可以尝试通过 《编译一个最小的 Linux 内核》 这篇文章中说的方式创建 qemu 的最小系统:
创建完后别忘了把刚才 make 后的那个 .ko 结尾的文件给扔到这个系统的文件系统下,不然启动后会找不到的哟:
然后就可以启动 qemu 了。正常启动系统之后,由于没有插入网卡,所以只能是用本地的 lo 回环设备,该设备默认是被 down 掉的,所以通过 ifconfig 命令给 up 起来:
然后对刚才外头弄进来的那个 .ko 结尾的模块文件做 “insmod” 插入到内核中:
最后我们 ping 一下 127.0.0.1 看看结果:
会发现结果是先打印 555 的 output,然后打印 444 的 output,这是因为在上面我们指定了 555 的优先级是 100 而 444 的是 200,所以小的先执行了。随后打印了 post_routing 的 666,之后数据包就正式出去了,不过由于是 ping 的 127.0.0.1,所以它又回来了,于是走到了 pre_routing 的 111,之后又走到了 input 的 222,但是此时代码中那个 index 被置为 1 了表示之后的数据包就要被丢弃了,所以等第二次 ping 的时候,会发现走完 111 之后会直接提示 “will reject”,并且真的没有 222 了。ok 完美地符合预期~
所以到这里,我们对 iptables 和 netfilter 结合着内核源码简单地去了解了一下,并且最后简单地在内核里玩了一把 netfilter,相信大家如果看到这儿的话,等日后再想自己深入去了解它们的话,一定能事半功倍~
未完待续
我们通过两篇文章介绍了 iptables,但我还是想再做点什么,所以下一篇文章,咱们尝试基于 iptables,去做一个“一键实现 proxy,代理它们的 https/http 请求到你的本地服务”的小工具~