Zeek源码分析(1)

Zeek的主循环

IO循环

查阅资料得知,在Zeek 3.1之前,Zeek的主循环使用轮询忙等待来检查是否有Ready的IO源,来处理的数据、事件

在3.1之后,Zeek 转而依赖于操作系统的内置的IO多路复用来fd上的更改,所以引入了 libkqueue

libkqueue是个跨平台的IO多路复用实现。它在 macOS 和 FreeBSD 下使用 kqueue 实现,在 Linux 系统下实际使用 epoll 实现(epoll不可用时使用poll实现)

在创建新的IO源时,会向IO源管理器注册fd。当源有要处理的数据时,此fd用于通知 kqueue,将新数据的处理推入 IO 源。而不是我们问源是否有事情要做

Zeek主循环干了哪些事?

  1. 遍历处理就绪的 IO 源(如拿到NIC中的包、触发定时器等)
  2. 更新全局的network_time并触发定时器
  3. 派发事件队列中的所有事件
  4. 判断是否有中断、终结信号
  5. 更新统计数据

Zeek脚本中Function、Event、Hook的区别

匿名 多实现和优先级 立即调用 可调度 默认参数 引用类型参数可变性 备用声明 返回值
Function × × ×
Hook × ×
Event × × ×

侵入式指针

一种智能指针。和std::shared_ptr语义一致

std::shared_ptr的引用计数与Object无关,由std::shared_ptr存储

而侵入式指针的引用计数由Object内部存储

这样的设计可以避免出现两个Control Block管理一个Object的情况

看一下以下错误代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
struct A {
int data;
};

int main()
{
A* raw_p = new A;
std::shared_ptr<A> p(raw_p);
std::shared_ptr<A> p2(raw_p);

return 250;
}

很明显可以看出编写者对std::shared_ptr理解不够深刻。这是一段等着死的代码。因为p和p2各自维护了自己的引用计数

创建时都加1,销毁时都减1,结果把A delete了两次

所以安全的使用std::shared_ptr得充分理解make_sharedenable_shared_from_this

而使用侵入式指针就不需要考虑这些事情

优点:

  • 优于std::shared_ptr的性能。使用make_shared时会有两次new操作,一次是指针本身,一次是引用计数
  • 避免同一个Object被多个引用计数管理导致double free。

缺点:

  • 所有类都要继承基类。耦合性过高

实际上如果观察基于引用计数来GC的语言,比如Python,就会发现基类PyObject就是侵入式引用计数

Zeek运行时的数据流

TFTP数据包到达Analyzer的过程

调用栈:

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
evnet tftp_read_request()
.... // 此处省略事件管理器分发事件的过程...
/*------------- Zeek脚本事件开始 -------------*/
analyzer::tftp::TFTP_Analyzer::DeliverPacket() // 分析出TFTP的操作码、文件名等信息
/*------------- 应用层协议分析开始 -------------*/
analyzer::pia::PIA::ReplayPacketBuffer() // 将之前构造的Buffer里的负载全部转给协议分析器(包括匹配之前所有同会话的负载)
analyzer::pia::PIA_UDP::ActivateAnalyzer() // 实例化TFTP_Analyzer,并放入UDPSessionAdapter子分析器列表
detail::RuleActionEnable::DoAction() // 启动dpd.sig里指定的协议分析器(enable tftp)
detail::RuleMatcher::ExecRuleActions() // 执行匹配到的dpd.sig里的Action和Dependency Conditions
detail::RuleMatcher::Match() // 将负载输入所有相关匹配器中,检查Conditions(Header、Content)
detail::RuleMatcherState::Match() // 调用rule_matcher->Match
analyzer::pia::PIA::DoMatch() // 检查RuleEndpointState是否初始化,否则初始化。转发给基类处理
analyzer::pia::PIA::PIA_DeliverPacket() // 添加到Buffer中(详见细节11)
analyzer::pia::PIA_UDP::DeliverPacket() // 转发给基类PIA处理
analyzer::Analyzer::NextPacket() // 先给SupportAnalyzer(如果存在),然后将数据转发到DeliverPacket
analyzer::Analyzer::ForwardPacket() // 转发给当前会话的所有协议分析器(和当前端口绑定的协议分析器、PIA、ConnSize)
/*------------- 匹配dpd.sig开始 -------------*/
packet_analysis::UDP::UDPAnalyzer::DeliverPacket() // 分析出源端口、目标端口、负载长度等
packet_analysis::IP::IPBasedAnalyzer::AnalyzePacket() // 创建Connection并放到session_mgr里,构造UDPSessionAdapter树(详见细节9)
packet_analysis::Analyzer::ForwardPacket() // 转发给对应的传输层包分析器
packet_analysis::IP::IPAnalyzer::AnalyzePacket() // IP包分析器,分析出IP地址、传输层协议类型等
packet_analysis::Analyzer::ForwardPacket() // 转发给对应的网络层包分析器
packet_analysis::Ethernet::EthernetAnalyzer::AnalyzePacket() // 以太网包分析器,分析出Mac地址、网络层协议类型等
packet_analysis::Analyzer::ForwardPacket() // root_analyzer送到对应的数据链路层包分析器中
packet_analysis::Manager::ProcessPacket() // packet_mgr开始处理包
/*------------- 包分析开始 -------------*/
run_state::detail::dispatch_packet() // 将包交给packet_mgr处理
iosource::PktSrc::Process() // 发现有数据包到来并处理
run_state::detail::run_loop() // 主循环
main()

  1. IO源循环中,发现有数据包到来。执行IOSource::Process(),调用派生类PcapSource实现的纯虚函数ExtractNextPacket取到包,再调用dispatch_packet
  2. dispatch_packet中调用packet_mgr->ProcessPacket(pkt)开始处理包
  3. 调用root_analyzer->ForwardPacket将包送到对应的二层包分析器的AnalyzePacket函数里
  4. 先进入EthernetAnalyzer类,分析出源Mac地址和目标Mac地址,以及上层协议是IP,然后将包转发给IPAnalyzer类【数据链路层】
  5. IPAnalyzer类分析出IP协议版本、源IP地址、是否分片、上层协议等等信息。并验证checksum等,最后将包转发给UDPAnalyzer类【网络层】
  6. UDPAnalyzer类先由基类IPBasedAnalyzer创建Connection(并触发new_connection事件)并放到session_mgr里、构造UDPSessionAdapter树;然后解析源端口、目标端口、负载长度等。最后将包转发给UDPSessionAdapter处理【传输层】
  7. UDPSessionAdapter通过PIA分析器的Match函数匹配dpd.sig,匹配到后调用ExecRuleActions函数执行enable tftp
  8. enable tftp,会先将TFTP_Analyzer添加到UDPSessionAdapter子分析器列表中,随后调用analyzer_mgr->InstantiateAnalyzer函数实例化一个TFTP_Analyzer对象
  9. 将UDP的负载通过PIA分析器的ReplayPacketBuffer函数DeliverPacket传递给TFTP_Analyzer对象
  10. TFTP_Analyzer分析出TFTP的操作码、文件名等信息【应用层】
  11. 最后通过自定义实现的事件通知到Zeek脚本

一些细节:

  1. dispatch_packet函数在第一个包到来前会触发network_time_init事件
  2. root_analyzer->ForwardPacket会触发触发raw_packet事件
  3. IPAnalyzer类会分析IPv6的包有没有扩展标头,比如有ESP的话就触发esp_packet事件。且说明负载被加密,不执行后面的操作
  4. TCPAnalyzerUDPAnalyzer都继承自IPBasedAnalyzer,因为基于IP的数据包,分析器的很多代码是一致的
  5. IPBasedAnalyzer会检查Connection是否reused,如果是会触发connection_reused事件,并重新将Connection放到session_mgr里
  6. IPBasedAnalyzer会触发new_packet事件
  7. UDPAnalyzer类会触发udp_request或udp_reply事件
  8. 所有的协议分析器都是树形结构的,根节点都是其传输层协议的会话适配器(UDPSessionAdapterTCPSessionAdapter等)
  9. UDPSessionAdapter树构造时,会检查并添加与端口绑定的协议分析器;以及增加PIA分析器、ConnSize分析器
  10. PIA分析器为TCP和UDP提供通用功能,可用于确定使用哪个协议分析器对其进行解析,也就是匹配dpd.sig;也提供reassembles包的功能
  11. PIA分析器里的Buffer用双向链表将包负载连起来,用于保存数据包有效载荷(包括 TCP 的序列号)和reassembles

源码中各种全局Manager的用途

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/*
* 预分配一些简单的Val对象(bool、port、空string等)
*
* 使用侵入式指针做共享所有权管理,以便资源复用。避免无谓的内存分配
*/
ValManager* val_mgr;
/*
* 维护和调度(链路层、传输层、网络层)包分析器
*
* 维护分析器名称与标识的映射、为数据包分配包分析器、转储数据包等
*/
packet_analysis::Manager* packet_mgr;
/*
* 维护和调度(应用层)协议分析器
*
* 维护分析器名称与标识的映射、为新连接初始化分析器树、给指定端口指定分析器等
*/
analyzer::Manager* analyzer_mgr;
/*
* 插件管理器
*
* 加载动态插件、初始化插件里的bif、管理插件的钩子等
*/
plugin::Manager* plugin_mgr;
/*
* 签名管理器
*
* 注册协议分析器内的.sig文件、识别文件的MIME类型等
*/
detail::RuleMatcher* detail::rule_matcher;
/*
* 异步DNS正向/反向查找
*/
detail::DNS_Mgr* detail::dns_mgr;
/*
* 定时器管理器
*
* 内部维护了优先级队列,每个任务会添加时间戳,最近的时间戳的任务会先出队
*/
detail::TimerMgr* detail::timer_mgr;
/*
* 日志流管理器
*
* 创建/移除/开启/关闭日志流、设置筛选器
*/
logging::Manager* log_mgr;
/*
* 线程管理器,负责协调所有子线程, 每个 BasicThread 都会被添加到管理器,直到终止后被删除
*
* 线程可以直接发送Zeek事件;MsgThread 还可以定时触发心跳
*/
threading::Manager* thread_mgr;
/*
* 输入流管理器
*
* 用于实现输入框架
*/
input::Manager* input_mgr;
/*
* 文件分析器交互的主要入口
*
* 提供解析协议中文件的各种接口:文件重组、MIME检测等
*/
file_analysis::Manager* file_mgr;
/*
* 管理Zeek脚本文件文档的跟踪和生成
*/
zeekygen::detail::Manager* detail::zeekygen_mgr;
/*
* IO源管理器
*
* 初始化注册IO源,然后在主循环中轮询处理Ready的IO源
* IO源不仅限于pcap,NIC,也包括broker_mgr、timer_mgr、event_mgr等
*/
iosource::Manager* iosource_mgr;
/*
* 管理 Zeek 进程间通信
*
* 进程间事件分发、日志流分发等
*/
Broker::Manager* broker_mgr;
/*
* 事件管理器
*
* 事件入队,用于触发Zeek事件
*/
EventMgr event_mgr;
/*
* 当前活动会话的管理器
*/
session::Manager* session_mgr;

从Zeek源码中的回调函数引发的对面向对象的思考

回调函数

回调函数是编码中常用的一种手段,用于实现异步操作、事件响应等

在C/C++中,实现回调的方法很多,比如最常用、简单的函数指针

在Zeek源码中的回调函数(如Timer的回调)几乎都是使用多态实现的

其实不难看出为什么使用多态实现,因为Zeek的代码几乎全部是OOP的,而函数指针指向成员函数比较的麻烦(并不是不能,需要写丑陋的using/typedef,而且调用时还需要传递this指针,破坏了OOP。或者直接干脆用static成员函数)

使用多态实现的回调既不会破坏OOP、也提供不错的回调性能(比函数指针多一次对虚表的内存寻址)

但也有一定的缺点,比如:

  1. 由于继承是强耦合的,如果新需求到来需要再加一个回调的业务实现,那么就需要一个新的派生类
  2. 代码可读性的下降

借此我们可以思考一下,有没有更好的写法

其实Modern C++指导思想下的写法就是使用std::function + 弱回调实现

好处如下:

  1. std::function可以接受一个C函数、C++成员函数、仿函数或lambda,更灵活
  2. 使用std::bind可以顺便保存响应函数的参数,将回调函数和回调上下文绑定起来,实现闭包
  3. 弱回调可以保证不影响(延长)对象生命周期,且对象销毁后触发回调也不会导致段错误
  4. 良好的代码可读性和良好的回调性能

弱回调的实现方法:利用std::weak_ptr,在回调的时候先尝试提升为std::shared_ptr,如果提升成功,说明接受回调的对象还在,执行回调;如果提升失败就忽略

对面向对象的思考

其实这样的思想就是函数式编程,把数据和计算放到闭包里,而OOP是把数据和操作放到对象里

可以看出Zeek源码的编写者是非常愿意拥抱现代C++的(C++17标准、std::string_view等的使用)。但他们很明显还在使用过时的设计思想。他们尝试把一切都使用OOP编写,也就是“纯”面向对象

计算机科学界有很多声音批判“纯”面向对象的声音

王垠的 解密“设计模式” 批判了(面向对象)设计模式的 “历史局限性”:

(设计模式)变成了一种教条,带来了公司里程序的严重复杂化以及效率低下 ... 什么都得放进 class 里 ... 代码弯了几道弯,让人难以理解。

孟岩的 function/bind的救赎(上) 也提到 “面向类编程” 脱离了 “对象的本质”:

C++ 静态消息机制 还引起了更深严重的问题 —— 扭曲了人们对面向对象的理解 ... “面向对象编程” 变成了 “面向类编程”,“面向类编程” 变成了 “构造类继承树”。

如何进行代码设计?

假设我们需要实现一个“异步下载”的功能,我们看如何进行实现

面向过程——朴素设计

下载完成后打印结果可以实现为:

1
2
3
4
void DownloadAsyncAndPrint() {
// ... download async and construct |result| ...
Print(result);
}

下载完成后写数据库可以实现为:

1
2
3
4
void DownloadAsyncAndWriteToDB() {
// ... download async and construct |result| ...
WriteToDB(result);
}

我们发现异步下载这部分功能是公共逻辑,所以可以通过抽取函数(extract function)手法来重构出异步下载的核心逻辑

1
2
3
4
5
6
7
8
9
10
11
std::future<Result> DownloadAsyncImpl();

void DownloadAsyncAndPrint() {
Result result = co_await DownloadAsyncImpl();
Print(result);
}

void DownloadAsyncAndWriteToDB() {
Result result = co_await DownloadAsyncImpl();
WriteToDB(result);
}

存在的问题: - 不可能针对所有需求提供上述接口(有人需要打印结果,有人需要写数据库,还有人需要...) - 需要提供不涉及实现细节的接口(比如 DownloadAsyncImpl 基于 C++ 20 的协程,可以改用多线程实现,但调用者并不关心)

本质上,面向过程的结构化设计,导致数据 result 生产和消费的逻辑耦合在了一起,不易于扩展

面向对象——解耦发送者和接收者

为了解决这个问题,需要引入控制反转(IoC),从纯面向对象的视角看:

  • 一个数据:result
  • 两个角色:发送者(ownloadAsyncImpl)和 接收者(Print/WriteToDB

而目的是解耦送者和接收者,可以通过以下两种方法实现

模板模式

通过继承,在发送者(虚基类)上重载接收者(纯虚方法)逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// interface
class Downloader {
public:
virtual ~Downloader() = default;
void DownloadAsync() {
Result result = co_await DownloadAsyncImpl();
Handle(result);
}
protected:
virtual void Handle(const Result& result) const = 0;
};

// client code
class PrintDownloader : public Downloader {
protected:
void Handle(const Result& result) const override {
Print(result);
}
};
auto print_downloader = std::make_unique<PrintDownloader>();
print_downloader->DownloadAsync();

策略模式

通过组合,向发送者(类/函数)传递接收者(派生类)逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// interface
class Handler {
public:
virtual ~Handler() = default;
virtual void Handle(const Result& result) const = 0;
};

void DownloadAsync(std::unique_ptr<Handler> handler) {
Result result = co_await DownloadAsyncImpl();
handler->Handle(result);
}

// client code
class WriteToDBHandler : public Handler {
public:
void Handle(const Result& result) const override {
WriteToDB(result);
}
};
DownloadAsync(std::make_unique<WriteToDBHandler>());

在实际的代码编写中,如果能写出以上两种设计的代码,其实已经足够好了。但依然有以下缺点: - 模板模式在运行时不能动态更换接收者 - 策略模式要为每种类型定义一个接收者的接口

在Zeek的源码中,各种分析器(Analyzer)就是基于模板模式设计的

而策略模式也有应用,比如RuleConditionTimer

本质上,面向对象的封装数据对数据的操作(方法)捆绑在类里,引入了复杂的类层次结构(class hierarchy),最后沦为面向类编程

回调闭包

其实,可以使用回调闭包(callback closure) 实现等效的依赖注入 (DI) 功能:

1
2
3
4
5
6
7
8
9
// interface
using OnDoneCallable = std::function<void(const Result& result)>;
void DownloadAsync(OnDoneCallable callback) {
Result result = co_await DownloadAsyncImpl();
callback(result);
}

// client code
DownloadAsync(std::bind(&Print));

上述代码去掉了class,把 handler 对象改为 callback 闭包,把 虚函数调用 改为 回调闭包的调用,不再需要接口和继承

脱离了 “类” 的束缚,是不是 清晰多了

泛型编程

实际上,也可以使用泛型编程(generic programming) 进一步化简

1
2
3
4
5
6
7
8
9
// interface
template <typename OnDoneCallable>
void DownloadAsync(OnDoneCallable callback) {
Result result = co_await DownloadAsyncImpl();
callback(result);
}

// client code
DownloadAsync(std::bind(&Print));

总结

其实各种编程范式的争论,在计算机科学领域一直喋喋不休

比如在C++标准库中,各种容器就是泛型编程的思想

而在Java标准库中,各种容器的迭代器基于Iterable接口、对象的比较基于Comparable接口

在本质上并没有谁更好谁更坏

能够随着项目入乡随俗,不单单是写出命名风格、缩进风格一致的代码,更要写出设计一致、默认正确的代码

这是一个优秀程序员的基本功

函数模板DownloadAsync<>只关心 callback 能处理 result,而不需要关心它的实际类型是什么

参考