使用 Go 語言實現 pping
大家好,我是鳥窩。
在前一篇 pping: 被動式 ping,計算網絡時延中,我給大家介紹了 pping 這個工具的原理和使用方法。這篇文章中,我將使用 Go 語言實現 pping 工具。
通過這篇文章,你將瞭解到:
-
如何使用 gopacket 來捕獲和解析網絡數據包
-
如何設置捕獲時長和過濾捕獲的數據包
-
如何在 CGO 下靜態編譯庫,如 libpcap
-
瞭解 TCP/IP 協議棧的基本知識,如 TCP Option
-
如何進行數據的統計和定時輸出和清理
-
如何使用 pflag 來解析命令行參數
代碼在: github.com/smallnest/pping-go[1]
使用 libpcap 捕獲數據包,並進行包過濾
我們並不直接使用 libpcap,而是使用封裝好的 gopacket[2]。
gopacket
是一個用於處理數據包的庫,它提供了一個高級的 API,可以用來處理數據包的解析、分析和生成。它支持多種數據包格式,包括 Ethernet、IPv4、IPv6、TCP、UDP、ICMP 等。
我們可以使用gopacket
來捕獲數據包,然後使用gopacket/layers
包來解析數據包的各個部分。
// 創建一個新的非活動 pcap 句柄, *liveInp是網卡的名稱
inactive, _ := pcap.NewInactiveHandle(*liveInp)
// 使用 defer 關鍵字確保在函數結束時清理非活動句柄
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 文件中解析包:
// 使用 pcap.OpenOffline 函數打開一個離線 pcap 文件,返回一個 pcap 句柄和可能的錯誤
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 的包:
// 使用 SetBPFFilter 方法設置 BPF 過濾器,過濾器的規則由變量 filter 定義
snif.SetBPFFilter(filter)
之後處理這個包:
src := gopacket.NewPacketSource(snif, layers.LayerTypeEthernet)
// 使用 src.Packets() 獲取一個數據包通道,我們可以從這個通道中讀取數據包
packets := src.Packets()
for packet := range packets {
processPacket(packet)
......
// 如果結束或者需要定期打印統計信息,可以使用下面的代碼
......
// 如果需要清理過期的數據
......
解析包
從 TCP Option 中解析時間戳的函數是getTSFromTCPOpts
,它的實現如下:
// getTSFromTCPOpts 用於從 TCP 選項中獲取時間戳信息
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 { // Timestamp 選項長度爲 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 解析出時間戳:
// processPacket 用於處理捕獲到的數據包
func processPacket(pkt gopacket.Packet) {
// 從數據包中獲取 TCP 層
tcpLayer := pkt.Layer(layers.LayerTypeTCP)
if tcpLayer == nil {
not_tcp++
return
}
tcp, _ := tcpLayer.(*layers.TCP)
// 從 TCP 選項中獲取時間戳信息
// 如果 TSval 爲 0 或者 TSecr 爲 0 並且不是 SYN 包,則不處理該數據包
tsval, tsecr := getTSFromTCPOpts(tcp)
if tsval == 0 || (tsecr == 0 && !tcp.SYN) {
no_TS++
return
}
// 從數據包中獲取網絡層
// 如果網絡層不是 IPv4 或 IPv6,則不處理該數據包
netLayer := pkt.Layer(layers.LayerTypeIPv4)
if netLayer == nil {
netLayer = pkt.Layer(layers.LayerTypeIPv6)
if netLayer == nil {
not_v4or6++
return
}
}
目前爲止我們從包中解析除了 IP 包和 TCP 包,接下里我們得到源目 IP 和源目端口,以及捕獲時間:
// 從網絡層中獲取源 IP 和目的 IP
// 從 TCP 層中獲取源端口和目的端口
// 用於構建流的源和目的
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
// 如果 offTm 小於 0,則將捕獲時間設置爲 offTm
if offTm < 0 {
offTm = captureTime.Unix()
startm = float64(captureTime.Nanosecond()) * 1e-9
// 如果 sumInt 大於 0,則打印第一個數據包的時間
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
中查找流,如果沒有則創建一個新的流,如果反向流已經存在,則設置反向流。如果反向流不存在,不處理。
fstr := srcStr + "+" + dstStr
fr, ok := flows[fstr]
if !ok { // 新流
// 如果流的數量大於 maxFlows,則返回
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 了:
// 統計流的發送字節數
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 存儲了從源到目標的數據流的字節數
fBytes := ti.fBytes
// dBytes 存儲了從目標到源的數據流的字節數
dBytes := ti.dBytes
// pBytes 存儲了從上一次發送到現在的數據包的字節數
pBytes := arr_fwd - fr.lstBytesSnt
// 更新上一次發送的字節數爲當前的發送字節數
fr.lstBytesSnt = arr_fwd
// 更新反向流的依賴字節數爲 fBytes
flows[dstStr+"+"+srcStr].bytesDep = fBytes
if machineReadable {
// 打印捕獲時間戳、本次rtt值、此流的最小值、字節數信息
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 {
// 打印捕獲時間、本次rtt值、此流的最小值、流的五元組
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 // 將條目標記爲已使用,避免再次保存這個 TSval
}
pktCnt++
}
清理過期數據
如果不清理,flows
和tsTbl
中的數據會越來越多,最終撐爆。我們遍歷,刪除過期的數據。
// 清理超期的數據
func cleanUp(n float64) {
// 如果 TSval 的時間超過 tsvalMaxAge,則刪除條目
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
功能更爲強大。這裏我們使用它解析參數,可以設置短參數和長參數:
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
來編譯我們的程序。
go build -o pping .
不過如果你使用ldd
查看這個程序,你會發現它有很多依賴的動態庫:
[root@cypress pping]# ldd pping
linux-vdso.so.1 => (0x00007ffcf33e1000)
libpcap.so.1 => /lib64/libpcap.so.1 (0x00007f4b81933000)
libresolv.so.2 => /lib64/libresolv.so.2 (0x00007f4b81719000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f4b814fd000)
libc.so.6 => /lib64/libc.so.6 (0x00007f4b8112f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4b81b74000)
我們可以採用靜態鏈接的方式,這樣編譯出來的 pping, 可以輕鬆的複製到其他的 Linux 機器上運行,不需要安裝 libpcap 庫。
[root@cypress pping]# go build -ldflags "-linkmode external -extldflags -static" .
# github.com/smallnest/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]# ldd pping
不是動態可執行文件
它的使用方法和標準庫的 flag 類似。這樣我們就能保證和 c++ 的 pping 工具一樣的參數解析了。
基於 "Rust 重寫一切" 的哲學,我期望早點能看到大家用 Rust 實現的 pping。
參考資料
[1]
github.com/smallnest/pping-go: https://github.com/smallnest/pping-go
[2]
gopacket: https://github.com/google/gopacket
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aIAZYp9dGq0yvbuXAtLCew