前言
懒癌患者来更新第二篇。在 上一篇 的 Agent 基本功能实现了以后,就要开始考虑实现 MTR 功能,要做 MTR 就不得不先实现 traceroute 功能。虽然 traceroute 原理很简单,但是如果要考虑 高效实现 会有不小的挑战。
细节
关于 traceroute 和 MTR 的原理细节,相信读者都已经了解,本文将直接从实现上遇到的具体问题开始聊。
具体来讲,我们会遇到几个问题,发什么包?怎么读到回包?回包读得效率太低怎么办?
发什么包做为探针?
在 Windows 上的 traceroute 用的是 ICMP echo request 。在 Linux 上的 traceroute 则是使用的 UDP。这两者各有优势。
因为大部分服务都使用的 TCP 协议,而且 UDP 一般与 DNS/NTP 反射攻击有强关联性,所以机房边缘的安全设备倾向于丢弃 UDP 包,进而导致基于 UDP 的 traceroute 很难深入机房内部。基于 ICMP 的探测则比较容易深入到机房内。
但是,由于 ICMP 包没有源目端口,当网络中存在等价多路径时,交换机芯片的 HASH 模块不能读到 ID 字段,导致了网络设备不能很好的将 ICMP 包分配到不同路径上,最终导致获取的信息缺失。而基于 UDP 的探测则没有这样的问题。
总结:UDP 广度很好,深度有问题,ICMP 深度很好,广度有问题。
那么我们全都要,UDP 和 ICMP 都发。
在未来,可能还会支持指定目的端口的 TCP 探测,以便探查路径上的安全过滤策略存在的位置。
如何在用户态获取到 TTL Expired 包
发出探测包后,内核并不会在发包的 socket 上回复内核收到的关于这个 socket 的各种 ICMP error 包。如果不考虑高效的话,可以用 上一篇 raw IP socket,指定 ICMP 协议号后,内核会将所有收到的 ICMP 包都送到 raw IP socket 里,然后在用户态程序中进行过滤。
在不会收到很多 ICMP 包的设备上这样的实现比较方便,也不会遇到什么问题。
但是,在我们的需求中。由于 Agent 会发出许多的 ICMP Echo request,自然也会收到很多的 ICMP Echo reply,所以又遇到了 上一篇 提到无效的内核态到用户态的拷贝,及很多用户态判断逻辑、 rust 中并发编程的难题。
如何高效实现 traceroute
我们的核心需求是,当 指定目的地址的 TTL Expired 包后再通知用户态程序,我们希望内核能帮我们做这个过滤,从而减少内核态用户态切换和内核态向用户态拷贝的开销。
正巧,Linux 内核有这个工具,它叫做 Linux Socket Filtering (LSF) 或者叫 Berkeley Packet Filter (BPF)。我们可以通过 setsockopt
函数给 socket 附加一个过滤器,这个过滤代码由内核的 JIT 编译器翻译成机器码,并由内核执行,只有通过了这个过滤器的包,才会被送到用户态。这样的过滤非常的高效,大名鼎鼎的 tcpdump 就是使用这个方式。
tcpdump 的执行过程大致如下:
- tcpdump 将用户提供的可读的过滤表达式,如
ip src 1.1.1.1
编译 bpf bytecode。 - 将 bpf bytecode 挂到 socket 上。
- 内核 JIT 编译器将 bpf bytecode 编译为机器码,并在收到包时执行。
在执行 tcpdump 时使用 -d 或 -dd 选项获取 tcpdump 生成的 bpf bytecode
如:
$ sudo tcpdump -d ip src 1.1.1.1 (000) ldh [12] (001) jeq #0x800 jt 2 jf 5 (002) ld [26] (003) jeq #0x1010101 jt 4 jf 5 (004) ret #262144 (005) ret #0 $ sudo tcpdump -dd ip src 1.1.1.1 { 0x28, 0, 0, 0x0000000c }, { 0x15, 0, 3, 0x00000800 }, { 0x20, 0, 0, 0x0000001a }, { 0x15, 0, 1, 0x01010101 }, { 0x6, 0, 0, 0x00040000 }, { 0x6, 0, 0, 0x00000000 },
-dd
选项生成的代码可以直接粘贴到 .c 文件中使用。
通过执行以下命令来判断内核 JIT 编译器是否开启。
cat /proc/sys/net/core/bpf_jit_enable
返回 1 则为开启,大部分 Linux 发行版已经默认开启,且不可关闭。
我们可以通过链接 libpcap 后使用 libpcap 中的函数来生成,或者自行编写 bpf bytecode,后使用setsockopt
来挂到 socket 上,请参考 Linux Socket Filtering (LSF) 及 BPF 论文。
更多细节
由于新建 socket,给 socket 挂上 filter 是两步,并不是一个原子步骤。所以会有一些「漏网之鱼」。
libpacp 的解决办法,大致如下:
- 先给 socket 挂上一个 zero filter,没有任何包可以通过这个 filter。
- 通过 recv 清理掉 socket 缓存中的所有数据包。
- 通过一次
setsockopt
调用挂上真正的 filter 。
由于第三步是原子的,所有可以保证此后收到的包都是过滤后的。
rust 的 socket2 并没有实现给 socket 挂上 filter 的接口,但是它提供了其他的基础设施,我们在 socket2 的基础上实现了这一接口,后续我们会考虑将这一改动回馈到 socket2 社区(已合入)。
由于 raw IP socket 已经将以太网头拆除,在使用 raw IP socket + tcpdump 生成的 bytecode 进行实验时,请注意二者数据包起始点不同,会影响 ld
等指令的 offset。
结果
当指定目的地址的 TTL Expired 包返回时候,加了 filter 的 socket 和未加 filter 的 socket 都能收到。
当不是指定目的地址的 TTL Expired 包返回时候,只有未加 filter 的 socket 能收到。
文章评论