0 前言
Hi,好久没有写博客,因为近段时间没有新的开源项目给大家。现在终于又写了一篇,是关于网络方向的内容,希望对部分读者有帮助。
1 WinDivert介绍
WinDivert是windows下为数不多的非常优秀网络库,非常适合用于开发抓包或修改包的应用程序,其拥有以下能力:
- 抓取网络数据包
- 过滤或丢弃网络数据包
- 嗅探网络数据包
- 注入网络数据包
- 修改网络数据包
同时WinDivert还提供了完整的loopback(回环)IP、IPv6的支持,简约而强大的Api、高级别的过滤语言(可以想象为sql一样的东西)。
如此优秀的项目自然有着各个语言的二次封装项目,我在github上也找到了对应多个的dotnet封装项目,但无一例外,他们封装的比较简陋或太过于简陋,下面是封装项目的一些不足之处:
- IPHeader、TcpHeader、UdpHeader等未提供网络和主机的Endian转换
- 局限于PInvoke,没有意识使用dotnet的对象(比如IPv4直接声明为uint类型)
- 没有面向对象的封装,甚至简陋到只有声明了static的PInvoke方法
- 过滤语言没有任何处理,使用时要翻阅WinDivert的文档(写手sql一个感觉)
- 没有异步IO封装,都是清一色的IO同步阻塞(异步IO封装难度大)
2 WindivertDotnet介绍
WindivertDotnet是面向对象的WinDivert的dotnet异步封装,其保持着完整的底层库能力,又提供dotnet的完美语法来操作:
- Filter对象支持Lambda构建filter language,脱离字符串的苦海;
- 内存安全的WinDivert对象,基于IOCP的ValueTask异步发送与接收方法;
- 内存安全的WinDivertPacket对象,提供获取包有效数据长度、解包、重构chucksums等;
- WinDivertParseResult提供对解包的数据进行精细修改,修改后对WinDivertPacket直接生效;
2.1 网络和主机的Endian自动转换
由于windows平台是LittleEndian,而标准的IPHeader、TcpHeader、UdpHeader网络定义都是BigEndian,如果未做任何处理,当接收到一个SrcPort为80、DstPort为443的Tcp包时映射为结构体时,你调式会看到如下结果:
字段 | 调试看到的值 | 要理解为的值 |
---|---|---|
SrcPort | 20480 | 80 |
DstPort | 47873 | 443 |
由于没有做Endian自动转换,在调试时看到的数据甚至让人抓狂,此时如果你把SrcPort改为我们理解为81端口,你是不能直接写xxx.SrcPort = 81
这样的csharp代码的,应该是xxx.SrcPort = 20736
。
WindivertDotnet项目花了很大的时间精力,为所有涉及的结构体字段访问时都做了必要的Endian读取和写入自动转换,让调用者不再为Endian问题费脑子。
2.2 结合使用dotnet类型
IPv4地址占用4字节,IPv6地址占用16字节,所以一些封装项目直接在结构体声明为uint SrcAddr
和fixed uint SrcAddr[4]
,当然这些声明是没有错误,但是你叫使用者怎么使用呢,使用者往往是var ipAddress = IPAddress.Parse("1.2.3.4)"
得到一个IPAddress
类型,他们没有精力去研究怎么把IPAddress转为你的uint或uint[4],或者从uint或uint[4]转换为IPAddress
类型,再加上使用了uint,又得注意Endian的转换,造成这种封装离实际应用太遥远。
WindivertDotnet在声明字段类型时,当存在对应的dotnet高级类型时,优先使用这些高级类型,除了IPAddress
之外,如果字段可以使用枚举的,也都声明为了枚举类型,甚至在修改这些属性值时,有严格的输入校验。
2.3 面向对象的封装
WindivertDotnet将零散的过程式c-api,包装为多种对象,而不是让你面对满天飞的各种静态方法PInvoke调用IntPrt句柄和维护这些句柄的生命周期,例如WinDivertPacket对象,其本质是一个非托管的缓冲区内存,在没有封装之前,它就是一个csharp的IntPrt
类型,看到这个类型,你得加个八倍镜观察可以做为参数传给哪些静态Api方法,同时确保不要忘记不使用之后,要手动去释放它,否则内存就一直占用。
Api | 原Api |
---|---|
int Capacity { get; } | 无 |
int Length { get; set;} | 无 |
Span |
无 |
void Clear() | 无 |
Span |
无 |
bool CalcChecksums(WinDivertAddress, ChecksumsFlag) | WinDivertHelperCalcChecksums |
bool CalcNetworkIfIdx(WinDivertAddress ) | 无 |
bool CalcOutboundFlag(WinDivertAddress) | 无 |
bool CalcLoopbackFlag(WinDivertAddress) | 无 |
bool DecrementTTL() | WinDivertHelperDecrementTTL |
int GetHashCode() | WinDivertHelperHashPacket |
int GetHashCode(long) | WinDivertHelperHashPacket |
WinDivertParseResult GetParseResult() | WinDivertHelperParsePacket |
void Dispose() | 无 |
2.4 Filter
filter language是WinDivert引以为豪的设计,对WinDivert来说就像是从0到1发明了sql一样,它允许使用简单的文本表达式来让驱动层高性能地过滤得自己感兴趣的数据包,比如outbound and !loopback and (tcp.DstPort == 80 or udp.DstPort == 53)
,这种filter的作用,想必使用过wireshark软件的都特别明白。
不足的是,人们在做dotnet封装时,仅仅做了Invoke(string filter)
这种传话筒式的封装,好家伙,filter language一共100个字段左右,我保证使用者不翻看filter language宝典的话,肯定不知道怎么构造这个string内容,您好歹从语法层面超越一下,提供一下filter的Builder也好啊。
WindivertDotnet提供Filter类型使用Lambda来构造这个filter language,有了它您不再需要珍藏filter language葵花宝典了,就像使用了EF之后不会sql又何妨呢,因为如下的csharp代码,每个人都打得出:
var filter = Filter.True
.And(f => f.Network.Outbound && !f.Network.Loopback)
.And(f => f.Tcp.DstPort == 80 || f.Udp.DstPort == 53);
2.5 异步IO封装
没有async和await的IO,那不是完美的IO,WinDivert提供了可选的LPOVERLAPPED
,让上层可以使用IOCP模型,遗憾的是目前没有任何封装项目应用了这个参数,并结合IOCP模型包装为dotnet的Task或ValueTask异步模型。他们都是直接PInvoke使用了Send
和Recv
这两个api,或者是SendEx
之后又同步阻塞等待LPOVERLAPPED的完成,这种和dotnet里的 Task.Wait()
其实是一个道理,调用工作线程在IO完成之前只能干等,而没法抽身回到线程池中。
WindivertDotnet将LPOVERLAPPED
与IOCP模型结合,并封装为dotnet的TAP异步模型,凝结出下面两个核心方法:
ValueTask<int> RecvAsync(WinDivertPacket, WinDivertAddress, CancellationToken);
ValueTask<int> SendAsync(WinDivertPacket, WinDivertAddress, CancellationToken);
Api方法是简单,但过程曲折,没有资料,碰壁无数,哪怕是小小的CancellationToken参数,但它却能让pendding的IO操作撤销下来。
3 后话
因FastGithub项目的需要,所以本项目才得以诞生,现在我是结合实际项目的中使用痛点来改进本项目,甚至添加了一些WinDivert目前没有的功能,相信本项目越来越好用。