TC Hairpin NAT 驱动使用手册(个人版)

说明 :本文档仅供个人回顾使用,记录了基于 TC eBPF 实现 Hairpin NAT 的完整操作流程与设计思路。不包含具体代码实现细节,仅描述原理、配置、调试与维护方法,以保证内核程序的稳定性与安全性。
1. 简介
本驱动实现了一个基于 eBPF 的 TC(Traffic Control)层 hairpin NAT 功能。
它可以在一个网络接口(例如 TUN/TAP 设备、物理网卡)上,根据预定义的 NAT 规则,对 TCP 数据包的源/目的 IP 和端口进行转换,并可选地将包重定向到指定的网络接口(或原接口)。
典型应用场景:
- 在 VNP 服务端实现端口映射或流量回注。
- 在容器或虚拟化环境中实现 Service 的 Hairpin NAT(让访问服务的请求经过转换后回到自身)。
核心组件:
- 内核程序:负责数据包匹配、地址端口修改、校验和更新、重定向决策。
- 用户态控制程序:用于加载/卸载 TC 程序,管理 NAT 规则(增删查)。
- 编译脚本:简化构建过程。
2. 技术原理
程序工作于 Linux TC 的 egress 方向(发包时)。当数据包从指定网卡发出时,内核会先调用我们的 eBPF 程序。程序解析 IP 头与 TCP 头,提取五元组(源 IP、源端口、目的 IP、目的端口、协议),以该五元组为键查询预先定义的 NAT 映射表。若命中,则按照映射表中的新地址、新端口修改数据包,并重新计算 IP 头与 TCP 头的校验和,保证协议栈能够正确接收。修改完成后,数据包被重定向到指定网卡(默认原网卡)的 ingress 方向继续处理,从而实现流量的"折返"效果。
整个处理过程在内核态完成,避免了用户态与内核态之间的上下文切换,具有极高的处理效率。
3. 环境准备
3.1 系统与内核
- 推荐 Ubuntu 20.04/22.04 或 WSL2(内核 5.10+)。
- 确认内核支持 BPF(通常默认开启)。
3.2 安装基础工具链
bash
sudo apt update -y
sudo apt upgrade -y
sudo apt install -y build-essential clang llvm git vim net-tools iproute2 tcpdump curl socat
3.3 安装 eBPF 开发库
bash
sudo apt install -y libbpf-dev libelf-dev libz-dev
3.4 安装 bpftool(管理 BPF 程序与 map)
bash
sudo apt install -y linux-tools-common linux-tools-generic
# 查找 bpftool 实际路径(例如 /usr/lib/linux-tools-5.15.0-173-generic/bpftool)
find /usr -name "bpftool" 2>/dev/null
# 建立软链接
sudo ln -sf /usr/lib/linux-tools-$(uname -r)/bpftool /usr/sbin/bpftool
3.5 挂载 BPF 文件系统(用于持久化 map)
bash
sudo mkdir -p /sys/fs/bpf
sudo mount -t bpf none /sys/fs/bpf
4. 编译
4.1 编译内核程序
bash
clang -O2 -target bpf -c driver.c -o driver.ko
4.2 编译用户态控制程序
bash
gcc loader.c -o loader -lbpf -lelf -lz
说明:
-O2是 BPF 程序推荐的优化级别。driver.ko虽然扩展名是.ko,但实际是 eBPF 字节码文件,并非内核模块。- 编译用户态程序时需链接
libbpf、libelf、libz。
4.3 关于内核常量的手动定义
在编写 BPF 程序时,可能会遇到某些常量未定义的情况(例如 TC_ACT_OK)。这些常量定义在 Linux 内核头文件中。若编译环境缺少这些定义,可在代码开头手动添加(已在源码中处理,无需用户关心)。
5. 网络环境准备
在加载 NAT 程序之前,需要先准备好网络环境。以下以创建一个 TUN 设备 tun0(IP 10.0.0.2)并配置路由为例。
5.1 创建 TUN 设备(可选)
如果你使用 VNP 或虚拟网络设备,这一步可能需要调整。示例使用 socat 创建一个 TUN 设备:
bash
socat TUN:10.0.0.2/24,up,tun-name=tun0,iff-up,iff-running,iff-noarp,iff-pointopoint,up -
参数解释:
TUN:10.0.0.2/24:TUN 设备 IP 为 10.0.0.2,掩码 24。up:设备立即启用。tun-name=tun0:设备名称。iff-up,iff-running等:设置标志位。
创建后,可用 ip addr show tun0 查看。
5.2 添加路由(使目标 IP 的流量进入 tun0)
假设我们要让访问 10.0.0.8 的流量走 tun0:
bash
ip route add 10.0.0.8/32 dev tun0
注意:根据实际场景调整路由,确保 NAT 转换前后的 IP 能够正确路由。
5.3 启动后端服务
NAT 转换后的最终目的地址需要有服务监听。例如,我们将在 10.0.0.2:8080 启动一个 HTTP 服务:
bash
python3 -m http.server 8080 --bind 10.0.0.2
确保该服务正在运行,否则测试时连接会失败。
6. 加载 BPF 程序
有两种方式:推荐使用我们自带的 loader 工具,也可以手动使用 tc 命令。
6.1 方式一:使用 loader 工具(推荐)
bash
# 挂载 BPF 文件系统(若未挂载,只需执行一次)
sudo mount -t bpf none /sys/fs/bpf
# 加载程序到指定网卡(例如 tun0)
sudo ./loader attach tun0
attach 命令会完成以下工作:
- 打开并加载
driver.ko字节码。 - 将规则表持久化到
/sys/fs/bpf/tun_nat_map。 - 在指定网卡的 egress 方向附加 TC 程序(handle 1, priority 1)。
成功输出类似:
规则表已 pin 到 /sys/fs/bpf/tun_nat_map
TC egress NAT 已成功附加到 tun0
现在可以使用 add / del / dump 管理规则
6.2 方式二:手动使用 tc 命令
如果你更熟悉 tc,也可以手动附加(前提是已编译出 driver.ko):
bash
# 确保网卡已有 clsact qdisc(若没有则创建)
tc qdisc add dev tun0 clsact
# 加载程序,指定节名(驱动程序中已定义)
tc filter add dev tun0 egress bpf da obj driver.ko sec <节名>
# 查看挂载状态
tc filter show dev tun0 egress
此时规则表没有自动 pin,如需管理规则,可以通过 bpftool 查找 map id 后操作(较繁琐,不推荐)。
7. NAT 规则管理
规则通过 loader 程序进行增、删、查。规则存储在 BPF 规则表中,key 是原始五元组(源IP、目的IP、源端口、目的端口、协议),value 是转换后的地址、端口以及重定向接口。
7.1 添加规则
bash
sudo ./loader add <src_ip> <src_port> <dst_ip> <dst_port> <new_src_ip> <new_src_port> <new_dst_ip> <new_dst_port> <redirect_ifindex>
参数说明:
<src_ip>:原始源 IP(点分十进制)。<src_port>:原始源端口(整数)。<dst_ip>:原始目的 IP。<dst_port>:原始目的端口。<new_src_ip>:转换后的源 IP。<new_src_port>:转换后的源端口。<new_dst_ip>:转换后的目的 IP。<new_dst_port>:转换后的目的端口。<redirect_ifindex>:重定向的目标网卡索引(0 表示使用原接口;可以是数字 ifindex 或网卡名,例如eth0、lo)。
重要:在 hairpin 场景中,通常需要添加两条规则(双向):
- 去程:将客户端访问服务的请求转换为服务所在后端地址。
- 回程:将后端响应转换为客户端可识别的地址。
示例 :
假设:
- 客户端 IP
10.0.0.2,使用源端口1111访问10.0.0.8:7777。 - 我们想将请求转换为从
10.0.0.1:2222发往10.0.0.2:8080(后端服务)。 - 回程时,将后端响应(
10.0.0.2:8080 -> 10.0.0.1:2222)转换为10.0.0.8:7777 -> 10.0.0.2:1111。
添加规则:
bash
# 去程
sudo ./loader add 10.0.0.2 1111 10.0.0.8 7777 10.0.0.1 2222 10.0.0.2 8080 0
# 回程
sudo ./loader add 10.0.0.2 8080 10.0.0.1 2222 10.0.0.8 7777 10.0.0.2 1111 0
注意:
- 两条规则严格对称,确保转换可逆。
redirect_ifindex设为 0,表示数据包修改后从原接口发出(即还是通过 tun0)。
7.2 查看所有规则
bash
sudo ./loader dump
输出示例:
当前 NAT 规则:
----------------------------------------------------------------------------------------
源IP:端口 → 目的IP:端口 | 新源IP:新端口 新目的IP:新端口 重定向 ifindex
----------------------------------------------------------------------------------------
10.0.0.2:8080 → 10.0.0.1:2222 | 10.0.0.8:7777 10.0.0.2:1111 0
10.0.0.2:1111 → 10.0.0.8:7777 | 10.0.0.1:2222 10.0.0.2:8080 0
7.3 删除规则
bash
sudo ./loader del <src_ip> <src_port> <dst_ip> <dst_port>
例如:
bash
sudo ./loader del 10.0.0.2 1111 10.0.0.8 7777
sudo ./loader del 10.0.0.2 8080 10.0.0.1 2222
8. 测试
配置完成后,使用 curl 测试(确保后端服务已启动):
bash
curl --local-port 1111 http://10.0.0.8:7777/
--local-port 1111 强制使用源端口 1111,使其匹配去程规则。
如果一切正常,应能收到后端 HTTP 服务的响应。
9. 调试
9.1 查看内核日志(bpf_printk)
驱动中使用调试输出,可以通过 trace_pipe 实时查看:
bash
# 确保 debugfs 已挂载(通常系统自动挂载)
sudo mount -t debugfs none /sys/kernel/debug # 如果未挂载
# 实时输出 BPF 日志
cat /sys/kernel/debug/tracing/trace_pipe | grep NAT
当有数据包匹配时,会看到类似:
NAT hit, redirect to 0
9.2 查看 BPF 程序状态
使用 bpftool:
bash
# 列出所有 BPF 程序,查找 tc_egress
bpftool prog list | grep tc_egress
# 假设程序 id 为 123,查看详细信息
bpftool prog show id 123 --pretty
9.3 查看规则表内容
bash
# 列出所有 map,找到规则表对应的 id
bpftool map list
# 导出规则表内容(假设 id 为 42)
bpftool map dump id 42
输出会显示所有规则,便于验证。
9.4 抓包验证
用 tcpdump 抓取双向流量,观察 NAT 转换是否生效:
bash
tcpdump -i any -n host 10.0.0.2 or host 10.0.0.8 or host 10.0.0.1
执行 curl 后,应能看到:
- 原始包:
10.0.0.2.1111 > 10.0.0.8.7777 - 转换后包:
10.0.0.1.2222 > 10.0.0.2.8080 - 回程包:
10.0.0.2.8080 > 10.0.0.1.2222 - 再次转换:
10.0.0.8.7777 > 10.0.0.2.1111
10. 清理
10.1 卸载 TC 程序
bash
# 使用 loader 卸载
sudo ./loader detach tun0
# 或手动删除 tc 过滤器
tc filter del dev tun0 egress
10.2 删除规则表持久化文件
bash
rm -f /sys/fs/bpf/tun_nat_map
10.3 删除 TUN 设备和路由(如果不再需要)
bash
ip link del tun0
ip route del 10.0.0.8/32
11. 个人笔记
11.1 为何选择 TC 而非 XDP
XDP 需要网卡驱动直接支持,且工作在驱动层之前。对于 TUN/TAP 这类纯软件虚拟设备,数据包在协议栈内部生成,不经过驱动层,因此 XDP 程序虽然可以附加,但实际上不会处理任何包。而 TC 工作在协议栈的出口点(egress),对包括虚拟设备在内的所有网络设备均有效,是实现 TUN 设备 NAT 的正确选择。
11.2 规则表容量
规则表采用哈希表结构,最大条目数设为 131072(2^17) ,即约 13 万条。这一容量远高于绝大多数实际场景的需求,确保即使在大量端口映射或动态连接时也不会出现表满溢出的风险。同时哈希表采用非预分配模式,仅在实际插入条目时才分配内存,避免因预分配大量内存造成内核资源浪费。
11.3 简单至上原则
驱动程序的逻辑被刻意保持简单:
- 只处理 TCP 协议,避免支持 UDP 等复杂协议带来的校验和更新等问题。
- 无状态设计,不维护连接跟踪(conntrack),完全依赖规则表匹配。
- 校验和更新采用增量方式,避免全量重新计算的性能开销。
- 错误处理极简 ,遇到无法处理的情况直接放行(
TC_ACT_OK),宁可丢包也不让内核崩溃。
这种"简单即稳定"的指导思想,确保了内核程序在高压场景下的可靠性与可维护性。任何复杂的特性(如 UDP 支持、动态超时等)都应放在用户态或单独模块中实现,而非塞入这个核心数据路径。
12. 常见问题 (FAQ)
Q1: 加载时提示 libbpf: failed to load program
- 原因:可能是 BPF 程序验证失败。
- 解决:查看
dmesg或/sys/kernel/debug/tracing/trace_pipe获取具体错误信息。常见原因包括校验和更新错误、指针越界等。
Q2: curl 无响应,但 tcpdump 显示转换后的包
- 检查后端服务是否监听在转换后的目的地址端口。
- 检查回程规则是否正确添加。
- 检查重定向接口是否配置正确(例如设为 0 时原接口是否能将包送到正确路径)。
Q3: 规则添加成功,但 dump 看不到
- 可能规则表没有正确 pin,或使用了错误的 map id。确保使用
./loader attach挂载程序,它会自动 pin 规则表。 - 尝试重新 attach 并添加规则。
Q4: 修改了驱动代码后重新 attach 不生效
- 必须重新编译内核程序,然后先 detach 旧程序,再 attach 新程序。
- 如果使用 tc 命令,需要先删除旧的过滤器。
Q5: 为什么必须添加双向规则?
- 驱动基于五元组精确匹配。若不添加回程规则,后端响应的数据包无法匹配任何条目,将原样发出,导致客户端无法识别连接,表现为无响应或重置。双向规则确保往返流量均被转换。
13. 附录:IP 和端口十六进制转换(用于 bpftool 手动操作)
如果你需要绕过 loader 直接用 bpftool 操作规则表,可以使用以下转换:
bash
# IP 转十六进制(大端)
printf "0x%02x%02x%02x%02x\n" 192 168 1 100 # 输出 0xc0a80164
# 端口转十六进制(大端)
printf "0x%04x\n" 12345 # 输出 0x3039
然后使用 bpftool map update id <map_id> key hex ... value hex ... 插入规则。