使用 Go 語言實現 pping

大家好,我是鳥窩。

在前一篇 pping: 被動式 ping,計算網絡時延中,我給大家介紹了 pping 這個工具的原理和使用方法。這篇文章中,我將使用 Go 語言實現 pping 工具。

通過這篇文章,你將瞭解到:

代碼在: 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++
}

清理過期數據

如果不清理,flowstsTbl中的數據會越來越多,最終撐爆。我們遍歷,刪除過期的數據。

// 清理超期的數據
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