在给 Cloudflare 的 boringtun 分支打了两年多、累计超过 70 个 PR 的补丁之后,零信任网络厂商 Firezone 终于放弃了维持 100% 上游 API 兼容的尝试,于 2026 年 10 月 31 日正式开源了用 Rust 编写的用户态 WireGuard 状态机实现 sanstun(v0.1.0 版本于 10 月 30 日发布,采用 Apache-2.0 许可证)。与人们熟悉的 Go 语言版 wireguard-go 或 boringtun 相比,sanstun 最激进的改变是彻底采用 sans-IO 架构——把网络读写、系统时钟、随机数生成以及内部缓冲全部从核心库里剥离,只留下一个纯粹的协议状态机。
这一重构直接解决了 Rust 网络编程里的一个顽疾,即如何在不引入内部锁的前提下实现单隧道内的多核并行加解密,同时也让全系统确定性仿真测试成为可能。但剥离副作用是有代价的:sanstun 把最容易出错的密码学安全边界推给了上层调用者,而且这个初出茅庐的 v0.1.0 版本目前尚未经过第三方安全审计。
两年 70 个补丁换来的决裂:内部缓冲与系统时钟为何成了累赘
跨平台客户端和容器化网关高度依赖用户态 WireGuard 实现,Firezone 早期顺理成章地选择了 Cloudflare 用 Rust 编写的 boringtun(sanstun 的 NOTICE.txt 中至今保留着源自 BoringTun 的 BSD-3-Clause 许可证声明)。但在长达两年的生产环境打磨中,Firezone 发现传统网络库将状态与副作用打包的做法,正在拖垮系统的可测试性与吞吐上限,原有的 firezone/boringtun 分支也随着 sanstun 的发布被正式弃用。

第一个痛点是系统时间耦合。为了排查极其隐蔽的分布式边缘 Bug,Firezone 需要对整个网络系统做确定性仿真测试(Deterministic Simulation Testing)。如果底层库直接调用操作系统的时钟,仿真器就无法快进或精确重放超时逻辑。Firezone 当初向自己维护的 boringtun 分支提交的首批大改(firezone/boringtun#5),就是给所有涉及时间的函数硬加上 _at 后缀并传入 Instant 参数。
第二个痛点是看似贴心的内部缓冲机制。当上层应用尝试发送数据包而隧道尚未完成握手时,boringtun 会自动在库内部把包缓存起来并触发会话建立。这种设计带来了三个工程包袱:
- 反直觉的排空契约.为了把积压在内部的缓冲区排空,调用方必须向
decapsulate函数传入一个零长度的数据报并在循环中反复调用。 - 额外的内存分配.库内部的隐式缓冲不可避免地增加了堆内存分配开销。
- 容量难以按需调优.底层库写死的缓冲策略无法适应千差万别的上层业务流量特征。
sanstun 的解法非常干脆,直接砍掉内部缓冲。发送数据包时只需调用 Tunnel::begin_encapsulate,如果当前没有活跃会话,由调用方自己在外部决定怎么缓存数据包,并用 Tunnel::initiate_handshake 显式发起握手。
绕开 Rust 借用检查器的手术刀:单隧道如何跑满多核
在协议层面,WireGuard 不同隧道(Tunnel)之间不共享状态,按对端节点做多线程并行非常容易。但如果两条高带宽节点之间只建了一条 WireGuard 隧道,所有五元组流量都挤在同一个会话里,单核 CPU 的 ChaCha20-Poly1305 对称加解密速度就会成为死瓶颈。
为了防止重放攻击,WireGuard 要求每个数据包携带单调递增的包计数器(Packet Counter)。在传统的 Rust 实现里,更新计数器状态和执行加解密被绑在同一个需要可变借用(&mut self)的函数中;只要一个线程还在算加密,其他线程就无法触碰这个隧道状态,除非在库内部塞满锁和原子变量。
sanstun 用了一把非常精巧的手术刀:把极轻量的状态记账与极耗时的密码学运算彻底拆开。
发送数据时,主线程调用 Tunnel::begin_encapsulate 并不执行加密,它只检查会话、预留一个包计数器并推进定时器状态,随后立刻归还隧道的可变借用,返回一个实现了 Send trait 的 PendingEncapsulate 结构体。调用方可以把这个结构体直接扔给后台线程池去跑真正的 encrypt。接收端同样被拆成 begin_decapsulate、跨线程 decrypt 和主线程 commit_decapsulate 三个阶段。
根据代码仓库 benches/data_path.rs 中基于 Criterion 框架的微基准测试,无论是 64 字节还是 1,280 字节的负载,begin_encapsulate 的记账耗时都稳定在 20.8 纳秒左右;而在 1,280 字节负载下,真正的 encrypt 耗时高达 933.5 纳秒(64 字节时为 226.2 纳秒)。记账开销只占整个发包加密流程的 2%。
这也让 Interface 和 Tunnel 的核心状态得以保持纯单线程运行,内部的防 DoS 速率限制器连一把锁或一个原子计数器都不需要。
但这里有一个原博客没有展开的工程现实:单靠组件级微基准测试里的 45 倍理论上限,还不能直接等同于真实网络里的吞吐暴涨。sanstun 的基准测试目前尚未包含 TUN/UDP 套接字读写、跨线程工作队列调度,也没有给出与早已内置并行加解密 worker 的 wireguard-go 的端到端吞吐量与延迟对比。在低负载场景下,把任务派发给外部工作线程的队列调度开销,反而可能拉高单包的尾延迟;这也是为什么 sanstun 保留了在单线程内直接内联调用 encrypt 的接口。
编译期防呆与外移的安全契约:谁该用、谁先别碰
在 API 安全性上,sanstun 充分展现了 Rust 类型系统的优势。WireGuard 的握手阶段需要进行开销不小的 Diffie-Hellman 密钥交换,攻击者一旦获知服务端公钥,就可以伪造源 IP 发起洪水攻击耗尽服务端 CPU;协议因此设计了基于接口层级的 Cookie 速率限制机制。

为了防止开发者在调用时漏掉限流检查,sanstun 把 HandshakeInitiation 等消息类型的构造函数全部设为包内私有(crate-private)。外部代码即便看到了 Tunnel::handle_handshake_initiation 这个公开函数,也无法直接构造参数去调用它,唯一合法的路径是先把原始数据报塞进 Interface::receive。这个函数会在底层强制执行速率限制、解析高 24 位的隧道索引,并在高负载时直接生成 Cookie 回复,从编译期就堵死了绕过限流器的可能。
状态机越纯粹,留给调用方的安全考卷就越残酷。
然而,当 sanstun 把所有 I/O 和环境副作用清理出门后,原本由传统网络库兜底的安全重担也一并转移到了集成方肩上。翻看其 GitHub 仓库的文档可以发现,调用者如果要在生产环境接入 sanstun,必须自己填平几道致命的安全鸿沟:
- 风险.
sanstun的 README 明确声明该项目目前尚未接受正式的第三方安全审计(formal security audit),且其安全策略仅支持最新发布的版本,不会向旧版本回溯移植安全补丁。
除了审计空白,集成方还必须独立承担四项核心安全职责:
- 严苛的高熵随机数种子调用方必须提供密码学安全的随机数生成器。一旦在初始化或会话中重用了随机数种子,会导致临时密钥、Nonce 和 Cookie 重复,直接击穿整个加密体系。
- 强制执行 Cryptokey 路由校验
sanstun不负责检查允许的 IP 列表(Allowed IPs),调用方必须在外部自行实现并强制校验路由表。 - 验证解密后的内层 IP 包解密完成后,上层业务必须自行校验内部 IP 头部的合法性,防止欺骗流量注入。
- 保证单调递增的时间输入外部传入的
Instant时间戳必须严格单调递增,否则会搞乱握手重传与密钥轮换计时器。
对于自研 SD-WAN、游戏加速器或容器网络的 Rust 基础设施团队来说,sanstun 提供了一个极其干净、不绑死 Tokio 等特定异步运行时的底座,甚至可以轻松塞进基于 io_uring 的自研事件循环里。但如果你所在的团队只需要一个开箱即用的 VPN 组件,既没有全系统确定性仿真的需求,也没有把握独立写对随机数熵源管理、Cryptokey 路由校验和无锁多线程队列调度,那么在 sanstun 完成独立第三方安全审计并补齐端到端性能实测之前,继续留在成熟的 wireguard-go 或经过多年验证的现有方案里,依然是更稳妥的选择。
