Linux 网络设备 - TUN/TAP

我们今天学习一下 Linux 上常见的网络设备 TUN/TAP,这两个设备经常被放到一起讨论,因为它们的功能其实非常类似,都是 Linux 内核提供的虚拟网络设备,就像一块真实的网卡,但它的一端连着操作系统协议栈,另一端连着用户空间的程序。如下图所示:
TUN/TAP

我们假设:右侧的应用程序代表 ping 命令,左侧的应用程序代表绑定了 TUN/TAP 设备的用户空间程序。
右侧的应用进程想尝试执行ping 172.1.1.100,那经过 ping 命令构建的 ICMP 的请求包会通过调用 Socket API 将数据发送到内核的 TCP/IP 协议栈,此时协议栈会查询系统路由表,根据路由策略来决定 ICMP 请求包会投递到哪张网卡,如果:

  • 被投递到物理网卡,那此时包会发往 Remote PC;
  • 被投递到 TUN/TAP 虚拟网卡,那此时包会发往绑定该 TUN/TAP 设备的用户空间程序;

当 ping 命令发送完请求在等待 ICMP 回包时:

  • Remote PC 接收到 ICMP 的请求,构造 ICMP 响应,通过物理网线发送回 Local PC的物理网卡,并投递到内核协议栈;
  • 用户空间程序接受到 ICMP 的请求,构造 ICMP 响应,通过 TUN/TAP 设备投递回内核协议栈;

所以,逻辑上来说,TUN/TAP 设备类似一块真实的物理网卡,而绑定 TUN/TAP 设备的用户空间程序则类似一台仅处理网络数据包的 Remote PC。

接下来,我们来编写用户空间程序,验证下刚才的分析是否正确,也顺便看一下 TUN 和 TAP 设备两者的区别:

  1. 分别创建 TUN 和 TAP 设备,为设备绑定 IP 10.1.1.100/24
  2. 绑定 TUN/TAP 设备,接收 ICMP 的请求并回包;

我们的实验环境是Ubuntu22.04,编程语言使用的golang-1.20,为了方便讲解,文章里没有贴完整的代码,需要的可以在 Github 自取:TUN/TAP

TAP 设备

我们先来创建一个名为tap0的 TAP 设备,这里使用的是github.com/songgao/water,API 可以参考官方文档:

config := water.Config{
  DeviceType: water.TAP,
  PlatformSpecificParams: water.PlatformSpecificParams{
    Name: "tap0",
  },
}
iface, _ := water.New(config)

这段代码执行完成后,tap0设备就创建成功并绑定到我们的用户空间程序,从详细信息里可以看到tun type tap,代表这个设备是 TAP 类型,符合预期。此外,我们应该注意到tap0设备是包含 MAC 地址的 ba:6b:1b:72:62:45,这表示它可以在二层工作(后面介绍的 TUN 设备仅工作在三层,没有 MAC 地址)。

$ ip -d link show dev tap0
4: tap0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether ba:6b:1b:72:62:45 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65521
    tun type tap pi off vnet_hdr off persist off addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535

我们给tap0设备绑定 IP 10.1.1.100,并且将其设置为启用状态:

cmd := exec.Command("ip", "addr", "add", "10.1.1.100/24", "dev", ifaceName)
_ = cmd.Run()

cmd = exec.Command("ip", "link", "set", "dev", "tap0", "up")
_ = cmd.Run()

再查看一下tap0设备,已经绑定 IP,并且处于 UP 状态:

ip a
...
10: tap0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 1000
    link/ether ba:6b:1b:72:62:45 brd ff:ff:ff:ff:ff:ff
    inet 10.1.1.100/24 scope global tap0
       valid_lft forever preferred_lft forever
    inet6 fe80::b86b:1bff:fe72:6245/64 scope link
       valid_lft forever preferred_lft forever

当我们为tap0绑定 IP 并启用时,内核会自动生成指向该设备的路由,我们查看一下路由表:

$ ip route list
default via 192.168.31.1 dev enp1s0 proto dhcp metric 100
10.1.1.0/24 dev tap0 proto kernel scope link src 10.1.1.100
169.254.0.0/16 dev enp1s0 scope link metric 1000
192.168.31.0/24 dev enp1s0 proto kernel scope link src 192.168.31.92 metric 100

里面多了一条10.1.1.0/24 dev tap0 proto kernel scope link src 10.1.1.100规则,我们来分析一下重点信息:

  • proto kernel代表该规则是内核根据网络配置自动生成的;
  • 10.1.1.0/24 dev tap0代表匹配该网段的数据包将会通过tap0设备传输;
  • src 10.1.1.100代表数据包的源 IP 会被设置为该 IP,同时我们应该也能推断出,源 MAC 会被设置为tap0设备的 MAC ba:6b:1b:72:62:45

OK,到这里我们再梳理一下流程,现在用户空间的程序已经有了(就是我们正在编写的创建并绑定 TAP 设备的程序),tap0 TAP 设备有了,路由规则有了,如果现在通过 ping 命令来执行ping 10.1.1.200(与路由规则同网段,会投递到tap0设备),那用户空间程序应该能接收到数据包?
我们来试一下,先调整下程序,打印出接收到的数据包:

buffer := make([]byte, 1500)
for {
  n, err := iface.Read(buffer)
  if err != nil {
    log.Printf("iface read failed: %v", err)
    continue
  }

  printPacketInHex("Received: ", buffer[:n])
}

好,我们来尝试下执行ping 10.1.1.200

# 执行 ping 命令
$ ping -c1 -i1 10.1.1.200
PING 10.1.1.101 (10.1.1.101) 56(84) bytes of data.
From 10.1.1.100 icmp_seq=1 Destination Host Unreachable
--- 10.1.1.101 ping statistics ---
1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms

# 用户空间程序日志
$ ./tap
2023-10-10 10:19:34: Received: ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 65
2023-10-10 10:19:35: Received: ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 65
2023-10-10 10:19:36: Received: ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 65
...

成功了?100% packet loss告诉我们 ping 命令没有响应,但是,我们同时也注意到在用户空间程序的日志中,打印出了我们接收到的数据包,说明至少数据包经过路由匹配后被发送到tap0设备,进而转发到连接tap0设备的用户空间程序,按照正常思路,只要我们接收到数据包并且按照规范处理,那 ping 命令收到响应只是迟早的事情。

好,我们分析下这个数据包:

ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 65

我们知道 TAP 设备工作在二层(还记得吗,之间看到该设备是有 MAC 地址的),所以至少可以确定该数据包是一个以太网帧。以太网帧的前 12 个字节是目标 MAC 地址ff ff ff ff ff ff和源 MAC 地址ba 6b 1b 72 62 45,之后的两个字节是以太网类型,我们查一下,08 06代表的是 ARP。按照 ARP 的格式,我们解析一下这个数据包:

原始字节含义
FF FF FF FF FF FF目标MAC地址
BA 6B 1B 72 62 45源MAC地址
08 06以太网类型(ARP)
00 01硬件类型(以太网)
08 00协议类型(IPv4)
06硬件地址长度(6字节)
04协议地址长度(4字节)
00 01操作码(ARP请求)
BA 6B 1B 72 62 45发送方MAC地址
0A 01 01 64发送方IP地址(10.1.1.100)
00 00 00 00 00 00目标MAC地址(未知)
0A 01 01 C8目标IP地址(10.1.1.200)

合理,对吧?在执行ping 10.1.1.200的时候,显然 ARP 表中还没有10.1.1.200的条目,所以需要发送 ARP 广播去问询,所以我们的用户空间程序里会收到大量的 ARP 的请求包。同时,我们可以看到发送方的 MAC 地址 和 IP 地址,对应就是tap0设备的信息,目标 IP 地址是我们想 ping 通的地址,而目标 MAC 地址未知,是因为在等待我们用户空间程序的响应!

好,到这里,我们的思路就比较清晰了,我们捋一下接下来的实现:

  • ping 10.1.1.200执行时 ARP 表中没有该 IP 的条目,所以会广播 ARP 的请求包,用户空间程序在接收到该请求包后,需要发送 ARP 响应;
  • ARP 条目写入后,用户空间程序会接收到 ICMP 请求,然后发送 ICMP 响应;

我们先处理 ARP 请求,这里使用的是 gopacket,具体用法可以参考官网:

sourceMACAddr, _ := net.ParseMAC("00:00:00:00:00:01")
sourceIPAddr := net.ParseIP("10.1.1.200")

arpReply := &layers.ARP{
  AddrType:          layers.LinkTypeEthernet,
  Protocol:          layers.EthernetTypeIPv4,
  HwAddressSize:     6,
  ProtAddressSize:   4,
  Operation:         layers.ARPReply,
  SourceHwAddress:   sourceMACAddr,
  SourceProtAddress: sourceIPAddr.To4(),
  DstHwAddress:      arpRequest.SourceHwAddress,
  DstProtAddress:    arpRequest.SourceProtAddress,
}
ethernetLayer := &layers.Ethernet{
  SrcMAC:       sourceMACAddr,
  DstMAC:       arpRequest.SourceHwAddress,
  EthernetType: layers.EthernetTypeARP,
}

frame := gopacket.NewSerializeBuffer()
gopacket.SerializeLayers(frame, gopacket.SerializeOptions{}, ethernetLayer, arpReply)

_, err = iface.Write(frame.Bytes())

这时候我们再执行执行ping 10.1.1.200,可以看到 ping 依然不通,但是 ARP 已经正常,并且终于可以接受到 ICMP 请求了。

# 执行 ping 命令
$ ping -c1 -i1 10.1.1.200
PING 10.1.1.200 (10.1.1.200) 56(84) bytes of data.
--- 10.1.1.200 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 2023ms

# 查看 ARP
$ arp -a
? (10.1.1.200) at 00:00:00:00:00:01 [ether] on tap0

# 用户空间程序日志
$ ./tap
2023-10-10 10:57:11: ARP REQUEST: ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 c8
2023-10-10 10:57:11: ARP REPLY: ba 6b 1b 72 62 45 00 00 00 00 00 01 08 06 00 01 08 00 06 04 00 02 00 00 00 00 00 01 0a 01 01 c8 ba 6b 1b 72 62 45 0a 01 01 64
2023-10-10 10:57:11: ICMP REQUEST: 00 00 00 00 00 01 ba 6b 1b 72 62 45 08 00 45 00 00 54 08 e2 40 00 40 01 1a 9a 0a 01 01 64 0a 01 01 c8 08 00 81 73 00 11 00 01 87 bd 24 65 00 00 00 00 02 85 09 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

最后,我们再处理一下 ICMP 的响应即可:

icmpReplyPacket := &layers.ICMPv4{
  TypeCode: layers.ICMPv4TypeEchoReply,
  Id:       icmpPacket.Id,
  Seq:      icmpPacket.Seq,
}

ipPacket := packet.Layer(layers.LayerTypeIPv4).(*layers.IPv4)
ipPacket.DstIP, ipPacket.SrcIP = ipPacket.SrcIP, ipPacket.DstIP

ethernetPacket := packet.Layer(layers.LayerTypeEthernet).(*layers.Ethernet)
ethernetPacket.DstMAC, ethernetPacket.SrcMAC = ethernetPacket.SrcMAC, ethernetPacket.DstMAC

frame := gopacket.NewSerializeBuffer()
gopacket.SerializeLayers(frame, gopacket.SerializeOptions{
  FixLengths:       true,
  ComputeChecksums: true,
}, ethernetPacket, ipPacket, icmpReplyPacket, gopacket.Payload(icmpPacket.Payload))

_, err = iface.Write(frame.Bytes())

最后尝试一下ping -c1 -i1 10.1.1.200

# 执行 ping 命令
$ ping -c1 -i1 10.1.1.200
PING 10.1.1.200 (10.1.1.200) 56(84) bytes of data.
64 bytes from 10.1.1.200: icmp_seq=1 ttl=64 time=102 ms
--- 10.1.1.200 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms

# 用户空间程序日志
$ ./tap
2023-10-10 11:07:05: ARP REQUEST: ff ff ff ff ff ff ba 6b 1b 72 62 45 08 06 00 01 08 00 06 04 00 01 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 0a 01 01 c8
2023-10-10 11:07:05: ARP REPLY: ba 6b 1b 72 62 45 00 00 00 00 00 01 08 06 00 01 08 00 06 04 00 02 00 00 00 00 00 01 0a 01 01 c8 ba 6b 1b 72 62 45 0a 01 01 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
2023-10-10 11:07:05: ICMP REQUEST: 00 00 00 00 00 01 ba 6b 1b 72 62 45 08 00 45 00 00 54 d6 75 40 00 40 01 4d 06 0a 01 01 64 0a 01 01 c8 08 00 43 43 00 12 00 01 d9 bf 24 65 00 00 00 00 ea b1 0d 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37
2023-10-10 11:07:05: ICMP REPLY: ba 6b 1b 72 62 45 00 00 00 00 00 01 08 00 45 00 00 54 d6 75 40 00 40 01 4d 06 0a 01 01 c8 0a 01 01 64 00 00 4b 43 00 12 00 01 d9 bf 24 65 00 00 00 00 ea b1 0d 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

好了,终于能 ping 通了,可以看到虽然我们实际上并没有 IP 是10.1.1.200和 MAC
00:00:00:00:00:01的设备,但通过用户空间程序对数据包的处理,我们让 ping 感知到似乎真的有这个设备存在(TAP 设备广泛的用在云计算的虚拟机上,作为虚拟机的网卡存在,后续会讲解)。

TUN 设备

有了 TAP 设备的经验在前,TUN 设备就显得简单很多,首先还是创建 TUN 设备,并绑定 IP 等。

config := water.Config{
  DeviceType: water.TUN,
  PlatformSpecificParams: water.PlatformSpecificParams{
    Name: "tun0",
  },
}
iface, err := water.New(config)

...


cmd := exec.Command("ip", "addr", "add", "10.1.1.100/24", "dev", ifaceName)
err = cmd.Run()
if err != nil {
  return err
}

cmd = exec.Command("ip", "link", "set", "dev", "tun0", "up")
err = cmd.Run()
if err != nil {
  return err
}

执行完成后,可以看到对应的tun0设备,类型是tun type tun,以及路由表信息10.1.1.0/24 dev tun0 proto kernel scope link src 10.1.1.100

$ ip a
23: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none
    inet 10.1.1.100/24 scope global tun0
       valid_lft forever preferred_lft forever
    inet6 fe80::682f:c735:83b0:b3ce/64 scope link stable-privacy
       valid_lft forever preferred_lft forever

$ ip -d link show dev tun0
23: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN mode DEFAULT group default qlen 500
    link/none  promiscuity 0 minmtu 68 maxmtu 65535
    tun type tun pi off vnet_hdr off persist off addrgenmode random numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
    
$ ip route list
default via 192.168.31.1 dev enp1s0 proto dhcp metric 100
10.1.1.0/24 dev tun0 proto kernel scope link src 10.1.1.100
169.254.0.0/16 dev enp1s0 scope link metric 1000
192.168.31.0/24 dev enp1s0 proto kernel scope link src 192.168.31.92 metric 100

这里我们要注意到差别,tun0设备并没有 MAC 地址,但可以绑定 IP,也就是说它的定位是工作在三层,我们来验证一下:

buffer := make([]byte, 1500)
for {
  n, err := iface.Read(buffer)
  if err != nil {
    log.Printf("iface read failed: %v", err)
    continue
  }

  printPacketInHex("Received: ", buffer[:n])
}

好,我们来尝试下执行ping 10.1.1.200

# 执行 ping 命令
$ ping -c1 -i1 10.1.1.200
PING 10.1.1.101 (10.1.1.101) 56(84) bytes of data.
From 10.1.1.100 icmp_seq=1 Destination Host Unreachable
--- 10.1.1.101 ping statistics ---
1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms

# 用户空间程序日志
$ ./tun
2023-10-10 11:19:34: Received: 45 00 00 54 df 4e 40 00 40 01 44 2d 0a 01 01 64 0a 01 01 c8 08 00 d6 35 00 13 00 01 be c4 24 65 00 00 00 00 74 b9 0b 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37
...

我们将接收到的包跟 TAP 设备接收到的 ICMP 对比,可以看到 TUN 设备接收到的包明显已经没有了以太网帧头,它只处理三层及以上的数据,也是这个原因,它自然就不再需要处理 ARP 的请求,可以直接接收到 ICMP 请求。

# TAP 接收到的 ICMP 请求
00 00 00 00 00 01 # 目的 MAC
ba 6b 1b 72 62 45 # 源 MAC
08 00 # 以太网类型
45 00 00 54 d6 75 40 00 40 01 4d 06 0a 01 01 64 0a 01 01 c8 08 00 43 43 00 12 00 01 d9 bf 24 65 00 00 00 00 ea b1 0d 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

# TUN 接收到的 ICMP 请求
45 00 00 54 df 4e 40 00 40 01 44 2d 0a 01 01 64 0a 01 01 c8 08 00 d6 35 00 13 00 01 be c4 24 65 00 00 00 00 74 b9 0b 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

所以这样就简单了很多,我们只要添加对 ICMP 的响应处理程序即可,代码与 TAP 设备基本一致,我就不再贴出来了,添加完后再执行ping 10.1.1.200,就会发现 ping 命令正常响应了。

$ ping -c1 -i1 10.1.1.200
PING 10.1.1.200 (10.1.1.200) 56(84) bytes of data.
64 bytes from 10.1.1.200: icmp_seq=1 ttl=64 time=1.21 ms
--- 10.1.1.200 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 1.212/1.212/1.212/0.000 ms

$ ./tun
2023-10-10 11:40:17 ICMP REQUEST: 45 00 00 54 04 10 40 00 40 01 1f 6c 0a 01 01 64 0a 01 01 c8 08 00 42 62 00 14 00 01 a1 c7 24 65 00 00 00 00 2d 89 03 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37
2023-10-10 11:40:17 ICMP REPLY: 45 00 00 54 04 10 40 00 40 01 1f 6c 0a 01 01 c8 0a 01 01 64 00 00 4a 62 00 14 00 01 a1 c7 24 65 00 00 00 00 2d 89 03 00 00 00 00 00 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

所以,到这里为止,我们的场景就验证完毕,再简单总结一下:

  • TUN/TAP 设备一端连着操作系统协议栈,另一端连着用户空间的程序:用户空间程序 — tap0&tun0 — TCP/IP 协议栈 — ping
  • TUN 工作在三层,无 MAC 地址,无法加入网桥;TAP 工作在二层,更接近物理网卡;
  • TUN 设备常用于 VPN 场景;TAP 设备常用于虚拟机网卡;

如果对文章内容有疑问,或者有其他技术上的交流,可以关注我的公众号:李若年

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值