之前看<<TCP/IP詳解卷一>>的時候,發現能夠根據IP報文中的TTL字段追蹤數據包的路由詳情,以爲頗有意思。後來知作別人早就把它實現出來了,就是linux下的traceroute命令(windows 的tracert),學了golang後也想實現一個go版本的,但中間都給種種事情耽擱了,最近把工做辭了,恰好有點時間,就想着把它作出來,順便看成我的項目去面試。linux
在分析traceroute以前,先介紹一下它的應用場景。不知道大家有沒有遇到過這樣狀況,就是買了個國外的服務器,用ssh鏈接的時候發現很慢,而後你就會忍不住ping一下看延遲多少,若是出來300的延遲你會忍不住吐槽一句:什麼破服務器,延遲這麼高。而後你確定想知道緣由,爲何這破服務器這麼卡。git
而這時候traceroute就能夠派上用場了,你用traceroute測一下就知道,它會能夠追蹤數據包的路由詳情,能夠知道從你的電腦到服務器之間通過了多少跳的路由,若是是數據包通過不少跳路由最終纔到服務器,天然就很卡。github
下面我用 vultr.com域名測試,先ping一下golang
Pinging vultr.com [108.61.13.174] with 32 bytes of data: Reply from 108.61.13.174: bytes=32 time=234ms TTL=50 Reply from 108.61.13.174: bytes=32 time=233ms TTL=49 Reply from 108.61.13.174: bytes=32 time=247ms TTL=49 Reply from 108.61.13.174: bytes=32 time=233ms TTL=49 Ping statistics for 108.61.13.174: Packets: Sent = 4, Received = 4, Lost = 0 (0% loss), Approximate round trip times in milli-seconds: Minimum = 233ms, Maximum = 247ms, Average = 236ms
200多的延遲,而後咱們再用tracert(windows下的traceroute)測一下:面試
Tracing route to vultr.com [108.61.13.174] over a maximum of 30 hops: 1 1 ms 2 ms 2 ms 192.168.0.1 [192.168.0.1] 2 2 ms 1 ms 1 ms 192.168.1.1 [192.168.1.1] 3 4 ms 3 ms 3 ms xxx.xx.xx.x 4 15 ms 40 ms 4 ms xxx.xx.xx.xx 5 8 ms 17 ms 18 ms xxx.xx.xx.xx 6 9 ms 7 ms 7 ms 202.97.90.162 廣州 7 17 ms 17 ms 16 ms 202.97.38.166 昆明 8 185 ms 192 ms 184 ms 202.97.51.94 上海 9 164 ms 167 ms 165 ms 202.97.90.118 10 191 ms 170 ms 183 ms 9-1-9.ear1.LosAngeles1.Level3.net [4.78.200.1] 11 * * * Request timed out. 12 235 ms 239 ms 247 ms 214.213.15.4.in-addr.arpa [4.15.213.214] 13 * * * Request timed out. 14 * * * Request timed out. 15 246 ms 248 ms 237 ms 174.13.61.108.in-addr.arpa [108.61.13.174]
能夠看到通過了15跳的路由,若是你分別查一下這些ip對應地方,會發現它從廣州繞到昆明,再繞到上海最後纔去了美國,繞了中國大半圈,延遲不高才怪呢。算法
下面來分析一下traceroute背後的原理,首先先介紹一個數據包在傳輸過程當中的一個特性,就是IP報文首部的TTL字段在每通過一跳路由的時候,TTL的值都會給路由器減1。就這樣每通過一跳路由就減1,當TTL的值減到0的時候,路由器將再也不轉發這個數據包,而是將其丟棄,然會返回一個ICMP報文到信源端。shell
這個特性有什麼用呢?你想啊,若是我手動把數據包TTL的值設爲1,發給目的地,而後IP數據報到下一條路由的時候就給丟棄了,並且還會收到下一跳路由的ICMP報文(裏面有該路由器的IP)。而後我再把TTL的值設爲2,數據包在第二條路由的時候又給丟棄了,又返回第二跳路由的ICMP報文,這樣我又能夠知道第二跳路由的IP了。就這樣經過投石問路的方式,不停地給目的地發送數據報,直到數據報到達目的地,就能夠把每一跳路由的IP給摸清楚了。windows
這裏有張圖,或許能夠方便理解服務器
好了,原理分析講完了,下面來運行tracert並抓包分析來驗證一下個人觀點。ssh
首先先打開wireshark,而後運行tracert (tracert www.baidu.com),固然你會在wireshark上面看到一堆密密麻麻的數據包,因此須要過濾一下,在綠色的選框那裏輸入icmp便可,由於只有icmp數據包纔是咱們想要的,你會看到相似輸出:
我已經分別用紅色和藍色的框標記起來了,能夠看到,tracert連續發送了3條TTL爲1的ICMP報文 (紅色框)到目的地,而後收到下一跳路由的ICMP報文(藍色框),內容爲TTL超時。
而後tracert繼續發送三個TTL2的ICMP報文到目的地:
仍是收到一樣的答覆,TTL超時
就這樣,每發送完一輪後,TTL加1,直到收到目的地的回覆才中止,如圖(我用藍框標記出來了):
看來我不是瞎猜的,上面的就是證據。
既然跟咱們預料中的同樣,那接下來是否是能夠寫代碼了?別急,還差一步,就是咱們剛纔只分析tracert發送的過程,只是一個大體的過程。但在寫代碼的時候,"差很少"是不行的,你須要精確地知道報文的格式和裏面的參數才能夠。
好比要發送ICMP報文到目的地時,ICMP的報文中的type要改8,code要改成0,表明的是回顯。如圖:
若是你熟悉ICMP報文的話,你會發現traceroute本質上就是一個ping,區別只是在於修改了一下IP首部的TTL字段而已
而後你會收到type爲11,code爲0的ICMP回覆,表明TTL超時
或者若是到達了目的地,會收到type爲0,code爲0的回覆。表明Echo Reply。就跟你平時ping某臺主機後所獲得的回覆是同樣的
traceroute本質上就是一個ping,只是修改了一下IP首部的TTL字段而已,我一開始覺得是件很簡單的事,可是實現過程一波三折。
我一開始先google一下,看有沒有人已經實現過golang版的traceroute了,免得我處處查API。結果然的有,點這裏
我滿懷好奇地點了進去看了下源碼,看思路是否和我是同樣的,而後發現他用的syscall這個庫來建立socket,不禁自主地感嘆了這老哥的強悍。syscall是在系統提供給的API上封裝的,這麼底層的東西,須要對底層有足夠的瞭解才能駕馭。
看了一會,而後把代碼複製下來跑一下,發現報了這個錯:
..\traceroute.go:198:72: undefined: syscall.IPPROTO_ICMP ..\traceroute.go:211:61: undefined: syscall.SO_RCVTIMEO
就是windows不支持這個系統調用,而後我看了一下項目的README,纔看注意到:Must be run as sudo on OS X
並且也有個在windows上開發的人也遇到一樣的問題,做者表示無能爲力,或者是懶得弄,在這個issue裏
而後想着既然做者用syscall實現的版本沒法在windows上運行,那我乾脆本身實現一個好了,而後我就去官網的標準庫查API,可是看了發現標準庫提供的函數不支持修改IP首部的TTL
而後我又google了一下,發現官方提供的 golang.org/x/net/ipv4的包居然支持修改TTL,我滿心歡喜地安裝了這個包,可是在實現過程當中發現,這個包的某些函數也是不支持windows的,若是你查看他的源碼會發現,他尚未實現,只是在裏面寫了個TODO標籤。別人也遇到一樣的問題,並提交到這個issue裏
我本覺得修改TTL只是查一下標準庫函數就能搞定了,沒想到不只標準庫不支持,並且官方提供的包和封裝底層系統調用的syscall都不支持windows,這時候我彷佛知道他們都用linux的緣由了,並且這種平臺的差別性已經不是我能搞定的了。應該仍是有辦法的,但我如今也不打算花時間糾結這個了,本着實現一個linux版本的好了的心態,打算動手開幹。
可是我發現官方提供的demo裏就有traceroute的實現,並且寫得還很精緻,既然官方例子已經實現出來了,我就沒有必要再去折騰了。
我看了一下源碼,思路跟的我差很少。怎麼說呢,我以爲到這裏,我也算是把traceroute給實現出來了把,雖然我不是去查API從零開始實現的。
下面我摘抄一部分核心代碼並分析以下:
好比ICMP報文的封裝:
wm := icmp.Message{ Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ ID: os.Getpid() & 0xffff, Data: []byte("HELLO-R-U-THERE"), }, }
echo的ICMP的報文格式應該是type:0,code:0,可是他已經定義並封裝好了。
還有這裏的ID是進程號,用於區分不一樣的程序,由於這個字段在報文中是16位的,因此和0xffff作了與運算
wm.Body.(*icmp.Echo).Seq = i
這裏是ICMP報文中的序列號,用於區分發送的第幾個ICMP數據報
if err := p.SetTTL(i); err != nil { log.Fatal(err) }
這裏是設置每次發送的TTL,封裝得太完全了,一行就搞定
switch rm.Type { case ipv4.ICMPTypeTimeExceeded: names, _ := net.LookupAddr(peer.String()) fmt.Printf("%d\t%v %+v %v\n\t%+v\n", i, peer, names, rtt, cm) case ipv4.ICMPTypeEchoReply: names, _ := net.LookupAddr(peer.String()) fmt.Printf("%d\t%v %+v %v\n\t%+v\n", i, peer, names, rtt, cm) return default: log.Printf("unknown ICMP message: %+v\n", rm) }
最後是根據這段代碼來判斷數據報是否已經到目的地的,能夠看到若是收到的是TTL超時報文會繼續發送,若是收到的是正常的回顯,則說明已經到達目的地,函數退出。
因爲這個庫封裝了底層的一些東西,好比不用考慮ICMP校驗和字段,IP首部校驗和算法的實現,因此實現起來代碼量很少,包註釋也就100行
完整的代碼以下:
package main import ( "fmt" "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "log" "net" "os" "time" ) func main() { // Tracing an IP packet route to www.baidu.com. const host = "www.baidu.com" ips, err := net.LookupIP(host) if err != nil { log.Fatal(err) } var dst net.IPAddr for _, ip := range ips { if ip.To4() != nil { dst.IP = ip fmt.Printf("using %v for tracing an IP packet route to %s\n", dst.IP, host) break } } if dst.IP == nil { log.Fatal("no A record found") } c, err := net.ListenPacket("ip4:1", "0.0.0.0") // ICMP for IPv4 if err != nil { log.Fatal(err) } defer c.Close() p := ipv4.NewPacketConn(c) if err := p.SetControlMessage(ipv4.FlagTTL|ipv4.FlagSrc|ipv4.FlagDst|ipv4.FlagInterface, true); err != nil { log.Fatal(err) } wm := icmp.Message{ Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ ID: os.Getpid() & 0xffff, Data: []byte("HELLO-R-U-THERE"), }, } rb := make([]byte, 1500) for i := 1; i <= 64; i++ { // up to 64 hops wm.Body.(*icmp.Echo).Seq = i wb, err := wm.Marshal(nil) if err != nil { log.Fatal(err) } if err := p.SetTTL(i); err != nil { log.Fatal(err) } // In the real world usually there are several // multiple traffic-engineered paths for each hop. // You may need to probe a few times to each hop. begin := time.Now() if _, err := p.WriteTo(wb, nil, &dst); err != nil { log.Fatal(err) } if err := p.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { log.Fatal(err) } n, cm, peer, err := p.ReadFrom(rb) if err != nil { if err, ok := err.(net.Error); ok && err.Timeout() { fmt.Printf("%v\t*\n", i) continue } log.Fatal(err) } rm, err := icmp.ParseMessage(1, rb[:n]) if err != nil { log.Fatal(err) } rtt := time.Since(begin) // In the real world you need to determine whether the // received message is yours using ControlMessage.Src, // ControlMessage.Dst, icmp.Echo.ID and icmp.Echo.Seq. switch rm.Type { case ipv4.ICMPTypeTimeExceeded: names, _ := net.LookupAddr(peer.String()) fmt.Printf("%d\t%v %+v %v\n\t%+v\n", i, peer, names, rtt, cm) case ipv4.ICMPTypeEchoReply: names, _ := net.LookupAddr(peer.String()) fmt.Printf("%d\t%v %+v %v\n\t%+v\n", i, peer, names, rtt, cm) return default: log.Printf("unknown ICMP message: %+v\n", rm) } } }