这篇把两段东西并在一起读:
上半场(理论与动机) 是读 WireGuard 论文(NDSS 2017)整理出来的——VPN 三代演进、论文怎么把 IPsec 当反派批判、为什么 VPN 必须走 UDP、为什么 Go 也能写出一个完整 VPN;
下半场(动手实现) 是顺着这套理论真正写起来的个人项目 mini-vpn-go(用户态 VPN,从零手写、逐 phase 演进,最终对标 WireGuard),目前跑通到 Phase 2 的明文 IP-in-UDP 隧道。
源码:~/Desktop/tun_example/phase2/(仓库名是 tun_example,但 Go module 是 github.com/1WesleyYou/mini-vpn-go)。


一、理论与动机

VPN 史前简介

VPN 这个名字里的 virtual 来自 1990 年代企业的实际诉求, 当时跨地办公要靠租用专线, 业界想用便宜的公网 Internet 模拟出专线效果, virtual 是说虚拟出一根原本要靠物理拉线才有的专网;

接下来 30 年里, VPN 的主流协议大致经过三代:

  • IPsec (1998, IETF), 工作在 IP 层, 由 ESP (加密 + 认证), AH (只认证), IKE (密钥协商) 三个子协议拼成; 内核态实现, 算法可协商, 规范文档加起来几千页, 配置极其复杂, 但靠厂商生态吃下了企业 site-to-site 市场; Linux 内核里对应的模块叫 XFRM;
  • OpenVPN (2001, James Yonan), 用户态实现, 复用 OpenSSL 和 TLS 做加密认证, 用 tun / tap 接管网络, 跑 UDP 1194 或 TCP 443; 部署只要一个二进制, 跨平台友好, 是 2000s 到 2010s 商业 VPN (NordVPN 等) 的默认协议; 但用户态架构和 TLS overhead 让它性能输给下一代, 吞吐只有 WireGuard 的四分之一左右;
  • WireGuard (2016, Jason Donenfeld), 一个人写出来的, 内核版 C 代码约 4000 行, 算法写死为 ChaCha20-Poly1305 + Curve25519 + BLAKE2s + HKDF, 握手用 Noise 协议的 IK 模式一个 1-RTT 完成, 2020 年进 Linux 5.6 内核主线;

把这三代的关系一句话拎出来: IPsec 代表什么都能配, OpenVPN 代表用 TLS 给 VPN 续命, WireGuard 代表拒绝协商, 算法写死, 代码极简; 后者的胜利不是技术天才, 而是工程克制;

论文怎么批判 IPsec

WireGuard 论文 §1 开篇就把 IPsec 当反派来 frame, 批判分成三个角度;

协议复杂性

IPsec 不是一个协议, 而是 ESP, AH, IKEv1, IKEv2, ISAKMP, SAD/SPD, NAT-T, IPComp 七八个 RFC 拼出来的生态, 一个"协议"要 7 个 RFC 才能描述清楚, 设计本身就有问题;

最具体的批判点: 协议被硬拆成用户态 + 内核态两半, IKE daemon (用户态守护进程, 比如 strongSwan 的 charon) 跑密钥协商, 内核里的 XFRM (Linux Transform Layer, 源码 net/xfrm/, transform 的缩写) 跑数据加解密和 SA 管理, 两者通过 netlink 接口同步状态机, 任何一边状态不对整条隧道就废; 这种双状态机互相依赖的设计是 IPsec 运维噩梦的根源, “我的 phase 2 起不来” 几乎是每个网工的童年阴影;

配置复杂性

算法可协商带来组合爆炸, ESP 支持 AES-CBC, AES-GCM, 3DES, ChaCha20, 各种 HMAC, 各种 DH group; 双方必须协商一致, 协商过程本身又是攻击面 (TLS 历史上一半 CVE 来自 downgrade 攻击, IPsec 同理); 即使是有经验的管理员都经常配错, 普通用户基本不可能配对;

直观感受一下 strongSwan 一份典型配置和 WireGuard 的对比, 同样是 site-to-site, 前者要 20 多行 + 证书库 + CA, 后者就这么几行:

1
2
3
4
5
6
7
8
[Interface]
PrivateKey = xxx
ListenPort = 51820

[Peer]
PublicKey = yyy
AllowedIPs = 10.0.2.0/24
Endpoint = 5.6.7.8:51820

实现脆弱性

复杂必然带 bug, 代码量对比就很说明问题:

实现 大致代码行数
strongSwan (IKE daemon + 工具链) ~400,000
Linux XFRM (内核 IPsec) ~13,000
WireGuard (内核版完整实现) ~4,000

TCB (Trusted Computing Base) 大一两个数量级, CVE 多几个数量级是必然; 2018 年 IKEv1 还在出 Bleichenbacher 攻击 (CVE-2018-5389), 2019 年 strongSwan 有远程 DoS, 2020 年 Linux XFRM 多个堆溢出, 不是实现者不努力, 而是协议本身复杂度太高, 实现层无法保证正确性;

Donenfeld 没说 IPsec 该死, 他说的是 IPsec 不应该被推荐给新场景; IPsec 现在的市场基本盘是企业级 site-to-site VPN, 靠的是厂商生态 (Cisco / Juniper / Fortinet 路由器开箱即用), 网络认证体系 (CCNP / CCIE 写进考纲), 行业合规要求, 以及几百万台已部署设备的沉没成本; 但在 2016 年这个时间点设计新协议, 还沿用 IPsec 那套思路就是抱残守缺;

为什么 VPN 走 UDP 而不是 TCP

读 abstract 时会注意到 WireGuard 明确写 “over UDP”, 这不是性能选择, 而是正确性选择;

核心问题是 TCP-over-TCP meltdown: 如果用 OpenVPN over TCP 跑一个 HTTP 下载, 内层应用 TCP-A 和外层隧道 TCP-B 各自有重传和拥塞控制, 一旦外层丢包, 两层 TCP 同时触发重传, 互相打架, 缓冲区堆积, 延迟雪崩; 经典分析见 2001 年 Olaf Titz 的 “Why TCP Over TCP Is A Bad Idea”;

用 UDP 做外层就完全没这个问题, UDP 不重传, 内层 TCP 感知到的网络行为就和直接跑公网一样, 自己的拥塞控制能正常工作; 顺带还有低延迟, NAT 友好, 无队头阻塞等好处;

OpenVPN 保留 TCP 模式只是为了实在没办法时的 fallback (突破只放 443/TCP 的企业防火墙), 性能换隐蔽性; WireGuard 拒绝这种妥协, 只支持 UDP, 要伪装就在外面单独套一层 (Tailscale 的 DERP, 或 Xray, Shadowsocks 这类专门做混淆的代理), 把传输和伪装解耦;

顺便一提, IPsec ESP 走的是 IP 协议号 50, 既不是 TCP 也不是 UDP, 这是它极难穿 NAT 的根本原因 (NAT 设备认 TCP/UDP 端口, 不认 ESP), 后来打补丁 NAT-T 才把 ESP 再包一层 UDP 4500; WireGuard 选 UDP 单端口正是吸取了这个教训;

为什么 Go 能写一个完整的 VPN

WireGuard 是个协议, 它有多个独立实现, Linux 走内核态, 其他平台几乎都靠 Donenfeld 自己写的 wireguard-go, 这是个约 4000 行 Go 的用户态实现, 走的是 /dev/net/tun 这条经典通路 (用户态进程通过虚拟网卡读 IP 包, 加密后用 UDP 发出去, 对端解密写回 tun); macOS, Windows, BSD, Android 客户端的统一底座都是它, Tailscale 这种跨平台 VPN 产品本质上就是 wireguard-go 加一层控制平面;

用户态加 Go runtime 调度让 wireguard-go 性能只有内核版的 1/5 到 1/10, 但跨平台收益换回来了, 也让它成为学习整个 WireGuard 协议的最佳读物 (比啃内核 C 容易得多);

理解到这里, 自然就想自己上手写一个。本篇下半场就是这件事, 配套的笔记/实现计划是三步走:

  1. 把 WireGuard 论文 (NDSS 2017) 的核心机制 (Cryptokey Routing, Noise IK 握手, timer state machine) 过一遍;
  2. 读 wireguard-go 的源码结构, 重点看 device/, tun/, conn/ 三个目录;
  3. 用 Go 写一个不加密的 toy tun 隧道, 然后逐步加上 ChaCha20-Poly1305, 看看离真实的 wireguard-go 还差什么 —— 这一条已经动手, 就是下面的 mini-vpn-go

二、动手实现:mini-vpn-go

理论铺完, 下面把它落地。mini-vpn-go 的设计是逐 phase 加料, 每个阶段都是一个能独立跑通验证的实物, 跑通了再进下一阶段:

Phase 主题 状态
1 从 TUN 网卡读取并解析 IP 包
2 明文 IP-in-UDP 隧道(双 netns 实验)
3 PSK + ChaCha20-Poly1305 对称加密 🚧
4 Curve25519 ECDH 密钥交换
5 Noise IK 握手协议
6 真实公网路由 + NAT 穿透

下面记录已经跑通的 Phase 2——第一个"能跑通的 VPN 数据面", 但它是裸奔的, 不碰任何加密。这一阶段的目标只有一个: 把"读 TUN → 发 UDP → 写 TUN"这条数据通路真正打通, 让两台命名空间隔离的主机能 ping 通隧道地址。

2.0 一句话本质

一个 VPN,机械地看,就是把两个读写循环用两个 goroutine 粘在一起:一个 TUN 设备 + 一个 UDP socket。

  • TUN 设备 = 内核给用户态开的一个"假网卡"。往里写一个 IP 包,内核会当成从真网卡收到的包来处理;从里读,拿到的是内核本来要发出去的整包 IP 报文。
  • UDP socket = 把那个 IP 包原封不动塞进 UDP 载荷,发给对端的公网地址。
  • 两个搬运方向各跑一个 goroutine,互不阻塞。

Phase 2 没有任何"协议"——UDP 载荷里就是裸的 IP 包,没有头、没有序号、没有加密。后面的 phase 3/4/5 才往这条管子里逐步加密钥、加握手。

2.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.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

2.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 包这一层,对上层协议无感知。

2.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 都能独立运行、又能被后面复用"的体现。

2.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 之类的清理钩子。(这些是笔记层面的改进点,当前脚本还没改。)

2.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 跑不起来。

2.7 Phase 2 在整个项目里的位置

Phase 2 是第一个"能跑通的 VPN 数据面",但它是裸奔的:任何能嗅探 underlay 的人都能看到原始 IP 包。它把数据通路这件事彻底夯实,留下一个干净的切入点——phase 3 只需要在 tunToUDP 发出前加密、在 udpToTun 写入前解密,就能把这根明文管子升级成加密隧道。再往后的 phase 4(Curve25519 ECDH)、phase 5(Noise IK 握手)才逐步逼近上半场理论里讲的真实 WireGuard。

2.8 三个最该记住的 takeaway

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

参考资料