前言
之前看<<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某台主机后所获得的回复是同样的
关于ICMP报文格式,能够参考wiki 或者百度也行
具体实现
实现过程
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
Must be run as sudo on OS X
而后想着既然做者用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) } } }