写了一个叫 address-book 的玩具项目

在实现 Packer Plugin 的时候,遇到了一个头疼的问题,就是一个新装好的 VM,如何感知它的 IP 地址呢?

通常虚拟化软件都会通过在虚拟机中安装的 VM Tools 来进行 IP 的获取,但是各种 Linux 发行版官方镜像又不会携带这些东西,要安装 VM Tools 又最好能够通过 IP SSH 上去,这就成了一个死结 。

那么在 VM 之外有没有办法能够感知到它的 IP 呢?答案是肯定的。

实现思路

通常情况下,一台虚拟机加入到一个网络中,它需要一个 IP 地址,这个地址通常有两个来源:

  • 静态地址
  • DHCP

静态地址就是用户或软件直接设置 IP 地址、子网掩码、网关等配置,写什么就是什么。如果因为写错了或者 IP 别人已经占用了,就无法正常访问网络。

DHCP(动态主机设置协议)则是通过虚拟机与外部的 DHCP 服务器交互,申领一个 IP 地址来使用。这中间便是通过 DHCP 协议来进行沟通交流,获取 IP、网关等配置的。

不难看出如果一个 VM 通过 DHCP 协议获取 IP 地址,那么它就会在网络中有活动了,我们如果是网络中的一员,那么就必然能够有所觉察了。

从 DHCP 数据包中解析 IP

既然用到 DHCP,那就得先了解 DHCP,了解一次 DHCP 是如何完成的。DHCP 完整的步骤分为四步

  1. DISCOVER:客户端(我们的虚机)发送一个 IP 地址为「255.255.255.255」的 UDP 数据包,也就是向全网广播:我,新来的,想要一个 IP 地址。
  2. OFFER:DHCP 服务器收到了这个广播的消息后,会发送 OFFER 数据包,就是说:刚刚吆喝那小子,你就用这个 IP、子网掩码、网关配置吧。
  3. REQUEST:客户端收到 OFFER 之后,还需要确认一下,顺便告诉其它的 DHCP 服务器:你们不用等我了,我已经接了某某某的 OFFER 了。
  4. ACK:最后 DHCP 服务器会反馈:我也知道了,再给你发一遍,你就用这个地址了。

至此 VM 就配置好了 IP。当然实际场景下,可能一个 VM 原本就使用过 IP,可以只发送 REQUEST 请求续租 IP。

了解了这个流程就很简单了,我么可以在收到 OFFER 数据包的时候,判断 dst MAC 地址是不是 VM 的 MAC 地址,是的话就可以解析数据包中所携带的 IP 信息了。当然这时候 IP 地址还没有完全敲定,我们还可以等一等 ACK 的数据包,也是判断是不是发到我们期望的 MAC 地址,如果是,就从 UDP Payload 中解析 IP 数据了。

通常来说,一台计算机拿到 IP 地址之后,就得全网嘚瑟一下,那接下来就会发 ARP(地址解析协议)了。

从 ARP 数据包中解析 IP

我们通常基于 IP 协议的数据传输使用的都是 IP 地址,但是在同一网络内,两台主机通过 IP 地址进行互相通信的时候,需要知晓目标 IP 的物理地址(MAC 地址),系统会检测本地的 ARP 表,看下有没有这个 IP,如果没有的话就会向网络中广播:谁有 192.168.5.10 这个 IP 呀,回我一下。设置了 192.168.5.10 这个 IP 的机器收到后,就会回复一条:是我了,没错。

那么我们就需要关注:

  • 任何一个 ARP 包都可以从 L2 层得到发包的 MAC 地址,并在 ARP 协议层找到 Source IP
  • Reply 类型的数据包就可以从 ARP 协议层找到回应者的 IP 与 MAC 地址,同时也包含接受者的 MAC 与 IP 地址。

通过对得到的 IP/MAC 地址进行匹配,就可以得到目标虚拟机的 IP 地址了。

编写程序

流量抓包,肯定离不开 libpcap 这个库了,这里使用 Golang 开发,因此核心用到的是 github.com/google/gopacket 这个包。

首先需要获取创建 Handle,并在得到 handle 实例后设置 Filter,我们只关心 UDP 67 68 端口(DHCP 协议使用的端口)与 ARP 协议。下面的代码还顺带过滤了 ICMP 协议(Ping 命令使用的协议),不过实际没有用到相关的数据包。

1
2
3
4
5
6
7
8
9
10
11
12
13
func NewCapture(db *Database, params CaptureParams) (*Capture, error) {
handle, err := pcap.OpenLive(params.Device, params.SnapshotLength, params.Promiscuous, params.Timeout)
if err != nil {
return nil, err
}

err = handle.SetBPFFilter("(udp and (port 67 or port 68)) or arp or (icmp and (icmp[icmptype] == 8 or icmp[icmptype] == 0))")
if err != nil {
return nil, err
}

return &Capture{Handle: handle, db: db}, nil
}

创建 Handle 之后,就可以使用 Handle 创建一个 PacketSource,我们从 source 中不断读取收到的数据包。

1
2
3
4
5
6
7
func (c *Capture) Run() {
source := gopacket.NewPacketSource(c.Handle, c.Handle.LinkType())
for packet := range source.Packets() {
addresses := c.getAddresses(packet)
// ...
}
}

具体处理数据包解析出实际的 IP/MAC 地址信息的就是 getAddress() 方法:

1
2
3
4
5
6
7
8
9
10
11
func (c *Capture) getAddresses(packet gopacket.Packet) []Address {
if arpLayer := packet.Layer(layers.LayerTypeARP); arpLayer != nil {
return c.getAddressFromARPPacket(arpLayer)
}

if dhcpLayer := packet.Layer(layers.LayerTypeDHCPv4); dhcpLayer != nil {
return c.getAddressFromDHCPPacket(packet)
}

return []Address{}
}

我们通过 packet.Layer() 方法来判断当前数据包是否为某个协议的数据包,然后只处理 ARP 与 DHCP 的数据包。

最后就按照上面的描述进行地址的解析:

小结

除了上面的核心代码,还会将所有捕获到的 IP/MAC 地址记录在内存数据库中,以供查询。因此程序提供了两个接口,可以用来根据 IP 查 MAC,也可以根据 MAC 查 IP,还可以列出当前记录的所有的地址记录,就像一个通讯录。

有了这个服务之后,Packer 就有一定办法可以获取到新建 VM 的 IP 地址了,不过还有一点局限性就是可能会有一些主机,即使加入了网络,也是一声不吭,完全自闭。这种情况就先不考虑了。