开发 Runnel 的一些记录
最近断断续续写了一个小工具,叫 Runnel。
一开始它其实不叫这个名字,目录名也很土,叫 proxy-rs。我原本只是想写一个自己能用的代理工具,顺便把一些网络相关的东西重新摸一遍。写着写着,东西越来越像一个小型 VPN/代理工具箱,于是改了个名字。Runnel 有“小水道”的意思,我觉得还挺贴切:不是要做一个很宏大的网络基础设施,就是在本机和远端之间挖一条能用、能看、能调试的小通道。
它现在支持几种模式:native-http、native-mux、几个 daze 风格的模式,以及后来主要投入精力做的 wg 模式。前面这些更像传统 SOCKS 代理,应用明确连到本地 SOCKS 端口,然后 Runnel 把流量转到远端。WG 模式不一样,它走 TUN 设备和 WireGuard 风格的 UDP 包,更像日常会用的全局 VPN。
真正让我把重心转到 WG 模式的原因也很普通:SOCKS 在浏览器里挺好用,但到系统级流量、命令行工具、各种后台服务时就没那么舒服了。你总会碰到有些软件不吃系统代理,有些软件自己做 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 pipitwg0
因为设备还在,地址也还在。再比如 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。
分流这件事绕不开 DNS
我原来只想做 IP 规则,例如:
client:
ip_rules:
direct:
- "10.*"
- "192.168.*"
这个在 WG 模式下比较直观,direct 就是给这些 IP 加本地直连 route,不让它们进 tunnel。后来很快又想要域名规则:
client:
domain_rules:
direct:
- "*.qq.com"
- "*.cn"
block:
- "*.xxx.com"
问题是 WG 看到的是 IP 包,不知道这个连接原本访问的是 qq.com 还是别的什么域名。能抓到域名的地方只有 DNS。
所以现在 WG 的 domain rules 是 DNS 驱动的。client 会把本机 DNS 指到 127.0.0.1:53,Runnel 在本地做一个很小的 DNS forwarder。它看到查询 www.qq.com,发现命中 direct 规则,就等上游 DNS 返回 IP,然后动态加一条 host route,让这个 IP 走本地网关。
这个方案很实用,但并不完美。第一次访问一个域名时,如果系统还没有拿到解析结果,它可能已经先走了 tunnel。DoH、DoT、浏览器缓存、直接访问 IP,也都绕过这条逻辑。CDN 共享 IP 还会带来另一个问题:你为了某个域名加的直连 route,可能影响另一个解析到同一 IP 的域名。
这些限制没法假装不存在,所以文档里也写清楚了。能解决 80% 的日常问题就很好,剩下 20% 要靠更底层的 packet inspection 或系统扩展,那又是另外一个坑。
Adblock 是顺手加的,但还挺好用
后来我把 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,我还专门去查了一下。它不是用户规则拦的,是订阅规则命中的。这个例子也提醒我,TUI 不能只显示 block,还得让用户知道大概是谁 block 的。否则所有东西看起来都像程序在自作主张。
TUI 比我预想中更有用
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。看到某个 tracker 域名一秒内 block 了几十次,就知道 UI 需要合并重复记录,不然屏幕全被刷满。
这里也有一些取舍。比如 tunnel speed 只能统计 WG tunnel 里的流量,直连出去的流量本来就不经过 tunnel,所以不会出现在 download wave 里。这个听起来像 bug,但其实是统计边界的问题。
Benchmark 没有想象中直观
我给所有模式都加了一个 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 差别太大。
Noise 和混淆
后来又加了 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 行为、重传模式,都可能暴露特征。但作为一个实验层,它有用:至少可以让标准 WireGuard 包的形状变得不那么裸。
我也看了一些类似工具的方向,比如 AmneziaWG、Nym 把 QUIC 和 WG 组合起来的思路。它们给我的启发是,VPN 工具不一定非要把“WireGuard 协议”和“底层传输形态”绑死。真正有意思的地方在中间那层:你能不能保留 WG 的 key、handshake、TUN 语义,同时换掉或包装它的传输外观。
Runnel 现在还只是很早期的版本,但 noise engine 让后面继续试 QUIC、padding profile、甚至更麻烦的 packet shaping 都方便了一些。
写到后来,变成了调试工具
如果只看 README,Runnel 像是一个代理工具。但我自己写下来,感觉它更像一个“网络调试工具顺手带了代理功能”。
它有 wg-config,因为我不想手写 key。
它有 dry_run 和 print_hooks,因为我不想一上来就改系统路由。
它有 handshake probe,因为我不想在 key 错的时候还把 TUN 装起来。
它有 TUI 和 telemetry socket,因为我不想在五个终端里来回翻。
它有 reload,因为改配置以后 stop/start 太烦。
它有 benchmark,因为体感经常会骗人。
这些都不是很酷的功能,但都是自己真用时一点点长出来的。我现在越来越觉得,小工具最好就是这样长出来:先解决一个真实的不爽,然后把这个不爽变成一个命令、一个日志、一条检查、一个默认配置。
Runnel 还没有到我觉得很完整的阶段。比如 WG 的 domain rules 还是 DNS 驱动,IPv6 和双栈还需要更好的 schema,macOS 上更细粒度的 app split tunneling 也不是简单路由表能解决的。后面如果继续做,可能会往两个方向走:一是把 WG 模式打磨到更适合日常使用,二是继续试验 noise/QUIC 这类 transport。
但目前这个状态我已经挺满意了。它不是一个周末玩具了,至少已经变成了我自己会认真拿来用、拿来测、拿来折腾网络问题的工具。
这大概就是个人项目最舒服的地方:不用先证明它有市场,也不用写漂亮的 roadmap。先让自己用得爽一点。然后下一次遇到问题,再把那个问题修掉。























