关于在nginx上实现dyserver的思路

engine上实现了dyups,来实现对upstream的动态load,即在不重启nginx的情况下,完成对upstream的动态加载。
我们今天讨论的问题有些类似,那就是如何实现动态server的加载,即让一段一段的server{}热生效。

我们先梳理一下目前静态server{}的加载流程,它是在ngx_init_cycle里完成的

  • 解析配置文件nginx.conf。解析的过程中会创建若干个ngx_http_srv_conf_conf_t对象(一个server对应一个)。对应的,每个http模块也会做相应的初始化,其context有时是srv,有时时loc。
  • 为所有模块创建共享内存,在各个模块的create_main_conf回掉里会指定创建的大小,标志等等
  • 创建好共享内存候回掉各个模块,说共享内存创建好了,as you wish,请使用
  • 删除旧的listening,可以这次不开始443端口了
  • 开始监听listening socket

大致就是上面的过程。
如果我们使用dyups的思路搞这个事情的话。比如每个worker可以加载一段server{}代码到自己的cycle中

1
2
3
4
5
6
7
8
9
10
{
server {
listen 80 default_server;
server_name _; # This is just an invalid value which will never trigger on a real hostname.
access_log logs/default.access.log main;
server_name_in_redirect off;
root /var/www/default/htdocs;
test_module_cmd on; // 一个新模块test_module,需要使用共享内存
}
}

加载这样的配置块,如果想生效,需要执行哪些步骤呢?共享内存和开启监听

第一步,肯定需要解析配置,创建ngx_http_srv_conf_conf_t,以及解析其内配置的各个模块命令
第二步,准备共享内存,通常是不需要的,因为共享内存都是在create_main_conf里创建,以及在shm等回掉里初始化,在当前的时机里,shm肯定初始化过了,shm和server{}无关,只和模块的main conf即http{}有关
第三步,开启监听以及相关事宜

第三步内容很复杂,可以考虑我的 nginx的配置管理 一文。但这部分对于某些方案来说,还是略有便利的。比如lvs + nginx这种架构。nginx上的监听,不是普普通通的监听,它是监听了一个大众socket,比如80,
所有的用户都使用这个80port,那如何区分从lvs的那个vs过来的流量呢?答案就是通过tcp option。
所以对于这样的case 第三步只是维护了一个虚假的监听,用户匹配从tcp option上拿到vs的信息即可。

一起理解nginx的监听流程

1. 入门

我们先看一个nginx配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
http {
....

server { // server1
listen 10.0.0.2:80;
server_name www.test1.com;
server_name login.test1.com;
...
}

server { // server2
listen 192.168.0.3:8080;
server_name www.test2.com;
...
}
server { // server3
listen 10.0.0.2:80;
listen 192.168.0.3:8080;
server_name www.test3.com;
...
}
}

上面是我编的配置,假设一个机器上有2个IP,10.0.0.2和192.169.0.3,我们可以让ngin监听在不同的IP上来完成对同的对外服务。
我们可以看到在不同的server{}段里,监听的IP和port是有冲突的,但我们是知道一个请求到来之后交给哪个server{}处理。

host 请求地址 命中的server{}
www.test1.com 10.0.0.2:80 server1
www.test1.com 192.168.0.3:8080 bad
www.test2.com 192.168.0.3:8080 server2
www.test3.com 10.0.0.2:80 server3
www.test3.com 192.168.0.3:8080 server3

也就是目标地址和域名 必须和server{}里的listene地址server_name匹配才行。
那么nginx是如何管理这么复杂的地址的呢?

2.解释

我们知道当一个解析到listen命令的时候,会执行函数

1
2
static char *
ngx_http_core_listen(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)

注意,第三个conf就是ngx_http_core_srv_conf_t, 也就是保存了listen命令所在地这个server{}的所有信息。
跟踪这个函数的执行,就能得到我们到所有信息了。
在理解细节之前,我们需要明白一个事情,也就是listen这个命令,最终会导致nginx去监听一个tcp地址。这个监听的地址到底是全局的?还是per server{}的呢?
答案自然是全局的。
有了这个答案,我们才好理解nginx是如何管理地址的。 我们先看一个http的全局conf的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typedef struct {
ngx_array_t servers; /* ngx_http_core_srv_conf_t */

ngx_http_phase_engine_t phase_engine;

ngx_hash_t headers_in_hash;

ngx_hash_t variables_hash;

ngx_array_t variables; /* ngx_http_variable_t */
ngx_array_t prefix_variables; /* ngx_http_variable_t */
ngx_uint_t ncaptures;

ngx_uint_t server_names_hash_max_size;
ngx_uint_t server_names_hash_bucket_size;

ngx_uint_t variables_hash_max_size;
ngx_uint_t variables_hash_bucket_size;

ngx_hash_keys_arrays_t *variables_keys;

ngx_array_t *ports;

ngx_http_phase_t phases[NGX_HTTP_LOG_PHASE + 1];
} ngx_http_core_main_conf_t;

这里有个变量是*ports, 这里保存的就是本nginx监听的所有port的list,针对章节1的例子,这个list成员有2个,一个代表80,一个代表8080,具体包装他们的结构就是ngx_http_conf_port_t:

1
2
3
4
5
typedef struct {
ngx_int_t family;
in_port_t port;
ngx_array_t addrs; /* array of ngx_http_conf_addr_t */
} ngx_http_conf_port_t;

一个port下可以挂很多个地址,还是看前面的例子,80port下的两个地址分别是10.0.0.2和192.168.0.3。对应的类型就是ngx_http_conf_addr_t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

typedef struct {
ngx_http_listen_opt_t opt;

ngx_hash_t hash;
ngx_hash_wildcard_t *wc_head;
ngx_hash_wildcard_t *wc_tail;

#if (NGX_PCRE)
ngx_uint_t nregex;
ngx_http_server_name_t *regex;
#endif

/* the default server configuration for this address:port */
ngx_http_core_srv_conf_t *default_server;
ngx_array_t servers; /* array of ngx_http_core_srv_conf_t */
} ngx_http_conf_addr_t;

opt是这个listen的一些选项,比如http2,ssl等等。对于一个地址即IP:port 会有多个server_name,这里说的就是虚拟主机。当地址一样的时候,nginx可以根据
输入的域名,来使用不同的server{}配置。看最后一个字段servers,一个监听地址可以对应多个server{}(因为不通的虚拟主机嘛)

3.nginx对虚拟主机的管理

那么nginx又是如何实现虚拟主机的管理的呢?

这一切的入口在ngx_http_optimize_servers里,当一个http{}加载完毕后,回执行这个函数。

1
2
3
static ngx_int_t
ngx_http_optimize_servers(ngx_conf_t *cf, ngx_http_core_main_conf_t *cmcf,
ngx_array_t *ports)

参数就是http_core模块的main conf和其内部的port list。nginx是基于port管理的,port下挂监听相同port的addr list,addr里挂virtual host list。
第一步,对于每个监听地址调用函数ngx_http_server_names。对其下的域名进行整理,根据配置使用hash表,通配,正则方式进行归类,方便以后查找用。
第二步,执行ngx_http_init_listening,可以简单说一下这里的处理逻辑

  • 对一个port下地址排序的时候会放到最后,比如:80,如果有这个,则认为支持wild匹配,server{}没有写listen命令的会使用这个配置
  • 组织运行态的port/addr/listen的数据结构,方便运行时查找到对应的配置。

配置态和运行态的数据结构组织关系图

最后我们看看来一个连接时候的查找流程。对于一个listen来讲,它可定是对应一个ngx_listening_t的,也就是对应一个ngx_http_port_t。
ngx_http_port_t是一个要么一个精准的IP:port结构,要么是1-n个精准的IP:port 外加一个*:port。

当accept一个链接的时候,我们能通过callback data知道是哪个listen,但还仍然需要根据目的地址,去找到底匹配了哪个IP:port。(仅针对IP:port + *:port的case下)。
最后找到了ngx_http_addr_conf_t(从运行态图里找)。后续可以根据这个找虚机主机相关的配置。

以上

理解字节序

前言

估计很多人和我一样,在debug过程中见到字节序就头大,比如wireshark里看到的是0x04d5表示一个长度,这个长度到底是多少呢?还比如 IP部分是 01020304,这到底是1.2.3.4还是4.3.2.1呢?

分清字节序

肯定是和计算机相关,计算机处理输入的时候肯定是一个字节,一个字节的顺序处理,而且从效率上考虑也是先处理低字节,后处理高字节。但我们人类还是习惯从高往低读,比如你的工资是几千几百几十万几千几百几十几。

那计算机是怎么读取数据的呢?都是按内存地址来,从低到高的来。先读到低的,然后处理低的。这就是小端。

那么网络的大端,是怎么回事呢?因为制定网络协议的时候,是写在纸上给人看的,所以自然需要按照人的读写方式。比如长度
是258,占2个字节,那么就需要写成0x0102。因为这样才符合人类的阅读方式。1 * 256 +2 = 258。所以1.2.3.4的IP地址会在wireshark里显示0x01020304.因为是人类的方式.

然后我们规定在网络传输中使用大端。即高位字节先传输,地位自己后传输。所以我们以内存接受数据的时候,自然是先写低地址,后写高地址,也就是把高字节写到了低地址。也就是大端,所以需要处理的时候,还需要调用ntoh翻转一下,即低位放在低地址上。

你明白了么?

a.out的幕后

a.out结构

我们在使用gcc编译一个程序的时候,如果不指定任何选项,默认的产出都会是a.out的一个文件。那么a.out到底是什么什么结构呢?

静态链接

动态链接

RSA算法证明

1
2
3
随机选取两个质数p1、p2,n=p1xp2,再随机选取一个整数e,e与φ(n)互质, e通常为65537, 再次计算一个d, 它是e对于φ(n)的模反元素,也就是e x d ≡ 1 (mod φ(n))
加密过程:(m^e) mod n=c,其中m为原信息(注意m < n),c为加密信息,n、e为公开密钥。
解密过程:(c^d) mod n=m,其中d为解密密钥。

上面就是加密和解密进行的操作了,我们后面可以证明一下,在证明之前,我们先介绍几个数学概念:

1
2
3
4
5
6
7
8
9
10
11
12
模反元素:如果两个正整数a和n互质,那么一定可以找到整数b,使得 a x b-1 被n整除,或者说ab被n除的余数是1,即a x b ≡ 1 (mod n)
φ(n):小于n且与n互质的正整数的个数,比如φ(10) = 4,因为小于10且与10互质的的数为1,3,7,9
欧拉定理:m和n互质,则m^φ(n) ≡ 1 (mod n)
欧拉函数:如果n为质数,φ(n)=n-1
欧拉函数是积性函数:若m,n互质, φ(mxn)=(m-1)(n-1)
同余性质:
1).反身性:a≡a (mod m);
2).对称性:若a≡b(mod m),则b≡a (mod m);
3).传递性:若a≡b(mod m),b≡c(mod m),则a≡c(mod m);
4).同余式相加:若a≡b(mod m),c≡d(mod m),则a+c≡b+d(mod m);
5).同余式相乘:若a≡b(mod m),c≡d(mod m),则axc≡bxd(mod m)。(特殊情况c=d下也成立)
6).幂运算:如果a≡b(mod m),那么a^k≡b^k(mod m);

下面开始证明,也就是证明:通过解密过程,可以从加密后的数据c得到加密前的数据m
解密过程的运算是

1
c^d ≡ m (mod n)

因为加密过程为
1
2
3
(m^e) ≡ c (mod n)

c = m^e - k x n

所以我们可以试图证明
1
(m^e - k x )^d ≡ m (mod n)

继续展开左边,相同与求证如下,因为看k x n的任意次方都是可以被n整除的,可以忽略
1
m^(e x d) ≡ m (mod n)

因为e和d是对于φ(n)的模反元素,即,exd = h x φ(n) + 1,就变成证明
1
m^(h x φ(n)+1) ≡ m (mod n)

考虑m和n互质与不互质两种情况
case 1:m和n互质,直接根据欧拉定理m^φ(n) ≡ 1 (mod n),根据同余的乘法性质
1
2
3
m^(h x φ(n)) x m = m (mod n)

m^(h x φ(n)+1) ≡ m (mod n)

证明完毕

case 2:m和n不互质,因为m < n (这是算法前提),n = p x q,所以m必然为p或者q的k倍,假设m=kxp,
其实这时k一定和q互质,因为k x p < q x p,q又是质数,然后kxp也必然和q互质。还是根据欧拉定理

1
2
3
(k x p)^φ(q) ≡ 1 (mod q)
根据欧拉函数
(k x p)^(q-1) ≡ 1 (mod q)

继续根据同余的幂运算和乘法运算性质

1
(k x p)^((q-1) x h x (p-1)) x (k x p) ≡ k x p (mod q)

根据欧拉函数是积性函数
1
2
3
(k x p)^(φ(p x q) x h) x (k x p) ≡ k x p (mod q)
继续
(k x p)^(φ(n) x h + 1) ≡ k x p (mod q)

考虑到e和d是对于φ(n)的模反元素,即e x d = h x φ(n) + 1
1
2
3
(k x p)^(e x d) ≡ k x p (mod q)
换种写法
(k x p)^(e x d) = t x q + k x p

因为p是一个大质数,t x q必然整除p,q不能整除p,所以t必然整除p
1
(k x p)^(e x d) = t1 x p x q + k x p

因为m = k x p,n = p x q,所以
1
2
m^(e x d) = t1 x n + m
m^(e x d) ≡ m (mod n)

证明完毕。

折腾完毕之后发现跑题了,我们只向看看指数和模数的使用而已,一句话,它们就是公钥。也就是说证书里包含了公钥。前面看到,证书里还有一些签名的东西。签名是啥呢?
签名就是CA对其颁发证书的一个认同。

SSL基础知识总结

SSL的相关概念

SSL(Secure Sockets Layer)是Netscape网景公司最早研发,SSL从最早的1.0发展到2.0,3.0。后来考虑标准化的时候需要摆脱公司的影响,重新命名为TLS,Transport Layer Security。演进的版本依次为
1.0,1.1,1.2, 1.3应该还没有发布。

为什么会有SSL呢

SSL的基础体系PKI

PKI的全称为Public Key Infrastructure。百科上说:PKI是一种遵循标准的利用公钥加密技术为电子商务的开展提供一套安全基础平台的技术和规范。那么PKI规范,包含哪些东西呢?
在说包含哪些东西之前,我们先思考一下,从电子商务的角度触发,会面临哪些问题?

  • 保密性 比如在传输中不给窃听盗取
  • 完整性 在传输过程中,不能被篡改
  • 身份认证和授权 对面是人是鬼?要能确认对方身份
  • 防止抵赖 交易完成后,不承认怎么办?

所以PKI体系需要解决上面的问题。

  • 完美的加密算法确保保密性和完整性,这部分就是SSL/TLS的算法部分需要提供的功能
  • CA认证中心 整个体系的核心。是对身份确认的权威机构
  • 证书服务器。公开证书信息,以便用户查询证书信息或者黑名单信息等
  • Client和Server系统
  • 等等

加密算法

我们知道,在传输过程中,为了保密,发送方需要把数据进行加密,接收方进行解密。其他人拿到中间数据后,因为没有密钥,所以干着急,没有办法解密。

加密算法通常是说对称加密算法,目前在SSL中使用最广泛的是AES(Advanced Encryption Standard)
以前比较常用的是DES、3DES(TripleDES),但现在已经过时。这些算法都是块加密算法,也就是对特定bit的块,一块一块的加密。目前AES都是对128字节大小的块做加密。但我们在算法里可以看到AES128和AES256,这些说的不是加密块的大小,而是密钥的大小。

1
2
3
4
5
6
ECDHE-RSA-AES256-GCM-SHA384 TLSv1.2 Kx=ECDH     Au=RSA  Enc=AESGCM(256) Mac=AEAD
ECDHE-RSA-AES256-SHA384 TLSv1.2 Kx=ECDH Au=RSA Enc=AES(256) Mac=SHA384
ECDHE-RSA-AES256-SHA SSLv3 Kx=ECDH Au=RSA Enc=AES(256) Mac=SHA1
ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH Au=RSA Enc=AESGCM(128) Mac=AEAD
ECDHE-RSA-AES128-SHA256 TLSv1.2 Kx=ECDH Au=RSA Enc=AES(128) Mac=SHA256
ECDHE-RSA-AES128-SHA SSLv3 Kx=ECDH Au=RSA Enc=AES(128) Mac=SHA1

密钥交换算法

上面提到的加密算法都是对称加密算法,也就是双方都需要知道加密的密钥,根据其来进行加密解密,所以密钥是安全体系的重中之重,但这密钥怎么传输呢?传输密钥的算法就是密钥交换算法。

目前有两类主流的密钥交换算法

  • RSA
  • DH/ECDH

RSA算法一个比较古老但还流行的算法,三位数学家Rivest、Shamir 和Adleman搞出来的。我们可以简单的说一下RSA加密原理,一句话:

1
计算2个大数的乘积很容易,但把大数做质数分解确很难

想了解RSA的算法原理以及证明,可以参考:http://abonege.github.io/2018/01/31/RSA算法证明/

目前RSA是加密密钥长度已经达到2048位,在量子计算机出关之前,想破解基本没戏了。

DH/ECDH算法,是另外一种密钥交换算法,具体的数学原理很复杂,我们可以举个例子,简单的比喻一下:

1
2
3
4
5
1.Alice和Bob说,我们有个共同的参数15(公开信息).
2.Alice产生一个随机数3(私钥),然后发给Bob 3+15
3.Bob产生一个随机数9(私钥),然后发给Alice 9+15
4.Bob收到18,计算自己的密钥 18 + 9 = 27
5.Alice收到24,计算自己的密钥 24+3 = 27

这样Alice和Bob协商出来了一个加密密钥

上面的2,3使用的算法只是简单的+,但DH算法本身是使用离散对数的原理。可以参考这个图:

后来,人们又对DH算法做了改进,即ECDH(Elliptic Curve Diffie–Hellman),主要是为了缓解上图中p和g的生成代价,在算法上也增加了破解难度。

但现在目前使用的最广泛,也基本是唯一推荐的是ECDHE算法,什么是ECDHE呢?
我们得先看看DH算法的分类:

  • Anonymous Diffie-Hellman
  • Fixed Diffie-Hellman
  • Ephemeral Diffie-Hellman

第一个是匿名DH算法,不认证公钥的合法性的,所以很容易收到中间人的攻击(Man-in-the-Middle attacks)。

第二个是固定的公钥参数. 这时类似RSA,把公钥的参数,即前面提到的p,g,A,都写入到证书,然后由CA签名,从此千秋不变。当然这时候私钥也是不变的。
第三个是临时的公钥参数,即每次A都是临时算出来的,也是目前主要使用的,即ECDHE,为什么呢?
因为下面这个小伙:

ECDHE算法想要避免的问题就是:向前安全性

当前RSA算法的加密密钥计算过程如下:

所以,拿到原始报文后,只要有朝一日,拿到私钥,就可以算出加密密钥,从而破解。

ECDHE算法就是为了避免这个事情,密钥的协商,都是临时的,阅后即焚,无法复现。但这时候的问题是,临时生成的公钥,如何避免中间人攻击,即
如何证明自己是可信的呢?这时候就轮到当前的主角ECDHE-RSA出场了。

DH算法的公钥需要使用RSA的私钥做签名。对方使用RSA的公钥来验证DH公钥的合法性。

HASH算法

前面提到的都是加密算法,很重要,如果中间者虽然不能窃听,但篡改包还是可以的,所以需要接受者知道收到的包是否经过篡改。这就是MAC(Message Authentication Code)算法。目前支持两种MD5(Message Digest 5)和SHA(Secure Hash Algorithm)。收到包后,先解密,然后看MAC的一致性,如果不一致,则直接丢弃该报文。

证书与签名

证书是什么呢?证书是权威机构颁发的,供个人或机构来证明自己合法身份的证明。目前有这么几类证书:

  • Class 4 SSL证书:即EV SSL证书,顶级SSL证书,又称扩展验证型SSL证书。安全级别最高,验证审核最严格,网站部署EVSSL证书后,浏览器地址栏将变成绿色并显示企业名称。EV SSL证书一般应用于金融、银行、电商等安全需求较高的网站。比如
    https://ebsnew.boc.cn/boc15/login.html

  • Class 3 SSL证书:即OV SSL证书,专业级SSL证书,又称机构验证型SSL证书。当前广泛应用的SSL证书,需要验证企业身份信息后颁发。OV SSL证书是当前最常见的证书类型,适用于行政、企业、科研、邮箱、论坛等各类大中型网站。

  • Class 2 SSL证书:即IV SSL证书,个人级SSL证书,沃通特有的SSL证书,又称个人验证型SSL证书。验证个人详细信息后颁发,主要应用于私人博客、自媒体等个人网站。

  • Class 1 SSL证书:即DV SSL证书,基础级SSL证书,又称域名验证型SSL证书。DV SSL证书是签发只验证域名所有权,快速颁发的SSL证书,安全级别较低。

证书长什么样子呢?列举百度的证书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 9 (0x9)
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2
Validity
Not Before: Feb 11 06:04:56 2015 GMT
Not After : Feb 8 06:04:56 2025 GMT
Subject: C=CN, ST=beijing, L=beijing, O=service operation department OU=Beijing Baidu Netcom Science Technology Co., Ltd, CN=baidu.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:a4:b0:dd:eb:c1:cf:5d:47:61:a6:ea:ef:8b:aa:
4b:f0:b4:2c:d8:96:c7:7c:ac:fa:c7:35:88:53:d0:
...
8a:76:dc:8f:8c:44:c8:0b:3c:36:88:5f:01:f0:44:
4e:81:e6:7a:2b:ff:ba:da:33:a5:27:11:c6:f0:08:
6e:f3
Exponent: 65537 (0x10001)
X509v3 extensions:
X509v3 Basic Constraints:
CA:FALSE
Netscape Comment:
OpenSSL Generated Certificate
X509v3 Subject Key Identifier:
07:C6:87:B7:C1:1E:28:E8:96:3F:EB:40:1E:82:41:45:CA:81:B6:3D
X509v3 Authority Key Identifier:
keyid:A4:C2:14:6A:39:D1:95:1E:BD:DF:3B:92:4A:5C:12:42:1B:BC:53:B8

Signature Algorithm: sha256WithRSAEncryption
0c:c6:81:70:cd:0a:2d:94:4f:cb:a4:1d:ef:9e:8e:e4:73:ae:
50:62:a8:9c:64:ef:56:0f:41:fe:6b:b4:d3:07:37:39:2c:ed:
...
6f:62:61:b8:03:d7:97:31:ab:05:44:20:07:65:8b:ad:e2:cc:
ad:65:73:f6:82:0f:9e:65:d0:ae:b7:1e:fd:9f:c1:d7:41:6c:
0f:06:95:ee
-----BEGIN CERTIFICATE-----
MIIEMDCCAxigAwIBAgIBCTANBgkqhkiG9w0BAQsFADCBtTELMAkGA1UEBhMCQ04x
EjAQBgNVBAgMCUd1YW5nRG9uZzERMA8GA1UEBwwIU2hlblpoZW4xJjAkBgNVBAoM
...
ujwwRar6pPzusO95WuS93HsNmL2ZFZ63DS4LcW9iYbgD15cxqwVEIAdli63izK1l
c/aCD55l0K63Hv2fwddBbA8Gle4=
-----END CERTIFICATE-----

大部分字段,我们通过名字,能猜出来是什么意思。关于Modulus和Exponent,不用细追究,它们是生成公钥的2个输入,公钥并不像私钥那样直接提供。而是提供了2个数,一个指数,一个是模数。这2个数可以直接生成公钥。

后面还有一个签名,算法为sha256WithRSAEncryption,意思是对证书先用sha256创建指纹摘要,然后使用CA的RSA私钥加密。浏览器如何验签呢?

  • 证书使用sha256算法加密后和下来的指纹是否一致
  • 使用CA的公钥解密签名,看是否和指纹一样
  • 还有证书过期,吊销等验证

怎么认证证书的合法性呢?
我们的操作系统内置了很多root CA,这些CA都是有颁发签署新证书能力的。再其颁发的所有证书上,都有CA对这个证书的签名(用CA私钥)。浏览器拿到证书后,可以下载到CA的公钥,然后用公钥验证签名。从而知道证书的合法性。

继续签名

CA帮助我们的证书做了签名,证明了这个证书的合法性。但是我们client/server实际交互的时候,还需要一个签名,这个签名是保证,server确实拥有这个证书,别人休想模仿我。

对于RSA来讲,这个问题是简单的。因为证书里有被CA签名过的server的公钥,client既然能使用这个公钥和server完成密钥协商,那么server一定是合法拥有这个证书的。

我们主要讨论后来广泛使用的ECDHE,考虑向前安全性,ECDH已经基本废了。ECDHE都是临时生成对称密钥的方式,证书里的公钥对应的私钥(保存在服务器中)没有用于协商密钥。
只有让其参与到ssl过程中来,才能证明其合法的拥有证书。所以ECDHE是需要签名的(这里的签名是数据传输过程中的签名,不是证书签名)。
通常有两种RSA和ECDSA。

先说RSA,为了证明server对这个证书的合法拥有,在server-key-exchange消息的时候,回把这个消息签名(使用证书私钥+RSA算法)

再说ECDSA

对于ECDSA来讲,普通的证书是无法使用的,我们需要使用ECC证书,也就是证书上需要有ECC算法的公钥,我们才能使用此公钥对应的ECC私钥做签名。

SSL协议

前面介绍了很多SSL的概念,知道这些概念后,了解SSL协议就方便很多了。

SSL协议可以认为是L5层协议,即会话层。看下图:

SSL的交互过程

我们先看一下SSL有哪些类型的消息

  • Alert
  • ApplicationData
  • Certificate
  • CertificateRequest
  • CertificateVerify
  • ChangeCipherSpec
  • ClientHello
  • ClientKeyExchange
  • Finished
  • HelloRequest
  • ServerHello
  • ServerHelloDone
  • ServerKeyExchange

基于RSA的握手

再看基于ECDHE的握手

session ID与 session ticket

为了减少SSL的握手过程,ssl协议提供了两种方案

  • session id
  • session ticket

session id是在加密信道种server传输一个session id给client,下次client在client hello消息中带上这个session id,server根据这个session id,就可以找到之前的master key,然后就可以直接通讯了。从而可以免去握手的过程。

session id也有缺点:

  • server需要维护很多session id以及其加密密钥的映射广西
  • 对于nginx/haproxy等集群设备来讲,彼此之间同步session id很是费力,否则session id无法使用。

session ticket在一定程度上能解决上面的问题。server会给client一个session ticket。下次client hello的时候带上这个ticket。
server对ticket进行解密(解密算法或密钥是server 控制的)。解密后可以得到原来的密钥,算法等信息。是不依赖server存储类似session id这样的东西的。
session ticket也有缺点:

  • rfc提到的很好,让server定期更新ticket的加密密钥,但网站部署者基本不会执行。所以一旦拿到STEK(Session Ticket Encryption Key)就完美绕开ECDHE,直接解密这些数据。(注意这里不一定是网络盗取STEK,也可能是行政获取)
  • 拿到STEK后,不仅能解密当前的copy的数据,也能解密之前的或是别的connection的数据(因为大家都是用这个STEK加密),先前兼容性全都没了。

关于keyless方案

keyless方案的出现,是基于如下的前提。

  • 在云环境中,很多客户不愿意把私钥交出来
  • 异地做RSA的私钥加解密操作能带来很多扩展,尤其是在设备进云的时候

使用私钥的操作为RSA握手时premaster私钥解密和DH握手时使用RSA私钥的签名。

在openssl环境中如何设计keyless方案

现在很多应用都是基于openssl开发的,比如nginx,haproxy等。平滑的支持keyless方案是很基本的一个需求。
幸好,openssl-1.1的ASYNC提供了一个简单的异步的机制。让我们可以在engine层面支持keyless,而且让上层应用基本不感知。但这个异步和aio,epoll那些还是不一样的。他就是提供了一种类似携程的东西,来完成一些SSL的异步操作。从release note上看,也一直提是为了”asynchronous capable engine”。

但整个方案还是需要依赖epoll等异步io机制。比如触发IN事件的时候去做SSL_read或者SSL_do_handshake这样的操作,但这些操作会返回SSL_ERROR_WANT_ASYNC,告诉app,等机会再来一次。

什么是engine呢?
engine是一个一个的so,每个so实现了openssl规定的一组函数:

1
2
3
4
5
6
7
8
int ENGINE_set_RSA(ENGINE *e, const RSA_METHOD *rsa_meth);
int ENGINE_set_DSA(ENGINE *e, const DSA_METHOD *dsa_meth);
int ENGINE_set_EC(ENGINE *e, const EC_KEY_METHOD *ecdsa_meth);
int ENGINE_set_DH(ENGINE *e, const DH_METHOD *dh_meth);
int ENGINE_set_RAND(ENGINE *e, const RAND_METHOD *rand_meth);
....
int ENGINE_set_ciphers(ENGINE *e, ENGINE_CIPHERS_PTR f);
int ENGINE_set_digests(ENGINE *e, ENGINE_DIGESTS_PTR f);

在engine中,可以自由实现你想独特实现的算法,比如rsa,只需定义好如下的函数,注册给engine即可

1
2
3
4
5
6
7
8
9
10
11
12
if ((dasync_rsa_method = RSA_meth_new("Test Async RSA method", 0)) == NULL
|| RSA_meth_set_pub_enc(dasync_rsa_method, dasync_pub_enc) == 0
|| RSA_meth_set_pub_dec(dasync_rsa_method, dasync_pub_dec) == 0
|| RSA_meth_set_priv_enc(dasync_rsa_method, dasync_rsa_priv_enc) == 0
|| RSA_meth_set_priv_dec(dasync_rsa_method, dasync_rsa_priv_dec) == 0
|| RSA_meth_set_mod_exp(dasync_rsa_method, dasync_rsa_mod_exp) == 0
|| RSA_meth_set_bn_mod_exp(dasync_rsa_method, BN_mod_exp_mont) == 0
|| RSA_meth_set_init(dasync_rsa_method, dasync_rsa_init) == 0
|| RSA_meth_set_finish(dasync_rsa_method, dasync_rsa_finish) == 0) {
DASYNCerr(DASYNC_F_BIND_DASYNC, DASYNC_R_INIT_FAILED);
return 0;
}

不愿意或没必要实现的,可以利用原来的默认算法。

那么openssl里的ASYNC机制的原理是什么呢?

有一个基本的概念叫job。可以认为是我们的一次加密/解密操作。举例说,当client发送一个pre-master secret过来的时候,
server需要拿自己的私钥解密,这个解密需要把私钥和pre-master secret一起扔到ssl offload server。这时,主程序不能等着啊,你先慢慢解,我一会来接你啊
这里面就有2个问题了:

  • 什么时候来接你?
  • 接你的时候,你还是那个你么?

针对第一个问题,是需要程序作者需要维护的,比如我们需要一个epoll轮询发现解密后的数据回来了,则知道要接了。对第二个问题是更棘手些的,『你还是那个你么』
说的是,当前的上下文,还是那个上下文么?
比如我的异步解密函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
int my_rsa_decrypt(arg1, arg2, arg3)
{
int data1_important;
int data2_important;
int async_result;
int result;

data1_important = xxxx();
data2_important = yyyy();
async_result = do_aync_job();
result = compute(data1_important, dat2_important, async_result);
}

假设我们异步的函数是do_async_job,我们在异步操作之前已经计算出来2个数,data1_important和data2_important。那我们理想的接上的方式,就应该是
直接执行do_async_job的下一行,且拿到了async_result这个值。没错,ASYNC就这么干的。因为他把当前的栈保存下来了。

举SSL_do_handshake为例,我们简述一下流程:

  • 在第一次调用SSL_do_handshake的时候,需要异步,会调用ASYNC_start_job开启一个job,并切换到job去执行(注意这里保存了『接你』栈)
  • job的func因为不能马山就绪,所以调用ASYNC_pause_job返回一个SSL_ERROR_WANT_ASYNC说等等
  • 多了一会,条件好了(pre-master secret解密数据回来了),主程序二次调用SSL_do_handshake
  • 在SSL_do_handshake里,会找到当前的job,然后调用ASYNC_start_job,恢复『接你』栈继续执行,从而透明的得到了一个SSL_do_handshake的结果,像什么都没发生一下样

看起来挺简单的,但实际上,我们面临的问题是,谁来驱动和远端通讯的socket(加入使用tcp的话)。
这时候,大部分需要app感知了。举nginx为例,修改后的nginx如下:

nginx的epoll需要额外托和keyless server通讯的socket,幸好pipe socket不需要。

nginx实现动态resolve的思路

1.摘要

在nginx用作负载均衡的时候,通常配置的源站都是一个或若干个IP+端口号,但在有些需求的情况下,我们需要回源到一个域名instead of一组ip,比如有如下的需求:

  • 一个域名对应的IP列表是变化的
  • nginx部署在不同的运营商后面,解析出来的IP可能是不一样的

一个简单的upstream可能如下

    upstream server_group {
        server mysite.com;
    }

我们想支持前面提到的需求,怎么办呢?在nginx的商业版中,配置是这样的:

    upstream server_group{
        server mysite.com resolve;
    }

这样就可以动态的解析mysite.com了。
我没有商业版的代码,表面上能满足我们动态解析的需求,但在有些情况下,上面的配置就显得捉襟见肘了。

我们可以先理一下,当upstream的server列表发生变化,哪些模块会受影响呢?这是和我们使用了哪些模块有关系的,在我的场景下,有如下模块收到了影响

  • 负载均衡模块
  • 健康检查模块

负载均衡模块需要感知变化,是因为需要在这些server负载均衡,多了少了server那得知道
健康检查模块也一样的,upstream的server列表指定了需要对哪些server做检查

因为通常来讲,这两个模块是需要感知upstream成员变化的。那么我们到底该如何改动源站域名的动态解析呢?

2.实现思路

我们可以先理一下,nginx现状是什么样呢?nginx本身是支持配置域名回源的,正如前面的配置。但这个配置是一次性判定的,加载配置的时候就定下来了,后期无法改变。
如果你的源站一直没有变化,其实这样的配置也是足够的。

但我们关注的是源站可以变化的这种case,要么随着时间变化,要么随着部署环境变化(部署在移动/联通/电信的IDC里)。

2.1.upstream模块的实现

在做一切之前,我们需要了解upstream模块是怎么实现的,以及upstream是如何和proxy_pass协作的,一个简单的协作配置可以为如下

    upstream server_group {
        server mysite.com;
    }

    server {
        listen       80;
        server_name  abonege.com;

        location / {

            proxy_pass http://server_group/;
        }

然后proxy模块会去找名字为server_group的upstream去回源,然后从server_group里指定的server列表里选择一个回源。
但我们需要注意到,把proxy_pass后面的东西换成合法的域名也可以work的。

    upstream server_group {
        server mysite.com;
    }

    server {
        listen       80;
        server_name  abonege.com;

        location / {

            proxy_pass http://www.baidu.com/;
        }

那么问题来了,nginx是如何知道这是一个合法的域名还是应该从upstream里找呢?
看了一下代码思路是这样的:
首先,解析到proxy_pass的时候,不管是域名还是upstream的名字,直接当成upstream,放到upstream模块的upstream数组里,

    u.url.len = url->len - add;
    u.url.data = url->data + add;
    u.default_port = port;
    u.uri_part = 1;
    u.no_resolve = 1;  // 不用resolve
    plcf->upstream.upstream = ngx_http_upstream_add(cf, &u, 0);

当然,parse一个upstream block的时候,也会调用相同的函数,来添加一个upstream

    u.host = value[1];
    u.no_resolve = 1;  //不用resolve
    u.no_port = 1;

    uscf = ngx_http_upstream_add(cf, &u, NGX_HTTP_UPSTREAM_CREATE
                                         |NGX_HTTP_UPSTREAM_WEIGHT
                                         |NGX_HTTP_UPSTREAM_MAX_FAILS
                                         |NGX_HTTP_UPSTREAM_FAIL_TIMEOUT
                                         |NGX_HTTP_UPSTREAM_DOWN
                                         |NGX_HTTP_UPSTREAM_BACKUP);

在ngx_http_upstream_add里有一些合并的逻辑,因为有可能先解析upstream{}这样的block,也可能先解析到proxy_pass里的upstream名字(这种情况只建了一个upstream的空壳),总之能merge起来。
那么回到之前的话题,我们如何区分proxy_pass里指定的是域名还是upstream的名字呢?
其实这发生在upstream初始化的时候

    (gdb) bt
    #0  ngx_inet_resolve_host (pool=0x6c0130, u=u@entry=0x7fffffffd9d0) at src/core/ngx_inet.c:1097
    #1  0x000000000044aacf in ngx_http_upstream_init_round_robin (cf=0x7fffffffe0a0, us=0x6dcbc0)
        at src/http/ngx_http_upstream_round_robin.c:189
    #2  0x0000000000444494 in ngx_http_upstream_init_main_conf (cf=0x7fffffffe0a0, conf=0x6c2bf0) at src/http/ngx_http_upstream.c:6103
    #3  0x000000000042e99f in ngx_http_block (cf=0x7fffffffe0a0, cmd=, conf=)    at src/http/ngx_http.c:262
    #4  0x000000000041c914 in ngx_conf_handler (last=1, cf=0x7fffffffe0a0) at src/core/ngx_conf_file.c:427
    #5  ngx_conf_parse (cf=cf@entry=0x7fffffffe0a0,    filename=filename@entry=0x6c0348) at src/core/ngx_conf_file.c:283
    #6  0x000000000041a38a in ngx_init_cycle (old_cycle=old_cycle@entry=0x7fffffffe150) at src/core/ngx_cycle.c:274
    #7  0x000000000040bcce in main (argc=, argv=) at src/core/nginx.c:276

ngx_inet_resolve_host就是我们要找的地方了。如果ngx_http_upstream_srv_conf_t里挂了server列表(例如upstream server_group),则走一个分支,如果是没有挂server列表的
则有可能是域名回源,然后对其进行域名解析,如果这种情况还是解析不了的,说明是瞎编的一个名字,既不是域名也不是upstream名字,则在启动的时候会报错,说host not found之类的。
总结一下:先看这个名字是不是upstream的名字,如果不是则尝试当成一个域名解析,成功则以,不成功则报错。

好啦,我们继续下一个问题,前面提到的mysite.com是什么时候解析的呢?因为我们想实现动态解析,必须知道当前是怎么解析的。
继续看ngx_http_upstream_init_round_robin
悲喜交加的是,你会发现,不知道什么时候,mysite.com已经被解析好躺在server的怀抱里了

    for (i = 0; i < us->servers->nelts; i++) {
        if (server[i].backup) {
            continue;
        }

        for (j = 0; j < server[i].naddrs; j++) {
            peer[n].sockaddr = server[i].addrs[j].sockaddr;
            peer[n].socklen = server[i].addrs[j].socklen;
            peer[n].name = server[i].addrs[j].name;
            peer[n].weight = server[i].weight;
            peer[n].effective_weight = server[i].weight;
            peer[n].current_weight = 0;
            peer[n].max_fails = server[i].max_fails;
            peer[n].fail_timeout = server[i].fail_timeout;
            peer[n].down = server[i].down;
            peer[n].server = server[i].name;

那么,server[i].addrs到底是啥时候解析出来的呢?在加载配置文件的时候

    gdb) bt
    #0  ngx_inet_resolve_host (pool=pool@entry=0x6c0130, u=u@entry=0x7fffffffd550) at src/core/ngx_inet.c:1120
    #1  0x0000000000413572 in ngx_parse_inet_url (u=0x7fffffffd550, pool=0x6c0130) at src/core/ngx_inet.c:787
    #2  ngx_parse_url (pool=0x6c0130, u=u@entry=0x7fffffffd550) at src/core/ngx_inet.c:545
    #3  0x0000000000444302 in ngx_http_upstream_server (cf=0x7fffffffe0a0, cmd=, conf=0x6d26b0)
        at src/http/ngx_http_upstream.c:5631
    #4  0x000000000041c914 in ngx_conf_handler (last=0, cf=0x7fffffffe0a0) at src/core/ngx_conf_file.c:427

所以,综上,我们认为在加载完毕配置文件的时候,想要的东西都好了。比如proxy和upstream的关联,upstream内部server名字的解析。但这一切都发生在解析文件的时候,
也就是一锤子打死的配置,不能变化,但我们要的就是变化,该当如何呢?

2.2.我们的方案

我们注意到,在ngx_http_upstream_init_request里居然还有

    ngx_resolve_start...
    ngx_http_upstream_resolve_handler...

这个逻辑是干啥用的么,看起来很像运行太解析的,对不对?难道nginx自动支持动态DNS解析么,想多了。那个商业版才有的。是这样的,因为proxy_pass传递的参数里,是可以以变量形式配置的

    proxy_pass http://$dest_hostname/;

如果$dst_hostname是动态解析出来的,比如针对a请求$dest_hostname翻译成a.com,针对b请求翻译成b.com,辣么这个回源的域名就是动态变化的,就需要我们动态解析。

有没有发现,已经在一定程度上解决我们的问题了。但前提是,我们不是用upstream回源,而是直接采用域名回源(nginx变量的形式)。
这样就能动态的翻译源站域名到不同的IP了。解决了我们的一部分问题,但还有别的问题,需要我们搞定,比如回话保持,健康检查都是配置在upstream里的。
如果我们不用upstream回源,相当于这部分功能无法使用了。那么应该怎么搞呢?

举tengine的dyups模块为例,他的工作原理大致如下,当一个nginx worker进程收到一个创建upstream的API后,会验证upstream的配置语法,然后通过share memory传递给其他的
worker。然后各个worker会把这个upstream同步给自己的内存,注意,这时候会调用健康检查的添加peer的逻辑,以及对应算法模块的初始化(rr,lc,haship等算法的初始化逻辑是不一样的)

所以我们为了支持动态解析,动手的逻辑应该是这里。

  • 持久化dyups的原始配置信息,虽然每个worker进程知道upstream信息,但是从upstream信息反推配置是很困难的。因为每次域名解析的内容变动后,我们需要重新构造dyups的upstream配置block
  • 周期的检查dyups的所有upstream配置,然后进行dns解析,这里nginx已经提供了很友好的函数。


ngx_resolve_start…
ngx_http_upstream_resolve_handler…

  • 如果发现dyups的server域名已经发生变化,则走dyups update流程,即删除旧的upstream,创建新的upstream

其实说句实话,选一个worker是不好选择的,我的建议是有一个专门的controller进程,用于做这件事。

以上。

nginx上支持手工封禁请求的的思路

1.背景

越来越多的厂家通过nginx扩展的方式来实现CC防护,如何实现自动防护是一个很大的课题,我们先不讨论,本文主要想讨论的是,知道封禁规则的情况下,如何实现封禁。

2.实现

封禁的规则,自然有很多条件比如

  • clientip
  • 一个client(ip标识或什么标识)的访问速度
  • URL
  • User-Agent
  • query
  • cookie
  • header
  • referrer

其中上面任何几个可以混杂,比如我们知道了一个可疑的URL(攻击者频繁访问的URL),我们可以根据2和3混合起来,比如访问”/“的速度大于10qps的才封禁。也可能是user-agent为Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 ,访问/的条件。

总之可以随意混合起来,设置清洗条件。

那么我么改如何组织这些条件形成的规则,并且根据这些规则进行清洗呢?

粗糙一点,维护一个列表,每个元素是一个规则,每个规则里包含若干条件。
当一个请求到来的时候,遍历规则中的每个条件,如果都匹配,则认为规则匹配,然后执行对应的动作。否则判断下一个规则。依次类推。

但这样效率是不是有点低下了,有女朋友的工程师可能就满足了,但大部分显然不会。

进阶一点,我们把这些有限的特征类别分别组织一下,比如可能归类如下的几种:

  • client ip以及此ip访问速率
  • URL
  • User-Agent
  • Referer
  • cookie
  • header

当然酌情可以更多,我们可以把所有规则下的所有条件,分别扔到对应的分类里去。各个分类自己组织自己的查找结构,比如IP的用bit二分查找或者hash表,url是hyperscan
host的用字符串匹配等等。

整体上的流程就是,依次过这些所有的分类检查,如果匹配上,则在回调里设置相应的flag,如果rule的所有flags已经标志满,则认为该规则匹配上了,则执行相应的处理。
所以这样,我们基本不受规则数目影响,来完成所有规则的匹配操作。需要注意的是,我们可能在一个分类里hit上多个条件,记得每个都需要标志flag,不能只关心第一个。

再进阶一点,如果匹配的规则,也就是rule支持了优先级会怎样?我们怎么找到最高的优先级呢?有了上面的基础,我相信不困难了。
也就是当一个规则的flags标志满以后,不急于执行action,而是把规则和优先级记录在ctx上,如果后面有了更高级别优先级的rule匹配上了,则替换之,
总之会在所有的分类匹配结束后,得到一个最合适的rule,然后再执行回调即可。

hyperscan使用

1.摘要

intel在2015年10月开源了其第一个hyperscan版本4.0。在其之前很多网络设备公司都自研或使用类似pcre之类的正则匹配工具。
也许是dpdk的光环太多,导致大家对hyperscan也趋之若鹜。而且从实际来看,hyperscan也确实表现不俗。
本文主要介绍一下hyperscan的一些概念,基础使用,以及在性能敏感的情况下的一些注意事项。

2.环境

工欲善其事,必先利其器。但hyperscan这个器,也需要在特定的环境下,才能表现出你要的性能来。hyperscan的表现和平台以及cpu是紧密相关的。
在compile正则的时候,我们可以指定平台,比如haswell,Sandy Bridg。也可以指定CPU的指令集,比如AVX 2和AVX512等。
hyperscan可以充分利用如下的指令集。

  • Intel Streaming SIMD Extensions 4.2 (SSE4.2)
  • the POPCNT instruction
  • Bit Manipulation Instructions (BMI, BMI2)
  • Intel Advanced Vector Extensions 2 (Intel AVX2)

我们可以通过如下的指令看看本机的指令

1

1
cat /proc/cpuinfo

关于软件也确实有一些要求:

  • GCC, v4.8.1 or higher
  • Clang, v3.4 or higher (with libstdc++ or libc++)
  • Intel C++ Compiler v15 or higher

编译的时候,需要一些依赖的lib

lib Version Note
CMake >=2.8.11
Ragel 6.9
Python 2.7
Boost >=1.57
Pcap >=0.8

3.编译正则表达式

为什么需要编译正则表达式呢?这也正好是多模匹配(我理解的)的一个特点了。多模匹配是说一个待匹配字符串和目标N个正则表达式匹配,得到匹配上的正则表达式,进行响应的处理。单模匹配自然是说一次只能判断是否和一个正则表达式匹配。如果想和多个正则表达式匹配,那就只能for循环了。

hyperscan就是用于支持多个正则表达式匹配的,内部状态机超级复杂,但好处是,基本以O(n)甚至更少的的代价,查找出其匹配的正则表达式。

把一组正则表达式编译好的二机制叫做pattern database。我们可以通过如下3个API,去执行编译操作:

  • hs_compile(): 把一个正则表达式编译成一个pattern database
  • hs_compile_multi(): 把一组正则表达式编译成一个pattern database.
  • hs_compile_ext_multi(): 基本和2一样,但多了一些参数可以设置,基本用于帮助我们限制一些查匹配件,以至于更快结束匹配过程,我们后续介绍。

当编译正则表达式的时候,我们需要决定,我们后续的匹配使用什么样的方式?hyperscan允许我们使用如下的三种方式:

  • Stream模式:流模式匹配,即待匹配数据不是连续的一块,比如tcp stream,我们的正则可能匹配的数据是跨块的。
  • Block模式:待匹配的数据很明确,就在这个块里。
  • Vector模式:在Stream和Block之间的模式,数据在指定的一系列Block里。

使用哪种模式,需要我们的权衡,单纯的从性能比较,BLock模式肯定最好,因为比Stream模式比较,少了对跨块的内部状态的跟踪。但Stream能很好的解决包分多Block来的情况。

3.1.支持什么样的正则?

hyperscan对正则的支持比PCRE要少,但对于我们大多数人来说是足够用了,我们可以简单的看一下:

  • 所有字符串字符以及字符转义的匹配
  • 字符类(charactor class) . (dot), [abc], 和 [^abc],以及\s, \d, \w, \v,及其否定形式(\S, \D, \W, \V, and \H).
  • 量词形式?,*,+,{n}, {m,n}, {n,}
  • 多选模式 foo|bar
  • 锚 ^, $, \A, \Z 和 \z.
  • 选项 i,m,s,x等

差不多够用了不是?不支持的我都不知道啥意思

3.2.关于匹配的语义Semantics

语法是和PCRE相同的,但语义Semantics是标准正则表达式是不一样的。不一样的有这么几个地方

  • 多模匹配:hyperscan的匹配是一次匹配多个正则表达式,这和PCRE里的 表达式1|表达式2|表达式3 这样从左到右依次匹配是不一样的
  • 无序:由于是多模匹配,所以多个表达式是一起匹配的,没有明显顺序,虽然基本按照谁先到匹配边界谁先结束
  • 默认仅返回匹配的尾部offset:默认情况下,在返回的时候,仅仅知道匹配的end的offset,如果想知道begin offset。需要编译的时候设置flag,但会影响性能
  • 全量匹配:比如从fooxyzbarbar里匹配/foo.*bar/, 默认PCRE采用贪婪匹配方式,只会匹配fooxyzbarbar,但hyperscan会匹配fooxyzbar和fooxyzbarbar两个。

在stream模式下,像PCRE语义那样支持最长匹配是不太可能的。还是举上面的例子,正则是 /foo.*bar/,如果数据分下面的3个block来:
block 1 | block 2 | block 3
fooxyzbar | baz |qbar

Stream模式最多匹配到第一个block就结束了,因为block 2又不匹配,考虑到效率,hyperscan不会无限制的等待后面是否有个bar了。否则,如果第500个block里有个bar,该怎么搞呢?

3.3.SOM

SOM是Start of Match。表示哪里开始匹配,我们之前也介绍过,默认情况下hyperscan只记录end of Match,不记录Start of Match。如果非要记录SOM,我们可以
在编译的时候设置 HS_FLAG_SOM_LEFTMOST这个flag。但设置之后有如下的缺点:

  • 减少hyperscan支持的正则样式。可能在编译的时候出现『Pattern too large』这样的错误
  • 增加Stream模式的状态,很容易了解,多了记录内容了嘛
  • 性能问题
  • 和其他的一些flag不兼容。 比如HS_FLAG_SINGLEMATCH和 HS_FLAG_PREFILTER。

此外,考虑性能问题,我们在使用SOM的时候,可以设置一个阈值,即start offset和end offset之间的差值,太长了还没到结尾的话,就重新来过,来减少过多的内部状态追踪

3.4.扩展参数

我们在hs_compile_ext_multi里提到的扩展参数,

  • flags: flags标记
  • min_offset: 用于标识最小匹配的offset
  • max_offset: 用于标识最大匹配的offset
  • min_length: 最小匹配多长
  • edit_distance: Levenshtein距离参数,用于模糊匹配.

比如还是正则表达式/foo.*bar/,如果指定min_offset是10,max_offset是15的话,foobar和foo0123456789bar就不会匹配,而foo0123bar和foo0123456bar就会匹配。

如果edit_distance是2的话,foobar, fooba, fobr, fo_baz, foooobar和/foobar/都会匹配

4.匹配过程

hyperscan对于上面的3种模式,也提供了不同的scan函数,都是以hs_scan开头。
一旦一个正则匹配后,会回调下面的函数

1
typedef (* match_event_handler)(unsigned int id, unsigned long long from, unsigned long long to, unsigned int flags, void *context)

这个函数的返回非0的话停止匹配,否则继续匹配下去,直到找到满意的。
from参数就是前面提到的start of match,to就是end of match,

4.1.Stream模式

在stream模式里会调用如下几个函数

  • hs_open_stream()
  • hs_scan_stream()
  • hs_close_stream()
    匹配上正则后,会回调callback,callback函数如果返回非0,则终止此次匹配过程。虽然在callback里中止了,但实际上stream的状态机还是停留在一个一个状态。
    后续如果继续调用hs_scan_stream,会立即返回HS_SCAN_TERMINATED。 最终还是需要调用者调用一个hs_close_stream,去释放资源。
    再次强调一下,由于Stream需要记录跨块扫描的内部状态,所以会有一定的性能损耗。

hyperscan也是从前往后的匹配,当遇到 $ \b等字符时,并不会在当前stream就发生回调,只有在收到下一个stream或者stream关闭的时候才能决定回调与否

4.1.1.Stream的管理

除了前面提到的几个stream的操作函数,还有如下的API可是使用

  • hs_reset_stream(): resets a stream to its initial state; this is equivalent to calling hs_close_stream() but will not free the memory used for stream state.
  • hs_copy_stream(): constructs a (newly allocated) duplicate of a stream.
  • hs_reset_and_copy_stream(): constructs a duplicate of a stream into another, resetting the destination stream first. This call avoids the allocation done by hs_copy_stream().

此外stream还支持如何在stream的内容是压缩的情况下做scan操作

  • hs_compress_stream()
  • hs_expand_stream()
  • hs_reset_and_expand_stream()

4.2.Block模式

Block模式特别简单,调用hs_scan()即可。

4.3.Vector模式

使用API hs_scan_vector()从block数组做正则匹配,从调用者来看,从block list里scan和a)把这些block看成stream的若干写入或b)把这些block list通过memcpy拼写成一个大的block,匹配的效果是一样的。但还需要根据实际情况决定使用哪种模式

4.4.Scratch空间

当做正则扫描的时候,需要一些额外的内存,来保存运行时内部数据。如果栈上申请又比较大,运行时临时申请又影响性能,所以需要我们提前申请的这些空间,我们叫
Scratch空间。

我们使用 hs_alloc_scratch()来申请Scratch空间,指定pattern database即可,不需要我们知道具体空间大小。如果我们有多个database的话,虽然我们会对
每个database调用hs_alloc_scratch,但实际上只生成一个一份大小最合适的Scratch空间。

  • 如果是递归scan,比如在回调的时候再做另一次scan,这时需要2个Scratch空间
  • 没有递归的情况下,Scratch空间应是per-thread的
  • 如果是一写多读的话,我们推荐使用hs_clone_scratch() 来替代多次 hs_alloc_scratch()

4.5.关于自定义内存申请/释放函数

默认情况下,我们都使用malloc/free作为内部申请函数,如果我们系统自己构造了自己的内存管理函数的话,把下面一些函数赋值成自己的函数即可:

  • hs_set_database_allocator(), which sets the allocate and free functions used for compiled pattern databases.
  • hs_set_scratch_allocator(), which sets the allocate and free functions used for scratch space.
  • hs_set_stream_allocator(), which sets the allocate and free functions used for stream state in streaming mode.
  • hs_set_misc_allocator(), which sets the allocate and free functions used for miscellaneous data, such as compile error structures and informational strings.

5.关于序列化

对于一些应用来说,确保scan操作之前,pattern database编译好了即可。但对于一些应用来说,也有其他的考量。比如:

  • 在其他主机上编译database(如果规则特别多,编译起来很慢的)
  • 把编译好的database持久化起来,仅在正则该表的时候才重新编译
  • 随时根据情况调整database的所在内存,比如从A地址迁移到B地址

但database在内存的存储里不是顺次存放的,里边有指针引用来引用去,为了让database是可移植的,所以hyperscan提供了如下的函数:

  • hs_serialize_database(): serializes a pattern database into a flat relocatable buffer of bytes.
  • hs_deserialize_database(): reconstructs a newly allocated pattern database from the output of hs_serialize_database().
  • hs_deserialize_database_at(): reconstructs a pattern database at a given memory location from the output of hs_serialize_database().
  • hs_serialized_database_size(): given a serialized pattern database, returns the size of the memory block required by the database when deserialized.
  • hs_serialized_database_info(): given a serialized pattern database, returns a string containing information about the database. This call is analogous to hs_database_info().

6.关于性能考虑

性能是我们使用hyperscan最最重要的原因,但不恰当的使用,会导致性能大打折扣。我们依次列举一下可能的影响性能的因素,大家共勉:

6.1.不要手工优化表达式

hyperscan比我们更懂正则表达式,比如不区分大小写匹配/abc/,如下几种写法都可以:

  • /[Aa][Bb][Cc]/
  • /(A|a)(B|b)(C|c)/
  • /(?i)abc(?-i)/
  • /abc/i

6.2.不必刻意优化对lib的使用

hyperscan能处理很多case,比如小包流,超大包等。除非确实遇到性能问题,否则就用最简单的方式使用hyperscan即可,即大道至简。比如我们知道block模式的匹配效果比stream的匹配效果好,但没有必要特意把一个stream上的所有数据收齐了后一起比较,除非包是一个字节一个字节收到的。
此外,hyperscan的性能随着正则表达式数量增加而性能逐渐下降,是平滑的,不想其他有些软件那样当到达一个阈值后陡降,这是hyperscan的一个优点
hyperscan的throughput也很大,对于3.0G频率的CPU来说,一个core在1us内能scan 3000-bit block 数据。但并不意味着22us能扫描完毕22*3000-bit block数据。
所以不要试图缓存数据来提升scan性能

6.3.如可能尽量使用block模式

block的性能表现要比stream好很多

6.4.pattern database的拆解

如果我们需要统计5种不同的流量,最好建立5个pattern database,分别存放5种待待匹配特征,而不是把所有正则混在一个database里,除非这5种流量的绝大部分的待匹配的内容是一样的,比如第一种流量里有abc,第二种也有,以此类推,并且相同的程度到达90%。

6.5.scratch空间

  • 提前申请好scratch空间,不要到匹配的时候申请,而是编译好database后马上申请。
  • 为每个context(一个线程)申请一个,不同的database可以复用一个scratch空间

6.7.多使用锚

  • 如果明确从开始匹配则使用\A或^作为表达式的开头 比如\^abc\
  • 如果明确结尾可使用$, \z 和 \Z
  • 前面提到的min_offset和max_offset扩展参数,可以提前帮我们判定匹配结束

6.8.避免任意匹配

比如 /./这样的正则会导致我们进行最多次的匹配,比如abcd这样的字符,会返回5次回调(pcre因为采用贪婪匹配只会返回最后的一个)
另外一个就是待匹配正则的前面和后面不要有可选的部分,比如/x?abcd
/ 前面的x?就是多余的,不影响匹配结果,后面的*也会导致我们匹配很多次
如果把这个正则改写成/abc/就会好很多,因为

  • 匹配/abc/的集合包含匹配/x?abcd*/,
  • 匹配次数明显减少,比如样本 0123abcdddd 匹配/abc/一次,但匹配/x?abcd*/高达5次。abc abcd abcdd abcddd abcddd

6.9.在stream模式下避免使用高重复的方式

/X.{1000,1001}abcd/ 高达1000多次的重复,会给性能带来很大的影响

6.10.

需要匹配的字符尽早出现,可变的部分或者正则发部分扔到后面去
/\wab\d\w\w\w/ 比下面的好
/\w\w\d
\w\w/,
/\w(abc)?\d*\w\w\w/

隐式的一些声明也比没有字符好,比如/[0-2][3-5].\w\w/ 也有效的包含了一些信息,注意,即使展开的很详细,比如/(03|04|05|13|14|15|23|24|25).\w\w/也是没啥帮助的

越长字符越有帮助,比如100个字符的表达式里有14字符比有4个字符性能会高出很大一截。

6.11.使用Dot all

Dot all模式,是使用HS_FLAG_DOTALL 这个标志控制的,关闭的话的代价会比较大。 /A.B/ 会变成 /A[^\n]B/.也就是跨行匹配。我们在执行匹配的时候
如果能1行匹配一次,最好打开这个标志

6.12仅匹配一次

前面提到过,hyperscan是多次匹配的,如果设计上确实只需要匹配一次就行了,比如有10条封禁规则,任何一条都可以封禁的话,那么该模式就需要启动。
可以通过(HS_FLAG_SINGLEMATCH)来开启这个模式

6.13.SOM

这个前面已经提到过,如果没有必要,需要关闭这个

6.14.模糊匹配

这个也是明显降低行呢功能的,能不用尽量不用

7.一些测试数据

我们做一点测试的对比,先不测正则,只测试子串查找,子串是16字节的数据,待匹配串是随机的64到96长度的串
测试多模匹配,子串个数分别测试1,8,16,测试算法是 strstr,ac,以及hyperscan,测试100w数据的匹配时间。

算法 1个子串 8个子串 16个子串 64个子串
strstr 7.660327 48.659695 95.293705 358.87231
hyperscan 12.827749 20.445274 22.351201 22.909993
ac 101.493826 101.534645 101:596807 102:515976

关于编译hyperscan

CMAKE

hyperscan是基于cmake生成Makefile,所以我们先需要下载cmake,我这里用的是3.9.5版本。
cmake我理解就是一个自动化构建编译工程文件(比如makefile,VS的 .proc文件)的东西,类似bjam。

1
2
3
4
5
tar zxf cmake-3.9.5.tar.gz
cd cmake-3.9.5
./configure
gmake -j 20
make install

至此cmake安装完毕

安装依赖

boost

需要boost的regex lib,我们把boost解压到/opt目录下

ragel

我安装的是6.10版本

1
2
3
/configure --prefix=/opt/
make -j 20
make install

pcap

我安装的是1.8.1版本

1
2
3
/configure --prefix=/opt/
make -j 20
make install

dbus

我安装的是dbus-1.11.12版本

1
2
3
/configure --prefix=/opt/
make -j 20
make install

最后准备

修改一下CMakefileList 加入头文件和lib依赖目录

1
2
3
4
5
6
7
8
9
10
ln -s /opt/boost_1_65_1/boost <hyperscan-src>/include/boost
include_directories(${PROJECT_SOURCE_DIR}/src)
include_directories(${PROJECT_BINARY_DIR})
include_directories(SYSTEM include)
include_directories(/opt/include) <----------

include (${CMAKE_MODULE_PATH}/boost.cmake)

link_directories("/opt/lib") <---------------

安装hyperscan

1
2
3
4
cd <where-you-want-to-build-hyperscan>
mkdir <build-dir>
cd <build-dir>
cmake -G "Unix Makefiles" -DCMAKE_C_COMPILER=/opt/compiler/gcc-4.8.2/bin/gcc -DCMAKE_INSTALL_PREFIX=/opt/ ../

在编译的时候会提示ELSE分支问题,需要修改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (FAT_RUNTIME)
if (NOT HAVE_SSSE3)
message(FATAL_ERROR "SSSE3 support required to build fat runtime")
endif ()
if (NOT HAVE_AVX2)
message(FATAL_ERROR "AVX2 support required to build fat runtime")
endif ()
if (BUILD_AVX512 AND NOT HAVE_AVX512)
message(FATAL_ERROR "AVX512 support requested but not supported")
endif ()
else (NOT FAT_RUNTIME)
if (NOT HAVE_AVX2)
message(STATUS "Building without AVX2 support")
endif ()
if (NOT HAVE_AVX512)
message(STATUS "Building without AVX512 support")
endif ()
#else (NOT FAT_RUNTIME)
# if (NOT HAVE_SSSE3)
# message(FATAL_ERROR "A minimum of SSSE3 compiler support is required")
# endif ()
endif ()

注释掉上面的4行,否则编译不过

各种检查通过以后,会生成我们想要的make file,然后执行

1
2
make -j 20
make install

编译的过程是很慢的,可以喝杯茶等着。
最后在我们/opt里有的lib和include头文件,第三方用这个就可以了