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给摸清楚了。

这里有张图,或许可以方便理解

golang版的traceroute实现

抓包分析

好了,原理分析讲完了,下面来运行tracert并抓包分析来验证一下我的观点。

首先先打开wireshark,然后运行tracert (tracert www.baidu.com),当然你会在wireshark上面看到一堆密密麻麻的数据包,所以需要过滤一下,在绿色的选框那里输入icmp即可,因为只有icmp数据包才是我们想要的,你会看到类似输出:

golang版的traceroute实现

我已经分别用红色和蓝色的框标记起来了,可以看到,tracert连续发送了3条TTL为1的ICMP报文 (红色框)到目的地,然后收到下一跳路由的ICMP报文(蓝色框),内容为TTL超时。

然后tracert继续发送三个TTL2的ICMP报文到目的地:

golang版的traceroute实现

还是收到同样的答复,TTL超时

就这样,每发送完一轮后,TTL加1,直到收到目的地的回复才停止,如图(我用蓝框标记出来了):

golang版的traceroute实现

看来我不是瞎猜的,上面的就是证据。
既然跟我们预料中的一样,那接下来是不是可以写代码了?别急,还差一步,就是我们刚才只分析tracert发送的过程,只是一个大致的过程。但在写代码的时候,"差不多"是不行的,你需要精确地知道报文的格式和里面的参数才可以。

比如要发送ICMP报文到目的地时,ICMP的报文中的type要改8,code要改为0,代表的是回显。如图:

golang版的traceroute实现

如果你熟悉ICMP报文的话,你会发现traceroute本质上就是一个ping,区别只是在于修改了一下IP首部的TTL字段而已

然后你会收到type为11,code为0的ICMP回复,代表TTL超时

golang版的traceroute实现

或者如果到达了目的地,会收到type为0,code为0的回复。代表Echo Reply。就跟你平时ping某台主机后所得到的回复是一样的

golang版的traceroute实现

关于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

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

相关推荐