开发 Runnel 的一些记录 | CatCoding
最近断断续续 Vibe Coding 写了一个小工具,叫 Runnel。因为我之前用的两个机场都跪了 (虽然后来又逐渐恢复了),于是萌生了自己写个代理工具的想法,顺便把一些网络相关的东西重新摸一遍。
Runnel 有“小河流”的意思,我觉得还挺贴切:不是要做一个很宏大的网络基础设施,就是在本机和远端之间挖一条能用、能看、能调试的小通道。
先让 AI 快速撸了一个车 native-http 版本的,然后又加了多路复用的 native-mux 模式。后来又想到一个同事说他一直用自己写的代理工具叫做 daze,所以立马让 AI 用 Rust 重写了一份跑了起来。
于是它现在支持几种模式:native-http、native-mux、几个 daze 风格的模式,以及后来主要投入精力做的 wg 模式。
前面这些更像传统 SOCKS 代理,应用明确连到本地 SOCKS 端口,然后 Runnel 把流量转到远端。WG 模式不一样,它走 TUN 设备和 WireGuard 风格的 UDP 包,更像日常会用的全局 VPN。
真正让我把重心转到 WG 模式的原因也很普通:SOCKS 在浏览器里挺好用,但到系统级流量、命令行工具、各种后台服务时就没那么舒服了。macOS 下总会碰到有些软件不吃系统代理,有些软件自己做 DNS,有些东西干脆只看路由表。最后还是得上 TUN。
WG 模式底层用的是 Cloudflare 的 boringtun,目标很简单:
- client 创建一个 TUN 设备;
- server 也创建一个 TUN 设备;
- 两边用 WireGuard key 建立 peer;
- client 把默认路由打进去;
- server 开转发和 NAT。
听起来像配置一遍 WireGuard 而已,但自己写以后就会发现,大部分时间并不在加密和解密上,而是在处理操作系统。
Linux 上需要 /dev/net/tun、ip、sysctl、iptables。macOS 上是 ifconfig、route、networksetup。命令不复杂,但出错方式很多。例如服务端没停干净,再起一次就会卡在:
ip address add 10.8.0.1 peer 10.8.0.2 dev runnel-wg0
因为设备还在,地址也还在。再比如 macOS 上如果之前的 tunnel 没清理干净,WG endpoint 的路由可能会被系统解析到某个 utun 上。这个时候 client 还没真正起来,但发往 server endpoint 的 UDP 包已经准备钻进旧 tunnel,最后当然连不上。
这类问题很烦,但它们也逼着我把启动阶段做得更啰嗦一点。现在 Runnel 会做 preflight,会打印 hook,会在 client 装路由之前先做一次短 handshake probe。key、endpoint 或两端配置有问题时,最好在启动阶段直接报错,而不是把系统路由改完,然后留给用户一个没有响应的黑盒。
我写这类工具最大的感受是:网络工具的“好用”,很多时候不是吞吐量高一点,而是坏的时候知道坏在哪里。
WG 的配置很容易写错,尤其是 private key、peer public key、tunnel IP 这种互相交叉的字段。手写一次就够让人烦了,所以后来加了:
runnel wg-config --server-endpoint SERVER-IP:1443 > runnel.yaml
这个命令会一次生成 client 和 server 两边配置。两台机器用同一个 YAML,只是启动时分别执行:
sudo runnel --config runnel.yaml server
sudo runnel --config runnel.yaml client --tui
它减少了很多“我复制错了吗”的问题,也方便把一些默认值放进去。比如现在生成配置默认带 adblock,默认启用 DNS capture,也会默认带上 noise engine 和 mask obfs:
client:
wg:
engine: noise
obfs: mask
obfs_padding_min: 8
obfs_padding_max: 96
obfs_handshake_padding: 256
obfs_response_padding: 192
这些东西不一定适合所有人,但它们很适合我自己的使用场景:默认配置应该尽量接近日常可用,而不是只给一个裸 tunnel。
我原来只想做 IP 规则,例如:
client:
ip_rules:
direct:
- "10.*"
- "192.168.*"
这个在 WG 模式下比较直观,direct 就是给这些 IP 加本地直连 route,不让它们进 tunnel。后来很快又想要域名规则:
client:
domain_rules:
direct:
- "*.baidu.com"
- "*.cn"
block:
- "*.xxx.com"
问题是 WG 看到的是 IP 包,不知道这个连接原本访问的是 github.com 还是别的什么域名。能抓到域名的地方只有 DNS。
所以现在 WG 的 domain rules 是 DNS 驱动的。client 会把本机 DNS 指到 127.0.0.1:53,Runnel 在本地做一个很小的 DNS forwarder。它看到查询 www.xxx.com,发现命中 direct 规则,就等上游 DNS 返回 IP,然后动态加一条 host route,让这个 IP 走本地网关。
这个方案很实用,但并不完美。第一次访问一个域名时,如果系统还没有拿到解析结果,它可能已经先走了 tunnel。DoH、DoT、浏览器缓存、直接访问 IP,也都绕过这条逻辑。CDN 共享 IP 还会带来另一个问题:你为了某个域名加的直连 route,可能影响另一个解析到同一 IP 的域名。
这些限制没法假装不存在,所以文档里也写清楚了。能解决 80% 的日常问题就很好,剩下 20% 要靠更底层的 packet inspection 或系统扩展,那又是另外一个坑。
后来我把 adblock-rust 集成进来了。原因很简单:既然 WG 模式已经有 DNS capture,那 block 域名就可以顺手做掉。
这里我比较在意优先级。最后定下来是:
- 用户自己的
domain_rules.block - adblock 规则
- 用户自己的 direct/proxy 规则
- 默认走 proxy,也就是 tunnel
用户手写规则永远应该赢过订阅规则。否则哪天 EasyPrivacy 把一个你需要的域名拦了,就会很难受。
性能上我一开始也有点担心。毕竟 tunnel 里每个包都可能很密集,如果每个请求、每个 packet 都去跑 adblock,那肯定不行。最后实现是 DNS 级别的:只有新的域名查询会进入规则匹配,订阅会缓存在本地,域名决策也有内存缓存。实际看下来,这个成本可以接受。
有次 TUI 里看到 p.data.cctv.com 被 block,我还专门去查了一下。它不是用户规则拦的,是订阅规则命中的。
Runnel 有一个 TUI,不是为了好看,主要是为了少开几个终端。
WG 模式刚开始出问题时,我经常要同时看:
- client 有没有握手;
- server 有没有看到 endpoint;
- 最近有没有 REKEY timeout;
- DNS 最近解析了什么域名;
- 哪些域名被 direct、proxy、block;
- tunnel 有没有流量。
这些信息散在 log、netstat、route、tcpdump 里会很痛苦。放到一个 TUI 里之后,很多问题一眼能看出方向。
比如看到 HANDSHAKE(REKEY_TIMEOUT) 连续刷,基本就是两边没有成功握手,先别怀疑浏览器。看到 www.qq.com pending,就知道 DNS query 被捕获了,但还没拿到可用于直连的 IP。
我给所有模式都加了一个 mode_perf benchmark。小请求跑 1000 次,大响应跑 8 个 1MiB 下载。非 WG 模式走 SOCKS path,WG 模式会真正拉起 child process,创建 TUN 设备,然后从 tunnel IP 发 HTTP 请求。
本机测试的结果挺有意思:
native-mux 小请求大约 3300 req/s
daze-czar 小请求大约 3400 req/s
wg 小请求大约 2500 req/s
但大响应吞吐 WG 看起来很低,只有几十 MiB/s。刚看到这个数字我也有点怀疑,毕竟实际用起来 WG 并不觉得卡。后来想想也正常:这个 benchmark 是 localhost 上的端到端测试,WG 走真实 TUN 设备,会经过内核路由、用户态加解密、UDP socket、NAT 这些路径;而很多 SOCKS 模式本质上就是本机 TCP 流转发,少了不少系统边界。
日常使用的“流畅”也不完全由大文件吞吐决定。连接建立、DNS、浏览器并发、小请求延迟,这些东西更容易影响体感。WG 在小请求上的数据并不差,而且系统级接管以后,很多应用不用再单独配置代理,反而省心。
所以 benchmark 还是要有,但不能只看一个数。尤其是代理/VPN 这种东西,不同 workload 差别太大。
标准的 WireGuard 长度非常固定,所以基本上会容易检测出来。我在最初使用单纯的 WG 用一段时间开始丢包,于是我开始琢磨如何加噪音来抗审查。
于是给 WG mode 加了 noise engine。默认的 device engine 是让 boringtun 自己管理 WireGuard 设备和 UAPI socket,比较标准。noise engine 则是 Runnel 自己跑 TUN/UDP loop,直接用 boringtun::noise::Tunn 做加解密。
这样做的好处是 transport 变得更可控。比如可以在 WireGuard UDP 包外面包一层 mask,把包头和长度藏一下,再加一些 padding。现在的 obfs: mask 做的就是这个方向。
我不想把它说成什么“抗审查神器”,但从我平稳使用的一周结果看这样的混淆对于个人使用已经足够。现实网络环境要麻烦得多,包长、时序、UDP 行为、重传模式,都可能暴露特征。
我也看了一些类似工具的方向,比如 AmneziaVPN、Nym 把 QUIC 和 WG 组合起来的思路,Runnel 现在还只是很早期的版本,但 noise engine 让后面继续试 QUIC、padding profile、甚至更麻烦的 packet shaping 都方便了一些。
Runnel 还没有到我觉得很完整的阶段,还有些细节可以继续打磨,比如 IPv6 和双栈还需要更好的 schema,macOS 上更细粒度的 app split tunneling 也不是简单路由表能解决的。
但目前这个状态我已经挺满意了,它不是一个周末玩具了,至少已经变成了我自己会认真拿来使用、测试、折腾网络问题的工具。
这大概就是个人项目最舒服的地方:先让自己用得爽一点,遇到问题再修复。我的日常使用场景是 macOS(client) + Linux(server),其他路径目前只是在单元测试中有覆盖,也许其他实际场景会碰到一些配置问题,欢迎 PR。