Vpn from WireGuard Impl — 从论文批判到 Go 手写隧道
这篇把两段东西并在一起读:
上半场(理论与动机) 是读 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 | [Interface] |
实现脆弱性
复杂必然带 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 容易得多);
理解到这里, 自然就想自己上手写一个。本篇下半场就是这件事, 配套的笔记/实现计划是三步走:
- 把 WireGuard 论文 (NDSS 2017) 的核心机制 (Cryptokey Routing, Noise IK 握手, timer state machine) 过一遍;
- 读 wireguard-go 的源码结构, 重点看
device/,tun/,conn/三个目录; - 用 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.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。
2.3 一次 ping 10.0.0.2 的完整旅程
1 | n1: ping 10.0.0.2 |
值得记住的一点:回包(echo reply)是内核 ICMP 协议栈自动生成的,phase2 完全不知道自己在转发的是 request 还是 reply,它只是无脑搬运 IP 包。这正说明了隧道的"透明性"——它工作在 IP 包这一层,对上层协议无感知。
2.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 都能独立运行、又能被后面复用"的体现。
2.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 之类的清理钩子。(这些是笔记层面的改进点,当前脚本还没改。)
2.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跑不起来。
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
- 隧道 = 两个读写循环 + 两层地址。 overlay 给用户看,underlay 真正承载,VPN 程序只是在两者之间无脑搬运 IP 包。
- 同主机会被
lo短路,所以必须 netns + veth 才能逼出一条真实网络路径来触发隧道。 - 隧道对上层透明: 回包是内核自动生成的,搬运程序对 ICMP/TCP/包的方向一无所知——它只认 IP 包这一层。
参考资料
- WireGuard 论文: https://www.wireguard.com/papers/wireguard.pdf
- wireguard-go 源码: https://git.zx2c4.com/wireguard-go/
- songgao/water 库: https://github.com/songgao/water
- Noise 协议规范: https://noiseprotocol.org/noise.html
- Linux TUN/TAP 文档: https://www.kernel.org/doc/Documentation/networking/tuntap.txt
- “Why TCP Over TCP Is A Bad Idea” (Olaf Titz, 2001): http://sites.inka.de/bigred/devel/tcp-tcp.html
