使用 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 實現的。
其中:
-
type: 8 代表 echo 消息, 0 代表 echo reply 消息
-
code: 總是 0
-
checksum: 整個消息的校驗和
-
Identifier:用來匹配 echo 和 reply,我們常常使用進程 ID
-
Sequence Number: 序列號,也是用來匹配 echo 和 reply, 比如同一個進程 ID, 不同的序列號代表不同的 echo
-
Data: payload 值。所以我們使用 ping 的時候是可以使用一定大小的數據的,用來測試 MTU 或者什麼
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