7. mini-vpn-go Phase2 明文 IP-in-UDP 隧道
个人项目
mini-vpn-go(用户态 VPN,从零手写、逐 phase 演进,最终对标 WireGuard)的第二阶段理解笔记。
这一阶段不碰任何加密,目标只有一个:把"读 TUN → 发 UDP → 写 TUN"这条数据通路真正打通,让两台命名空间隔离的主机能ping通隧道地址。
源码:~/Desktop/tun_example/phase2/(仓库名是tun_example,但 Go module 是github.com/1WesleyYou/mini-vpn-go)。
0. 一句话本质
一个 VPN,机械地看,就是把两个读写循环用两个 goroutine 粘在一起:一个 TUN 设备 + 一个 UDP socket。
- TUN 设备 = 内核给用户态开的一个"假网卡"。往里写一个 IP 包,内核会当成从真网卡收到的包来处理;从里读,拿到的是内核本来要发出去的整包 IP 报文。
- UDP socket = 把那个 IP 包原封不动塞进 UDP 载荷,发给对端的公网地址。
- 两个搬运方向各跑一个 goroutine,互不阻塞。
Phase 2 没有任何"协议"——UDP 载荷里就是裸的 IP 包,没有头、没有序号、没有加密。后面的 phase 3/4/5 才往这条管子里逐步加密钥、加握手。
1. 两层网络:overlay vs underlay
这是理解整个实验的关键。隧道方案天然有两层地址:
| 层 | 设备 | 网段 | 角色 |
|---|---|---|---|
| overlay(隧道内) | tun0 |
10.0.0.0/24 |
虚拟内网。ping 10.0.0.2 打的是这一层,用户只看得到这层 |
| underlay(承载层) | veth-a / veth-b |
192.168.100.0/24 |
模拟"公网"。UDP 数据报真正在这一层上跑 |
隧道做的事就是:把 overlay 的 IP 包,装进 underlay 上的 UDP 报文里运过去,到对端再拆出来还原成 overlay 的包。这就是 “IP-in-UDP”。
1 | ┌─────────────────────────────┐ ┌─────────────────────────────┐ |
2. 为什么必须用 network namespace(最重要的坑)
最自然的想法是:在同一台机器上配 10.0.0.1 和 10.0.0.2 两个地址直接对 ping。这样做隧道永远不会被触发。
原因:当源和目的地址都属于本机时,内核的 local 路由表会把这次通信识别为"本地到本地",直接走 lo 回环短路——把 output 直接 copy 成 input,完全绕过 IP 转发逻辑和 TUN 设备。你的 phase2 进程根本读不到包。
解决办法:把两个端点放进各自独立的 network namespace(n1、n2),中间用一对 veth(虚拟网线)连起来。netns 之间网络栈隔离,流量被迫走一条"真实"的网络路径,才会命中 tun0 的路由、被 phase2 截获。
几个连带的细节:
- 两端的 TUN 都叫
tun0,不是 tun0/tun1。因为接口名是 netns 作用域的,各 ns 里第一个 TUN 都是 tun0,互不冲突。 - 这是本地交付(local delivery / input)而不是转发(forwarding):每个 netns 自己做路由查找,目的地址(10.0.0.x)就是它本地 tun0 的地址,所以不需要开
ip_forward,也没有路由器角色,是纯点对点。 - overlay 地址用
ip addr add 10.0.0.1 peer 10.0.0.2 dev tun0的点对点(peer)形式配置——这一句同时把"去往对端的路由"也装好了。如果忘了给 tun0 配地址,会直接Network is unreachable。
3. 一次 ping 10.0.0.2 的完整旅程
1 | n1: ping 10.0.0.2 |
值得记住的一点:回包(echo reply)是内核 ICMP 协议栈自动生成的,phase2 完全不知道自己在转发的是 request 还是 reply,它只是无脑搬运 IP 包。这正说明了隧道的"透明性"——它工作在 IP 包这一层,对上层协议无感知。
4. 核心代码(tunnel.go,package main,约 154 行)
整个数据面就是两个对称的循环,各跑一个 goroutine:
1 | // 出向:TUN → UDP |
main() 的骨架:
1 | // 参数:<listen-addr> <peer-addr> |
设计上的小要点:
buf设 2000 字节,略大于以太网 1500 MTU,足够装下一个 TUN 包。- 单 peer 假设:
ReadFromUDP的来源地址直接丢弃;对端地址在启动时写死成命令行参数。多 peer(按对端公钥/地址分流)是后面 phase 的事。 - 错误处理分两档:
Read/ReadFromUDP出错(设备/socket 级)→return err让程序退出;Write出错(瞬时)→continue容忍。 - 复用 phase1 做 library:
import ".../phase1"调phase1.SummarizeIPv4。phase1 原本是个main,这一阶段配套的提交把它改成了package phase1导出函数(解析 version、protocol(byte 9)、src(12–15)、dst(16–19),打印一行[tun] IPv4 src -> dst proto=ICMP len=...)。这是"每个 phase 都能独立运行、又能被后面复用"的体现。
5. 实验脚本(net_exp.sh)
三个子命令,把上面那套拓扑一键搭起来:
setup:①ip netns add n1/n2;② 建veth-a/veth-b并各塞进一个 ns;③ 配 underlay192.168.100.1/.2/24,up veth 和 lo;④ 在两个 ns 里各启动一个./phase2(n1 监听.1:8080peer.2:8080,n2 对称);⑤sleep 0.5等 tun0 出现;⑥ 给 tun0 配 overlay 点对点地址并 up。exp:ip netns exec n1 ping -c 10 10.0.0.2。reset:pkill -f ./phase2+ip netns del n1/n2(veth 随 ns 删除一起消失)。
这里有个已知的脆弱点
tun0 只有在 phase2 进程调用 water.New() 之后才存在。脚本现在用 sleep 0.5 来"等"它出现——这是个竞态:机器慢一点 0.5s 不够,第 ⑥ 步 ip addr add ... dev tun0 就会因为 tun0 还没建好而失败。更稳的写法是轮询:
1 | until ip netns exec n1 ip link show tun0 &>/dev/null; do sleep 0.05; done |
另外 set -euo pipefail 配合后台进程有个隐患:setup 中途任何一步失败直接 abort,会留下半残的 netns 和孤儿 phase2 进程。生产化的话应该加 trap reset EXIT 之类的清理钩子。(这些是笔记层面的改进点,当前脚本还没改。)
6. 运行环境
- TUN 设备和
ip netns都是 Linux-only。songgao/water库也只在 Linux 上能开 TUN。 - 在 macOS 上开发:仓库自带
Dockerfile+.devcontainer,在容器里跑,需要--cap-add=NET_ADMIN和挂/dev/net/tun。 - 构建运行:
go build -o phase2 .然后sudo ./net_exp.sh setup && sudo ./net_exp.sh exp。 - 构建坑:
tunnel.go必须是package main,否则go build产出的是不可执行的库归档(.a),脚本里./phase2跑不起来。
7. 它在整个项目里的位置
| Phase | 主题 | 状态 |
|---|---|---|
| 1 | 从 TUN 网卡读取并解析 IP 包 | ✅ |
| 2 | 明文 IP-in-UDP 隧道(双 netns 实验) | ✅ |
| 3 | PSK + ChaCha20-Poly1305 对称加密 | 🚧 |
| 4 | Curve25519 ECDH 密钥交换 | ⬜ |
| 5 | Noise IK 握手协议 | ⬜ |
| 6 | 真实公网路由 + NAT 穿透 | ⬜ |
Phase 2 是第一个"能跑通的 VPN 数据面",但它是裸奔的:任何能嗅探 underlay 的人都能看到原始 IP 包。它把数据通路这件事彻底夯实,留下一个干净的切入点——phase 3 只需要在 tunToUDP 发出前加密、在 udpToTun 写入前解密,就能把这根明文管子升级成加密隧道。
8. 三个最该记住的 takeaway
- 隧道 = 两个读写循环 + 两层地址。 overlay 给用户看,underlay 真正承载,VPN 程序只是在两者之间无脑搬运 IP 包。
- 同主机会被
lo短路,所以必须 netns + veth 才能逼出一条真实网络路径来触发隧道。 - 隧道对上层透明: 回包是内核自动生成的,搬运程序对 ICMP/TCP/包的方向一无所知——它只认 IP 包这一层。
