ping- 歸來後- 請告訴我你經過的路由

在使用 Go 實現 ping 工具 [1] 那一篇文章中,我們自己實現了 ping 工具的基本功能,我們也瞭解了 ping 底層實現的原理。讀者 @ZerOne 提出一個問題:能不能實現單向跟蹤路由的功能,類似 ping -R一樣,從 A 端 tracert B 端,同時顯示 B 端到 A 端的路徑?

Record Route 背景知識

我先前沒有用過 ping 的-R參數,ZerOne 這麼一問,我感覺很有趣。因爲最近兩年我一直在做網絡相關的工作,特別是網絡監控的能力,如果如 ZerOne 說的,ping 如果有這個能力,可以很好地用在我們網絡監控上,所以我迅速在網上搜索這相關的信息,並使用 Go 實現類似的功能, 最終形成了這一篇文章,太長不看的話:這個問題的答案是,行,但不現實。

這也算是 Go 高級網絡編程系列中插播的一篇吧,另外一個讀者評論批量發送包的時候提出了 sendmmsg 和 sendv(應該是想說 writv) 的區別,我會在下一篇文章介紹,這兩篇算是插播的知識點。歡迎大家一起探討高級網絡編程的知識,私信我或者加入 Go 高級編程研討羣進行交流。

首先man ping看它的幫助文檔中對-R參數的介紹:

-R    Record route.  Includes the RECORD_ROUTE option in the ECHO_REQUEST packet and displays the route buffer on returned packets.  Note that the IP header is only large enough for nine such routes. Many hosts ignore or discard this option.

-R    記錄路由。在 Echo 請求包中包含 RECORD_ROUTE選項,在返回的包中顯示緩存的路由。注意 IPV4 的頭部只能最多存 9 個這樣的路由地址。很多節點會忽略或者丟棄這個選項。

Mac OS 的 ping 工具已經把這個選項標記爲棄用了,即使你添加了這個參數,也相當於一個 no-op 操作。

RR 的功能是利用 ipv4 header 的 option 實現的。

普通的 ipv4 header 是 20 個字節 (圖中每一行是 4 個字節, 32bit), 但是協議還設置了 Option, 可以擴展它的基本功能。

IHL 是 4 個 bit, 代表 IP 頭的長度 (行數),因爲 4 個 bit 最大也就是 15,所以 ipv4 的 header 最大也就是 15 * 4byte = 60 byte,留給 option 的也只有 40 byte (60 byte - 40 byte)。

Option 的格式採用 TLV 格式定義,第一個 byte 是 Type, 第二個 byte 是此 Option 的長度 Length,剩餘的字節包含 Option 的值 Value:

rfc791[2] 中定義了 IPv4 的幾個選項:

其中 record route(RR) 就是本文要討論的對象。

IPv4 record route(RR)選項指示路由器在數據包中要記錄它們的 IP 地址。RR 是 Internet 協議的標準部分,可以在任何數據包上啓用。與 traceroute 類似,RR 記錄和報告從源到目的地沿着 Internet 路徑的 IP 地址,但它比 traceroute 具有幾個優點。例如,RR 可以逐跳地拼接回到目的地的反向路徑,這對於 traceroute 和其他傳統技術來說是不可見的;並且它可以發現一些不響應 traceroute 探測的跳數。

但是這也帶來了安全問題,尤其是雲服務當道的今天。如果從雲機房的 IP 包開啓了這個選項,那麼它經過的雲機房路由就會到雲機房外,就會把雲服務商的網絡架構暴露出來,甚至黑客還可以利用其它選項,讓流走特定的設備實現攻擊,同時每個數據包還額外增加了一些數據多佔帶寬,喫力不討好,所以你的機器是購買的雲虛機的我話,ping -R大概率都被丟棄掉了,可能在源機房,也可能在目的機房。比如我在騰訊雲的雲虛機做測試,8.8.8.8忽略了這個選項,1.1.1.1github.com丟棄了請求,127.0.0.1返回了這個選項:

但是論文 The record route option is an option![3] 卻提出一個新的觀點,認爲 RR 非常具有網絡測量的潛力,並且可以與 traceroute 結合使用來增強對網絡拓撲結構的理解。這篇論文呼籲重新評估 RR 選項潛力,並探索新的使用方式。

以上就是Record Route選項的背景知識。接下來我們就在原來我們實現的 ping 程序上加上 RR 的功能

使用 Go 實現 RR 功能

解析 RR 選項

首先我們先做一個準備工作,如果我們收到一個 IPv4 包,我們需要解析出它的選項。可能包括多個選項,我們需要按照 TLV 的格式把它們都解析出來。

解析選項我們還是使用的 gopacket 包, 首先解析 T, 根據 T 的類型決定解析 L 和 V:

func parseOptions(data []byte) ([]layers.IPv4Option, error) {
 options := make([]layers.IPv4Option, 0, 4)

 for len(data) > 0 {
  opt := layers.IPv4Option{OptionType: data[0]}
  switch opt.OptionType {
  case 0: // End of options
   opt.OptionLength = 1
   options = append(options, opt)
   return options, nil
  case 1: // 1 byte padding, no-op
   opt.OptionLength = 1
   data = data[1:]
  default:
   if len(data) < 2 {
    return options, fmt.Errorf("invalid ip4 option length. Length %d less than 2", len(data))
   }
   opt.OptionLength = data[1]
   if len(data) < int(opt.OptionLength) {
    return options, fmt.Errorf("IP option length exceeds remaining IP header size, option type %v length %v", opt.OptionType, opt.OptionLength)
   }
   if opt.OptionLength <= 2 {
    return options, fmt.Errorf("invalid IP option type %v length %d. Must be greater than 2", opt.OptionType, opt.OptionLength)
   }
   opt.OptionData = data[2:opt.OptionLength]
   data = data[opt.OptionLength:]
   options = append(options, opt)
  }
 }

 return options, nil
}

我們只關注 RR 選項,它的 T 是0x07, 它的第三個 byte 是一個從 1 開始的指針,指向存放路由 IP 地址的下一個字節,初始是 4,也就是它後面的那個字節,我們需要解析出它已存放的 IP 地址:

type RecordRouteOption struct {
 layers.IPv4Option
 IPs []net.IP
}

func parseRecordRouteOption(rr layers.IPv4Option) *RecordRouteOption {
 if rr.OptionType != 0x07 {
  return nil
 }

 ptr := int(rr.OptionData[0] - 3)

 var ips []net.IP
 for i := 1; i+4 <= ptr; i += 4 {
  ips = append(ips, net.IP(rr.OptionData[i:i+4]))
 }

 return &RecordRouteOption{
  IPv4Option: rr,
  IPs:        ips,
 }
}

func (rr RecordRouteOption) IPString(prefix, suffix string) string {
 var ips []string
 for _, ip := range rr.IPs {
  ips = append(ips, prefix+ip.String()+suffix)
 }

 return strings.Join(ips, "")
}

解析 option 的代碼已經完成,接下來的工作就是在發送 icmp Echo 請求的時候,把 RR 設置上。並且有幸收到回包的話,你需要把 RR 中的路由列表打印出來。

實現帶 RR 功能的 ping

和先前我們實現的 ping 工具不同,我們需要再深入一下。因爲先前的 ping 實現我們直接發送 ICMP 的包,不需要 IPv4 這一層,現在既然我們需要設置 IPv4 的 option, 我們就得手工構造 IPv4 的包,發送 IPv4 的包有多重途徑,我本來在這個系列中就要講如何發送 IPv4 的包,因爲插入了這一篇介紹 RR 的文章,所以在這篇文章我就介紹其中一種方式,其他方式請關注鳥窩聊技術微信公衆號。

我們可以使用pc, err := ipv4.NewRawConn(conn),把net.PacketConn轉換成 *ipvr.RawConn ,就可以發送和接收帶 IPv4 header 的包了。

重點是構造 IP header, 設置 RR 選項最多支持 9 個路由 (32 byte + ptr), ptr 的初始值設置爲 4:

 ip := layers.IPv4{
  Version:  4,
  SrcIP:    net.ParseIP(local),
  DstIP:    dst.IP,
  Protocol: layers.IPProtocolICMPv4,
  TTL:      64,
  Flags:    layers.IPv4DontFragment,
 }

 // 準備 Record Route 選項
 recordRouteOption := layers.IPv4Option{
  OptionType:   0x07,
  OptionLength: 39,
  OptionData:   make([]byte, 37),
 }
 recordRouteOption.OptionData[0] = 4

 // 添加選項
 ip.Options = append(ip.Options, layers.IPv4Option{OptionType: 0x01}, recordRouteOption)

我們使用下面的語句收取回包數據:

iph, payload, _, err := pc.ReadFrom(reply)

它可以讀取 ip header 以及 payload 數據 (ICMP 回包)。

如果檢查收到的數據是對應的 icmp 回包,我們就可以使用一開始我們準備的解析數據,解析出 option:

                opts, _ := parseOptions(iph.Options)

    for _, opt := range opts {
     if opt.OptionType != 0x07 {
      continue
     }

     rr := parseRecordRouteOption(opt)
     if rr != nil {
      fmt.Println("\nRR:" + rr.IPString("\t""\n"))
     }
    }

完整的代碼如下:

package main

import (
 "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
)

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)
 }

 local := localAddr()

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

 pc, err := ipv4.NewRawConn(conn)
 if err != nil {
  log.Fatal(err)
 }

 // 構造 IP 層
 ip := layers.IPv4{
  Version:  4,
  SrcIP:    net.ParseIP(local),
  DstIP:    dst.IP,
  Protocol: layers.IPProtocolICMPv4,
  TTL:      64,
  Flags:    layers.IPv4DontFragment,
 }

 // 準備 Record Route 選項
 recordRouteOption := layers.IPv4Option{
  OptionType:   0x07,
  OptionLength: 39,
  OptionData:   make([]byte, 37),
 }
 recordRouteOption.OptionData[0] = 4

 // 添加選項
 ip.Options = append(ip.Options, layers.IPv4Option{OptionType: 0x01}, recordRouteOption)

 // 構造 ICMP 層
 icmpLayer := layers.ICMPv4{
  TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0),
  Id:       uint16(os.Getpid() & 0xffff),
  Seq:      1,
 }
 // 添加 ICMP 數據
 payload := []byte("ping!")

 // 封裝到 gopacket.Packet
 buf := gopacket.NewSerializeBuffer()
 opts := gopacket.SerializeOptions{
  ComputeChecksums: true,
  FixLengths:       true,
 }
 err = gopacket.SerializeLayers(buf, opts, &ip, &icmpLayer, gopacket.Payload(payload))
 if err != nil {
  panic(err)
 }

 // 發送 ICMP 報文
 start := time.Now()
 _, err = pc.WriteToIP(buf.Bytes(), 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)
  }
  iph, payload, _, err := pc.ReadFrom(reply)
  if err != nil {
   log.Fatal(err)
  }
  duration := time.Since(start)

  // 解析 ICMP 報文
  msg, err := icmp.ParseMessage(protocolICMP, payload)
  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 iph.Src.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)

    opts, _ := parseOptions(iph.Options)

    for _, opt := range opts {
     if opt.OptionType != 0x07 {
      continue
     }

     rr := parseRecordRouteOption(opt)
     if rr != nil {
      fmt.Println("\nRR:" + rr.IPString("\t""\n"))
     }
    }

    return
   }
  default:
   fmt.Printf("unexpected ICMP message type: %v\n", msg.Type)
  }
 }
}

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 && ipNet.IP.To4()[0] != 192 {
    return ipNet.IP.String()
   }
  }
 }

 panic("no local IP address found")
}

測試一下, 可以從回包中收到 RR 信息:

參考資料

[1]

使用 Go 實現 ping 工具: https://colobu.com/2023/04/26/write-the-ping-tool-in-Go/

[2]

rfc791: https://www.rfc-editor.org/rfc/rfc791

[3]

The record route option is an option!: https://dl.acm.org/doi/10.1145/3131365.3131392

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