netfilter内核实现概述

  • Post author:
  • Post category:其他




netfilter内核实现概述



一、前言

netfilter是Linux内核中网络防火墙的基础,无论是基于xtables的iptables,还是conntrack、nftables,其底层都是基于netfilter的。总体来说,netfilter提供了一个相对来说比较通用的防火墙框架,这个框架提供了个入口,内核中的其他网络模块可以通过这个入口来实现自己的网络逻辑。本文简单地对netfilter的整个框架以及其内核实现进行了梳理。



二、netfilter框架



2.1 基本原理

相比于那些基于netfilter所实现的一些功能(如iptables),netfilter本身的实现并不复杂,其本质上是在内核网络中提供了一些HOOK点。不同的协议族(如ip协议族、arp协议、bridge网桥设备等)所提供的HOOK点也不同。以ipv4协议为例,其提供的HOOK点与我们平时所接触的iptables中的五个链一致(详情可参考:

iptables原理

中关于规则链的介绍):

在这里插入图片描述

内核在实现的时候,采用

struct netns_nf

来表示当前所支持的所有的协议族及其HOOK点,如下所示:

struct netns_nf {
#if defined CONFIG_PROC_FS
	struct proc_dir_entry *proc_netfilter;
#endif
	const struct nf_queue_handler __rcu *queue_handler;
	const struct nf_logger __rcu *nf_loggers[NFPROTO_NUMPROTO];
#ifdef CONFIG_SYSCTL
	struct ctl_table_header *nf_log_dir_header;
#endif
    /* 由于每个HOOK点对应一个struct nf_hook_entries,这里采用数组的方式
     * 来存储当前协议族所有的HOOK点对应的entries。
     */
	struct nf_hook_entries __rcu *hooks_ipv4[NF_INET_NUMHOOKS];
	struct nf_hook_entries __rcu *hooks_ipv6[NF_INET_NUMHOOKS];
#ifdef CONFIG_NETFILTER_FAMILY_ARP
	struct nf_hook_entries __rcu *hooks_arp[NF_ARP_NUMHOOKS];
#endif
#ifdef CONFIG_NETFILTER_FAMILY_BRIDGE
	struct nf_hook_entries __rcu *hooks_bridge[NF_INET_NUMHOOKS];
#endif
#if IS_ENABLED(CONFIG_DECNET)
	struct nf_hook_entries __rcu *hooks_decnet[NF_DN_NUMHOOKS];
#endif
#if IS_ENABLED(CONFIG_NF_DEFRAG_IPV4)
	bool			defrag_ipv4;
#endif
#if IS_ENABLED(CONFIG_NF_DEFRAG_IPV6)
	bool			defrag_ipv6;
#endif
};

这里可以看出,netfilter实现的关键在于

struct nf_hook_entries

这个结构体。这个结构体定义了内核里所有的模块在其对应的HOOK点上注册的钩子函数。

/* 以数组的方式将多个nf_hook_entry组织起来的结构体。一般,一个协议族或者是
 * 一种防火墙(如ipv4)的一个HOOK点会对应一个nf_hook_entries,代表所有注册
 * 在这个HOOK点上的处理函数。
 * 
 * 这个结构体在数据存储方面采用了下面的结构图:
 * num_hook_entries | (struct nf_hook_entry) * num_hook_entries |
 * (struct nf_hook_ops) * num_hook_entries
 * 
 * 每个entry都有一个与之对应的ops,通过index可以直接找到entry和ops。
 */
struct nf_hook_entries {
	u16				num_hook_entries;
	/* padding */
	struct nf_hook_entry		hooks[];
	 */
};



2.2 优先级

同一个协议族的同一个HOOK点上有若干个钩子函数,这些函数的执行顺序是根据注册的时候的优先级决定的,这个可以从钩子函数的注册函数

__nf_register_net_hook()

看出来。在进行

nf_hook_ops

注册的时候,会先根据命名空间和协议族以及HOOK号找到对应的

nf_hook_entries

,然后调用

nf_hook_entries_grow

将其插入到

nf_hook_entries

中。插入的时候,会对所有的

nf_hook_entry

按照优先级从小到大的顺序存储。

每个协议族都有自己的一套优先级定义,以IPv4为例,其优先级列表如下所示:

enum nf_ip_hook_priorities {
	NF_IP_PRI_FIRST = INT_MIN,
	NF_IP_PRI_RAW_BEFORE_DEFRAG = -450,
	NF_IP_PRI_CONNTRACK_DEFRAG = -400,
	NF_IP_PRI_RAW = -300,
	NF_IP_PRI_SELINUX_FIRST = -225,
	NF_IP_PRI_CONNTRACK = -200,
	NF_IP_PRI_MANGLE = -150,
	NF_IP_PRI_NAT_DST = -100,
	NF_IP_PRI_FILTER = 0,
	NF_IP_PRI_SECURITY = 50,
	NF_IP_PRI_NAT_SRC = 100,
	NF_IP_PRI_SELINUX_LAST = 225,
	NF_IP_PRI_CONNTRACK_HELPER = 300,
	NF_IP_PRI_CONNTRACK_CONFIRM = INT_MAX,
	NF_IP_PRI_LAST = INT_MAX,
};

可以看出,虽然CONNTRACK和NAT都是在同一个HOOK点上(PREROUTING)起作用,但是CONNTRACK始终会先于NAT运行,因为其优先级较高。



2.3 HOOK注册

这里简单看一下HOOK上的钩子函数是如何注册上去的。钩子函数的定义和注册都是通过

struct nf_hook_ops

结构体来实现的。该结构体的pf和hooknum两个字段基本就确定了当前钩子函数所属于的协议族和作用的HOOK点;

nf_hookfn

则定义了要执行的回掉函数。

struct nf_hook_ops {
	/* User fills in from here down. */
	nf_hookfn		*hook;
	struct net_device	*dev;
	void			*priv;
	u_int8_t		pf;
	unsigned int		hooknum;
	/* 优先级,同一个HOOK点上的不同ops通过这个来进行排序,按照从小到大
	 * 的顺序排列。
	 */
	int			priority;
};



conntrack

模块中的

ip4

协议为例,其定义了如下的

struct nf_hook_ops

static const struct nf_hook_ops ipv4_conntrack_ops[] = {
	{
		.hook		= ipv4_conntrack_in,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_PRE_ROUTING,
		.priority	= NF_IP_PRI_CONNTRACK,
	},
	{
		.hook		= ipv4_conntrack_local,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_OUT,
		.priority	= NF_IP_PRI_CONNTRACK,
	},
	{
		.hook		= ipv4_confirm,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_POST_ROUTING,
		.priority	= NF_IP_PRI_CONNTRACK_CONFIRM,
	},
	{
		.hook		= ipv4_confirm,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_IN,
		.priority	= NF_IP_PRI_CONNTRACK_CONFIRM,
	},
};

在启用conntrack的时候(详见

nf_ct_netns_get()

函数),上面的四个钩子函数会注册到对应的HOOK点。具体的注册过程比较简单,首先根据

nf_hook_ops

的pf和hooknum从当前网络命名空间中找到对应的

struct nf_hook_entries

,然后按照一定的优先级将ops插入到entries中。



2.4 执行

netfilter的执行就比较简单了,一般都是使用宏定义

NF_HOOK()

来执行。它会根据协议族和hooknum找到当前命名空间的对应的

struct nf_hook_entries

static inline int
NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk, struct sk_buff *skb,
	struct net_device *in, struct net_device *out,
	int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
	int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);
	if (ret == 1)
		ret = okfn(net, sk, skb);
	return ret;
}

随后,遍历

struct nf_hook_entries

上所有的

struct nf_hook_entry

,并调用其上的回调函数。

/* 执行特定协议族的特定HOOK点上的所有注册的钩子函数,并根据返回值做相应的处理。
 * 该函数如果返回1,代表okfn应该被执行;否则,不执行。
 */

int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
		 const struct nf_hook_entries *e, unsigned int s)
{
	unsigned int verdict;
	int ret;

	for (; s < e->num_hook_entries; s++) {
		verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
		switch (verdict & NF_VERDICT_MASK) {
		case NF_ACCEPT:
			break;
		case NF_DROP:
			kfree_skb(skb);
			ret = NF_DROP_GETERR(verdict);
			if (ret == 0)
				ret = -EPERM;
			return ret;
		case NF_QUEUE:
			ret = nf_queue(skb, state, s, verdict);
			if (ret == 1)
				continue;
			return ret;
		default:
			/* Implicit handling for NF_STOLEN, as well as any other
			 * non conventional verdicts.
			 */
			return 0;
		}
	}

	return 1;
}



三、nftables

与iptables类似,nftables也是Linux系统中的一个防火墙工具。不同的是iptables仅支持ip协议族,例如如果想要拦截arp,就要使用arptable。且由于iptables实现的框架过于繁琐,效率低等原因,目前高版本的发行版已经使用nftables取代iptables了。这里我们就以nftables为例,简单看一下nftables是如何与netfilter关联在一起的。



3.1 基本原理

对于表、链的概念,nftables与iptables是完全一致的,这也是为什么用户可以在不感知的情况下能够从iptables切换到nftables。想要理解nftables的内核实现,以下几个结构体是作用是需要了解的:

struct nft_chain_type



struct nft_chain



struct nft_table

。如果你已经对iptables(xtables)的实现有了一定了了解,那这里的概念就会变得很容易理解。

关于表、链的基本概念这里不再赘述,需要着重讲一下链类型(chain_type)的概念,这个与iptables中有些不一样。iptables中的表是预定义好的,比如filter表用作过滤、nat表用于转发,即通过表来区分规则的作用。nftables有些不一样,nftables更加灵活,其没有固定的(默认的)表,用户可以随意增加表。那么一条规则,是如何区分他是要进行过滤还是NAT呢?这里是通过链类型来决定的。我们来简单看一下nftables的使用过程,下面的命令我们创建了filter表,并给其中增加了input、output和forward三条链,这样这个表的作用就和iptables中的filter的作用一样了。而决定这三条链只能用作filter而不能用作nat的,就是命令中的type字段。

nft create table inet filter
nft add chain inet filter input { type filter hook input priority filter \; }
nft add chain inet filter output { type filter hook output priority filter \; }
nft add chain inet filter forward { type filter hook forward priority filter \; }

下面我们把NAT类型的链也创建到filter表中,可以看到也添加成功了,这说明决定规则类型的是链的类型,而不是表的类型。

nft add chain inet filter prerouting { type nat hook prerouting priority dstnat \; }



3.2 内核实现

通过上面的分析,我们大概可以知道nftables的处理逻辑是通过链类型来决定的,内核中采用

struct nft_chain_type

来表示。事实上,chain_type可以理解为nftables的元数据,其定义了当前链类型上支持的HOOK点、处理函数等信息,如下为ipv4的filter类型的chain_type的定义:

static const struct nft_chain_type nft_chain_filter_ipv4 = {
	.name		= "filter",
	.type		= NFT_CHAIN_T_DEFAULT,
	.family		= NFPROTO_IPV4,
	.hook_mask	= (1 << NF_INET_LOCAL_IN) |
			  (1 << NF_INET_LOCAL_OUT) |
			  (1 << NF_INET_FORWARD) |
			  (1 << NF_INET_PRE_ROUTING) |
			  (1 << NF_INET_POST_ROUTING),
	.hooks		= {
		[NF_INET_LOCAL_IN]	= nft_do_chain_ipv4,
		[NF_INET_LOCAL_OUT]	= nft_do_chain_ipv4,
		[NF_INET_FORWARD]	= nft_do_chain_ipv4,
		[NF_INET_PRE_ROUTING]	= nft_do_chain_ipv4,
		[NF_INET_POST_ROUTING]	= nft_do_chain_ipv4,
	},
};

所有的内核支持的链类型都注册到了全局数组chain_type中。这是个二维数组,索引分别是协议号和链类型号。

/* 按照协议族、表类型以二维数组的方式把nft_chain_type组织起来。
 * 其中,每个nft_chain_type对应一个协议族的一个表。
 *
 * NFT_CHAIN_T_MAX代表一个协议族表的最大数量,比如FILTER、NAT等。
 */
static const struct nft_chain_type *chain_type[NFPROTO_NUMPROTO][NFT_CHAIN_T_MAX];

其中,链类型号目前支持的就三种:

  • NFT_CHAIN_T_DEFAULT:默认的链类型(filter类型)
  • NFT_CHAIN_T_ROUTE:路由类型的链
  • NFT_CHAIN_T_NAT:进行NAT地址转发的链



3.3 链的添加

下面我们来看一下一条链是如何添加到内核中的,以及其是如何和netfilter联系起来的。所有的nftables操作都是通过netlink来实现的,链的添加最终会调用

nf_tables_addchain()

这个内核函数。从下面的函数处理过程可以看出,在创建链的时候内核会为每个链(非NAT类型)创建一个对应的nf_hook_ops结构体并注册到netfilter框架中对应的entries中。对于NAT类型的链,由于NAT涉及到各个模块之间的配合,因此稍微复杂点,下面会专门讲这一部分。

static int nf_tables_addchain(struct nft_ctx *ctx, u8 family, u8 genmask,
			      u8 policy, u32 flags)
{
	const struct nlattr * const *nla = ctx->nla;
	struct nft_table *table = ctx->table;
	struct nft_base_chain *basechain;
	struct nft_stats __percpu *stats;
	struct net *net = ctx->net;
	char name[NFT_NAME_MAXLEN];
	struct nft_trans *trans;
	struct nft_chain *chain;
	struct nft_rule **rules;
	int err;

	if (table->use == UINT_MAX)
		return -EOVERFLOW;

	if (nla[NFTA_CHAIN_HOOK]) {
		struct nft_chain_hook hook;

		if (flags & NFT_CHAIN_BINDING)
			return -EOPNOTSUPP;

		/* 从netlink中提取注册钩子函数所需要的信息,包括当前链的类型
		 * 优先级、hooknum等,并存放到nft_chain_hook结构体中。
		 */
		err = nft_chain_parse_hook(net, nla, &hook, family, true);
		if (err < 0)
			return err;

		/* 链在内核中采用nft_chain结构体表示。该结构体主要保存了
		 * 链与表、规则之间的内容和关系。每个nft_chain都内嵌在
		 * nft_base_chain结构体中,这个结构体保存了链与netfilter
		 * 之间的相关内容,包括注册到netfilter中的nf_hook_ops、
		 * 链的类型等。
		 */
		basechain = kzalloc(sizeof(*basechain), GFP_KERNEL);
		if (basechain == NULL) {
			nft_chain_release_hook(&hook);
			return -ENOMEM;
		}
		chain = &basechain->chain;

		if (nla[NFTA_CHAIN_COUNTERS]) {
			stats = nft_stats_alloc(nla[NFTA_CHAIN_COUNTERS]);
			if (IS_ERR(stats)) {
				nft_chain_release_hook(&hook);
				kfree(basechain);
				return PTR_ERR(stats);
			}
			rcu_assign_pointer(basechain->stats, stats);
			static_branch_inc(&nft_counters_enabled);
		}

		/* 初始化bashchain,包括使用hook里的优先级、HOOK点来初始化
		 * bashchain->ops,使用type里的hook_fn来初始化钩子函数。
		 */
		err = nft_basechain_init(basechain, family, &hook, flags);
		
    [......]

	/* 将ops注册到netfilter。对于非NAT类型的链,这里会调用
	 * nf_register_net_hook()来直接将basechain->ops进行注册;
	 * 对于NAT类型的链,会调用chain_type里的注册方法进行注册。
	 * 以ipv4为例,这里会调用nf_nat_ipv4_register_fn()进行注册。
	 */
	err = nf_tables_register_hook(net, table, chain);
	if (err < 0)
		goto err_destroy_chain;

	[......]



四、conntrack与nat



4.1 conntrack基本原理


conntrack

即连接跟踪(

connect track

),是用来跟踪系统中所有的连接状态的,包括

tcp



udp



icmp

等各种协议。

conntrack

也是基于netfilter的,不同的协议族其

conntrack

的框架也稍有不同,目前支持的协议族包括

ipv4



ipv6



bridge

(网桥设备),这个可以从

nf_ct_netns_do_get()

函数中看出来。这里以

ipv4

为例来讲解conntrack实现的大体框架。

在这里插入图片描述



ipv4_conntrack_ops

定义我们可以得到上图的原理图。

ipv4



conntrack

使用了netfilter中的四个HOOK点,分别是

PREROUTING



LOCAL_OUT



LOCAL_IN



POSTROUTINTG

。我们可以对这四个HOOK点进行分类,其中前两个为报文进入到IP协议层的HOOK点,后两个为报文离开IP层的HOOK点。

首先我们来介绍一下内核用来进行conntrack的数据结构吧。ct的核心数据结构为

struct nf_conn

,这里可以重点看一下其中的tuplehash字段的说明。一条ct记录实际上维护了一条连接的两个方向的状态,这两个方向称为ORIGIN和REPLY。顾名思义,ORIGIN即原始方向,可以理解为创建ct的时候的那个报文的方向;REPLY为响应方向,即响应报文的方向。为了维护一个连接的两个方向,

nf_conn

使用了一个长度为2的类型为struct nf_conntrack_tuple_hash的数组:

struct nf_conn {
	[...]

#ifdef CONFIG_NF_CONNTRACK_ZONES
	struct nf_conntrack_zone zone;
#endif
	/* 这个可以说是conntrack里最重要是一个字段了,他是一个数组,长度为2,
	 * 类型为struct nf_conntrack_tuple_hash。
	 * 
	 * struct nf_conntrack_tuple_hash是用来表示一个连接的,例如本机A
	 * 向服务器B建立了一条TCP连接,那么这个数组中的第一个元素的代表着
	 * A -> B的连接,tuplehash[0].src存储了A的地址和端口,tuplehash[0].dst
	 * 存储了B的地址和端口。
	 * 
	 * 由于conntrack跟踪的是双向连接,因此tuplehash[1]存储的是这个连接
	 * 中B -> A的连接,即tuplehash[1].src存储了B的地址和端口,
	 * tuplehash[1].dst存储了B的地址和端口。
	 */
	struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];

	/* 当前ct的状态标志,包括是否confirm、是否收到过响应包等。 */
	unsigned long status;

	u16		cpu;
    /* 所属于的网络命名空间。 */
	possible_net_t ct_net;

#if IS_ENABLED(CONFIG_NF_NAT)
	struct hlist_node	nat_bysource;
#endif
	/* all members below initialized via memset */
	struct { } __nfct_init_offset;

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

#if defined(CONFIG_NF_CONNTRACK_MARK)
	u_int32_t mark;
#endif

#ifdef CONFIG_NF_CONNTRACK_SECMARK
	u_int32_t secmark;
#endif

	/* Extensions */
	struct nf_ct_ext *ext;

	/* 
	 * 协议相关的内容,比如TCP的窗口信息等。
	 */
	union nf_conntrack_proto proto;
};

接下来我们要介绍以下ct的几个状态和一些标志位。下面几个是ct的几个标志(会设置在上面的status字段中):

enum ip_conntrack_status {
	/* It's an expected connection: bit 0 set.  This bit never changed */
	IPS_EXPECTED_BIT = 0,
	IPS_EXPECTED = (1 << IPS_EXPECTED_BIT),

	/* 当前ct是否收到过REPLY方向的报文。 */
	IPS_SEEN_REPLY_BIT = 1,
	IPS_SEEN_REPLY = (1 << IPS_SEEN_REPLY_BIT),

	/* Conntrack should never be early-expired. */
	IPS_ASSURED_BIT = 2,
	IPS_ASSURED = (1 << IPS_ASSURED_BIT),

	/* 当前ct是否被confirm过了。这里的confirm可以理解为生效,报文
	 * 在离开IP协议层的时候新创建的ct会被确认,并加入到哈希表中。
	 *
	 * 没有被确认的ct是没有生效的。
	 */
	IPS_CONFIRMED_BIT = 3,
	IPS_CONFIRMED = (1 << IPS_CONFIRMED_BIT),

	/* 标志着这个连接需要进行SNAT */
	IPS_SRC_NAT_BIT = 4,
	IPS_SRC_NAT = (1 << IPS_SRC_NAT_BIT),

	/* 标志着这个连接需要进行DNAT */
	IPS_DST_NAT_BIT = 5,
	IPS_DST_NAT = (1 << IPS_DST_NAT_BIT),

	/* Both together. */
	IPS_NAT_MASK = (IPS_DST_NAT | IPS_SRC_NAT),

	/* Connection needs TCP sequence adjusted. */
	IPS_SEQ_ADJUST_BIT = 6,
	IPS_SEQ_ADJUST = (1 << IPS_SEQ_ADJUST_BIT),

	/* 标志着SNAT初始化完成。 */
	IPS_SRC_NAT_DONE_BIT = 7,
	IPS_SRC_NAT_DONE = (1 << IPS_SRC_NAT_DONE_BIT),

	/* 标志着DNAT初始化完成了。 */
	IPS_DST_NAT_DONE_BIT = 8,
	IPS_DST_NAT_DONE = (1 << IPS_DST_NAT_DONE_BIT),

	/* Both together */
	IPS_NAT_DONE_MASK = (IPS_DST_NAT_DONE | IPS_SRC_NAT_DONE),

	[...]
};

除了ct拥有状态,当前skb上也维护了一个代表当前报文所处于的方向上的一些状态,存储在skb中。根据这个状态,可以判断出当前报文是ORIGIN方向的还是REPLY方向的(根据状态值是否大于IP_CT_IS_REPLY来判断)。这个值会在ct初始化或者恢复的时候设置到skb上。

enum ip_conntrack_info {
	/* ct已经建链。这个在收到响应报文后就会进入这个状态。 */
	IP_CT_ESTABLISHED,

	/* 与NEW类似,还没搞清楚干啥的 */
	IP_CT_RELATED,

	/* ct刚创建 */
	IP_CT_NEW,

	/* 这是个分割线,下面的都是REPLY方向的状态。 */
	IP_CT_IS_REPLY,

	/* REPLY方向建链 */
	IP_CT_ESTABLISHED_REPLY = IP_CT_ESTABLISHED + IP_CT_IS_REPLY,
	/* REPLY方向 */
	IP_CT_RELATED_REPLY = IP_CT_RELATED + IP_CT_IS_REPLY,
	
	IP_CT_NUMBER,
};



4.2 conntrack的初始化

conntrack会在报文刚进入到IP层的时候进行conntrack记录(下面简称ct)的初始化,这个可以从

ipv4_conntrack_in()/ipv4_conntrack_local()

的实现看出来,这两个函数都会调用

nf_conntrack_in()

,这个函数会从conntrack的哈希表中根据当前报文的元组信息查找对应的ct,并恢复到skb报文上。如果没有找到对应的ct,那么就会调用

init_conntrack()

创建新的ct,整个过程可以从

resolve_normal_ct()

看出来。查找到(或者创建)ct后,内核会将ct以及上面说的状态设置到skb的_nfct字段上(通过

nf_ct_set

函数)。

由于NAT、IPVS等模块都会使用到ct,因此这里的钩子函数要先于其他模块执行,其优先级也比较高,为NF_IP_PRI_CONNTRACK。

在将ct设置到skb上之后,内核还会调用特定协议的处理函数还对ct进行进一步的处理,这个由

nf_conntrack_handle_packet()

来完成。以TCP为例,TCP会在ct上维护一些TCP特有的状态信息(如SYN_SEND、SYN_RECEIVE等)以及做一些合法性校验等。比如在TCP报文的序列号或者ACK号不在窗口范围内,那么会重置skb上的ct,不对其进行trace。



4.3 conntrack的确认

在报文离开IP协议层之前,内核会对ct进行确认。ct的确认只会进行一次,确认过后会设置对应的标志位,在后续的报文中不会再重复确认。ipv4的确认会调用

ipv4_confirm

,最终会调用

__nf_conntrack_confirm

。所谓的确认,就是将ct插入到

nf_conntrack_hash

这个哈希表中。由于报文从进入到离开IP层,报文内容可能会被修改(如NAT、IPVS等),所以confirm需要在最后进行,这也是为什么在POSTROUTING的HOOK点中conntrack的钩子函数的优先级是最低的。

ct的插入函数为

__nf_conntrack_hash_insert()

static void __nf_conntrack_hash_insert(struct nf_conn *ct,
				       unsigned int hash,
				       unsigned int reply_hash)
{
	hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_ORIGINAL].hnnode,
			   &nf_conntrack_hash[hash]);
	hlist_nulls_add_head_rcu(&ct->tuplehash[IP_CT_DIR_REPLY].hnnode,
			   &nf_conntrack_hash[reply_hash]);
}

从这个函数里可以看出,进行哈希插入的时候插入的并不是ct,而是ct上的tunplehash,其对tunplehash[0]和tunplehash[1]分别进行了哈希。这是合理的,以下图的一个简单的转发(forward)报文为例,同一个连接的ct需要能够根据两个方向的报文来进行哈希查找,因此需要把两个方向的元组加入到哈希表。同时,根据查找到的元组是哪一个(第一个还是第二个)可以判断出当前报文的方向(ORIGIN或者REPLY)。

在这里插入图片描述



4.4 NAT原理

netfilter的

NAT

模块与

conntrack

是分不开的,因此这里就一块介绍吧。NAT可以分为SNAT(源地址转发)和DNAT(目的地址转发),能够进行NAT的模块由

iptables



nftables

等。需要注意的是无论是iptables还是nftables,其使用的NAT都是netfilter提供的,是在netfilter中实现的,

iptables/nftables

本身只是对netfilter的NAT的简单调用。这里同样以IPV4的NAT为例来进行一下介绍。

static const struct nf_hook_ops nf_nat_ipv4_ops[] = {
	/* Before packet filtering, change destination */
	{
		.hook		= nf_nat_ipv4_in,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_PRE_ROUTING,
		.priority	= NF_IP_PRI_NAT_DST,
	},
	/* After packet filtering, change source */
	{
		.hook		= nf_nat_ipv4_out,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_POST_ROUTING,
		.priority	= NF_IP_PRI_NAT_SRC,
	},
	/* Before packet filtering, change destination */
	{
		.hook		= nf_nat_ipv4_local_fn,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_OUT,
		.priority	= NF_IP_PRI_NAT_DST,
	},
	/* After packet filtering, change source */
	{
		.hook		= nf_nat_ipv4_fn,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_IN,
		.priority	= NF_IP_PRI_NAT_SRC,
	},
};

NAT的钩子点可以说是与conntrack的相辅相成,也是在那四个HOOK点上注册的钩子函数。这四个钩子函数最终都会调用相同的函数

nf_nat_inet_fn()

,所以我们只需要看这一个函数就够了。

这里需要澄清几个概念。首先是代码里的maniptype,这个是用来区分SNAT和DNAT的HOOK点的,它的值包括:


  • NF_NAT_MANIP_SRC

    :代表当前HOOK点需要检查是否需要进行SNAT,这个在包括

    POSTROUTING



    LOCAL_IN

    两个HOOK点

  • NF_NAT_MANIP_DST

    :代表当前HOOK点需要检查是否需要进行DNAT,这个包括

    PREROUTING



    LOCAL_OUT

    两个HOOK点

其次是NAT初始化的问题。在ct从创建到被确认这段期间,会完成对ct上的NAT状态的初始化的工作。其中,在

NF_NAT_MANIP_SRC

处会进行SNAT状态的初始化;在

NF_NAT_MANIP_DST

处进行DNAT状态的初始化。这里的初始化是实现NAT的关键,从下面的switch语句可以看出在当前ct未进入到ESTABLISHED状态(收到了REPLY报文)都会走到初始化流程。

这里需要再说明一下NAT的钩子函数的注册。上面已经分析了,netfilter在四个HOOK点上注册钩子,那么加入我们使用nftable/iptables往加一条NAT类型的链会发生什么呢?此时这个链对应的nf_hook_ops不会注册到netfilter的HOOK点上,而是会注册到对应的HOOK点上的NAT的钩子函数的私有字段中去。当NAT的钩子函数发现自己身上注册了别的钩子,那么就会调用这些钩子去进行NAT的初始化,也就是在这里netfilter将控制权交给

nftables/iptables

的。如果没有找到钩子,那么就会调用netfilter默认的初始化函数

nf_nat_alloc_null_binding()

来进行NAT的初始化。

unsigned int
nf_nat_inet_fn(void *priv, struct sk_buff *skb,
	       const struct nf_hook_state *state)
{
	struct nf_conn *ct;
	enum ip_conntrack_info ctinfo;
	struct nf_conn_nat *nat;
	/* maniptype == SRC for postrouting. */
	enum nf_nat_manip_type maniptype = HOOK2MANIP(state->hook);

	/* 该函数为netfilter进行NAT的核心函数,在prerouting、postrouting、
	 * local_out和local_in处都会被调用。
	 */

	/* NAT收发包处理逻辑。首先检查是否存在conntrack信息,如果没有的话就直接
	 * 返回ACCEPT。 CONNTRACK模块会先于NAT模块进行conntrack信息的创建。
	 *
	 * conntrack的创建发生于PREROUTING(ipv4_conntrack_in()函数里)或者
	 * LOCAL_OUT(ipv4_conntrack_local()函数),由此可以看出内核会对所有
	 * 本地发送出去的报文和接收到的报文进行conntrack跟踪。
	 */

	ct = nf_ct_get(skb, &ctinfo);
	if (!ct)
		return NF_ACCEPT;

	nat = nfct_nat(ct);

	switch (ctinfo) {
	case IP_CT_RELATED:
	case IP_CT_RELATED_REPLY:
		/* Only ICMPs can be IP_CT_IS_REPLY.  Fallthrough */
	case IP_CT_NEW:

		/* 进行NAT两个方向上的初始化。这个过程一般在触发conntrack
		 * 记录创建的那个报文的处理过程中完成。
		 * 
		 * 在PREROUTING/LOCAL_OUT处会完成DNAT的初始化;
		 * 在POSTROUTING/LOCAL_IN处会完成SNAT的初始化。
		 */
		if (!nf_nat_initialized(ct, maniptype)) {
			struct nf_nat_lookup_hook_priv *lpriv = priv;
			struct nf_hook_entries *e = rcu_dereference(lpriv->entries);
			unsigned int ret;
			int i;

			if (!e)
				goto null_bind;

			/* 遍历当前ops上的entries,并进行报文的处理。如果当前
			 * HOOK点上存在ops且对该报文进行了NAT,那么ct上的NAT
			 * 信息就会被初始化,然后跳转到do_nat。
			 */

			for (i = 0; i < e->num_hook_entries; i++) {
				ret = e->hooks[i].hook(e->hooks[i].priv, skb,
						       state);
				if (ret != NF_ACCEPT)
					return ret;
				if (nf_nat_initialized(ct, maniptype))
					goto do_nat;
			}
null_bind:
			/* 缺省的NAT初始化函数(不进行NAT) */
			ret = nf_nat_alloc_null_binding(ct, state->hook);
			if (ret != NF_ACCEPT)
				return ret;
		} else {
			pr_debug("Already setup manip %s for ct %p (status bits 0x%lx)\n",
				 maniptype == NF_NAT_MANIP_SRC ? "SRC" : "DST",
				 ct, ct->status);
			if (nf_nat_oif_changed(state->hook, ctinfo, nat,
					       state->out))
				goto oif_changed;
		}
		break;
	default:
		/* ESTABLISHED */
		WARN_ON(ctinfo != IP_CT_ESTABLISHED &&
			ctinfo != IP_CT_ESTABLISHED_REPLY);
		if (nf_nat_oif_changed(state->hook, ctinfo, nat, state->out))
			goto oif_changed;
	}
do_nat:
	/* 在ct中的NAT信息初始化好后,调用这里来进行报文内容的修改。这里会
	 * 在ct->status标记为启用了NAT的情况下使用ct里的信息来修改报文的
	 * 内容(地址、端口等)。
	 */
	return nf_nat_packet(ct, ctinfo, state->hook, skb);

oif_changed:
	nf_ct_kill_acct(ct, ctinfo, skb);
	return NF_DROP;
}

无论是默认的初始化函数,还是默认的初始化函数,最终都会调用

nf_nat_setup_info()

这个函数。这个函数的作用包括:

  • 从range里选取一个可用的(未被使用)的元组,并设置到ct的REPLY方向的那个元组上
  • 设置NAT初始化的状态

注意这里的range,其里面存放的是元组的范围。如果当前函数是由默认的初始化函数调用,那么range存放的就是原始元组(参考

nf_nat_alloc_null_binding()

的实现),使用

get_unique_tuple()

获取到的新元组必定和原来一样;如果是

nftables/iptables

的钩子函数,那么这里的range存放的就是目标元组(SNAT的话,是源地址、端口信息;DNAT的话,是目的地址信息),获取到的新元组必定与原来的不一样(可参考

nft_nat_eval()

函数的实现)。

unsigned int
nf_nat_setup_info(struct nf_conn *ct,
		  const struct nf_nat_range2 *range,
		  enum nf_nat_manip_type maniptype)
{
	struct net *net = nf_ct_net(ct);
	struct nf_conntrack_tuple curr_tuple, new_tuple;

	/* Can't setup nat info for confirmed ct. */
	if (nf_ct_is_confirmed(ct))
		return NF_ACCEPT;

	/* 该函数进行conntrack记录中NAT的状态初始化工作,包括从range里获取
	 * 可用元组、进行ct状态的设置等。
	 */

	WARN_ON(maniptype != NF_NAT_MANIP_SRC &&
		maniptype != NF_NAT_MANIP_DST);

	if (WARN_ON(nf_nat_initialized(ct, maniptype)))
		return NF_DROP;

	/* What we've got will look like inverse of reply. Normally
	 * this is what is in the conntrack, except for prior
	 * manipulations (future optimization: if num_manips == 0,
	 * orig_tp = ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple)
	 */
	nf_ct_invert_tuple(&curr_tuple,
			   &ct->tuplehash[IP_CT_DIR_REPLY].tuple);

	/* 获取可用的元组(存放到tuple变量中)。该方法会先检查当前元组
	 * (origin_tuple)是否可用,如果可用的话就直接使用。判断是否可用的依据
	 * 是使用nf_nat_used_tuple()判断元组是否已经被占用。
	 * 
	 * 如果已经被占用,那么就会从可选区域中查找可用的地址或者端口,并存放到
	 * tunple中。
	 * 
	 * 对于NAT来说,一般像iptables/nftables会调用nf_nat_setup_info()来
	 * 初始化SNAT或者DNAT里的元组信息,初始化的时候将range设定为自己想要的
	 * 值。在初始化完成后,netfilter会调用nf_nat_packet()来根据ct里的信息
	 * 来修改报文。
	 * 
	 * 注意:对于SNAT或者DNAT操作,new_tuple和curr_tuple不会相等,因为
	 * range里会限定要修改的地址或者端口范围。所以,对于SNAT或者DNAT,
	 * ct->status标志位中一定设置了IPS_SRC_NAT或者IPS_DST_NAT。
	 */
	get_unique_tuple(&new_tuple, &curr_tuple, range, ct, maniptype);

	if (!nf_ct_tuple_equal(&new_tuple, &curr_tuple)) {
		struct nf_conntrack_tuple reply;

		/* 如果新元组与老的不一样,那说明发送了SNAT或者DNAT。此时,将
		 * 新的元组应用到ct上。
		 */
		/* Alter conntrack table so will recognize replies. */
		nf_ct_invert_tuple(&reply, &new_tuple);
		nf_conntrack_alter_reply(ct, &reply);

		/* 根据当前的HOOK点判断是发生了SNAT还是DNAT。 */
		if (maniptype == NF_NAT_MANIP_SRC)
			ct->status |= IPS_SRC_NAT;
		else
			ct->status |= IPS_DST_NAT;

		if (nfct_help(ct) && !nfct_seqadj(ct))
			if (!nfct_seqadj_ext_add(ct))
				return NF_DROP;
	}

	if (maniptype == NF_NAT_MANIP_SRC) {
		unsigned int srchash;
		spinlock_t *lock;

		srchash = hash_by_src(net,
				      &ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple);
		lock = &nf_nat_locks[srchash % CONNTRACK_LOCKS];
		spin_lock_bh(lock);
		hlist_add_head_rcu(&ct->nat_bysource,
				   &nf_nat_bysource[srchash]);
		spin_unlock_bh(lock);
	}

	/* 走到这里,说明NAT冲突检测已经完成,已经进入了可以进行NAT的状态。 */
	if (maniptype == NF_NAT_MANIP_DST)
		ct->status |= IPS_DST_NAT_DONE;
	else
		ct->status |= IPS_SRC_NAT_DONE;

	return NF_ACCEPT;
}

在完成了NAT的初始化后,

nf_nat_inet_fn -> nf_nat_packet

函数会被调用,这个函数是用来根据ct里的元组信息对报文内容进行修改的:

unsigned int nf_nat_packet(struct nf_conn *ct,
			   enum ip_conntrack_info ctinfo,
			   unsigned int hooknum,
			   struct sk_buff *skb)
{
	enum nf_nat_manip_type mtype = HOOK2MANIP(hooknum);
	enum ip_conntrack_dir dir = CTINFO2DIR(ctinfo);
	unsigned int verdict = NF_ACCEPT;
	unsigned long statusbit;

	/* 判断当前的HOOK点是SNAT还是DNAT,并且与ct是在进行SNAT还是DNAT进行
	 * 对比,如果一致的话就说明需要进行NAT(修改报文)。
	 */
	if (mtype == NF_NAT_MANIP_SRC)
		statusbit = IPS_SRC_NAT;
	else
		statusbit = IPS_DST_NAT;

	/* 如果报文是响应报文,那么判断的方向也应该取反。比如SNAT,这里认为
	 * PREROUTING的HOOK点应该要对响应报文做处理。
	 */
	if (dir == IP_CT_DIR_REPLY)
		statusbit ^= IPS_NAT_MASK;

	/* Non-atomic: these bits don't change. */
	if (ct->status & statusbit)
		verdict = nf_nat_manip_pkt(skb, ct, mtype, dir);

	return verdict;
}

从上面的流程中可以看出来,

nftables/iptables

中的NAT规则只有在ct刚创建的时候会被使用,一旦ct创建完成,后面的报文都不会再使用NAT规则,只会根据ct上的信息来进行NAT。这里可以看出,即使删除对应的NAT规则,也不会影响到现有连接的进行。

这里需要说明的是,conntrack、nat的钩子是在被使用的时候才会注册。nat依赖于conntrack,所以nat的钩子注册的时候会触发conntrack钩子的注册;nftables/iptables的NAT依赖于netfilter的NAT,因此当nftables添加NAT类型的链的时候会触发netfilter的NAT的钩子的注册(注销也是同样的原理)。



版权声明:本文为qq_17045267原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。