golang版的traceroute实现
前言
以前看<<TCP/IP详解卷一>>的时候,发现可以根据IP报文中的TTL字段追踪数据包的路由详情,觉得很有意思。后来知道别人早就把它实现出来了,就是linux下的traceroute命令(windows 的tracert),学了golang后也想实现一个go版本的,但中间都给种种事情耽搁了,最近把工作辞了,刚好有点时间,就想着把它做出来,顺便当作个人项目去面试。
应用场景
在分析traceroute之前,先介绍一下它的应用场景。不知道你们有没有遇到过这样情况,就是买了个国外的服务器,用ssh连接的时候发现很慢,然后你就会忍不住ping一下看延迟多少,如果出来300的延迟你会忍不住吐槽一句:什么破服务器,延迟这么高。然后你肯定想知道原因,为什么这破服务器这么卡。
而这时候traceroute就可以派上用场了,你用traceroute测一下就知道,它会可以追踪数据包的路由详情,可以知道从你的电脑到服务器之间经过了多少跳的路由,如果是数据包经过很多跳路由最终才到服务器,自然就很卡。
下面我用 vultr.com域名测试,先ping一下
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 183.17.228.1 4 15 ms 40 ms 4 ms 153.106.38.59.broad.fs.gd.dynamic.163data.com.cn [59.38.106.153] 5 8 ms 17 ms 18 ms 183.56.65.14 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报文到信源端。
这个特性有什么用呢?你想啊,如果我手动把数据包TTL的值设为1,发给目的地,然后IP数据报到下一条路由的时候就给丢弃了,而且还会收到下一跳路由的ICMP报文(里面有该路由器的IP)。然后我再把TTL的值设为2,数据包在第二条路由的时候又给丢弃了,又返回第二跳路由的ICMP报文,这样我又可以知道第二跳路由的IP了。就这样通过投石问路的方式,不停地给目的地发送数据报,直到数据报到达目的地,就可以把每一跳路由的IP给摸清楚了。
这里有张图,或许可以方便理解
抓包分析
好了,原理分析讲完了,下面来运行tracert并抓包分析来验证一下我的观点。
首先先打开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) } } }