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的钩子的注册(注销也是同样的原理)。