TAP/TUN与自定义网络协议栈

这个文章对TAP/TUN讲的比较清楚

https://blog.csdn.net/tjcwt2011/article/details/160653673

《深入高可用系统原理与设计》https://www.thebyte.com.cn/network/tuntap.html

一、在用户空间实现自定义网络协议栈

核心思想

内核协议栈是个黑盒------你想改 TCP 拥塞控制算法?想试一种新的传输协议?以前只能改内核源码、重新编译。TUN/TAP 彻底改变了这件事:

把协议栈从内核里"搬"到用户空间,用普通程序实现,而无需修改一行内核代码。


工作原理

复制代码
你的用户态协议栈 ──write──→ /dev/net/tun (TAP设备)
                                    │
                              内核协议栈收到一个"以太网帧"
                                    │
                              当作从真实网卡收到的一样处理
                                    │
                              路由查找 → 可能转发到物理网卡发出

反过来:
物理网卡收到包 → 内核协议栈 → 路由判定发往 TAP → 你的程序 read() 收到

你的程序既是发送方 也是接收方,完全控制每个字节怎么处理。


典型项目(都是真实可跑的)

项目 做了什么 用的模式
microps 轻量级 TCP/IP 协议栈,专为学习设计 TAP
tapip 用户态 TCP/IP 实现,GitHub 热门 TAP
level-ip 完整的用户态协议栈,支持 curl TAP

microps 为例,实际操作流程:

bash 复制代码
# 1. 创建 TAP 设备
sudo ip tuntap add dev tap0 mode tap

# 2. 配置 IP(让宿主机能访问这个虚拟网络)
sudo ip addr add 10.0.0.1/24 dev tap0
sudo ip link set dev tap0 up

# 3. 运行你自己写的协议栈
sudo ./app/tcps -i tap0    # 启动一个 TCP 服务器

# 4. 从另一个终端连接测试
telnet 10.0.0.1 8080       # 真的能通!

# 5. Wireshark 抓 tap0,看到的就是你协议栈发出的帧

为什么这对学习/研究有巨大价值

传统方式(改内核) TUN/TAP 方式
改一行代码 → 重新编译内核 → 重启 → test 改一行代码 → 重新编译程序 → 重跑 → 立即看到结果
崩溃 = 内核 panic,机器挂了 崩溃 = 程序退出,宿主机 unaffected
无法用 Wireshark 抓"中间过程" Wireshark 直接抓 TAP 接口,每个包都能看
只能在自己机器上测 可以在任何 Linux 机器上跑,零硬件成本

一句话:TUN/TAP 给了你一个完全隔离、可观测、可随意炸掉重建的网络沙盒。


二、流量监控与过滤

核心思想

所有经过 TUN/TAP 的数据包,用户态程序都能原样读到。这意味着你可以:

  • 看到每个包的完整内容(不像 iptables 只能看头部)
  • 决定放行/丢弃/修改/重定向
  • 实现内核做不到的灵活策略

两种部署模式

模式 原理 典型工具
软件 TAP(本文重点) 创建虚拟接口,把特定流量导过去 httptap、自研过滤器
硬件 Tap(物理设备) 在交换机和设备之间串一个物理 Tap 盒 商业网络嗅探方案

实战案例:httptap ------ 监控任意程序的 HTTP/HTTPS 流量

这是一个真实项目,用法极其简单:

bash 复制代码
# 监控 curl 发出的所有 HTTP/HTTPS 请求
httptap -- curl https://example.com

# 指定只看 80 和 443 端口
httptap --http 80,8080 --https 443,8443 -- firefox

# 导出为 HAR 文件,后续用 Chrome DevTools 分析
httptap --dump-har output.har -- your-app

它的内部实现就是:

复制代码
1. 创建 TUN 设备(工作在 L3,拿到 IP 包)
2. 用户态程序自己实现一个 TCP/IP 栈(gvisor 或 handrolled)
3. 解析出 HTTP 请求/响应
4. 打印 / 存储 / 转发

自定义防火墙策略怎么做?

逻辑非常直观:

c 复制代码
// 伪代码:一个基于 TUN 的用户态防火墙
while (1) {
    packet = read(tun_fd, buf, sizeof(buf));    // 从 TUN 读包
    
    if (packet.dst_ip == 1.2.3.4 && packet.dst_port == 443) {
        drop(packet);                           // 丢包:屏蔽这个 IP
    } else if (packet.payload_contains("malware")) {
        modify_and_forward(packet);             // 修改后转发
    } else {
        write(tun_fd, packet, len);             // 原样放回内核栈
    }
}
能力 iptables/nftables TUN/TAP 用户态
基于 IP/端口过滤
基于 payload 内容过滤 ❌ 做不到 ✅ 任意正则/字符串匹配
修改包内容(如重写 Host 头) ✅ NAT 能做一部分 ✅ 任意修改
看到完整包内容(包括 payload)
性能 极高(内核态) 较低(用户态拷贝)

性能瓶颈在哪?怎么优化?

瓶颈 原因 优化方案
内核↔用户态拷贝 每个包都要 copy_to_user / copy_from_user TPACKET_V3 + mmap 零拷贝(httptap 就是这么做的,用了 1000 大小的缓冲通道)
用户态协议栈慢 自己实现 TCP 比内核慢得多 用 gvisor 栈代替手写栈,兼容性和性能都更好
单线程处理 一个 read() 循环扛所有包 多线程 + 缓冲通道(httptap 用 chan []byte 做缓冲)

一句话总结

场景 TUN/TAP 解决了什么
自定义协议栈 不改内核,在用户空间造一个完整的 TCP/IP,可炸可重建可观测
流量监控过滤 内核做不到的深度包检测(DPI),在用户态随便写,代价是性能

如果你是想学习协议 ,选 TAP + microps/tapip,零成本开干。如果你是想监控特定应用的流量 ,httptap 这种工具开箱即用。如果你要生产环境的高性能过滤,TUN/TAP 不是最佳选择,但它是最灵活的起点。

microps 例子中,tap0和真实物理网卡没有做桥接,报文是如何从物理网卡发出去的?

靠内核路由表,不需要 bridge。

你的直觉是对的------tap0 和 eth0 之间确实没有二层连接。但它们能通,靠的是三层路由,不是二层桥接。


完整路径长这样

以 microps 发起一个到 8.8.8.8 的包为例:

复制代码
microps 写包到 tap0
       │
       ▼
  内核收到 tap0 的以太网帧(目的 MAC 是网关的 MAC)
       │
       ▼
  内核查路由表:8.8.8.8 不在 10.0.0.0/24 → 走默认网关
       │
       ▼
  默认网关 = 192.168.1.1 → 下一跳从 eth0 出去
       │
       ▼
  eth0 发出,到达物理交换机 → 互联网

反过来也一样

复制代码
互联网回包到达 eth0
       │
       ▼
  内核查路由表:去 10.0.0.0/24 的路由 → 下一跳是 tap0
       │
       ▼
  包从 tap0 送到 microps 的 read()

为什么能通?三个关键

条件 说明
✅ tap0 有 IP ip addr add 10.0.0.1/24 dev tap0,内核认为它是一个真实接口
✅ 有默认路由 ip route add default via 192.168.1.1 dev eth0,告诉内核未知目的地走 eth0
✅ ip_forward 开启 sysctl net.ipv4.ip_forward=1,允许内核在接口间转发

这就够了。内核根本不关心包是从哪个接口进来的,它只查路由表。


和 bridge 方案的本质区别

microps 方案(tun/tap + 路由) 虚拟机方案(tap + bridge)
连接方式 三层路由 二层桥接
tap0 有 IP 吗 ✅ 有(10.0.0.1) ❌ 无
eth0 有 IP 吗 ✅ 有(192.168.1.50) ❌ 无(IP 移到 br0 上了)
包怎么出去 内核路由 → eth0 bridge 转发 → eth0
虚拟机能发 ARP 吗 ❌ 不能(没有二层域) ✅ 能(bridge 模拟真实交换机)

一句话

tap0 不需要和 eth0 桥接,因为内核路由表会自动把包从 tap0 导向 eth0。bridge 解决的是二层问题(ARP/广播),microps 这种场景根本不需要二层,三层路由就够了。

这其实就是我最早说的:tun 只能工作在 L3,但 L3 靠 ip_forward 完全可以和物理网卡连通。

microps 方案中,明明创建的是tap,而不是tun,为什么说是三层路由呢


你这个问题问到点子上了。确实不矛盾,但需要把"谁在做二层、谁在做三层"拆清楚。

矛盾在哪?

你的理解 我之前说的
TAP = 二层设备,收发以太网帧 这个方案靠三层路由通的
→ 既然是二层,应该靠 bridge 才对 → 桥接才是二层的事啊

看起来矛盾,但其实两句话说的是不同层的事


拆开看:谁负责哪一层

以 microps 发一个包到 8.8.8.8 为例:

复制代码
microps 协议栈(用户态)
│
│ 1. 自己实现 ARP,查到网关 MAC = aa:bb:cc:dd:ee:ff
│ 2. 自己构造完整以太网帧:
│    ┌─────────────────────────────────────┐
│    │ eth_hdr: dst=aa:bb:cc:dd:ee:ff      │  ← microps 自己填的 MAC 头
│    │ ip_hdr:  dst=8.8.8.8                │  ← microps 自己实现的 IP 层
│    │ tcp_hdr: dst=443                    │  ← microps 自己实现的 TCP 层
│    └─────────────────────────────────────┘
│ 3. write() 写到 /dev/net/tap → tap0
│
▼
内核协议栈收到 tap0 的以太网帧
│
│ 4. 一看目的 MAC = aa:bb:cc:dd:ee:ff(网关)→ 是给我的
│ 5. 剥掉以太网头,拿到 IP 包,查路由表:
│    8.8.8.8 不在本地 → 走默认网关,从 eth0 出去
│ 6. 重新封装以太网帧(dst MAC = 真实网关 MAC),eth0 发出
│
▼
物理网卡 → 互联网
层级 谁在做 用什么设备
二层(MAC/ARP) microps 自己(用户态协议栈) TAP 只是个管道,透传以太网帧
三层(IP 路由) 内核路由表 不管包从 tap0 还是 eth0 进来,内核都查同一张路由表

所以为什么说"三层路由"?

因为包从 tap0 出来之后,内核的处理方式和从 eth0 收到包完全一样

剥二层头 → 查三层路由表 → 从另一个接口发出

这就是纯三层路由,和 bridge 没有任何关系。


那为什么 microps 用 TAP 而不用 TUN?

这才是关键。如果用 TUN:

TAP TUN
收发的数据 完整以太网帧(含 MAC 头) 纯 IP 包(无 MAC 头)
microps 能实现 ARP 吗? ✅ 能,自己构造 MAC 头 ❌ 不能,没有 MAC 头可以填
microps 能实现完整 TCP/IP 吗? ✅ 能,从二层到四层全自己来 ⚠️ 缺二层,不完整

microps 的目的是实现一个完整的 TCP/IP 协议栈用于学习,ARP 是 TCP/IP 的一部分。用 TUN 的话,ARP 没地方放,协议栈就是残的。

所以选 TAP 不是为了桥接,而是因为:

TAP 给了 microps 完整的以太网帧,让它能自己实现 ARP、自己填 MAC 头,从而拥有一个真正完整的协议栈。至于包出去之后怎么路由,那是内核的事,和 TAP 是几层设备无关。


一句话

TAP 是二层设备没错,但 microps 用 TAP 不是为了做二层桥接,而是为了拿到完整以太网帧来实现自己的 ARP。包出了 TAP 之后,内核该查路由查路由,该走三层走三层------设备是二层的,转发路径是三层的,不矛盾。