个人项目 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
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────┐         ┌─────────────────────────────┐
│ netns: n1 │ │ netns: n2 │
│ ping 10.0.0.2 │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ tun0 (10.0.0.1) ← overlay│ │ tun0 (10.0.0.2) │
│ │ phase2 进程 │ │ ▲ phase2 进程 │
│ ▼ │ │ │ │
│ veth-a (192.168.100.1) ───┼────UDP──┼──► veth-b (192.168.100.2) │
└─────────────────────────────┘ └─────────────────────────────┘
underlay = 192.168.100.0/24(模拟公网)
overlay = 10.0.0.0/24(隧道内虚拟网)

2. 为什么必须用 network namespace(最重要的坑)

最自然的想法是:在同一台机器上配 10.0.0.110.0.0.2 两个地址直接对 ping。这样做隧道永远不会被触发。

原因:当源和目的地址都属于本机时,内核的 local 路由表会把这次通信识别为"本地到本地",直接走 lo 回环短路——把 output 直接 copy 成 input,完全绕过 IP 转发逻辑和 TUN 设备。你的 phase2 进程根本读不到包。

解决办法:把两个端点放进各自独立的 network namespacen1n2),中间用一对 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
2
3
4
5
6
7
8
9
10
11
12
13
n1: ping 10.0.0.2
→ 路由查找:命中 tun0(10.0.0.x 走 tun0)
→ ICMP echo request 写入 tun0
→ n1 phase2 的 tunToUDP: ifce.Read() 读出整个 IP 包
→ conn.WriteToUDP(包, 192.168.100.2:8080) # 走 veth 到对端
─────────────────────── underlay (UDP over veth) ───────────────────────
→ n2 phase2 的 udpToTun: conn.ReadFromUDP() 收到载荷(就是裸 IP 包)
→ ifce.Write(包) 注入 n2 的 tun0
→ 内核看到目的是本地 10.0.0.2 → 本地交付给 ICMP 模块
→ 内核 ICMP 自动生成 echo reply(应用层不参与!)
→ reply 路由命中 tun0 → n2 phase2 读出 → UDP 发回 n1
→ n1 phase2 写回 tun0 → 内核向上交付给在等待的 ping 进程
ping 看到 reply,往返成功。

值得记住的一点:回包(echo reply)是内核 ICMP 协议栈自动生成的,phase2 完全不知道自己在转发的是 request 还是 reply,它只是无脑搬运 IP 包。这正说明了隧道的"透明性"——它工作在 IP 包这一层,对上层协议无感知。


4. 核心代码(tunnel.go,package main,约 154 行)

整个数据面就是两个对称的循环,各跑一个 goroutine:

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
// 出向:TUN → UDP
func tunToUDP(ifce *water.Interface, conn *net.UDPConn, peer *net.UDPAddr) error {
buf := make([]byte, 2000)
for {
leng, err := ifce.Read(buf) // 一次 Read 恰好返回一个完整 IP 包
if err != nil {
return err
}
if _, err2 := conn.WriteToUDP(buf[:leng], peer); err2 != nil {
continue // UDP 瞬时错误:跳过,不让循环挂掉
}
}
}

// 入向:UDP → TUN
func udpToTun(conn *net.UDPConn, ifce *water.Interface) error {
buf := make([]byte, 2000)
for {
length, _, err := conn.ReadFromUDP(buf) // 源地址被忽略:单 peer 场景用不上
if err != nil {
return err
}
if _, err2 := ifce.Write(buf[:length]); err2 != nil {
continue
}
phase1.SummarizeIPv4(buf[:length]) // 复用 phase1 打一行包摘要日志
}
}

main() 的骨架:

1
2
3
4
5
6
7
8
9
10
// 参数:<listen-addr> <peer-addr>
ifce, _ := water.New(water.Config{DeviceType: water.TUN}) // 开 TUN
localUDP, _ := net.ResolveUDPAddr("udp", listenAddr)
conn, _ := net.ListenUDP("udp", localUDP) // 绑 UDP
peer, _ := net.ResolveUDPAddr("udp", peerAddr)

done := make(chan error, 2) // 缓冲 2,避免另一个 goroutine 泄漏
go func() { done <- tunToUDP(ifce, conn, peer) }()
go func() { done <- udpToTun(conn, ifce) }()
log.Fatal(<-done) // 任一循环出错就整体退出

设计上的小要点:

  • buf 设 2000 字节,略大于以太网 1500 MTU,足够装下一个 TUN 包。
  • 单 peer 假设ReadFromUDP 的来源地址直接丢弃;对端地址在启动时写死成命令行参数。多 peer(按对端公钥/地址分流)是后面 phase 的事。
  • 错误处理分两档Read/ReadFromUDP 出错(设备/socket 级)→ return err 让程序退出;Write 出错(瞬时)→ continue 容忍。
  • 复用 phase1 做 libraryimport ".../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;③ 配 underlay 192.168.100.1/.2/24,up veth 和 lo;④ 在两个 ns 里各启动一个 ./phase2(n1 监听 .1:8080 peer .2:8080,n2 对称);⑤ sleep 0.5 等 tun0 出现;⑥ 给 tun0 配 overlay 点对点地址并 up。
  • expip netns exec n1 ping -c 10 10.0.0.2
  • resetpkill -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-onlysonggao/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

  1. 隧道 = 两个读写循环 + 两层地址。 overlay 给用户看,underlay 真正承载,VPN 程序只是在两者之间无脑搬运 IP 包。
  2. 同主机会被 lo 短路,所以必须 netns + veth 才能逼出一条真实网络路径来触发隧道。
  3. 隧道对上层透明: 回包是内核自动生成的,搬运程序对 ICMP/TCP/包的方向一无所知——它只认 IP 包这一层。