技术知识文章集合TECHNICAL ARCHIVE · 807 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBE

用 Rust 写一个玩具 VPN(wontun)

所属分类
Backend
文件格式
Markdown
原始路径
Backend/用 Rust 写一个玩具 VPN(wontun)
本文目录11 个章节
  1. 动机与目标
  2. 核心概念:tun/tap 虚拟网卡
  3. 测试环境:Docker 模拟双机
  4. 包是怎么流动的
  5. 架构演进一:PoC——两个线程两个循环
  6. 架构演进二:epoll 事件循环
  7. 架构演进三:多 Peer 与协议
  8. 三节点全连接测试:ip_forward 与 ICMP Redirect
  9. 架构演进四:多线程
  10. 结论与延伸阅读
  11. 整理者注

用 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,两个线程各自跑一个死循环:

  1. 循环 A:从 iface 读 IP 包 → udp.send_to 发给 peer;
  2. 循环 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 拓扑、友好配置界面。

原文的延伸阅读清单:

整理者注

本文与本分类多篇内容互相印证:

  • tun0、ip_forward=1、iptables -A FORWARD、MASQUERADE——正是《闲置安卓手机当旁路网关实现全家科学上网》那套脚本的全部要素,只不过那边是"配置内核",这边是"自己在用户态实现隧道的收发两端";
  • AllowedIPs 的双重用途(路由 + 准入),解释了 wireguard 配置为什么长那样;
  • EPOLLET + 非阻塞 + 事件循环的话题,与 Nginx 系列文章同源。
807 DOCUMENTS · 12 COLLECTIONS
ARCHIVE SEARCH807 篇文章