背景

之前需要有将 WireGuard 包装进代理的需要,因而尝试了许多方案。众所周知,WireGuard 使用 UDP,这个地方比较棘手:很多代理形式不支持 UDP,支持的效率也不一定理想。当初,我尝试了多种方案,但多多少少都有一些让我不满意的地方,比如:

  • 代理支持 UDP 但很不稳定(实际上这个应该是网络环境的锅)
  • 配置非常复杂(比如需要联合使用多个组件,配 iptables 等)
  • 转换成 TCP 后速度很慢
  • MTU 限制(这个最麻烦,如果隧道 MTU 很小,则需要相应调低 WireGuard 的 MTU,会带来很多问题)

总之,一段时间的探索后并没有得到令我满意的方案。前些日子正好有些空闲时间,干脆自己写一个好了。ushuttle 整个开发过程大概占用了我四五天的业余时间。使用 Rust 进行网络编程无疑是很愉快的,个人比较欣赏的地方包括优雅的错误处理、便捷的依赖管理和 Tokio 带来的异步支持等。最终 rust-analyzer 检查通过全部源码,程序工作(大体上)符合预期的时候,不禁回想起本科时拿 C 手撸网络编程的日子… 只恨没有更早接触现代化的编程语言。然而,实际上在开发过程中还是遇到了不少坑,也第一次尝试了 Rust 从开启项目到发布的完整流程和坑,因而我想值得水一贴来记录下整个过程。

程序设计与开发

大致设计

整个程序不必很复杂,因为我期望的功能非常简单:客户端将 UDP 数据包包裹进 TCP 连接发送到远方,再由服务端拆出 UDP 包完成转发。

要实现这个目标,就又回到了老生常谈的问题上了:UDP 和 TCP 的应用场景有何不同?我想,具体到我的使用场景上的话,主要有两个比较大的问题:第一个是丢包,如果传输网络质量差的话,TCP 频繁进行差错控制和重连势必会对内部的 WireGuard 产生很大影响。第二个是 TCP 的三次握手,WireGuard 连接的首包延迟会比较高。

然而在我的使用场景中,传输网络的质量是比较好的。此外,若考虑到程序将来更广泛的用途的话,为每个 UDP 流分别建立一个 TCP 连接会造成效率低下。试想,如果内部流量仅仅是一次 DNS 查询,则三次握手四次挥手的开销会大于实际流量的开销,用户也会感知到明显的延迟。为此,我选择让客户端与服务端之间保持若干个 TCP 长连接,UDP 包到达时无需再次握手而是直接打包转发,从而实现在特定场景下“消除” UDP 和 TCP 的差异。

用户鉴权是必须要有的。服务端程序将暴露在公网上,因而需要完成鉴权才允许实际转发。为此,我设计了一套简单的鉴权机制:客户端和服务端之间共享一个预共享密钥,客户端发起新 TCP 连接时需要用预共享密钥加密此时此刻的 UNIX 时间戳。服务端若能正确解出时间戳,且与本地时间相差小于阈值,则认为鉴权通过,并准备接收实际数据。为了防止有搞事的人建立一堆握手连接却不发数据,我还对握手设定了时限,若时限内未完成握手鉴权则立即切断连接。这个机制也有比较大的缺陷,即抗重放攻击能力不够强。若存在一个监听者,则可以在阈值时间内发起重放攻击。目前我自用的环境不太可能存在这样的监听者,因此先仅仅这样简单设计,今后再考虑加固。

鉴权通过之后,就是平淡无奇的转发了。客户端每收到一个 UDP 包,都在 TCP 缓存中填入两个字节的 UDP 包长度(因为 UDP 包长度用两个字节足够表示),再原封不动放入 UDP 的数据部分,发送到服务端。服务端根据两个字节的包长度读取 UDP 包并转发。返程流量同理。目前没有实现对流量的加密,将来考虑实现。

代理实现

Rust 生态中居然没有统一的代理库!那干脆也自己写一个算了。之前读完了 SOCKS5 和 HTTP 代理的 RFC 文档,磨磨蹭蹭撸了个简易的代理库出来。库名 proxie,参见 crates.iodocs.rs 以及 GitHub

固定服务端 UDP 端口

OK,设计完美,开始测试!iperf 跑起来后日志闪瞎了我的钛合金狗眼,无数新连接建立的通知一闪而过,最后还附赠了一个回程包丢了的提示… 不对不对,这肯定不对,一想就知道对于同一个 UDP 流应该保持转发路径稳定,不能来一个包开一个 UDP socket。对策也很简单,UDP socket 本质上是一个文件描述符,那么我存一些下来应该也没有问题的吧,因此我给每个 UDP 流打上一个标记,如果不是首包则使用已有的 UDP socket 就行了。

解决乱序包

完成上述的机制后,使用 iperf 测试的时候发现总存在一定数量的乱序包。尽管 UDP 本身也不保证有序,但还是考虑下设计中哪里存在问题吧。既然出现了乱序,那么很可能是程序中出现了竞争现象。罪魁祸首实际上就是多个 TCP 长连接。在最初的设计中,每次收到 UDP 包时会使用不同 TCP 连接转发。而不同连接由不同线程管理,显然存在竞争的可能性。为此,利用上面说的 UDP 流的标记来将 UDP 流固定在一个 TCP 连接上,从而解决了乱序包的问题。

这种做法也存在很大弊端,即牺牲了一定的并发性能。虽然多个 UDP 流仍然可以并发地使用资源,但单个 UDP 流只能使用一个 TCP 连接,即使全局仅存在一个 UDP 流时也如此。后续可以考虑在服务端加入一定的同步机制或滑动窗口机制,暂存乱序包,从而实现并发度和乱序之间的平衡。

错误重连

现实网络环境复杂,因此有必要做一些错误处理。这里非常感谢 Rust 优雅的错误处理机制,让这个工作格外简单:每当底层发来一个错误时,在客户端和服务端的主循环中都会截获这个错误,打印并进入下一轮循环。如果是连接断开了,则调用相关机制重新打开连接即可。

关于 TCP 的关闭流程,假如服务端意外关闭了,或 TCP 连接在中途被噶了,客户端往往只能知道一个方向的 TCP 连接断开了。很显然,我们希望转发任意 UDP 包的话,是无法预知 TCP 连接的生命周期的。因此,只要有一个方向断开,则立即中断另一个方向的连接是合理的,之后调用有关机制来重开连接即可。

当然,现实中的网络环境远远比想象中的更复杂。按照上面的设计,基本的错误处理能力是有了,只是可能还不够优雅或高效。之后再根据实际情况慢慢迭代吧…

“清洁工”

上面提到,无论是客户端还是服务端中都有记录 UDP 流信息,时间长了会积攒下来许多,最坏情况是塞爆内存。因此,单独起一个线程,每隔一段时间扫描记录表,清理掉许久没有出现过的连接。“清洁工”线程的职责还有恢复断掉的 TCP 连接。TCP 连接断掉后不会立即重连,而是等到有需要时再重连。但假如迟迟没有需要的话,“清洁工”线程也会定时进行重连,在传输网络畅通的前提下保证程序状态最佳。

性能测试

Rust 的性能本身还是很顶的,在我没有对程序逻辑进行什么优化的情况下,初步的测试结果(在我 i5-1240P 的开发机上):不使用 ushuttle 直连可以达到 7Gbps,使用 ushuttle 但不使用代理的情况下则为 2.1 Gbps。以上的测试结果中,丢包率都小于 1%。使用 ushuttle 时,如果进一步提高客户端发送速率,则会收获很多 TCP 连接错误,且 CPU 的占有率不高。这说明 TCP 连接很可能是瓶颈之一,毕竟单个 UDP 流仅使用一个 TCP 连接。尽管如此,我想这个测试结果已经十分令人满意了,毕竟若配合代理工作的话,代理的速率往往是瓶颈。

我在程序中设置了一些线程间共享的有锁的数据结构,在并发度高的情况下无疑会带来一定的性能损失。这个也留到后面有需要时再处理吧…

构建打包

GitHub 上的这类网络小工具往往都会发布各种平台的预构建二进制,我也希望能够这么做。但 Rust 的交叉编译体验属实是令人发指。关于这个问题,我想后面单独开一个贴子探讨 Rust 工程的交叉编译和静态链接,细数一下其中的坑。这里就不再专门花篇幅描述。

最终,ushuttle 的源码和预构建二进制位于 GitHub。假如有朋友需要类似的方案的话欢迎测试建议以及点星。我个人对这个小玩具是比较满意的,使用一段时间后发现意外地还算好用,各种指标都满足了我的预期。总体而言,除了最后的构建,整个流程都算得上是一次愉快的经历。