网络流量处理中的协议解析七: Protolens
实现
综合前面几篇文章,流重组+协议解码的所有环节都已经没有问题。可以据此实现一个完整的协议解析库:Protolens。
我们把这个库限定在这样的使用场景之下:
- 作为一个库,被通常的多线程流量处理引擎调用。
- 但库本身没有必要跨线程使用,也就是说流量处理引擎的每个线程都有自己的协议解析库的实例。
- 每个五元组会话,也就是通常所说的流节点都有一个解码器。解码器针对每条连接解码。
- 解码器本身不具备协议识别能力。协议识别能力由流量处理引擎提供。协议解析和协议识别看似很接近,但这两个功能如果搅合在一起,显然是个错误的思路。
- 解码器的输入是数据包,输出是解码后的协议数据。
- 解码器通过回调函数把协议数据返回给用户。
此外,作为一个库,应该尽量利用用户程序的功能,不要重复做功。比如对数据包的解码(ip地址,tcp,udp,端口等),库内部不应该重复做。既然调用者是流量处理引擎,那么流量处理引擎应该早已具备这些功能。库内部如果再做一遍,就会造成重复做功,浪费性能。为此,库可以设置一个传入数据包的trait,要求调用者实现这个trait。在库内部通过这个trait获取数据包的信息即可。
使用方式
Protolens用于处理数据包,tcp流重组,协议解析,协议还原的场景。作为一个库,通常用在网络安全监控,网络流量分析,网络流量还原等引擎当中。
流量引擎通常具有多个线程,每个线程都有自己的流表。每个流节点就是一个五元组。protolens基于这种架构,并不能跨线程使用。
每个线程都应该初始化一个Protolens。在你的流表为一个连接新建一个节点的时候,应该为这个连接新建一个task。
为了获得结果,你需要为感兴趣的每个协议的每个字段设置回调函数。比如 protolens.set_cb_smtp_user(user_callback)设置之后,smtp的user字段就会通过user_callback被回调。
此后,这个连接每到来一个数据包,都要通过run方法,将这个数据包加入到这个task当中。
但protolens,task内部并没有协议识别的能力。此时虽然数据包被传入了task,但是task内部没有开始解码。它会缓存一定数量的数据包,默认是128个。所以你最好在超出缓存的数据包之前,通过set_task_parser告诉task这条连接是什么协议。此后task就开始解码,并通过回调函数把还原内容返回给你。
protolens会被同时编译为c语言可调用的so。使用过程和rust类似。
具体使用请参考rust_example目录和c_example目录。更详细的回调函数用法,可以参考smtp.rs中的测试用例。
你可以通过回调函数得到协议字段,比如smtp的user,邮件内容,http的头字段,请求行,body等。当你在回调函数中得到这些数据等时候,他们是对内部数据等引用。所以,如果你可以在此时立即处理。但如果要后续继续使用,则需要copy一份,放在你指定的地方。你不能把引用保留到外部。rust程序会阻止你这么作,但c程序中作为指针,如果你只把指针保留到后续过程,会指向错误的地方。
如果你想获得原始的tcp流,也有对应的回调函数。此时你的到是一段一段的原始字节。但是经过重组之后的连续的流。同时有对应的序列号。
假设你需要审计协议字段,比如判断http的url是否符合要求。你可以注册对应的回调函数。在函数中,做出判断,或者保存流节点上,供后续模块判断。这是最直接的使用方式。
以上只能看到url,host等独立的协议字段。假设你有这样的要求:在原始tcp流中定位url的位置。因为你还想找到url后面,前面有什么东西。你需要这样做:
通过原始tcp流的回调函数,你可以得到原始的tcp流和seq序列号。copy到你维护的一个buff中。通过url回调函数,的到url和对应的seq。此时你就可以在buff中根据seq确定url的位置。这样,就可以在一个连续的buff空间中处理诸如:url后面有什么内容,前面有什么内容之类的需要。
而且你可以根据seq来取舍buff中的数据。比如你只需要处理url后面的数据,那么你可以根据url的seq,从buff中删除前面的数据。这样,你就可以在一个连续的buff空间中处理url后面的数据。
性能
Strem trait
最开始的版本,为内部的重组结构实现了Stream trait。简单明了,但其next方法是逐个字节返回,虽然性能可以足够达到常规标准。但逐字节处理,相当于每个字节都要调用一次next函数,显然是没必要的。
于是,放弃了Stream trait。在每个读取方法中实现异步执行。这样,就可以在读取重组数据的时候在一次函数调用中在一个buff中一次完成遍历。大大加快了速度。
Packet trait
TCP数据包会乱序,为了重组,就需要缓存数据包。所以Protolens需要获得传入packet当所有权。那么当数据包在内部保存的时候,就需要写入数据。
如果写入的是完整的数据包,在高性能流量处理引擎中,相当于每个数据包都要copy一份。这显然是不合理的。所以最初Protolens在内部实现了一个包装结构。用来只写入指针。但实际上这部分是不必要的。因为通常的流量处理引擎中,肯定会为数据包实现高性能的clone属性,和包装结构。否则流量处理引擎本身也没办法高效处理数据包。那么,Protolens完全可以直接接收用户的Packet。在内部直接视作完整的Packet写入。如果用户实现了高效的包装,则写入的是指针。如果用户没有实现,则写入的就是完整的数据包。把这部分功能依赖用户的实现。避免冗余。
mmchr
在buff中查找一行,一段数据。mmchr会带来明显的性能提升。buff越大效果越明显。
内存池
在实际测试过程中发现,Protolens中的内存池分配对性能的消耗占比并不大。原来用定长数组实现了内存池,为了简化代码,被去掉了。重新使用简单的Vec,只需要让Vec不动态增长即可。
测试结果
以下是测试结果,注意linux的结果是很老旧的cpu,并不是在当前主流高性能cpu上的测试结果。 其中new_task 为单纯新建解码器,不包含解码过程。因为解码过程是按行读取,所以用readline系列单独测试读取一行的性能,这种方式最能代表http smtp类协议的解码性能。每行25个字节,一共100个包。readline100代表每个包100个字节,readline500代表每个包500个字节。readline100_new_task代表新建解码器+解码过程。http,smtp等为实际的pcap数据包。但smtp和pop3最具代表性,因为这两个测试用例的pcap中完全是逐行构造的。其余的有按size读取,所以更快。统计的时候以字节为单位,没有计算数据包头部仅计算数据包的载荷。
测试项目 | mamini m4 | linux | linux jemalloc |
---|---|---|---|
new_task | 3.1871 Melem/s | 1.4949 Melem/s | 2.6928 Melem/s |
readline100 | 1.0737 GiB/s | 110.24 MiB/s | 223.94 MiB/s |
readline100_new_task | 1.0412 GiB/s | 108.03 MiB/s | 219.07 MiB/s |
readline500 | 1.8520 GiB/s | 333.28 MiB/s | 489.13 MiB/s |
readline500_new_task | 1.8219 GiB/s | 328.57 MiB/s | 479.83 MiB/s |
readline1000 | 1.9800 GiB/s | 455.42 MiB/s | 578.43 MiB/s |
readline1000_new_task | 1.9585 GiB/s | 443.52 MiB/s | 574.97 MiB/s |
http | 1.7723 GiB/s | 575.57 MiB/s | 560.65 MiB/s |
http_new_task | 1.6484 GiB/s | 532.36 MiB/s | 524.03 MiB/s |
smtp | 2.6351 GiB/s | 941.07 MiB/s | 831.52 MiB/s |
smtp_new_task | 2.4620 GiB/s | 859.07 MiB/s | 793.54 MiB/s |
pop3 | 1.8620 GiB/s | 682.17 MiB/s | 579.70 MiB/s |
pop3_new_task | 1.8041 GiB/s | 648.92 MiB/s | 575.87 MiB/s |
imap | 5.0228 GiB/s | 1.6325 GiB/s | 1.2515 GiB/s |
imap_new_task | 4.9488 GiB/s | 1.5919 GiB/s | 1.2562 GiB/s |
sip (udp) | 2.2227 GiB/s | 684.06 MiB/s | 679.15 MiB/s |
sip_new_task (udp) | 2.1643 GiB/s | 659.30 MiB/s | 686.12 MiB/s |