跳到主要内容

9.6 IPTables:规则的前端实现

上一节我们聊了 Xtables 的扩展机制,知道了 Target 和 Match 是如何注册到内核的。这就像是在工厂里准备好了一套标准的模具。

但光有模具还不够,你得有一条生产线把这些模具串起来,还得有人决定在哪个环节用哪套模具。这就是 IPTables 在 Netfilter 体系里扮演的角色——它是我们在用户空间最熟悉的界面,也是底层 Netfilter 钩子的直接使用者。

这一节,我们拆解 IPTables 的这套「生产线」是怎么运作的。


表与钩子:建立映射

IPTables 在内核层面并没有什么神秘的魔法,它本质上也是 Netfilter 的一个客户。

内核部分的核心代码位于 net/ipv4/netfilter/ip_tables.c(IPv4)和 net/ipv6/netfilter/ip6_tables.c(IPv6)。每个所谓的「表」(Table),比如我们常用的 filter 表或 nat 表,在内核里都是由一个 xt_table 结构体来表示的。

注册和注销这些表的方法很简单:

  • IPv4: ipt_register_table() / ipt_unregister_table()
  • IPv6: ip6t_register_table() / ip6t_unregister_table()

这些表指针最终都会存放到网络命名空间对象里。netns_ipv4 结构体里存着像 iptable_filteriptable_manglenat_table 这样的指针;同理,IPv6 的 netns_ipv6 里也挂着对应的 ip6tables 表。

为了看清楚这玩意儿到底怎么跑起来的,我们不看书上的理论,直接拿最常用的 filter 表开刀。假设我们只编译了 filter 表,并且只启用 LOG 目标。

先看 filter 表的定义:

#define FILTER_VALID_HOOKS ((1 << NF_INET_LOCAL_IN) | \
(1 << NF_INET_FORWARD) | \
(1 << NF_INET_LOCAL_OUT))

static const struct xt_table packet_filter = {
.name = "filter",
.valid_hooks = FILTER_VALID_HOOKS,
.me = THIS_MODULE,
.af = NFPROTO_IPV4,
.priority = NF_IP_PRI_FILTER,
};

(net/ipv4/netfilter/iptable_filter.c)

注意到了吗?FILTER_VALID_HOOKS 宏里定义了三个位:LOCAL_INFORWARDLOCAL_OUT。这意味这个表只在这三个 Netfilter 钩子点上生效。

初始化过程分两步走。

第一步,设置钩子回调。

调用 xt_hook_link(),把 iptable_filter_hook 挂到 Netfilter 的钩子点上:

static struct nf_hook_ops *filter_ops __read_mostly;

static int __init iptable_filter_init(void)
{
. . .
// 将 packet_filter 表与 iptable_filter_hook 函数关联
filter_ops = xt_hook_link(&packet_filter, iptable_filter_hook);
. . .
}

第二步,注册表本身。

通过 ipt_register_table() 把表注册到网络命名空间中,这样内核就知道这个表的存在了:

static int __net_init iptable_filter_net_init(struct net *net)
{
. . .
// 将注册好的表指针存入 net->ipv4 命名空间
net->ipv4.iptable_filter =
ipt_register_table(net, &packet_filter, repl);
. . .
return PTR_RET(net->ipv4.iptable_filter);
}

(net/ipv4/netfilter/iptable_filter.c)

好了,现在表已经在内核里就位了。接下来我们下一条规则,看看数据包来的时候会发生什么。


实战追踪:一个 UDP 包的生命旅程

假设我们敲下了这么一条命令:

iptables -A INPUT -p udp --dport=5001 -j LOG --log-level 1

这条命令的意思是:把所有发往本机(INPUT 链)、目标端口是 5001 的 UDP 包的头信息都记到 syslog 里去,日志级别设为 1。

⚠️ 注意 这里有个前提:要想用 LOG 这个 target,你的内核配置里必须开启 CONFIG_NETFILTER_XT_TARGET_LOG。它的实现在 net/netfilter/xt_LOG.c 里,是一个标准的 iptables target 模块。

现在,假设有一个 UDP 包,目标端口是 5001,它从网卡硬件里钻出来,一路向上走到网络层(L3)。

第一站:PRE_ROUTING

包遇到的第一个钩子点是 NF_INET_PRE_ROUTING。 但是别忘了,我们刚才看的 filter 表定义里,它的 valid_hooks 并没有包含 PRE_ROUTING。所以 filter 表在这里是无感的。 什么也不发生,直接通过,进入 ip_rcv_finish() 进行路由查找。

第二站:路由判决

路由子系统会决定这个包的命运:是给我自己用的(本地投递),还是帮我转发的(转发)?我们分两种情况来看。

情况一:投递给本地主机

路由判决发现目的地 IP 是本机,于是包被送到了 ip_local_deliver()

int ip_local_deliver(struct sk_buff *skb)
{
. . .
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}

看到了吗?NF_INET_LOCAL_IN。 这正是 filter 表注册的钩子点之一。NF_HOOK 宏在这里会触发 iptable_filter_hook()

让我们看看这个 hook 函数干了什么:

static unsigned int iptable_filter_hook(unsigned int hook, struct sk_buff *skb,
const struct net_device *in,
const struct net_device *out,
int (*okfn)(struct sk_buff *))
{
const struct net *net;
. . .
net = dev_net((in != NULL) ? in : out);
. . .

// 关键调用:查表,执行规则
return ipt_do_table(skb, hook, in, out, net->ipv4.iptable_filter);
}

(net/ipv4/netfilter/iptable_filter.c)

它直接调用了 ipt_do_table()。这个函数就是那张「规则检查表」的执行引擎。它会遍历表里的规则,看这个包是否匹配。

在我们的例子里,包是 UDP 且端口 5001,匹配上了! 于是,ipt_do_table() 调用了 LOG target 的回调函数 ipt_log_packet()。这个函数把包头信息打印到了 syslog 里。

规则执行完了,返回 NF_ACCEPTNF_HOOK 宏继续调用它的 okfn,也就是 ip_local_deliver_finish()。包继续向上传输层(L4)飞奔,交给对应的 Socket 处理。

情况二:转发该包

如果路由查找发现这包不是给我的,而是要我从别的口转发出去,内核就会调用 ip_forward()

int ip_forward(struct sk_buff *skb)
{
. . .
return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dst.dev,
rt->dst.dev, ip_forward_finish);
. . .
}

NF_INET_FORWARD。 这也是 filter 表支持的钩子点。所以 iptable_filter_hook() 再次被调用,ipt_do_table() 再次跑一遍。

但这里有个关键的坑:我们刚才加的规则是加在 INPUT 链上的。 INPUT 链只对应 LOCAL_IN 钩子。当包走到 FORWARD 钩子时,它走的是 filter 表里的 FORWARD 链,那里是空的。 所以,虽然 filter 表被触发了,但这条 UDP 包在 FORWARD 链里找不到匹配的规则,于是直接通过(默认策略通常是 ACCEPT)。

接着执行 ip_forward_finish(),最后调用 ip_output() 准备发出去。 在 ip_output() 里会有 POST_ROUTING 钩子,但 filter 表也没挂在那儿,所以继续走人。


结合连接跟踪(Conntrack)

说到这里,你可能想问:如果我想根据连接的状态来过滤包呢?比如只放行那些「已建立」的连接?

这正是上一节我们聊的 conntrack 大显身手的地方。

你可以下这样一条规则:

iptables -A INPUT -p tcp -m conntrack --ctstate ESTABLISHED -j LOG --log-level 1

这里使用了 -m conntrack,这是一个 match 扩展。当 ipt_do_table() 执行到这里时,它会去查 conntrack 的那棵哈希树(我们在前面几节见过的 nf_conn 结构体)。 如果这个 TCP 包属于一个已经确认过的连接,conntrack 模块就会返回匹配成功,然后 LOG target 就会把它记下来。

回到那个「生产线」的比喻: 现在你能看清全貌了吗? xt_table 是你的工作台,ipt_do_table 是流水线传送带。 Match 扩展(比如 conntrack)是传送带上的「质检传感器」,负责检查包的成色; Target 扩展(比如 LOG)是传送带末端的「机械臂」,负责对包做最终处理。 而把这一切串起来的,就是我们在这一节看到的——Netfilter 的 Hook 机制。