大家好,我是鸟窝。
在前一篇“pping:被动式ping,计算网络时延”一篇中,我给大家介绍了 pping 这个工具的原理和使用方法。这篇文章中,我将使用 Go 语言实现 pping 工具。 通过这篇文章,你将了解到:
如何使用gopacket来捕获和解析网络数据包
如何设置捕获时长和过滤捕获的数据包
如何在CGO下静态编译库,如libpcap
了解TCP/IP协议栈的基本知识,如TCP Option
如何进行数据的统计和定时输出和清理
如何使用 pflag 来解析命令行参数
代码在: github.com/smallnest/pping-go
使用libpcap捕获数据包,并进行包过滤
我们并不直接使用libpcap,而是使用封装好的gopacket 。
gopacket
是一个用于处理数据包的库,它提供了一个高级的API,可以用来处理数据包的解析、分析和生成。它支持多种数据包格式,包括Ethernet、IPv4、IPv6、TCP、UDP、ICMP等。
我们可以使用gopacket
来捕获数据包,然后使用gopacket/layers
包来解析数据包的各个部分。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inactive, _ := pcap.NewInactiveHandle(*liveInp)
defer inactive.CleanUp()
inactive.SetSnapLen(snapLen)
snif, err = inactive.Activate()
if err != nil {
fmt.Printf("couldn't open %s: %v\n" , *fname, err)
os.Exit(1 )
}
当然你也可以从一个tcpdump这样的工具捕获的pcap文件中解析包:
1
2
3
4
5
6
7
snif, err = pcap.OpenOffline(*fname)
if err != nil {
fmt.Printf("couldn't open %s: %v\n" , *fname, err)
os.Exit(1 )
}
之后设置filter进行包过滤, filter的格式和tcpdump使用的过滤格式一样,默认它会加上TCP
,只处理TCP的包:
1
2
snif.SetBPFFilter(filter)
之后处理这个包:
1
2
3
4
5
6
7
8
9
10
11
12
13
src := gopacket.NewPacketSource(snif, layers.LayerTypeEthernet)
packets := src.Packets()
for packet := range packets {
processPacket(packet)
......
......
......
解析包
从TCP Option中解析时间戳的函数是getTSFromTCPOpts
,它的实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
func getTSFromTCPOpts(tcp *layers.TCP) (uint32 , uint32 ) {
var tsval, tsecr uint32
opts := tcp.Options
for _, opt := range opts {
if opt.OptionType == layers.TCPOptionKindTimestamps && opt.OptionLength == 10 {
tsval = binary.BigEndian.Uint32(opt.OptionData[0 :4 ])
tsecr = binary.BigEndian.Uint32(opt.OptionData[4 :8 ])
break
}
}
return tsval, tsecr
}
解析IP和TCP包,并从TCP包的Option解析出时间戳:
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
func processPacket(pkt gopacket.Packet) {
tcpLayer := pkt.Layer(layers.LayerTypeTCP)
if tcpLayer == nil {
not_tcp++
return
}
tcp, _ := tcpLayer.(*layers.TCP)
tsval, tsecr := getTSFromTCPOpts(tcp)
if tsval == 0 || (tsecr == 0 && !tcp.SYN) {
no_TS++
return
}
netLayer := pkt.Layer(layers.LayerTypeIPv4)
if netLayer == nil {
netLayer = pkt.Layer(layers.LayerTypeIPv6)
if netLayer == nil {
not_v4or6++
return
}
}
目前为止我们从包中解析除了IP包和TCP包,接下里我们得到源目IP和源目端口,以及捕获时间:
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
var ipsStr, ipdStr string
if ip, ok := netLayer.(*layers.IPv4); ok {
ipsStr = ip.SrcIP.String()
ipdStr = ip.DstIP.String()
} else {
ip := netLayer.(*layers.IPv6)
ipsStr = ip.SrcIP.String()
ipdStr = ip.DstIP.String()
}
srcStr := ipsStr + ":" + strconv.Itoa(int (tcp.SrcPort))
dstStr := ipdStr + ":" + strconv.Itoa(int (tcp.DstPort))
captureTime := pkt.Metadata().CaptureInfo.Timestamp
if offTm < 0 {
offTm = captureTime.Unix()
startm = float64 (captureTime.Nanosecond()) * 1e-9
capTm = startm
if sumInt > 0 {
fmt.Printf("first packet at %s\n" , captureTime.Format(time.UnixDate))
}
} else {
capTm = float64 (captureTime.Unix()-offTm) + float64 (captureTime.Nanosecond())*1e-9
}
接下来是从全局哈希表flows
中查找流,如果没有则创建一个新的流,如果反向流已经存在,则设置反向流。如果反向流不存在,不处理。
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
fstr := srcStr + "+" + dstStr
fr, ok := flows[fstr]
if !ok {
if flowCnt >= maxFlows {
return
}
fr = &flowRec{
flowname: fstr,
min: 1e30 ,
}
flows[fstr] = fr
flowCnt++
if _, ok := flows[dstStr+"+" +srcStr]; ok {
flows[dstStr+"+" +srcStr].revFlow = true
fr.revFlow = true
}
}
fr.last_tm = capTm
if !fr.revFlow {
uniDir++
return
}
既然找到反向流了,说明正向反向的两个packet我们都获取到了了,那么就可以利用两次的捕获时间计算 RTT 了:
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
arr_fwd := fr.bytesSnt + float64 (pkt.Metadata().Length)
fr.bytesSnt = arr_fwd
if !filtLocal || localIP != ipdStr {
addTS(fstr+"+" +strconv.FormatUint(uint64 (tsval), 10 ), &tsInfo{capTm, arr_fwd, fr.bytesDep})
}
ti := getTS(dstStr + "+" + srcStr + "+" + strconv.FormatUint(uint64 (tsecr), 10 ))
if ti != nil && ti.t > 0.0 {
t := ti.t
rtt := capTm - t
if fr.min > rtt {
fr.min = rtt
}
fBytes := ti.fBytes
dBytes := ti.dBytes
pBytes := arr_fwd - fr.lstBytesSnt
fr.lstBytesSnt = arr_fwd
flows[dstStr+"+" +srcStr].bytesDep = fBytes
if machineReadable {
fmt.Printf("%d.%06d %.6f %.6f %.0f %.0f %.0f" , int64 (capTm+float64 (offTm)), int ((capTm-float64 (int64 (capTm)))*1e6 ), rtt, fr.min, fBytes, dBytes, pBytes)
} else {
fmt.Printf("%s %s %s %s\n" , captureTime.Format("15:04:05" ), fmtTimeDiff(rtt), fmtTimeDiff(fr.min), fstr)
}
now := clockNow()
if now-nextFlush >= 0 {
nextFlush = now + flushInt
}
ti.t = -t
}
pktCnt++
}
清理过期数据
如果不清理,flows
和tsTbl
中的数据会越来越多,最终撑爆。 我们遍历,删除过期的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func cleanUp(n float64 ) {
for k, ti := range tsTbl {
if capTm-math.Abs(ti.t) > float64 (tsvalMaxAge)/float64 (time.Second) {
delete (tsTbl, k)
}
}
for k, fr := range flows {
if n-fr.last_tm > float64 (flowMaxIdle)/float64 (time.Second) {
delete (flows, k)
flowCnt--
}
}
}
使用 pflag 解析参数
相对于标准库的 pflag, github.com/spf13/pflag
功能更为强大。这里我们使用它解析参数,可以设置短参数和长参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var (
liveInp = pflag.StringP("interface" , "i" , "" , "interface name" )
fname = pflag.StringP("read" , "r" , "" , "pcap captured file" )
filterOpt = pflag.StringP("filter" , "f" , "" , "pcap filter applied to packets" )
)
func main() {
pflag.DurationVarP(&sumInt, "sumInt" , "q" , 10 *time.Second, "interval to print summary reports to stderr" )
pflag.BoolVarP(&filtLocal, "showLocal" , "l" , false , "show RTTs through local host applications" )
pflag.DurationVarP(&timeToRun, "seconds" , "s" , 0 *time.Second, "stop after capturing for <num> seconds" )
pflag.IntVarP(&maxPackets, "count" , "c" , 0 , "stop after capturing <num> packets" )
pflag.BoolVarP(&machineReadable, "machine" , "m" , false , "machine readable output" )
pflag.DurationVarP(&tsvalMaxAge, "tsvalMaxAge" , "M" , 10 *time.Second, "max age of an unmatched tsval" )
pflag.DurationVarP(&flowMaxIdle, "flowMaxIdle" , "F" , 300 *time.Second, "flows idle longer than <num> are deleted" )
pflag.Parse()
...
}
静态编译
差点忘了。 我们使用gopacket来捕获数据包,它依赖于libpcap。我们需要在编译时链接libpcap库。但是在不同的操作系统上,libpcap的位置和名称可能不同。为了解决这个问题,我们可以使用CGO来链接libpcap库,然后使用go build
来编译我们的程序。
不过如果你使用ldd
查看这个程序,你会发现它有很多依赖的动态库:
1
2
3
4
5
6
7
[root@cypress pping]
linux-vdso.so.1 => (0 x00007ffcf33e1000)
libpcap.so.1 => /lib64/libpcap.so.1 (0 x00007f4b81933000)
libresolv.so.2 => /lib64/libresolv.so.2 (0 x00007f4b81719000)
libpthread.so.0 => /lib64/libpthread.so.0 (0 x00007f4b814fd000)
libc.so.6 => /lib64/libc.so.6 (0 x00007f4b8112f000)
/lib64/ld-linux-x86-64 .so.2 (0 x00007f4b81b74000)
我们可以采用静态链接的方式,这样编译出来的pping,可以轻松的复制到其他的Linux机器上运行,不需要安装libpcap库。
1
2
3
4
5
6
7
8
9
10
11
12
[root@cypress pping]
/tmp/go-link-79680640 /000006 .o:在函数‘_cgo_97ab22c4dc7b_C2func_getaddrinfo’中:
/tmp/go-build/cgo-gcc-prolog:60 : 警告:Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
//usr/local/lib/libpcap.a(nametoaddr.o):在函数‘pcap_nametoaddr’中:
/root/libpcap-1.10 .0 /./nametoaddr.c:181 : 警告:Using 'gethostbyname' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
//usr/local/lib/libpcap.a(nametoaddr.o):在函数‘pcap_nametonetaddr’中:
/root/libpcap-1.10 .0 /./nametoaddr.c:270 : 警告:Using 'getnetbyname_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
//usr/local/lib/libpcap.a(nametoaddr.o):在函数‘pcap_nametoproto’中:
/root/libpcap-1.10 .0 /./nametoaddr.c:527 : 警告:Using 'getprotobyname_r' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
[root@cypress pping]
不是动态可执行文件
它的使用方法和标准库的flag类似。这样我们就能保证和c++的pping工具一样的参数解析了。
基于"Rust重写一切"的哲学,我期望早点能看到大家用Rust实现的 pping。