Linux/Network · #linux#networking#tun/tap#vpn

Linux TUN/TAP:虚拟网络设备原理与实践

2020.10.03 4 min 1.8k
// 目录 · contents

学习 TUN/TAP 是因为在排查 Kubernetes 网络问题时,反复看到 flannel.1cni0veth 这些奇怪的网络接口,一直没搞清楚背后的机制。最后发现很多容器网络方案的底层都依赖 TUN/TAP,理解了这个之后,很多之前困惑的东西就通了。

TUN 和 TAP 的区别

两者都是 Linux 内核提供的虚拟网络设备,本质区别在于工作在哪一层:

  • TUN(Network Tunnel):工作在第三层(IP 层),处理 IP 数据包
  • TAP(Network Tap):工作在第二层(以太网层),处理以太网帧,包括 ARP 广播

普通物理网卡和 TUN/TAP 的区别是:物理网卡的另一端接的是实际的物理网络,TUN/TAP 的另一端接的是一个用户空间程序。内核把要发往虚拟接口的数据包交给程序读取,程序写入数据包就像从网络收到了数据。

ip link 可以看到 TUN 接口就像普通网络接口一样出现:

1
2
3
4
5
$ ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
3: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500
link/none

创建 TUN 接口

操作 TUN/TAP 设备通过字符设备 /dev/net/tun。以下是一个最简单的 C 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <fcntl.h>
#include <linux/if_tun.h>
#include <net/if.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

// 创建 TUN 接口,返回文件描述符
int tun_alloc(char *dev) {
struct ifreq ifr;
int fd, err;

// 打开 /dev/net/tun
if ((fd = open("/dev/net/tun", O_RDWR)) < 0) {
perror("open /dev/net/tun");
return -1;
}

memset(&ifr, 0, sizeof(ifr));

// IFF_TUN:三层设备(IP 包)
// IFF_TAP:二层设备(以太网帧)
// IFF_NO_PI:不包含额外的包头信息
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;

if (*dev) {
strncpy(ifr.ifr_name, dev, IFNAMSIZ);
}

// 向内核注册这个虚拟接口
if ((err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0) {
perror("ioctl TUNSETIFF");
close(fd);
return err;
}

// 把实际分配的接口名写回
strcpy(dev, ifr.ifr_name);
return fd;
}

int main() {
char dev[IFNAMSIZ] = "tun0";
int tun_fd = tun_alloc(dev);

printf("Created TUN device: %s\n", dev);

// 配置 IP 地址(通常用 ip 命令,这里演示用 ioctl)
// 之后就可以 read/write 这个 fd 来收发 IP 包了

// 读取从 TUN 接口收到的 IP 包
unsigned char buffer[4096];
while (1) {
int n = read(tun_fd, buffer, sizeof(buffer));
if (n < 0) {
perror("read");
break;
}
// buffer 里就是原始的 IP 数据包
// 第 0 字节:IP 版本(高 4 位)+ 头部长度(低 4 位)
// 第 9 字节:协议号(TCP=6, UDP=17, ICMP=1)
printf("Received %d bytes, protocol: %d\n", n, buffer[9]);
}

close(tun_fd);
return 0;
}

创建接口后还需要用 ip 命令配置:

1
2
3
4
5
6
7
8
# 给 TUN 接口分配 IP
ip addr add 10.8.0.1/24 dev tun0

# 启动接口
ip link set tun0 up

# 测试:给 10.8.0.2 发包,TUN 程序会收到 ICMP 包
ping 10.8.0.2

VPN 的工作原理

理解了 TUN/TAP,VPN 的原理就很清晰了。以 OpenVPN 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
客户端                              服务端
───────── ─────────
应用程序
│ TCP/IP

tun0 (10.8.0.2) tun0 (10.8.0.1)
│ 原始 IP 包 │
▼ │
OpenVPN 客户端进程 OpenVPN 服务端进程
│ 加密 + 封装 │
▼ │
eth0 ──── 公网 ────────────────── eth0
UDP/TCP 封装的加密包

核心步骤: 1. 应用发出的 IP 包到达 tun0,被 VPN 客户端程序从 fd 读取 2. VPN 客户端加密这个 IP 包,再包一层 UDP/TCP 头,通过物理网卡 eth0 发出去 3. VPN 服务端收到后解密,把原始 IP 包写入服务端的 tun0 4. 内核把包交给目标程序

这就是为什么 VPN 能”绕过防火墙”——防火墙只看到你在和 VPN 服务器通信,看不到里面的实际流量。

容器网络中的 TUN/TAP

Kubernetes 的 Flannel VXLAN 模式也用了类似机制。每个 Node 上有 flannel.1 接口(VXLAN 设备),Pod 间的跨节点流量会被封装在 VXLAN 头里,通过宿主机的物理网卡传输。

1
2
3
4
5
6
# 在 K8s 节点上看到的接口
$ ip link show
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> # 宿主机物理网卡
3: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> # VXLAN 虚拟接口
4: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> # 本节点 Pod 的网桥
5: vethxxx: <BROADCAST,MULTICAST,UP,LOWER_UP> # Pod 的 veth pair 一端

flannel.1 不是严格意义上的 TUN 设备,但工作原理类似:跨节点的 Pod 通信数据包到达 flannel.1,Flannel 进程把它封装成 VXLAN UDP 包,再通过 eth0 发往目标节点。

用 Python 写一个简单演示

用 Python 做个最小演示,捕获发往 TUN 接口的 ICMP 包并打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import os
import struct
import fcntl

TUNSETIFF = 0x400454ca
IFF_TUN = 0x0001
IFF_NO_PI = 0x1000

def create_tun(name="tun0"):
tun = open("/dev/net/tun", "r+b", buffering=0)
# struct ifreq: 接口名(16字节)+ 标志(2字节)
ifr = struct.pack("16sH", name.encode(), IFF_TUN | IFF_NO_PI)
fcntl.ioctl(tun, TUNSETIFF, ifr)
return tun

def parse_ip_header(packet):
# IPv4 头:版本/头长/服务/总长/标识/标志/TTL/协议/校验/源IP/目的IP
if len(packet) < 20:
return None
version_ihl = packet[0]
protocol = packet[9]
src_ip = ".".join(str(b) for b in packet[12:16])
dst_ip = ".".join(str(b) for b in packet[16:20])
return protocol, src_ip, dst_ip

if __name__ == "__main__":
tun = create_tun("tun1")
# 需要 root 权限
# ip addr add 10.9.0.1/24 dev tun1 && ip link set tun1 up
print("Listening on tun1...")
while True:
packet = tun.read(4096)
result = parse_ip_header(packet)
if result:
proto, src, dst = result
proto_name = {1: "ICMP", 6: "TCP", 17: "UDP"}.get(proto, str(proto))
print(f"{src} -> {dst} [{proto_name}] {len(packet)} bytes")

运行后,执行 ping 10.9.0.2,可以看到 ICMP 包被程序捕获并打印出来。

踩坑记录

在搭一个简单的点对点隧道测试时,遇到过一个很迷惑的问题:两端 TUN 程序都在运行,ping 却不通,但 tcpdump 在 TUN 接口上能看到包。

排查很久才发现:写入 TUN 接口的 IP 包,源 MAC 地址没有对应的 ARP 条目,内核在回包时不知道往哪里发。因为 TUN 是三层设备,不处理 ARP,而我没有在路由表里加正确的静态路由。

解决方案:在两端分别加上对方的路由条目:

1
2
3
4
5
# 节点 A(10.8.0.1)到节点 B(10.8.0.2)
ip route add 10.8.0.2/32 dev tun0

# 节点 B(10.8.0.2)到节点 A(10.8.0.1)
ip route add 10.8.0.1/32 dev tun0

如果用 TAP 则不需要,因为 TAP 是二层设备,会处理 ARP 广播,自动学习 MAC 地址。这是 TUN 和 TAP 最实际的区别之一。

作者 · authorzt
发布 · date2020-10-03
篇幅 · length1.8k 字 · 4 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论