用 Rust 写一个玩具 VPN(wontun)
Category(分类): Backend Status: 已读
原文:Write a Toy VPN in Rust(yiransheng),配套项目 wontun。 原文为 9 万多字符的深度长文,本文为导读式整理:保留项目动机、核心概念、架构演进主线与关键代码骨架,跳过部分实现细节。深入阅读请回看原文。
动机与目标
作者用了五年 wireguard,始终觉得它的内部原理像"巫术":网络概念的迷宫、PostUp 里那些 iptables 咒语到底干了什么。受 Jon Gjengset(jonhoo)的《Implementing TCP in Rust》直播中关于 tun 接口的讲解启发,决定动手写一个简化版 wireguard,项目名 wontun。
明确的目标与边界:
- ✅ 能建模复杂 peer 拓扑(像 wireguard 那样);
- ❌ 不处理丢包、peer 重启等真实网络状况(活在"UDP 永远可靠送达"的理想世界);
- ❌ 无安全、无加密(别跑在公网上);
- ⚠️ 考虑并发与效率但不优化;
- 📚 顺带学习
ip、iptables等 Linux 网络管理工具。
大量参考(照抄)了 Cloudflare 的 boringtun(wireguard 的用户态 Rust 实现),整体架构以 boringtun 为蓝本但大幅简化,并用第三方 crate 或更直白的设计替换了大量 unsafe 代码。
核心概念:tun/tap 虚拟网卡
tun/tap 是 Linux 内核的两个虚拟网络设备驱动:
tun(network TUNnel)模拟网络层(三层/IP)设备;tap(network tap)模拟链路层(二层/以太网帧)设备。
在操作系统看来,它们就是真实网卡。wireguard 的 wg0 接口其实就是 wg-quick 创建的 tun 接口——这是本文对作者最大的"开窍点"。
用 tun-tap crate 创建接口并监听:
let iface = Iface::new("testtun", Mode::Tun).unwrap();
cmd("ip", &["addr", "add", "dev", iface.name(), "10.107.1.2/24"]);
cmd("ip", &["link", "set", "up", "dev", iface.name()]);
let mut buffer = vec![0; 1504];
loop {
// 每一次 read 就是一个完整 IP 包;buffer 太小会被截断
let size = iface.recv(&mut buffer).unwrap();
println!("Packet: {:?}", &buffer[4..size]);
}
测试环境:Docker 模拟双机
VPN 一台机器自言自语没意义,但又没有两台真机——用 Docker 模拟:
- 宿主机当客户端(模拟 NAT 后的家庭电脑,主动发起连接);
- Docker 容器当服务端(有"公网 IP",等待连接),挂在
--network=wontun-test桥接网络里(宿主机172.18.0.1,容器172.18.0.2); - 容器需要
--cap-add=NET_ADMIN和--device=/dev/net/tun才能操纵 tun 接口。
验收标准(PoC):两端运行后建立虚拟网络 10.8.0.1/24(客户端 10.8.0.3、服务端 10.8.0.1),服务端 nc -l 10.8.0.1 80、客户端 nc 10.8.0.1 能互通,就说明隧道打通。
包是怎么流动的
应用 → 内核路由表 → tun0 → [wontun 用户态程序读出 IP 包]
→ 包装成 UDP → 物理网络 → 对端 wontun
→ 解包 → 写入对端 tun0 → 对端内核路由 → 对端应用
MTU 账:原始 IP 包外再套一层 IP+UDP 头要 28 字节开销。路径 MTU 1500 时,tun0 收到 1500 字节的包再封装就会分片——所以把 tun0 的 MTU 调低(wireguard 默认 1420,本文用 1472/1400)。
架构演进一:PoC——两个线程两个循环
最初的版本极简,一个 Device(持有一个 tun 接口 + 一个 UdpSocket)+ 一个 Peer,两个线程各自跑一个死循环:
- 循环 A:从
iface读 IP 包 →udp.send_to发给 peer; - 循环 B:从
udp收包 → 记下对方地址 →iface.send写入本机 tun。
"雏鸭印随"式握手:服务端启动时不知道客户端地址(现实世界客户端 IP 是动态的),所以必须客户端先发包,服务端把第一个见到它的人"认亲"(set_endpoint 只在 endpoint 为 None 时设置)。为防服务端信任任何野包,加了个极简握手:客户端先发 hello?,服务端收到才认。
架构演进二:epoll 事件循环
PoC 每个 peer 两个线程,无法扩展。第二阶段引入 epoll + 非阻塞 IO,用单个事件循环管理所有 fd:
- epoll 三板斧:
epoll_create(创建实例)、epoll_ctl(增删改关注的 fd 与事件)、epoll_wait(等事件); epoll_event.data是用户自定义的 u64,C 里常塞回调指针,Rust 里那样要 unsafe——改用自定义Token枚举安全地编码/解码(Token::Tun/Token::Sock(id));- 只注册读事件(
EPOLLIN),发送在读处理器里直接做,省掉可写状态管理。
另附一个实用知识点:端口复用(SO_REUSEADDR)与 UdpSocket connect 行为——为已连接的 peer 建 connected socket,内核帮你过滤来源。
架构演进三:多 Peer 与协议
要建模 wireguard 级拓扑,需要解决两个问题:包往哪个 peer 发(出向路由)、包来自哪个 peer(入向识别)。
AllowedIPs 的双重用途
// 出向:按目的 IP 找 peer(路由表)
let Some(peer) = self.peers_by_ip.get(dst.into()) else { continue; };
// 入向:校验源 IP 是否是该 peer 声明的网段(准入过滤)
if peer.is_allowed_ip(src_addr) {
let _ = self.iface.send(data);
}
wireguard 的 AllowedIPs 一条配置两个用途:既是路由表(目的地址命中就发给这个 peer),又是准入列表(peer 发来的包源地址不在名单内就丢弃)。
协议:四状态握手
从单条 hello? 升级为三消息协议:
Client Server
│ HandshakeInit(name, idx) ──→│ → HandshakeReceived
│←─── HandshakeResponse(idx) │
│ Connected │ 收到第一条 Data 后 Connected
双方最终都持有对方的 remote_idx,后续数据包带 sender_idx 即可直接索引 peer。Packet 枚举:HandshakeInit / HandshakeResponse / Data / Empty。
已知的协议缺陷(作者坦然列出):不处理丢包、无超时、节点重启后重发握手会被无视、双端同时主动握手会死锁——在受控环境 + 非对称 client/server 角色下,选简单不选健壮。
职责划分:Peer vs Device
Peer:状态机 + 身份管理(handshake_state、endpoint、allowed_ips);Device:驱动事件循环 + peer 查找。
Peer.handle_incoming_packet 返回 Action 枚举(WriteToTun / WriteToNetwork / None),把"发生了什么"与"怎么响应"解耦。细节亮点:数据包热路径用 RwLock::read 而非 write,为多线程时代避免写锁饿死其他线程。
配置文件
命令行参数不够用了,改用 wireguard 配置格式的子集:
[Interface]
Name=client-A
Address=10.10.0.3/24
[Peer]
Name=server
Endpoint=172.18.0.2:19988
AllowedIPs=10.10.0.1/24
三节点全连接测试:ip_forward 与 ICMP Redirect
三台节点(client-A、server、client-B)两两互通。client-A → client-B 的包实际路径是:A 的 tun0 → server 解包 → server 内核转发(重新进入 server 的 tun0)→ B。
server 侧需要的系统配置:
# docker run 时需传 --sysctl="net.ipv4.ip_forward=1"
iptables -A FORWARD -i tun0 -j ACCEPT
-A FORWARD:加转发链规则(处理"不是发给本机、要路由出去"的包);-i tun0:匹配从 tun0 进来的包;-j ACCEPT:放行。
traceroute 10.10.0.2 显示两跳(10.10.0.1 → 10.10.0.2),完美符合预期。
意外收获——ICMP Redirect:ping 输出里出现 From 10.10.0.1 Redirect Host。原因:server 发现"这个包从 tun0 进来、目的地址也在 tun0 网段、直接可达",就好心建议 client-A"下次直连别绕我了"。但 VPN 拓扑里 A、B 之间并没有直连线路,这个建议无法执行。解法:
sysctl -w net.ipv4.conf.all.send_redirects=0
sysctl -w net.ipv4.conf.default.send_redirects=0
wireguard 历史代码里也有同样的处理(注册 tun 设备时直接关掉 SEND_REDIRECTS)。
架构演进四:多线程
最后一阶段反高潮——因为全程用了 Arc + RwLock 且小心避免 &mut self,多线程改造只是一个很小的 patch:
wait()改名event_loop(i: usize)(带线程编号);- 主线程
dev.event_loop(0),另起 N-1 个线程(默认 4)跑同样的事件循环; - 编译器保证无数据竞争——fearless concurrency。
为什么多线程安全:
- epoll 事件是原子的:一个事件只会报告给一个线程;
- 选用
EPOLLET边缘触发:只在状态变化时通知一次,避免多线程被同一事件反复唤醒/惊群;代价是必须"一次把数据读完",否则事件不会再来; - UDP
recv每次原子地读一个完整数据报,两个线程不会读到同一个包的两半; - tun 读操作同理,每次读一个完整 IP 包(但多线程下包的处理顺序不保证)。
结论与延伸阅读
作者的收获清单:tun 接口打通了用户态与内核网络栈、epoll 事件通知、VPN 协议与拓扑、OS 路由与 iptables;以及最重要的——理解复杂系统的最好方式是动手造一个。Rust 的类型系统与文档文化让系统编程的门槛比啃 C 与 man 页友好得多。
未来方向:基础加密、性能优化、mesh 拓扑、友好配置界面。
原文的延伸阅读清单:
- WireGuard unofficial documentation
- WireGuard protocol
- Understanding modern Linux routing (and wg-quick)
- Surpassing 10Gb/s over Tailscale
- Epoll is fundamentally broken
- Beej's Guide to Network Programming
- Operating Systems: Three Easy Pieces
- The Architecture of Open Source Applications: nginx
整理者注
本文与本分类多篇内容互相印证:
- tun0、
ip_forward=1、iptables -A FORWARD、MASQUERADE——正是《闲置安卓手机当旁路网关实现全家科学上网》那套脚本的全部要素,只不过那边是"配置内核",这边是"自己在用户态实现隧道的收发两端"; - AllowedIPs 的双重用途(路由 + 准入),解释了 wireguard 配置为什么长那样;
EPOLLET+ 非阻塞 + 事件循环的话题,与 Nginx 系列文章同源。