前言

之前看<<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

这里有张图,或许能够方便理解服务器

d1900102224993c8.png

抓包分析

好了,原理分析讲完了,下面来运行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)
        }
    }
}