业务需求
在应用防火墙的开发中,防cc攻击是一个重要且复杂的模块。如果说防waf攻击是依靠对请求报文中字符串特征的识别来决定是否拦截,那么防cc攻击就是对访问频率的计算来决定是否拦截。防cc攻击逻辑中一般会有ip黑名单或白名单的检查,访问的来源ip在黑名单就直接拦截,在白名单就直接放行。
在实际业务中有这样得一种需求场景:很多访问行为来自于大互联网公司的数据中心,我在实际项目中发现此类ip(段)有4000多个,并且全部是ipv4,它们应该在防cc攻击的白名单中。以此为例,以下探究如何存储和查询这个ip白名单。由于我平时工作内容就是应用防火墙,它基于openresty框架,业务开发用的是lua语言。下面所讲的ip名单的存储与查询的方法都是基于使用lua+openresty。
方法探究
lua table存储与lua查询
如何存储
lua是一种脚本语言,用c编写,它有一种数据类型叫做table,我们可以把它当成数组和字典进行任意的赋值与取值操作。table的底层实现有具体文章可参考:lua之table(我写的),此处简而言之:table底层实现为数组部分和哈希表部分,lua虚拟机会在使用过程中动态调整两者容量并重新安排所有元素的位置,两者容量任何时刻都是2的次幂,且各自真实放置的元素数量介于(容量/2,容量]之间。
用table存储ip名单分为2种情况:
- 如果是精确的ip,则键为该ip(字符串),值为true(布尔值)
- 如果是ip段,则第一层键为对应的掩码(整数),值为表;第二层键为ip段右移(32-掩码)位得到的整数,值为true(布尔值)
假如有一个个ip为1.1.1.1、两个ip段为3.3.3.3/24和4.4.4.0/24 ,则存储这些ip(段)的table是:
{
"1.1.1.1":true,
24:{
197379:true, //代表3.3.3.3/24
263172:true //代表4.4.4.0/24
}
}
如何查询
当请求到来的时候,我们拿到来源ip。按照以下步骤进行查询:
- 询以该ip字符串为键的值是否为true,是则结束查询;否则下一步
- 查询以该ip字符串为键的值是否为true,是则结束查询;否则下一步
- 将该ip转换为4字节的无符号整数
- 遍历实际业务中每个掩码,找到该掩码为第一层键的表,有则继续;否则下一个掩码
- 将整数格式的ip右移(32-掩码)位得到一个整数,查询以该整数为键的值是否为true,有则结束查询;否则上一步
空间与时间复杂度
空间复杂度
实际业务中,共有4000多个ip(段),根据掩码不同,数量分布为:1154个精确ip, 剩下的都是ip段,共17种掩码(从15到31)。掩码15有1个段,掩码16有4个段,掩码17有2个段,掩码18有8个段,掩码19有21个段,掩码20有14个段,掩码21有28个段,掩码22有50个段,掩码23有142个段,掩码24有668个段,掩码25有161个段,掩码26有216个段,掩码27有267个段,掩码28有271个段,掩码29有311个段,掩码30有315个段,掩码31有396个段。
它们形成table后,经过实际的计算和打印证实,共占据约264K的内存。每个数组元素占16个字节,每个哈希表元素占40个字节,为什么哈希表元素占这么多字节?因为它不仅要存值也要存键。还有最主要的,因为哈希表和数组的容量都是2的次幂,必然造成浪费:1154个精确ip,就需要分配容量为2048的哈希表;17正好比32/2大一点,所以17个掩码表都存在容量为32的数组里。这两个不争气的,都是卡着比一个幂稍微多一点导致容量分配上升了一个幂。不仅如此,每个掩码表自身也是哈希表,这么算也有不少空间浪费。这样的内存占用分析都是来自于table底层实现。
时间复杂度
为了方便起见,不对所有掩码都举例,只简单举例:对在名单中的精确ip、掩码16、掩码24、掩码31以及名单外的ip各自进行10000万次的查询。以下是时间分布:
ip类型 | 查询时间(s) |
---|---|
名单中的精确ip | 1.01 |
名单中的掩码16 | 1.50 |
名单中的掩码24 | 2.17 |
名单中的掩码31 | 2.93 |
名单外的ip | 3.12 |
该结果和查询逻辑可以对得上,因为查询都是从精确ip先查,然后再按照掩码从小到大查,直到最后查不到。
mmdb存储与ffi查询、lua c查询
如何存储
mmdb是一种特殊格式的文件,它的结构有具体文章可参考:mmdb文件结构解析(我写的),简单说,它就是把ip(段)按照从高比特位到低比特位的顺序组织成二叉树,0往左1往右。每个节点有2个成员,第一个成员代表如果接下来一位是0那么应该走到哪个节点;第二个成员代表如何接下来一位是1那么应该走到哪个节点。如果走着走着发现,当前所在节点对应的成员值大于等于节点总数量,就说明查询到结果了:成员值等于节点总数量,那么该ip(段)在文件中;成员值大于节点总数量,那么该ip(段)不在文件中。
如何查询
由于mmdb文件的打开和查询也有独立的项目,用c语言写成,因此我们需要在lua中调用外部c库。调用外部c库有2种方式:一种是mmdb源代码独立打包成动态库,然后在openresty中用ffi调用;另外一种是将mmd源代码和lua c接口一起打包成动态库,然后在openresty中直接调用。下面对这两种调用的查询方式进行阐述。
ffi查询
ffi是一种专在openresty环境中使用的调用c库的方法。本例中,我写了一个lua模块封装了ffi,并以纯lua的方式对业务暴露打开和查询mmdb文件的接口:
local ffi = require("ffi")--先加载ffi
local c_libmmdb = ffi.load("mmdb")--加载mmdb库,本例中,我把实现了打开和查询mmdb文件的mmdb源代码打包成动态库,取名叫做libmmdb.so
ffi.cdef[[--把要用到的mmdb源代码中的数据类型和函数原型进行集中的声明
typedef struct MMDB_ipv4_start_node_s {
uint16_t netmask;
uint32_t node_value;
} MMDB_ipv4_start_node_s;
typedef struct MMDB_description_s {
const char *language;
const char *description;
} MMDB_description_s;
typedef struct MMDB_metadata_s {
uint32_t node_count;
uint16_t record_size;
uint16_t ip_version;
const char *database_type;
struct {
size_t count;
const char **names;
} languages;
uint16_t binary_format_major_version;
uint16_t binary_format_minor_version;
uint64_t build_epoch;
struct {
size_t count;
MMDB_description_s **descriptions;
} description;
} MMDB_metadata_s;
typedef struct MMDB_s {
uint32_t flags;
const char *filename;
ssi