使用 Go 實現 traceroute 工具

traceroute 是一種用於診斷網絡連接問題的實用程序,它可以確定兩臺計算機之間的網絡路徑和網絡時延。traceroute 工具在網絡工程、系統管理和網絡安全中都有廣泛的應用。

traceroute 工具也是使用了 ICMP 這種 Internet 控制消息協議,它可以讓用戶探測到目標主機與本地主機之間的網絡路徑和路由器(或網關)的數量。traceroute 工具會向目標主機發送一系列 UDP 或 ICMP 報文,每個報文的 Time To Live (TTL) 值逐漸增加,直到達到設定的最大值,如果到達目標主機,則目標主機可能返回一個 ICMP DestinationUnreachable 包,否則返回一個 ICMP TimeExceeded 包。通過分析響應包中的 IP 地址和時間信息,traceroute 可以確定網絡中的路由器和每個路由器的延遲時間。通過多次執行 traceroute,可以幫助用戶更好地理解網絡的拓撲結構和性能瓶頸,以便優化網絡連接。

最主要的, traceroute 利用 IP 協議中的 TTL 的作用。在 IP 協議中,TTL(Time to Live)是一個 8 位字段,代表着一個 IP 數據包在網絡中最多可以經過的路由器數量,也就是生存時間。每經過一個路由器,TTL 的值就會被減一,當 TTL 的值變成 0 時,該數據包會被路由器丟棄,並向源主機發送一個 ICMP 時間超時消息。

TTL 的作用是爲了防止 IP 數據包在網絡中無限制地循環,也就是防止出現數據包在網絡中無限制地跳轉,浪費網絡資源。通過設定 TTL 的值,可以讓數據包在網絡中跳轉一定的次數後被丟棄,從而避免網絡中的擁塞和不必要的負荷。使用 traceroute 工具時,就是通過逐步減小 TTL 的值,依次向距離越來越遠的路由器發送 ICMP 消息,從而獲取到路由路徑信息。

Linux 中的 traceroute 是一個功能強大的工具,有很多的參數:

traceroute [-46dFITUnreAV] [-f first_ttl] [-g gate,...]
               [-i device] [-m max_ttl] [-p port] [-s src_addr]
               [-q nqueries] [-N squeries] [-t tos]
               [-l flow_label] [-w waittimes] [-z sendwait] [-UL] [-D]
               [-P proto] [--sport=port] [-M method] [-O mod_options]
               [--mtu] [--back]
               host [packet_len]

這篇文章主要介紹 traceroute 底層的實現原理,所以不會完全復刻 Linux 自帶的 traceroute 所有的參數的功能,否則會有大段的代碼處理這些參數的邏輯,本文只是實現一個最基本的功能。

注意 traceroute 工具發送設置了 TTL 的 IP 包時,可以使用 ICMP、UDP、ICMP 甚至其他的 IP 支持的協議,Linux 支持 UDP、TCP、ICMP 這三種協議, MacOS 使用 UDP 協議,不過 TTL 爲 0 後返回的還是 ICMP 協議。Apple 公司的 traceroute.c[1] 是一個很好的學習 traceroute 的代碼,雖然它支持發送 UDP 協議的包,不過這次我們使用 Go 語言介紹如何實現 traceroute。

說起協議了,有些人可能會問了,爲啥不直接使用 ICMP 包,而是還要實現 UDP 和 TCP 的發送包呢?這個物理網絡實際的網絡設備的處理是有關的。在同一個層級的節點中,比如北京聯通的網絡出口上,並不會只有一臺網絡設備,否則這臺設備掛了,或者這臺設備的帶寬不夠了,就會導致網絡丟包或者不通,所以一般會部署多臺設備,那麼對於一個網絡流來說,一般會使用他們的源目地址和源目端口做哈希,以便把同一個 session 的數據流發送到同一臺設備上,所以使用 UDP 或者 TCP 可以固定五元組,讓探測流總是經過同一臺設備,以便檢查固定的鏈路是不是有問題。當然這也不是絕對的,有可能同一個五元組也會經過不同的設備。

比如下面的 traceroute, 在第 9 跳的時候就經過了三臺設備 (其他跳中也有經過多臺設備的情況)

在 Linux 中,默認情況下,traceroute 使用的是 UDP 協議,目的端口從起始值爲 33434 開始,每個 TTL 值加 1,最大值爲 65535。這是因爲當 TTL 值爲 1 時,數據包到達第一個路由器,如果該路由器啓用了 ICMP 錯誤消息的生成功能,它會將一個 ICMP TTL 過期消息返回給 traceroute。爲了避免端口被旁路其他應用程序佔用,traceroute 將目標端口號加上 TTL 值作爲 UDP 包的目的端口。這樣每個 TTL 的數據包都會使用不同的目的端口號,保證 traceroute 能夠得到正確的 TTL 值。

使用 UDP 包探測 (raw socket)

首先,我們使用 UDP 包進行探測,然後處理返回的 ICMP 包。

這裏有幾個技術點:

第一種方式是我們使用 raw socket, 利用 gopacket 生成探測包,設置 TTL, 創建一個 syscall.Socket 用來發送 UDP 包,再創建一個 icmp.PacketConn 用來接收 ICMP 包。

rawsocket 的生成使用下面的方法:

fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)

然後調用Sendto系統調用發送 IP+UDP 的包:

err = syscall.Sendto(fd, data, 0, dstAddr)

讀取 ICMP 消息理論也可以使用這個 socket 讀取,不過這裏我們使用下面的方法專門接收 icmp 的包:

rconn, err := icmp.ListenPacket("ip4:icmp"local)

這個專門讀取 ICMP 的 rconn 嘗試讀取 ICMP 包:

replyBytes := make([]byte, 1500)

正常情況下會讀取到 ICMP 的返回包,也可能讀取到其他 traceroute 和 ping 的返回的包,所以先解析出 ICMP message, 還要進一步的根據源目 IP 和 ID、Seq 等進行判斷。一個設備返回 ICMP TimeExceeded 包時,會把 IP Header 以及之後的 8 個字節的數據返回。對於 UDP 來說,IP header 中包含源目 IP,UDP 前 4 個字節正好是源目端口,基本上我們使用這四元組可以將返回的包和請求包匹配在一起,但是爲了進一步避免誤判,我們還可以設置 IP Header 中的 id, 把它設置成進程 id, 這樣再增加一個匹配項,基本可以避免誤判。注意這裏我們目的端口每次 ttl 加一它也會加一,你也可以目的端口固定, “任從你心”:

            if replyMsg.Type == ipv4.ICMPTypeTimeExceeded {
    te, ok := replyMsg.Body.(*icmp.TimeExceeded)
    if !ok {
     continue
    }

    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }

    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport || ipAndPayload.ID != id {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    if peer.String() == dst {
     return
    }

    continue loop_ttl
   }

完整的代碼如下,關鍵行上我加上了註釋:

package main

import (
 "encoding/binary"
 "flag"
 "fmt"
 "log"
 "net"
 "os"
 "syscall"
 "time"

 "github.com/google/gopacket"
 "github.com/google/gopacket/layers"
 "golang.org/x/net/icmp"
 "golang.org/x/net/ipv4"
)

const (
 protocolICMP = 1
 maxHops      = 64
)

var (
 sport = flag.Int("sport", 12345, "source port")
 dport = flag.Int("p", 33434, "destination port")
)
func main() {
 flag.Parse()

 if len(os.Args) != 2 {
  log.Fatalf("usage: %s host", os.Args[0])
 }
 dst := os.Args[1]

 timeout := 3 * time.Second
 dstAddr := &syscall.SockaddrInet4{}
 copy(dstAddr.Addr[:], net.ParseIP(dst).To4())

    // 得到本機的地址
 local := localAddr()
    // 生成一個icmp conn, 用來讀取ICMP回包
 rconn, err := icmp.ListenPacket("ip4:icmp"local)
 if err != nil {
  log.Fatalf("Failed to create ICMP listener: %v", err)
 }
 defer rconn.Close()

    // 得到進程ID
 id := uint16(os.Getpid() & 0xffff)

    // 生成一個用來寫udp的raw socket,這裏使用syscall.IPPROTO_RAW,因爲我們需要自己設置IP Header
 fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW)
 if err != nil {
  fmt.Println(err)
  return
 }
 defer syscall.Close(fd)
    // 設置此項,我們自己手工組裝IP header
 err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1)
 if err != nil {
  fmt.Println(err)
  return
 }

    // TTL遞增探測
loop_ttl:
 for ttl := 1; ttl <= maxHops; ttl++ {
  *dport++
        // 拼裝一個IP+UDP的包, IP header使用指定的id和ttl, udp 的payload使用一段字符串
  data, err := encodeUDPPacket(local, dst, id, uint8(ttl)[]byte("Hello, are you there?"))
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }
        
        // 發送UDP包
  start := time.Now()
  err = syscall.Sendto(fd, data, 0, dstAddr)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

  // listen for the reply
  replyBytes := make([]byte, 1500)
  if err := rconn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
   log.Fatalf("Failed to set read deadline: %v", err)
  }

        // 嘗試讀取3次
        // 你也可以使用死循環+一個超時來控制
  for i := 0; i < 3; i++ {
   n, peer, err := rconn.ReadFrom(replyBytes)
   if err != nil {
    if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
     fmt.Printf("%d: *\n", ttl)
     continue loop_ttl
    } else {
     log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)
    }
    continue
   }

   // 解析 ICMP message
   replyMsg, err := icmp.ParseMessage(protocolICMP, replyBytes[:n])
   if err != nil {
    log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)

    continue
   }

            // 如果是 DestinationUnreachable,說明探測到了目的主機
   if replyMsg.Type == ipv4.ICMPTypeDestinationUnreachable {
    te, ok := replyMsg.Body.(*icmp.DstUnreach)
    if !ok {
     continue
    }

                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 判斷這個回包是否是本次請求匹配?
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport || ipAndPayload.ID != id {
     continue
    }

                // 如果匹配,這已經到達目的主機了,把時延打印出來,返回
    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    return
   }

            // 如果是中間設備而回包
   if replyMsg.Type == ipv4.ICMPTypeTimeExceeded {
    te, ok := replyMsg.Body.(*icmp.TimeExceeded)
    if !ok {
     continue
    }
                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 判斷這個回包是否是本次請求匹配?
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport || ipAndPayload.ID != id {
     continue
    }

                // 打印中間設備IP和時延
    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    if peer.String() == dst {
     return
    }

    continue loop_ttl
   }
  }
 }
}

// 構造IP包和UDP包
func encodeUDPPacket(localIP, dstIP string, id uint16, ttl uint8, payload []byte) ([]byte, error) {
 ip := &layers.IPv4{
  Id:       uint16(id), // ID
  SrcIP:    net.ParseIP(localIP),
  DstIP:    net.ParseIP(dstIP),
  Version:  4,
  TTL:      ttl, // ttl
  Protocol: layers.IPProtocolUDP,
 }

 udp := &layers.UDP{
  SrcPort: layers.UDPPort(*sport),
  DstPort: layers.UDPPort(*dport),
 }
 udp.SetNetworkLayerForChecksum(ip)

 buf := gopacket.NewSerializeBuffer()
 opts := gopacket.SerializeOptions{
  ComputeChecksums: true,
  FixLengths:       true,
 }

 err := gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(payload))

 return buf.Bytes(), err
}

type ipAndPayload struct {
 Src     string
 Dst     string
 SrcPort int
 DstPort int
 ID      uint16
 TTL     int
 Payload []byte
}

// 抽取匹配項
func extractIPAndPayload(body []byte) (*ipAndPayload, error) {
 if len(body) < ipv4.HeaderLen {
  return nil, fmt.Errorf("ICMP packet too short: %d bytes", len(body))
 }

 ipHeader, payload := body[:ipv4.HeaderLen], body[ipv4.HeaderLen:] // 抽取ip header和payload(UDP packet的前8個字節)

 iph, err := ipv4.ParseHeader(ipHeader)
 if err != nil {
  return nil, fmt.Errorf("Error parsing IP header: %s", err)
 }

 srcPort := binary.BigEndian.Uint16(payload[0:2]) // 前兩個字節是源端口
 dstPort := binary.BigEndian.Uint16(payload[2:4]) // 接下來兩個字節是目的端口

 return &ipAndPayload{
  Src:     iph.Src.String(),
  Dst:     iph.Dst.String(),
  SrcPort: int(srcPort),
  DstPort: int(dstPort),
  ID:      uint16(iph.ID),
  TTL:     iph.TTL,
  Payload: payload,
 }, nil
}

func localAddr() string {
 addrs, err := net.InterfaceAddrs()
 if err != nil {
  panic(err)
 }
 for _, addr := range addrs {
  if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
   if ipNet.IP.To4() != nil {
    return ipNet.IP.String()
   }
  }
 }

 panic("no local IP address found")
}

注意使用 root 權限或者給程序加 cap_net_raw, 當然最簡單的就是使用 root 進行測試了。

使用 UDP 包探測

Go 標準庫是支持發送 UDP 包的,所以我們也可以使用標準庫來發送探測包,使用相同的 icmp 包處理返回的 ICMP 消息。

可以爲什麼我們不一開始就介紹這種方式呢?

這是因爲標準庫爲我們封裝的太好了,所以我們基本上只能發送 UDP 包,很難設置 IP Header, 所以每辦法設置 ip header 中的 ID (ttl 可以使用 net 擴展包中的 ipv4 來設置),這樣就少了一項匹配項,只能通過源目 IP 和原木端口進行判斷了。

使用標準庫下面的方法創建發送的 net.PacketConn:

wconn, err := net.ListenPacket("ip4:udp"local)

因爲我們沒有辦法設置 IP header 中的 ttl, 還需要創建一個 ipv4.PacketConn 來設置 TTL:

pconn := ipv4.NewPacketConn(wconn)

還是使用 rconn 來讀取 icmp 包:

rconn, err := icmp.ListenPacket("ip4:icmp"local)

完整代碼如下:

package main

import (
 "encoding/binary"
 "flag"
 "fmt"
 "log"
 "net"
 "os"
 "time"

 "github.com/google/gopacket"
 "github.com/google/gopacket/layers"
 "golang.org/x/net/icmp"
 "golang.org/x/net/ipv4"
)

const (
 protocolICMP = 1
 maxHops      = 64
)

var (
 sport = flag.Int("sport", 12345, "source port")
 dport = flag.Int("p", 33434, "destination port")
)

func main() {
 flag.Parse()

 if len(os.Args) != 2 {
  log.Fatalf("usage: %s host", os.Args[0])
 }
 dst := os.Args[1]
 dstAddr, err := net.ResolveIPAddr("ip4", dst)
 if err != nil {
  log.Fatalf("failed to resolve IP address for %s: %v", dst, err)
 }

 timeout := 3 * time.Second

 local := localAddr()

    // 使用net.PacketConn發送udp請求,發送的數據只是udp layer
 wconn, err := net.ListenPacket("ip4:udp"local)
 if err != nil {
  log.Fatalf("failed to listen packet: %v", err)
 }
 defer wconn.Close()
 pconn := ipv4.NewPacketConn(wconn) // 用來設置ttl

    // 此net.PacketConn處理返回的icmp的包
 rconn, err := icmp.ListenPacket("ip4:icmp"local)
 if err != nil {
  log.Fatalf("Failed to create ICMP listener: %v", err)
 }
 defer rconn.Close()

loop_ttl:
 for ttl := 1; ttl <= maxHops; ttl++ {
  pconn.SetTTL(ttl) // 設置ttl
  *dport++
  data, err := encodeUDPPacket(local, dst, uint8(ttl)[]byte("Hello, are you there?"))
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

        // 寫入udp探測包
  start := time.Now()
  _, err = wconn.WriteTo(data, dstAddr)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

  // listen for the reply
  replyBytes := make([]byte, 1500)
  if err := rconn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
   log.Fatalf("Failed to set read deadline: %v", err)
  }

  for i := 0; i < 3; i++ {
            // 讀取icmp包
   n, peer, err := rconn.ReadFrom(replyBytes)
   if err != nil {
    if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
     fmt.Printf("%d: *\n", ttl)
     continue loop_ttl
    } else {
     log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)
    }
    continue
   }

   // 解析 ICMP message
   replyMsg, err := icmp.ParseMessage(protocolICMP, replyBytes[:n])
   if err != nil {
    log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)

    continue
   }

   if replyMsg.Type == ipv4.ICMPTypeDestinationUnreachable {
    te, ok := replyMsg.Body.(*icmp.DstUnreach)
    if !ok {
     continue
    }

                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 根據四元組做匹配檢查
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    return
   }

   if replyMsg.Type == ipv4.ICMPTypeTimeExceeded {
    te, ok := replyMsg.Body.(*icmp.TimeExceeded)
    if !ok {
     continue
    }
                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 根據四元組做匹配檢查
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    if peer.String() == dst {
     return
    }

    continue loop_ttl
   }
  }
 }
}

func encodeUDPPacket(localIP, dstIP string, id uint16, ttl uint8, payload []byte) ([]byte, error) {
 ip := ......

 udp := ......
 udp.SetNetworkLayerForChecksum(ip)

 buf := gopacket.NewSerializeBuffer()
 opts := gopacket.SerializeOptions{
  ComputeChecksums: true,
  FixLengths:       true,
 }

    // 注意這裏我們只使用udp和payload做序列化,並沒有使用ip layer
 err := gopacket.SerializeLayers(buf, opts, udp, gopacket.Payload(payload))

 return buf.Bytes(), err
}

type ipAndPayload struct {
 Src     string
 Dst     string
 SrcPort int
 DstPort int
 ID      uint16
 TTL     int
 Payload []byte
}

func extractIPAndPayload(body []byte) (*ipAndPayload, error) {
 ......
}

func localAddr() string {
 ......
}

整體代碼和上一節的代碼類似,只不過我們沒有辦法設置 ip header 了。只能通過 ipv4.PacketConn 設置一下 ttl。

使用 TCP 包探測

和上面的 UDP 方法類似,我們也可以發送 TCP 的包進行探測。

我們只會發送 TCP 的 PSH 包 (syn 包也可以), 中間設備會返回 ICMP TimeExceeded 包,目的主機極大可能認爲這是一個非法的包,直接把這個包丟棄,而不是返回一個 ICMP DestinationUnreachable, 所以你可能需要等待最大 TTL 探測完。

發送這個探測包理論不會對目標主機造成影響,因爲 TTL 已經爲 0 了。

發送我們使用下面的 wconn:

wconn, err := net.ListenPacket("ip4:tcp"local)

接收 icmp 包我們還是使用下面的 rconn:

rconn, err := icmp.ListenPacket("ip4:icmp"local)

每次構造一個 TCP PSH 包進行探測,這裏我們的 PSH 包的 payload 沒有設置,如有需要你也可以加上:

        pconn.SetTTL(ttl)

  seq++
  data, err := encodeTCPPacket(local, dst, id, uint8(ttl), seq)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

  start := time.Now()
  _, err = wconn.WriteTo(data, dstAddr)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

處理 ICMP 回包的方法基本和上面類似。

上面發送 UDP 包的時候不是沒有辦法設置 IP header 的 ID 麼?TCP 探測包有了新的途徑。TCP 包中的前兩個字節是源端口,接下來兩個字節是目的端口,再接下來四個字節是 ID,我們正好可以使用這個 id 做匹配。所以抽取匹配項的時候我們把這個 id 抽取出來了,當然發送的時候也使用探測段的進程 id 進行了設置。

這裏我們還嘗試把設備的 IP 地址轉換成域名,更方便的檢查中間設備所在的區域。

如果我們能結合 IP 地址地理位置庫,我們還可以顯示出設備所在的國家、城市、服務商等。

完成的代碼如下:

package main

import (
 "encoding/binary"
 "flag"
 "fmt"
 "log"
 "net"
 "os"
 "time"

 "github.com/google/gopacket"
 "github.com/google/gopacket/layers"
 "golang.org/x/net/icmp"
 "golang.org/x/net/ipv4"
)

const (
 protocolICMP = 1
 maxHops      = 64
)

var (
 sport = flag.Int("sport", 12345, "source port")
 dport = flag.Int("p", 33433, "destination port")
)

// 使用IP packet的ID檢查
func main() {
 flag.Parse()

 if len(os.Args) != 2 && len(os.Args) != 4 {
  log.Fatalf("usage: %s host", os.Args[0])
 }
 dst := os.Args[1]

 timeout := time.Second

 dstAddr, err := net.ResolveIPAddr("ip4", dst)
 if err != nil {
  log.Fatalf("failed to resolve IP address for %s: %v", dst, err)
 }

    // 發送tcp的net.PacketConn
 local := localAddr()
 wconn, err := net.ListenPacket("ip4:tcp"local)
 if err != nil {
  log.Fatalf("failed to listen packet: %v", err)
 }
 defer wconn.Close()
 pconn := ipv4.NewPacketConn(wconn) // 用來設置tos

    // 讀取icmp的net.PacketConn
 rconn, err := icmp.ListenPacket("ip4:icmp"local)
 if err != nil {
  log.Fatalf("Failed to create ICMP listener: %v", err)
 }
 defer rconn.Close()

    // ID, 這裏還增加了一個seq, 使用id+seq來設置tcp 的id
 id := uint16(os.Getpid() & 0xffff)
 seq := uint32(0)
loop_ttl:
 for ttl := 1; ttl <= maxHops; ttl++ {
  pconn.SetTTL(ttl)

  seq++
        // 構造一個tcp psh包
  data, err := encodeTCPPacket(local, dst, id, uint8(ttl), seq)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

        // 發送
  start := time.Now()
  _, err = wconn.WriteTo(data, dstAddr)
  if err != nil {
   log.Printf("%d: %v", ttl, err)
   continue
  }

  replyBytes := make([]byte, 1500)

  for i := 0; i < 3; i++ {
   if err := rconn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
    log.Fatalf("Failed to set read deadline: %v", err)
   }
            // 讀取icmp包
   n, peer, err := rconn.ReadFrom(replyBytes)
   if err != nil {
    if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
     fmt.Printf("%d: *\n", ttl)
     continue loop_ttl
    } else {
     log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)
    }
    continue
   }
   rconn.SetReadDeadline(time.Time{})

   // 解析 ICMP message
   replyMsg, err := icmp.ParseMessage(protocolICMP, replyBytes[:n])
   if err != nil {
    log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)

    continue
   }

   if replyMsg.Type == ipv4.ICMPTypeDestinationUnreachable { // 其實無用
    te, ok := replyMsg.Body.(*icmp.DstUnreach)
    if !ok {
     continue
    }

                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 檢查是否和探測包匹配
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport || ipAndPayload.ID != int(id)+int(seq) {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))

    return
   }

   if replyMsg.Type == ipv4.ICMPTypeTimeExceeded {
    te, ok := replyMsg.Body.(*icmp.TimeExceeded)
    if !ok {
     continue
    }
                // 抽取匹配項
    ipAndPayload, err := extractIPAndPayload(te.Data)
    if err != nil {
     continue
    }
                // 檢查是否和探測包匹配
    if ipAndPayload.Dst != dst || ipAndPayload.Src != local || ipAndPayload.SrcPort != *sport || ipAndPayload.DstPort != *dport || ipAndPayload.ID != int(id)+int(seq) {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))

    if peer.String() == dst {
     return
    }

    continue loop_ttl
   }
  }
 }
}

func encodeTCPPacket(localIP, dstIP string, id uint16, ttl uint8, seq uint32) ([]byte, error) {
 ip := &layers.IPv4{
  SrcIP:    net.ParseIP(localIP),
  DstIP:    net.ParseIP(dstIP),
  Version:  4,
  TTL:      ttl,
  Protocol: layers.IPProtocolTCP,
 }

 tcp := &layers.TCP{
  SrcPort: layers.TCPPort(*sport),
  DstPort: layers.TCPPort(*dport),
  Seq:     uint32(id) + seq,
  PSH:     true,
 }
 tcp.SetNetworkLayerForChecksum(ip)

 buf := gopacket.NewSerializeBuffer()
 opts := gopacket.SerializeOptions{
  ComputeChecksums: true,
  FixLengths:       true,
 }

 err := gopacket.SerializeLayers(buf, opts, tcp)

 return buf.Bytes(), err
}

type ipAndPayload struct {
 Src     string
 Dst     string
 SrcPort int
 DstPort int
 ID      int
 Payload []byte
}

func extractIPAndPayload(body []byte) (*ipAndPayload, error) {
 if len(body) < ipv4.HeaderLen {
  return nil, fmt.Errorf("ICMP packet too short: %d bytes", len(body))
 }

 ipHeader, payload := body[:ipv4.HeaderLen], body[ipv4.HeaderLen:]

 iph, err := ipv4.ParseHeader(ipHeader)
 if err != nil {
  return nil, fmt.Errorf("Error parsing IP header: %s", err)
 }

 srcPort := binary.BigEndian.Uint16(payload[0:2])
 dstPort := binary.BigEndian.Uint16(payload[2:4])
 id := binary.BigEndian.Uint32(payload[4:8])

 return &ipAndPayload{
  Src:     iph.Src.String(),
  Dst:     iph.Dst.String(),
  SrcPort: int(srcPort),
  DstPort: int(dstPort),
  ID:      int(id),
  Payload: payload,
 }, nil
}

func localAddr() string {
 ......
}

使用 ICMP 包探測

最終,如果沒有特殊的需求,我們可以使用簡單的 ICMP 包作爲探測請求包。

使用 icmp 探測的好處就是我們可以使用一個 icmp 的 PacketConn 來進行發送和讀取,第二個好處就是我們可以使用 icmp 中的 Echo 消息中的 ID 和 seq 進行匹配。

這裏我們沒有必要自己進行匹配項的抽取了,直接嘗試把返回的結果解析成 Echo 消息進行匹配項檢查即可:

                echoReply, ok := msg.Body.(*icmp.Echo)
    if !ok || echoReply.ID != id || echoReply.Seq != seq {
     continue
    }

完整的代碼如下:

package main

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

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

const (
 protocolICMP = 1
 maxHops      = 64
)

func main() {
 if len(os.Args) != 2 {
  log.Fatalf("Usage: %s host", os.Args[0])
 }

 dst := os.Args[1]
 timeout := time.Second * 3

 // resolve the host name to an IP address
 ipAddr, err := net.ResolveIPAddr("ip4", dst)
 if err != nil {
  log.Fatalf("Failed to resolve IP address for %s: %v", dst, err)
 }

 // create a socket to listen for incoming ICMP packets
 conn, err := icmp.ListenPacket("ip4:icmp""0.0.0.0")
 if err != nil {
  log.Fatalf("Failed to create ICMP listener: %v", err)
 }
 defer conn.Close()

 id := os.Getpid() & 0xffff

 seq := 0
loop_ttl:
 for ttl := 1; ttl <= maxHops; ttl++ {
  // set the TTL on the socket
  if err := conn.IPv4PacketConn().SetTTL(ttl); err != nil {
   log.Fatalf("Failed to set TTL: %v", err)
  }

  seq++
  // create an ICMP message
  msg := icmp.Message{
   Type: ipv4.ICMPTypeEcho,
   Code: 0,
   Body: &icmp.Echo{
    ID:   id,
    Seq:  seq,
    Data: []byte("hello, are you there?"),
   },
  }

  // serialize the ICMP message
  msgBytes, err := msg.Marshal(nil)
  if err != nil {
   log.Fatalf("Failed to serialize ICMP message: %v", err)
  }

  // send the ICMP message
  start := time.Now()
  if _, err := conn.WriteTo(msgBytes, ipAddr); err != nil {
   log.Printf("%d: %v", ttl, err)
   continue loop_ttl
  }

  // listen for the reply
  replyBytes := make([]byte, 1500)
  if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
   log.Fatalf("Failed to set read deadline: %v", err)
  }

  for i := 0; i < 3; i++ {
   n, peer, err := conn.ReadFrom(replyBytes)
   if err != nil {
    if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
     fmt.Printf("%d: *\n", ttl)
     continue loop_ttl
    } else {
     log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)
    }
    continue
   }

   // parse the ICMP message
   replyMsg, err := icmp.ParseMessage(protocolICMP, replyBytes[:n])
   if err != nil {
    log.Printf("%d: Failed to parse ICMP message: %v", ttl, err)

    continue
   }

   // check if the reply is an echo reply
   if replyMsg.Type == ipv4.ICMPTypeEchoReply {
    echoReply, ok := msg.Body.(*icmp.Echo)
    if !ok || echoReply.ID != id || echoReply.Seq != seq {
     continue
    }

    fmt.Printf("%d: %v %v\n", ttl, peer, time.Since(start))
    break loop_ttl
   }

   if replyMsg.Type == ipv4.ICMPTypeTimeExceeded {
    echoReply, ok := msg.Body.(*icmp.Echo)
    if !ok || echoReply.ID != id || echoReply.Seq != seq {
     continue
    }

    var raddr = peer.String()
    names, _ := net.LookupAddr(raddr)
    if len(names) > 0 {
     raddr = names[0] + " (" + raddr + ")"
    } else {
     raddr = raddr + " (" + raddr + ")"
    }

    fmt.Printf("%d: %v %v\n", ttl, raddr, time.Since(start))
    continue loop_ttl
   }
  }
 }
}

我們使用這個程序探索一下 github.com 的機器,我使用的是阿里雲的機器,消息經過了阿里雲北京機房內網、北京電信、杭州電信、中國電信香港節點、日本節點、新加坡節點達到了新加坡機房。

其實,你可以使用其他的 IP protocol 進行探測,本文的代碼已經很多了,我們就不贅述了,有興趣的同學可以自己測試下。

下一篇,點贊數如果是偶數,我們介紹單播、組播和廣播,點贊數如果是奇數,我們介紹如何發送 IP 包,如果點贊數爲 0,本系列停更,我們去更新 Go 併發和 Rust 併發的系列。

參考資料

[1]

traceroute.c: https://opensource.apple.com/source/network_cmds/network_cmds-77/traceroute.tproj/traceroute.c.auto.html

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