Linux 上常见的网络设备 TUN/TAP,这两个设备经常被放到一起讨论,因为它们的功能其实非常类似,都是 Linux 内核提供的虚拟网络设备,就像一块真实的网卡,但它的一端连着操作系统协议栈,另一端连着用户空间的程序。如下图所示:
我们假设:右侧的应用程序代表 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 设备两者的区别:
- 分别创建 TUN 和 TAP 设备,为设备绑定 IP
10.1.1.100/24
; - 绑定 TUN/TAP 设备,接收 ICMP 的请求并回包;
我们的实验环境是Ubuntu22.04
,编程语言使用的golang-1.20
。
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
设备的 MACba: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 设备常用于虚拟机网卡;