网络协议栈测试神器 - packetdrill

简介

packetdrill是 Google 开源的一个 测试脚本工具,可以用于测试TCP、UDP、IP网络协议栈,其是由基于时间序的脚本行组成,按时间顺序逐条执行。

它的语言设计十分接近于 tcpdump 和 strace ,包含四种类型的语句:

  • 数据包。使用类似于 tcpdump 的语法,支持TCP、UDP、ICMP数据包,同时也提供了常见TCP选项的配置,包括 SACK、MSS、window scale等。

  • 系统调用。使用类似于 strace 的语法。

  • Shell 命令。通过``进行调用,可以进行系统参数配置或断言验证网络协议栈状态。

  • Python 脚本。通过%{ command }%进行调用,可以输出或者断言验证 TCP 状态。

编译、安装

参考官方GitHub - packetdrill

基础使用

通过一个简单的例子,来看看 packetdrill 的使用。

下面一段脚本是用于测试 TCP 三次握手的过程。

1
2
3
4
5
6
7
8
0       socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0      setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0      bind(3, ..., ...) = 0
+0      listen(3, 1) = 0

+0      < S  0:0(0) win 1000 <mss 1000>
+0      > S. 0:0(0) ack 1 <...>
+0      < .  1:1(0) ack 1 win 1000

脚本一共做了两件事情:

  • 通过系统调用创建并配置 Socket ,绑定端口开始监听
1
0       socket(..., SOCK_STREAM, IPPROTO_TCP) = 3

我们创建了一个 Stream 类型的 Socket,并指定协议为 TCP。

0 表示绝对时间,即从第0秒开始。
是默认缺省,表示系统自动配置填充。
= 3 表示函数调用结果断言为 3 ,即创建的 Socket 对应的FD。

剩下的代码就是配置并监听。

这里可能会有一个疑问,为什么新建 Socket 对应的 FD 是 3 ?

对于一个进程而言,默认会打开 stdin(0) 、 stdout(1) 、stderr(2),那么正常情况下,第一个新打开的 FD 正好是 3。

  • 三次握手

通过三行脚本来发起和校验三次握手的过程。

1
2
3
4
5
6
// 向内核网络协议栈注入SYN包
+0      < S  0:0(0) win 1000 <mss 1000>
// 断言:从协议栈收到SYN+ACK包,且ACK序号为1.
+0      > S. 0:0(0) ack 1 <...>
// 注入返回的ACK包,完成三次握手
+0      < .  1:1(0) ack 1 win 1000

+0 相对时间,上一条命令执行结束的 0 秒后,开始执行当前命令。
< 往内核网络协议栈中,注入数据包。
> 从内核网络协议栈中,收到数据包,并断言收到的数据包与定义的包是否一致,例如 ack 是否为1。
S SYN报文,对应 TCP 数据包中的 Flag。
0:0(0)开始序号:结束序号(数据包长度)。首包 sequence 是0,SYN数据包没有数据payload,所以长度是0,结束序号 = 开始序号 + payload length = 0。
ack 1 win 1000 ACK序号为1,窗口大小为1000。

TCP 网络状态

TCP是可靠的,需要保持连接状态来维持连接,连接状态的变化往往是我们非常关注的部分。packetdrill 支持对连接状态的检查和查看,对应的 TCP 连接信息定义在了 tcp.h 文件中,内容如下:

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

/* Data returned by the TCP_INFO socket option. */
struct _tcp_info {
	__u8	tcpi_state;
	__u8	tcpi_ca_state;
	__u8	tcpi_retransmits;
	__u8	tcpi_probes;
	__u8	tcpi_backoff;
	__u8	tcpi_options;
	__u8	tcpi_snd_wscale:4, tcpi_rcv_wscale:4;
	__u8	tcpi_delivery_rate_app_limited:1;

	__u32	tcpi_rto;
	__u32	tcpi_ato;
	__u32	tcpi_snd_mss;
	__u32	tcpi_rcv_mss;

	__u32	tcpi_unacked;
	__u32	tcpi_sacked;
	__u32	tcpi_lost;
	__u32	tcpi_retrans;
	__u32	tcpi_fackets;

	/* Times. */
	__u32	tcpi_last_data_sent;
	__u32	tcpi_last_ack_sent;     /* Not remembered, sorry. */
	__u32	tcpi_last_data_recv;
	__u32	tcpi_last_ack_recv;

	/* Metrics. */
	__u32	tcpi_pmtu;
	__u32	tcpi_rcv_ssthresh;
	__u32	tcpi_rtt;
	__u32	tcpi_rttvar;
	__u32	tcpi_snd_ssthresh;
	__u32	tcpi_snd_cwnd;
	__u32	tcpi_advmss;
	__u32	tcpi_reordering;

	__u32	tcpi_rcv_rtt;
	__u32	tcpi_rcv_space;

	__u32	tcpi_total_retrans;

	__u64	tcpi_pacing_rate;
	__u64	tcpi_max_pacing_rate;
	__u64	tcpi_bytes_acked;    /* RFC4898 tcpEStatsAppHCThruOctetsAcked */
	__u64	tcpi_bytes_received; /* RFC4898 tcpEStatsAppHCThruOctetsReceived */
	__u32	tcpi_segs_out;	     /* RFC4898 tcpEStatsPerfSegsOut */
	__u32	tcpi_segs_in;	     /* RFC4898 tcpEStatsPerfSegsIn */

	__u32	tcpi_notsent_bytes;
	__u32	tcpi_min_rtt;
	__u32	tcpi_data_segs_in;	/* RFC4898 tcpEStatsDataSegsIn */
	__u32	tcpi_data_segs_out;	/* RFC4898 tcpEStatsDataSegsOut */
	__u64   tcpi_delivery_rate;

	__u64	tcpi_busy_time;      /* Time (usec) busy sending data */
	__u64	tcpi_rwnd_limited;   /* Time (usec) limited by receive window */
	__u64	tcpi_sndbuf_limited; /* Time (usec) limited by send buffer */

	__u32	tcpi_delivered;
	__u32	tcpi_delivered_ce;

	__u64	tcpi_bytes_sent;     /* RFC4898 tcpEStatsPerfHCDataOctetsOut */
	__u64	tcpi_bytes_retrans;  /* RFC4898 tcpEStatsPerfOctetsRetrans */
	__u32	tcpi_dsack_dups;     /* RFC4898 tcpEStatsStackDSACKDups */
	__u32	tcpi_reord_seen;     /* reordering events seen */
};

当我们需要在连接的各个阶段查看状态变化时,可以通过 python 脚本进行打印:

1
+0      %{ print("RTO @1: ", tcpi_rto) }%

脚本尝试在上一条命令执行结束后,立刻开始执行一段 python 脚本,打印 TCP 的超时重传时间。

Shell 命令

packetdrill 支持 Shell 命令调用,实现了在测试执行过程中,插入一些额外操作,例如系统配置获取和设置。

1
+0	`sysctl -q net.ipv4.tcp_timestamps=0`

上面的命令尝试在前一条脚本执行结束后,立刻执行一段 Shell 命令,命令调用 sysctl 修改 TCP 配置,关闭了时间戳。

时间模式

由于大部分网络协议对时间都比较敏感,因此 packetdrill 的每一行脚本都需要严格定义时间。如果事件发生的时间与预期不符,会进行报错,并提示事件实际发生的时间。

在时间的定义上, packetdrill 也提供了多种模式的支持:

在使用中,可能会出现定义的时间与实际时间存在偏差的情况,不同测试模式以及环境,偏差的多少往往也会不同,有时会难以控制。这种情况下,可以调整时间偏差的容忍值:

1
2
// 在 time 时间偏差内都是可以容忍的
./packetdrill --tolerance_usecs={time}

本地/远程测试

packetdrill 支持两种测试模式:本地模式与远程模式。

  • 本地模式

本地模式可在单机环境下进行,packetdrill 使用本地单机和一个 TUN 虚拟网络设备进行数据包传输和测试,可用于测试系统调用、Sockets、TCP/IP协议层。

本地模式是 packetdrill的默认模式,调用起来也十分简单:

1
./packetdrill test.pkt
  • 远程模式

在远程模式下,我们运行两个独立的 packetdrill 进程,它们在两个不同的机器上,通过 LAN 进行数据传输,可用于测试整个网络系统,包括系统调用、Sockets、TCP、IP、NIC硬件和驱动、无线网络等。

在使用远程模式时,需要定义好两端所扮演的角色,分为客户端和服务端。

首先需要开启服务端,接收客户端连接请求:

1
./packetdrill --wire_server

而在创建客户端进程时,需要指定角色及服务端参数:

1
./packetdrill --wire_client --wire_server_ip={server_ip} test.pkt

两者通过 TCP 进行连接,并由客户端将脚本内容发送到服务端,两者共同协同完成整个脚本,整个过程主要用于测试客户端网络协议栈。

总结

packetdrill 语法并不复杂,能帮助我们学习和测试内核网络协议栈,模拟一些极端场景下的网络数据包。在 Google 发布的论文中,他们投入了超过18个月的时间,使用 packetdrill 来测试 Linux 内核网络模块,并且发现一些 bug,包括 DSACK undo、TCP Fast Open server等。