普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月23日程序员的喵

开发 Runnel 的一些记录

作者 yukang
2026年4月24日 03:15

最近断断续续写了一个小工具,叫 Runnel

一开始它其实不叫这个名字,目录名也很土,叫 proxy-rs。我原本只是想写一个自己能用的代理工具,顺便把一些网络相关的东西重新摸一遍。写着写着,东西越来越像一个小型 VPN/代理工具箱,于是改了个名字。Runnel 有“小水道”的意思,我觉得还挺贴切:不是要做一个很宏大的网络基础设施,就是在本机和远端之间挖一条能用、能看、能调试的小通道。

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

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

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

  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 和混淆

后来又加了 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_runprint_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。先让自己用得爽一点。然后下一次遇到问题,再把那个问题修掉。

昨天以前程序员的喵

macOS 奇怪的安全扫码机制

作者 yukang
2026年2月13日 00:55

今天跑 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

参考

❌
❌