TUN / TAP 虚拟网卡技术详解:三层隧道与二层网桥的取舍

八月 16, 2026 [网络与安全] #tun #Linux #vpn #qemu #tailscale

背景:本机网络栈里那些看不见的"网卡"

日常 ip link 会看到一堆奇怪的接口——tun0、tap0、tailscale0、docker0、veth。它们不是物理网卡,而是内核虚拟设备。本机(Proxmox 宿主)实测:

tailscale0: <POINTOPOINT,MULTICAST,NOARP> ...    ← TUN,三层
    inet 100.64.0.3/32 scope global tailscale0
tun0: <POINTOPOINT,MULTICAST,NOARP> ...          ← TUN,OpenVPN
    inet 10.8.0.6 peer 10.8.0.5/32 scope global tun0
vmbr0: ...                                        ← 网桥,二层

TUN 和 TAP 是 Linux 提供的一对"用户空间 ↔ 内核协议栈"的虚拟管道设备:程序打开 /dev/net/tun,通过 read/write 收发数据包。区别只在收发的是什么东西。

TUN 与 TAP 的基本概念

特性TUNTAP
工作层级三层(网络层)二层(数据链路层)
处理的数据IP 数据包以太网帧
有 MAC 地址?没有(link/none)有(link/ether)
有 IP 地址?有(手动配置)有(手动配置)
能加入网桥?不能可以
典型应用VPN(OpenVPN、WireGuard、Tailscale/Headscale)虚拟机网络(QEMU/KVM)

TUN 设备详解:三层、无 MAC、点对点

基本属性

TUN 是三层(网络层)设备,只处理 IP 包,不涉及以太网帧,因此没有 MAC 地址:

# TUN 设备 —— link/none,没有 MAC
5: tun0: <POINTOPOINT,MULTICAST,NOARP> ...
    link/none
# ifconfig 视角:硬件地址全是零
tun0  Link encap:UNSPEC  HWaddr 00-00-00-00-00-00-...
      inet addr:10.8.0.5  P-T-P:10.8.0.5  Mask:255.255.255.0
      UP POINTOPOINT RUNNING NOARP MULTICAST

为什么不需要 MAC?

TUN 工作在三层,数据直接以 IP 包形式在内核协议栈和用户空间程序之间传递,不经过以太网帧封装,自然不需要 MAC 做二层寻址。

POINTOPOINT(点对点)标志揭示了它的本质:一条虚拟点对点链路——一端是宿主机内核协议栈,另一端是用户空间的 VPN 程序。数据通过这条管道直接传递 IP 包,不存在二层寻址的概念。

这也是 Tailscale/OpenVPN 等 VPN 选择 TUN 的根本原因:省去 ARP、MAC、广播等二层烦恼,VPN 程序只需专注"路由 IP 包 → 加密 → UDP/TCP 发到远端"。本机 tailscale0 就是 TUN,100.64.0.3/32 点对点。

TAP 设备详解:二层、必须"有"MAC、喂给网桥

创建与配置

sudo ip tuntap add dev tap0 mode tap user $(whoami)   # user 参数允许普通用户读写
sudo ip link set tap0 up
sudo ip addr add 10.0.0.1/24 dev tap0

MAC 地址的两个"参与者"必须不同

两者必须不同,原因在网桥:br0 依靠 MAC 地址表决定帧的转发端口,若 tap0 与 VM 网卡 MAC 相同,网桥将无法判断该 MAC 对应的设备在 tap0 端口侧还是外部物理网络侧,转发混乱。libvirt 的规律:把 VM MAC 首字节改为 0xFE 生成 tap0 的 MAC(如 VM 52:54:d7:18:09:2a → tap0 fe:54:d7:18:09:2a)。

tap0 的 MAC 几乎没有实际作用

回顾数据流:网桥学习的是帧里的源 MAC(VM 的),不是 tap0 接口自身的 MAC;帧经过 tap0 时 MAC 不被替换;TAP 设备设了 NOARP,没人 ARP 查询它。那为什么它还存在?——Linux 内核 net_device 结构体的硬性要求:任何网络设备都必须有一个 MAC 地址,即使实际用不到。它是"存在即合理"的占位符。

数据包如何路由到虚拟机:网桥的 MAC 表动态学习

数据包路由到 VM 依赖的是网桥 MAC 转发表,由 VM 发出帧的源 MAC 动态学习建立:

初始:br0 转发表为空
VM1 发帧(src MAC 52:54:d7:18:09:2a)从 tap0 进入
  → 网桥记录:52:54:d7:18:09:2a → tap0 端口
VM2 发帧(src MAC 52:54:aa:bb:cc:dd)从 tap1 进入
  → 网桥记录:52:54:aa:bb:cc:dd → tap1 端口
回包:外部帧 dst=52:54:d7:18:09:2a
  → 网桥查表 → 从 tap0 端口转发 → QEMU 读帧注入 VM1

数据包交互流程:VM → 外网

拓扑:VM(eth0 10.0.0.2) ↔ QEMU 进程 ↔ 宿主机 tap0 ↔ 网桥 br0(10.0.0.1) ↔ 物理网卡 eth0 ↔ 外网。

发送(VM1 → 外网):

VM1 应用 → VM1 内核查路由表 → 虚拟网卡 eth0
  → QEMU 进程把帧写入宿主机 tap0
  → 内核发现 tap0 挂在 br0 上,转发到网桥
  → br0 查 MAC 表,目标不在本地 VM → 从物理网卡 eth0 发出
  → (VM 访问外网通常还需宿主机 NAT:iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE)
  → 路由器 → 外网目标

接收(外网 → VM1):对称反向——物理网卡收帧 → br0 查 MAC 表找到 tap0 端口 → 帧交给 QEMU → 注入 VM 虚拟网卡 → VM 内核协议栈 → 应用。

核心要点:

50 行 Python 简易 VPN:验证"VPN 本质"

核心逻辑:打开 /dev/net/tun → ioctl 配置为 tap0 → 死循环读包/写包。示例程序只处理 ARP 请求,构造假回复声称"10.0.0.2 存在":

import os, struct
from fcntl import ioctl

TUNSETIFF = 0x400454ca
IFF_TAP   = 0x0002
IFF_NO_PI = 0x1000

def create_tap(ifname):
    tap_fd = os.open("/dev/net/tun", os.O_RDWR)
    ifr = struct.pack("16sH", ifname.encode("utf-8"), IFF_TAP | IFF_NO_PI)
    ioctl(tap_fd, TUNSETIFF, ifr)
    return tap_fd

def handle_arp_request(packet, tap_fd):
    host_mac = packet[6:12]          # 提取 ARP 请求源 MAC
    fake_mac = bytes([0x00,0x11,0x22,0x33,0x44,0x55])
    arp_reply = (host_mac + fake_mac + b'\x08\x06' + b'\x00\x01' + b'\x08\x00' +
                 b'\x06' + b'\x04' + b'\x00\x02' + fake_mac +
                 b'\x0a\x00\x00\x02' + host_mac + b'\x0a\x00\x00\x01')
    os.write(tap_fd, arp_reply)
    print("捕获到 ARP 请求,已伪造 10.0.0.2 的回复!")

def main():
    tap_fd = create_tap("tap0")
    print("启动简易 VPN 程序,正在监听 tap0...")
    while True:
        packet = os.read(tap_fd, 1514)
        if packet[12:14] == b'\x08\x06':   # ARP
            handle_arp_request(packet, tap_fd)
        else:
            pass                            # 其他包(IP)直接丢弃

if __name__ == "__main__":
    main()

运行与现象:python3 simple_vpn.py + 另一终端 ping 10.0.0.2——先通(ARP 被拦截伪造了假回复),随后 Destination Host Unreachable 或卡住。为什么卡住:ping 拿到假 MAC 后发出 ICMP IP 包,程序在 else 分支直接丢弃——没有完整 TCP/IP 协议栈就回不了 ICMP Echo Reply。

这揭示了 VPN 的本质:

各虚拟化平台的 TAP/TUN 管理

场景需要手动创建?说明
QEMU/KVM 手动配置需要每个 VM 一个 TAP
libvirt / virt-manager自动libvirt 自动管理
VMware / VirtualBox自动工具内部自动处理
Docker自动用 veth pair,非 TAP
Tailscale/Headscale自动每节点自动创建自己的 TUN

总结