阅读视图

发现新文章,点击刷新页面。

开发 Runnel 的一些记录

最近断断续续 Vibe Coding 写了一个小工具,叫 Runnel。我原本只是想写一个自己能用的代理工具,顺便把一些网络相关的东西重新摸一遍。写着写着,东西越来越像一个小型 VPN/代理工具箱。Runnel 有“小水道”的意思,我觉得还挺贴切:不是要做一个很宏大的网络基础设施,就是在本机和远端之间挖一条能用、能看、能调试的小通道。

最初的动机是因为自己用的两个机场都跪了 (虽然后来又逐渐恢复了),于是萌生了一个自己写个工具来翻墙的想法。先让 AI 快速撸了一个车 native-http 版本的,然后又加了多路复用的 native-mux 模式。

后来又想到一个同事说他一直用自己写的代理工具叫做 daze,所以立马让 AI 用 Rust 重写了一份跑了起来。

于是它现在支持几种模式:native-httpnative-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/tunipsysctliptables。macOS 上是 ifconfigroutenetworksetup。命令不复杂,但出错方式很多。例如服务端没停干净,再起一次就会卡在:

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:
      - "*.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 域名就可以顺手做掉。

这里我比较在意优先级。最后定下来是:

  1. 用户自己的 domain_rules.block
  2. adblock 规则
  3. 用户自己的 direct/proxy 规则
  4. 默认走 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、netstatroutetcpdump 里会很痛苦。放到一个 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 和混淆

标准的 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 行为、重传模式,都可能暴露特征。但作为一个实验层,它有用:至少可以让标准 WireGuard 包的形状变得不那么裸。

我也看了一些类似工具的方向,比如 AmneziaWG、Nym 把 QUIC 和 WG 组合起来的思路。它们给我的启发是,VPN 工具不一定非要把“WireGuard 协议”和“底层传输形态”绑死。真正有意思的地方在中间那层:你能不能保留 WG 的 key、handshake、TUN 语义,同时换掉或包装它的传输外观。

Runnel 现在还只是很早期的版本,但 noise engine 让后面继续试 QUIC、padding profile、甚至更麻烦的 packet shaping 都方便了一些。


Runnel 还没有到我觉得很完整的阶段。比如 WG 的 domain rules 还是 DNS 驱动,IPv6 和双栈还需要更好的 schema,macOS 上更细粒度的 app split tunneling 也不是简单路由表能解决的。后面如果继续做,可能会往两个方向走:一是把 WG 模式打磨到更适合日常使用,二是继续试验 noise/QUIC 这类 transport。

但目前这个状态我已经挺满意了,它不是一个周末玩具了,至少已经变成了我自己会认真拿来用、拿来测、拿来折腾网络问题的工具。

这大概就是个人项目最舒服的地方:先让自己用得爽一点,遇到问题再修复。

macOS 奇怪的安全扫码机制

今天跑 Rust 编译器测试的时候又发现非常地慢,CPU 资源根本无法利用起来,我记得几年前碰到过这个问题,当时我写了篇文章分享出来,并发现很多人都有同样的困扰。

而我今天碰到的这个问题虽然现象一样,但解决方法又不同了。我不确定是 macOS 系统更新,亦或是我更新了 VS Code 造成的。

问题

复现脚本很简单,循环创建随机命名的 shell 脚本,然后对比首次和再次执行的耗时:

#!/bin/bash
rm -rf /tmp/speed_test
mkdir -p /tmp/speed_test

for i in {1..10}; do
    FILENAME=$(openssl rand -hex 10)
    echo $'#!/bin/sh\necho Hello' > "/tmp/speed_test/$FILENAME.sh"
    chmod a+x "/tmp/speed_test/$FILENAME.sh"
    FILE="/tmp/speed_test/$FILENAME.sh"

    first=$(TIMEFORMAT="%R"; (time $FILE > /dev/null) 2>&1)
    second=$(TIMEFORMAT="%R"; (time $FILE > /dev/null) 2>&1)
    echo "第一次: $first  第二次: $second"
done

在 VS Code 终端的输出:

第一次: 0.525  第二次: 0.007
第一次: 0.290  第二次: 0.009
第一次: 0.280  第二次: 0.007
第一次: 0.272  第二次: 0.008
第一次: 0.307  第二次: 0.008
...

差距大概 30-50 倍。换到 Warp 终端跑同一个脚本,两次都在 0.006s 左右。

定位:syspolicyd

应该不是我上篇文章提到的 SIP 问题,我确定 System Settings → Privacy & Security → Developer Tools 中已经加入了 VS Code。

看起来也不像是文件系统缓存的原因,因为 0.2-0.5 秒远超磁盘缓存的量级。用 log show 看了下系统日志:

log show --predicate 'subsystem == "com.apple.syspolicy.exec"' --last 2m --style compact

输出大量这样的记录:

GK performScan: PST: (path: 8d0e4c2de41c3e77), (team: (null)), (id: (null)), (bundle_id: (null))
Error Domain=NSOSStatusErrorDomain Code=-67062
GK evaluateScanResult: 2, PST: (path: 8d0e4c2de41c3e77), ... (bundle_id: NOT_A_BUNDLE), 0, 0, 1, 0, 7, 7, 0

从日志上看每次执行新文件 syspolicyd 都会做一次 GK performScan。这就是 macOS 的 Gatekeeper 安全扫描——对首次执行的新可执行文件做代码签名验证和恶意软件检查。扫描结果会被缓存,所以同一个文件第二次执行就快了。

进一步验证:我们把测试脚本里改成 (time /bin/sh $FILE > /dev/null) 2>&1,这样就是直接通过 sh 来执行:

直接执行 ./script.sh  → 0.248s (触发 execve → Gatekeeper 扫描)
/bin/sh ./script.sh   → 0.006s (只是让 /bin/sh 读取文件,不触发安全扫描)

原因确认了。当用 ./script.sh 执行时,内核的 execve 系统调用会触发 AppleSystemPolicy.kext 中的 MACF hook (mpo_proc_notify_exec_complete),通知 syspolicyd 进行评估。而 /bin/sh script.sh 只是让已受信的 /bin/sh 进程读取文件内容来解释执行,不触发 execve 的安全检查路径。

Full Disk Access 有效

接着试了 System Settings → Privacy & Security → Full Disk Access,给 VS Code 完全磁盘访问权限。重启 VS Code,再跑脚本:

第一次: 0.005  第二次: 0.005
第一次: 0.005  第二次: 0.005
第一次: 0.006  第二次: 0.006
...

问题消失了。syspolicyd 日志中的 performScan 也不再出现。

为什么 Full Disk Access 有效

Full Disk Access (FDA) 在 macOS 的 TCC (Transparency, Consent, and Control) 框架中对应的是 kTCCServiceSystemPolicyAllFiles 权限。这个权限的含义远超“磁盘访问“——它实际上是 TCC 框架中最高级别的信任授权。

macOS 会追踪每个进程的 responsible process(负责进程)。在 VS Code 终端中敲的命令,它的 responsible process 是 VS Code 本身。当 AppleSystemPolicy.kext 的 MACF hook 拦截到 execve 后,会检查 responsible process 的信任级别。拥有 FDA 授权的进程被识别为高信任来源,syspolicyd 会走快速路径,跳过完整的 Gatekeeper 扫描。

而 Warp 这些原生终端,因为我已经加入系统默认信任的开发工具列表,所以它们派生的子进程一开始就不会触发完整扫描。

需要说明:Apple 没有公开文档化这个具体流程。上面的描述来自实验推断和社区逆向分析,不是官方说法。

一个有趣的细节:信任会被“记住“

发现 FDA 有效之后,我尝试反向验证:把 VS Code 从 FDA 列表中移除,重启 VS Code,再跑脚本。

结果:仍然很快。问题没有复现。

syspolicyd 的扫描评估结果存储在 /var/db/SystemPolicyConfiguration/ExecPolicy 这个 SQLite 数据库中(35MB),同时 AppleSystemPolicy.kext 在内核中维护了一个运行时缓存:

$ sysctl security.mac.asp.stats.cache_entry_count
security.mac.asp.stats.cache_entry_count: 4700

也就是说,当 VS Code 拥有 FDA 时,它被评估为可信 responsible process,这个信任结果被持久化了。移除 FDA 后,历史记录并不会被清除。macOS 的安全评估系统是“学习型“的——它记住过去的信任决策。

要彻底重现原来的问题,可能需要重启 Mac 清除内核缓存,或者更极端地清理 ExecPolicy 数据库。

总结

如果你也遇到类似的问题——新编译的程序、新创建的脚本首次执行莫名其妙地慢,可以检查一下是不是 Gatekeeper 的锅:

# 查看最近的 syspolicyd 扫描记录
log show --predicate 'subsystem == "com.apple.syspolicy.exec"' --last 5m --style compact | grep performScan

解决方案按排序:

  1. 给你的程序加如到 Developer Tools 列表
  2. 给你的程序加 Full Disk Access

参考

❌