使用 Go 語言實現 ping 工具

ping 是一個網絡工具,它被廣泛地用於測試網絡連接的質量和穩定性。當我們想知道我們的電腦是否能夠與其他設備或服務器進行通信時,ping 就是我們最好的朋友。當我們想偵測網絡之間的連通性和網絡質量的時候,也常常使用 ping 工具測量,因爲它是操作系統常帶的一個網絡診斷工具,小而強大。

ping 最初是由 Mike Muuss 在 1983 年爲 Unix 系統開發的。它的名字是來自於海軍潛艇的聲納系統,聲納系統通過發送一個聲波並測量其返回時間來確定目標的位置。Ping 的工作原理類似,它發送一個小數據包到目標設備,然後等待該設備返回一個響應,以測量其響應時間和延遲。

當我們使用 Ping 測試網絡連接時,它能夠告訴我們兩個重要的指標:延遲和丟包率。延遲是指從發送 ping 請求到接收到響應所需的時間,通常以毫秒爲單位計算。丟包率則是指在 ping 請求和響應之間丟失的數據包的百分比。如果丟包率過高,說明網絡連接可能存在問題,導致數據傳輸不穩定或者甚至無法連接。

除了基本的 ping 命令外,還有許多其他 ping 命令和選項可供使用。例如,可以使用 “-c” 選項指定發送 ping 請求的次數,使用 “-i” 選項指定 Ping 請求之間的時間間隔。此外,還可以使用 “-s” 選項指定發送 ping 請求的數據包大小。

儘管 ping 是一個非常有用的工具,但它也有一些限制。ping 測試的結果可能會受到許多因素的影響,例如網絡擁塞、防火牆、路由器丟棄等等。此外,一些設備或服務器可能已禁用對 ping 請求的響應,因此無法獲得 ping 測試的結果。

儘管它有一些限制,但它仍然是網絡管理員和用戶必備的工具之一。

實現原理

ping 工具是基於 rfc 792 (ICMP 協議)[1] 來實現的。它是一份名爲 “Internet 控制消息協議(ICMP)規範” 的文件,由 Jon Postel 和 J. Reynolds 在 1981 年 9 月發佈。該文檔定義了 ICMP 協議,該協議是 TCP/IP 網絡協議套件中的一個重要組成部分。

ICMP 協議是一種網絡層協議,用於傳輸與網絡控制和錯誤處理相關的消息。該協議通常與 IP 協議一起使用,用於在 Internet 上交換信息。RFC 792 詳細介紹了 ICMP 協議中的不同消息類型及其用途。ping 就是利用發送一個 Echo 請求得到一個 Echo Reply 實現的。

其中:

ICMP 是封裝在 IP 包中傳輸的。

等下一篇介紹 traceroute 工具實現的時候,我們還會介紹 ICMP。

對於 ping 來說,就是簡單的發送一個 echo 消息,收到對應的 echo reply 消息後計算時延,如果超時未收到 reply,就計算一次丟包。

比如我們常用的 ping 命令,可以顯示收包情況和時延,你可以指定發送的總數,最後還會有一個統計信息:

本文的介紹和例子都是針對 IPV4 的,IPv6 類似但又有些不同。

你在網上搜ping.c, 很容易搜到通過 C 語言實現的 ping 工具,如果使用 Go 語言,也有幾個實現方式。

“作弊” 方式

最容易的實現方式就是調用操作系統中自帶的 ping 工具:

package main

import (
 "fmt"
 "os"
 "os/exec"
)

func main() {
 host := os.Args[1]

 output, err := exec.Command("ping""-c""3", host).CombinedOutput()
 if err != nil {
  panic(err)
 }

 fmt.Println(string(output))
}

簡單幾行代碼。

使用 golang.org/x/net/icmp

Go 的 net 擴展庫專門實現了 icmp[2] 協議。我們可以使用它來實現 ping。

插入一個知識點。如果使用 SOCK_RAW 實現 ping,是需要 cap_net_raw 權限的, 你可以通過下面的命令設置:

setcap cap_net_raw=+ep /path/to/your/compiled/binary

在 Linux 3.0 新實現 [3] 了一種 Socket 方式,可以實現普通用戶也能執行 ping 命令: socket(PF_INET, SOCK_DGRAM, IPPROTO_ICMP)

不過你還需要設置:
```shell
sudo sysctl -w net.ipv4.ping_group_range="0 2147483647"

首先,我們實現non-privileged ping方式的 ping, icmp 包爲我們做了封裝,所以我們不必使用底層的socket, 而是直接使用icmp.ListenPacket("udp4", "0.0.0.0")來實現。

完整的代碼如下:

package main

import (
 "fmt"
 "log"
 "net"
 "os"
 "time"

 "golang.org/x/net/icmp"
 "golang.org/x/net/ipv4"
)

const (
 protocolICMP = 1
)

func main() {
 if len(os.Args) != 2 {
  fmt.Fprintf(os.Stderr, "Usage: %s host\n", os.Args[0])
  os.Exit(1)
 }
 host := os.Args[1]

    // 使用icmp得到一個*packetconn,注意這裏的network我們設置的`udp4`
 c, err := icmp.ListenPacket("udp4""0.0.0.0")
 if err != nil {
  log.Fatal(err)
 }
 defer c.Close()

    // 生成一個Echo消息
 msg := &icmp.Message{
  Type: ipv4.ICMPTypeEcho,
  Code: 0,
  Body: &icmp.Echo{
   ID:   os.Getpid() & 0xffff,
   Seq:  1,
   Data: []byte("Hello, are you there!"),
  },
 }

 wb, err := msg.Marshal(nil)
 if err != nil {
  log.Fatal(err)
 }

    // 發送,注意這裏必須是一個UDP地址
 start := time.Now()
 if _, err := c.WriteTo(wb, &net.UDPAddr{IP: net.ParseIP(host)}); err != nil {
  log.Fatal(err)
 }

    // 讀取回包
 reply := make([]byte, 1500)
 err = c.SetReadDeadline(time.Now().Add(5 * time.Second))
 if err != nil {
  log.Fatal(err)
 }
 n, peer, err := c.ReadFrom(reply)
 if err != nil {
  log.Fatal(err)
 }

 duration := time.Since(start)

 // 得到的回包是一個ICMP消息,先解析出來
 msg, err = icmp.ParseMessage(protocolICMP, reply[:n])
 if err != nil {
  log.Fatal(err)
 }

 // 打印結果
 switch msg.Type {
 case ipv4.ICMPTypeEchoReply: // 如果是Echo Reply消息
  echoReply, ok := msg.Body.(*icmp.Echo) // 消息體是Echo類型
  if !ok {
   log.Fatal("invalid ICMP Echo Reply message")
   return
  }

        // 這裏可以通過ID, Seq、遠程地址來進行判斷,下面這個只使用了兩個判斷條件,是有風險的
        // 如果此時有其他程序也發送了ICMP Echo,序列號一樣,那麼就可能是別的程序的回包,只不過這個幾率比較小而已
        // 如果再加上ID的判斷,就精確了
  if peer.(*net.UDPAddr).IP.String() == host && echoReply.Seq == 1 {
   fmt.Printf("Reply from %s: seq=%d time=%v\n", host, msg.Body.(*icmp.Echo).Seq, duration)
   return
  }
 default:
  fmt.Printf("Unexpected ICMP message type: %v\n", msg.Type)
 }

}

關鍵代碼都加了註釋,主要注意回包的解析和回包的判斷。尤其是回包的判斷,我們在下一章實現 traceroute 的時候尤其需要注意這一點。

使用 ip4:icmp 實現

即使我們想實現 privileged ping,我們也不需要直接使用 raw socket,還是使用 icmp 包。

在這種場景下,我們的 network 需要是ip4:icmp, 能夠發送 ICMP 包,而不是上面的udp4

package main

import (
 "fmt"
 "log"
 "net"
 "os"
 "time"

 "golang.org/x/net/icmp"
 "golang.org/x/net/ipv4"
)

const (
 protocolICMP = 1
)

func main() {
 if len(os.Args) != 2 {
  fmt.Fprintf(os.Stderr, "usage: %s host\n", os.Args[0])
  os.Exit(1)
 }
 host := os.Args[1]

 // 解析目標主機的 IP 地址
 dst, err := net.ResolveIPAddr("ip", host)
 if err != nil {
  log.Fatal(err)
 }

 // 創建 ICMP 連接
 conn, err := icmp.ListenPacket("ip4:icmp""0.0.0.0")
 if err != nil {
  log.Fatal(err)
 }
 defer conn.Close()

 // 構造 ICMP 報文
 msg := &icmp.Message{
  Type: ipv4.ICMPTypeEcho,
  Code: 0,
  Body: &icmp.Echo{
   ID:   os.Getpid() & 0xffff,
   Seq:  1,
   Data: []byte("Hello, are you there!"),
  },
 }
 msgBytes, err := msg.Marshal(nil)
 if err != nil {
  log.Fatal(err)
 }

 // 發送 ICMP 報文
 start := time.Now()
 _, err = conn.WriteTo(msgBytes, dst)
 if err != nil {
  log.Fatal(err)
 }

 // 接收 ICMP 報文
 reply := make([]byte, 1500)
 for i := 0; i < 3; i++ {
  err = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
  if err != nil {
   log.Fatal(err)
  }
  n, peer, err := conn.ReadFrom(reply)
  if err != nil {
   log.Fatal(err)
  }
  duration := time.Since(start)

  // 解析 ICMP 報文
  msg, err = icmp.ParseMessage(protocolICMP, reply[:n])
  if err != nil {
   log.Fatal(err)
  }

  // 打印結果
  switch msg.Type {
  case ipv4.ICMPTypeEchoReply:
   echoReply, ok := msg.Body.(*icmp.Echo)
   if !ok {
    log.Fatal("invalid ICMP Echo Reply message")
    return
   }
   if peer.String() == host && echoReply.ID == os.Getpid()&0xffff && echoReply.Seq == 1 {
    fmt.Printf("reply from %s: seq=%d time=%v\n", dst.String(), msg.Body.(*icmp.Echo).Seq, duration)
    return
   }
  default:
   fmt.Printf("unexpected ICMP message type: %v\n", msg.Type)
  }
 }
}

和上面的例子比較,主要是發送的邏輯不一樣,大同小異,發送額度內容都是 ICMP Echo 消息,只不過這次發送的失效,地址不是 UDP 地址,而是 IP 地址。

使用 go-ping

雖然 Go net 擴展庫提供了 icmp 包,方便我們實現 ping 能力,但是代碼還是有點偏底層的處理,網上有一個 go-ping/ping[4] 庫,還是被使用很多的,提供了更高級或者說更傻瓜的方法。

三年了,疫情給世界帶來的影響已經潛移默化到影響到互聯網,影響到開源社區。我看到很多的開源項目因爲一些原因都不再維護了,包括這個go-ping項目,光靠作者用愛發電無法做到持久,有點遺憾,不過好歹是它已經比較成熟了,我們項目中使用沒有問題。prometheus 社區基於這個項目,維護了一個新的項目:pro-bing[5]。

它的 README 文檔中的例子已經很少的解釋了它的使用方法,你可以利用它實現一個類似 ping 工具的功能,如果想大批量實現 ping 的功能,這個庫就不合適了。

下面代碼就是一個 ping 的基本功能,沒什麼好說的,ping3 次得到結果:

 // ping 並收集結果
 pinger, err := probing.NewPinger("github.com")
 if err != nil {
  panic(err)
 }
    // ping的次數
 pinger.Count = 3
 err = pinger.Run() // 阻塞直到完成或者超時
 if err != nil {
  panic(err)
 }
 stats := pinger.Statistics() // 得到統計結果
 pretty.Println(stats)

如果要實現 Linux 下 ping 的功能,可以稍微複雜些:

    pinger, err = probing.NewPinger("github.com")
 if err != nil {
  panic(err)
 }

 // Listen for Ctrl-C.
 c := make(chan os.Signal, 1)
 signal.Notify(c, os.Interrupt)
 go func() {
  for _ = range c {
   pinger.Stop()
  }
 }()

 pinger.OnRecv = func(pkt *probing.Packet) {
  fmt.Printf("%d bytes from %s: icmp_seq=%d time=%v\n",
   pkt.Nbytes, pkt.IPAddr, pkt.Seq, pkt.Rtt)
 }

 pinger.OnDuplicateRecv = func(pkt *probing.Packet) {
  fmt.Printf("%d bytes from %s: icmp_seq=%d time=%v ttl=%v (DUP!)\n",
   pkt.Nbytes, pkt.IPAddr, pkt.Seq, pkt.Rtt, pkt.TTL)
 }

 pinger.OnFinish = func(stats *probing.Statistics) {
  fmt.Printf("\n--- %s ping statistics ---\n", stats.Addr)
  fmt.Printf("%d packets transmitted, %d packets received, %v%% packet loss\n",
   stats.PacketsSent, stats.PacketsRecv, stats.PacketLoss)
  fmt.Printf("round-trip min/avg/max/stddev = %v/%v/%v/%v\n",
   stats.MinRtt, stats.AvgRtt, stats.MaxRtt, stats.StdDevRtt)
 }

 fmt.Printf("PING %s (%s):\n", pinger.Addr(), pinger.IPAddr())
 err = pinger.Run()
 if err != nil {
  panic(err)
 }

前面也說了,處理返回的消息並和發送請求做匹配是一個技術點,那麼 go-ping 是怎麼實現的呢? 主要是下面的代碼:

 switch pkt := m.Body.(type) {
 case *icmp.Echo:
  if !p.matchID(pkt.ID) {
   return nil
  }

  if len(pkt.Data) < timeSliceLength+trackerLength {
   return fmt.Errorf("insufficient data received; got: %d %v",
    len(pkt.Data), pkt.Data)
  }

  pktUUID, err := p.getPacketUUID(pkt.Data)
  if err != nil || pktUUID == nil {
   return err
  }

  timestamp := bytesToTime(pkt.Data[:timeSliceLength])
  inPkt.Rtt = receivedAt.Sub(timestamp)
  inPkt.Seq = pkt.Seq
  // 檢查是否收到重複的包
  if _, inflight := p.awaitingSequences[*pktUUID][pkt.Seq]; !inflight {
   p.PacketsRecvDuplicates++
   if p.OnDuplicateRecv != nil {
    p.OnDuplicateRecv(inPkt)
   }
   return nil
  }
  // 已經得到返回結果
  delete(p.awaitingSequences[*pktUUID], pkt.Seq)
  p.updateStatistics(inPkt)
 default:
  // Very bad, not sure how this can happen
  return fmt.Errorf("invalid ICMP echo reply; type: '%T', '%v'", pkt, pkt)
 }

首先檢查 body 必須是*icmp.Echo類型,這是基本操作。接着檢查 pkt.ID, 這一下就把非本程序的包 ICMP echo reply 包過濾了。

這裏它在發送的 payload 中還加上自己的 uuid 和發送的時間戳。

這裏還處理了重複的包, uuid+seq 標識同一個 Echo 請求。

通過這幾個例子,你應該瞭解了 ping 工具的底層實現,收藏起來,遇到相關的問題的時候不妨返回來查一查。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uwdL6QjLIG6SbMKeb2HOAw