TL;DR:用户报「VPN 连上了但 YouTube 打不开」。查下来是 Android 端 hot-reload 到主配置这条路整条是坏的——主配置两个月没真正生效过,全程只有 bootstrap 配置在跑。根因是三件事叠加:默认网卡推送晚了一拍、remote rule-set 初始化是 start 的致命依赖、以及失败对上层完全不可见。本文记录完整的取证过程、我自己犯的几个错,以及一个反直觉的结论:让 bug 潜伏两个月的不是 bug 本身,是「失败没人看见」。
现象
一句话工单:连上了,但访问不了 YouTube。
这类描述最麻烦的地方在于它同时兼容好几种完全不同的故障:节点挂了、分流规则把它送错了出站、DNS 被污染、或者隧道压根没在工作只是图标亮着。得靠日志分辨。
第一个方法论:用出站 tag 判断「哪份配置在生效」
这个 app 的移动端是两段式启动:先用内嵌的 bootstrap 配置起一个隧道(只为了让 SSO 登录能出去),登录拿到订阅后再 hot-reload 到主配置。好处是 VPN 图标不闪断、不用重新授权。
关键在于两份配置的节点协议不同:
| bootstrap | 主配置 | |
|---|---|---|
| 协议 | trojan | anytls |
| 节点名 | HK-01/02 | 同名,但端口不同 |
| 组 | 无 | urltest[...] / block |
所以 sing-box 日志里的出站 tag 就是一枚指纹:
grep -oE "outbound/[a-z]+\[[^]]+\]" tunnel.log | sort | uniq -c | sort -rn第一台设备的结果:
46 outbound/direct[direct]
14 outbound/trojan[HK-01]
3 outbound/trojan[HK-02]anytls 零次,urltest[...] 零次。 主配置的节点一次都没被用过。日志里明明写着 mobile bootstrap: hot-reloaded to main config 成功。
第二个方法论:按天聚合,找回归时间点
第二台设备的 tunnel.log 是累积的,有两个月的数据。按天统计出站 tag:
| 日期 | 出站 tag |
|---|---|
| 7/23 – 8/5 | urltest[GROUP-A]、urltest[GROUP-B]、block[block]、direct |
| 8/19 – 9/18 | 只有trojan[HK-01/02] + direct |
| 9/3、9/5、9/6 | 偶现urltest[google] |
7 月底之前主配置能正常接管,8/19 之后基本只剩 bootstrap。偶尔成功说明是竞态,不是必然失败。这个日期直接可以拿去 bisect。
顺带一个教训:日志级别决定你能看到什么。 那两个月大部分时间 log level 是 error,只记录失败的连接。我第一遍看的时候因为只看到 direct 的失败,差点得出「只有 YouTube 走了 direct」的错误结论——实际上成功的连接一条都没打印。
用户看到的现象,和它的真实成因
bootstrap 配置的分流很简单:公司域名走代理,其余走 direct。所以主配置没接管的时候:
- 公司内网、自家的服务 → 走
trojan代理 → 正常 - 其他一切,包括 YouTube → 走
direct→ 从国内裸连
日志里是这样的:
ERROR open connection to m.youtube.com:443 using outbound/direct[direct]:
dial wlan0 (37): dial tcp 174.132.167.252:443: i/o timeout
ERROR open connection to i.ytimg.com:443 using outbound/direct[direct]:
dial tcp 31.13.92.37:443: i/o timeout那几个目标 IP(174.132.167.252、31.13.92.37、208.101.21.43)是典型的 DNS 污染地址。说明连 fake-ip 劫持都没生效,手机拿着污染 IP 从 Wi-Fi 裸连 YouTube。
用户只抱怨 YouTube,是因为公司的东西还能用,所以「VPN 看起来是好的」。 这个信息不对称把排查方向带偏了整整一轮。
我的第一个误判,以及一个手写的 SRS 解码器
我最初的结论是:YouTube 被钉在一个没有备份的单节点上。
依据是配置里第 20 条路由规则:
{ "outbound": "AI-GROUP", "rule_set": ["geosite-google"] }而 AI-GROUP 是个只有一个成员的 urltest:
{ "type": "urltest", "tag": "AI-GROUP", "outbounds": ["NODE-X"] }单成员 urltest 没有任何 failover。如果 NODE-X 挂了,Google 全家桶整组全黑,而其他流量走 final 的港区组照常工作——完美符合「只有 YouTube 不通」。
为了确认 geosite-google 到底包不包含 YouTube,我把打包在 assets 里的 .srs 文件解了出来。sing-box 的 rule-set 二进制格式是:SRS magic + 版本字节 + zlib 流,里面是一个 succinct trie(leaves / labelBitmap / labels 三个数组),域名以反转形式存储。写了段 Python 遍历它:
raw = zlib.decompress(open('geosite-google.srs','rb').read()[4:])
# ... 解析 leaves / labelBitmap / labels,BFS 走 trie结果 1754 条,确实包含 youtube.com、youtu.be、ytimg.com、ggpht.com、googlevideo.com,还有两条 *.googlevideo.com 的正则。所以规则匹配没问题。
但这个结论是错的。 我用作证据的两次 NODE-X 连接失败:
10:42:32 ERROR dial tcp 203.0.113.10:19650: operation was canceled
10:45:52 ERROR dial tcp 203.0.113.10:19650: operation was canceled两个时间戳精确落在两次 hot-reload 的瞬间。operation was canceled 是旧实例被拆除时的取消,不是节点故障。
后来拿到 iOS 的日志,anytls[NODE-X] 有 56 条成功连接——节点一直是活的。锅确实在 Android 的 reload。
教训:operation was canceled/context canceled这类错误要先看时间戳落在哪。它们经常是生命周期事件的副产物,不是故障本身。
根因一:默认网卡推送晚了一拍
Android 上 sing-box 跑在 VpnService 里,必须由宿主把「底层物理网卡」告诉它,否则每个 outbound 不知道该绑哪张网卡。注入的配置里有:
"route": { "auto_detect_interface": true, "override_android_vpn": true }libbox 通过 PlatformInterface.startDefaultInterfaceMonitor(listener) 要这个信息。而 reload 会重建内部的 NetworkManager,旧的默认接口状态全部丢失。
代码里其实有兜底,注释写得很清楚:
// 关键:reload 后 sing-box 内部 NetworkManager 重建,默认接口信息丢失。
// 主动把当前 underlying 接口推给 listener,否则 direct outbound 全部
// 报 "no available network interface"。
currentPlatform?.refireDefaultInterface()问题是这个兜底在时序上不可能生效:
refireDefaultInterface()是在startOrReloadService返回之后才调的- 它内部走
mainHandler.post { ... } - 而
reloadConfig本身跑在主线程
所以那个 post 进去的 Runnable 必须等 reloadConfig 整个返回才有机会执行。而 sing-box 在 startOrReloadService 内部就已经开始拨号了(DNS bootstrap、remote rule-set 下载)。等推进去,早就晚了。
日志里的证据:
DEBUG router: updating rule-set geosite-cn from URL: https://.../cn.srs
DEBUG dns: lookup failed for ...: dial UDP connection: no available network interface
<日志到此完全断掉>对照上游 sing-box-for-android 的做法——在 startDefaultInterfaceMonitor 里同步推,就地重试,不走 handler:
override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
DefaultNetworkMonitor.setListener(listener) // 立刻同步推
}
private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
for (times in 0 until 10) {
val lp = connectivity.getLinkProperties(newNetwork) ?: { Thread.sleep(100); continue }
val idx = try { NetworkInterface.getByName(lp.interfaceName).index }
catch (e: Exception) { Thread.sleep(100); continue }
listener.updateDefaultInterface(lp.interfaceName, idx, false, false)
}
}修法就是对齐它:用一个 inServiceStart 标志区分「CommandServer 构造期」(此时碰 listener 会 SIGABRT,只能缓存)和「startOrReloadService 进行中」(Go 侧状态已就绪,可以同步推)。
根因二:remote rule-set 是 start 的致命依赖
修完接口推送,隧道还是起不来,报:
startOrReloadService failed: initialize rule-set[0]: initial rule-set:
geosite-cn: unexpected status: 404 Not Found这条错误暴露了一个之前没意识到的事实:sing-box 把 remote rule-set 的初始化放在 start() 里同步等,下载失败会让整个 start 失败。
而 tun 设备此时已经被系统建好了。所以:
- 下载慢 → 用户看到「已连接,但什么都打不开」
- 下载失败 → start 失败,隧道彻底是个黑洞
实测冷启动代价:
14:52:15 start() 开始
14:52:25 WARN router: initialize rule-set take too much time to finish!
14:52:43 INFO router: updated rule-set geosite-cn
14:52:43 INFO inbound/tun[tun-in]: started at tun0 ← 紧跟着 tun 才起
14:52:43 INFO sing-box started (28.38s)28.38 秒。 而订阅里那个 URL 在第二台设备上指向 raw.githubusercontent.com,国内根本连不上——28 秒变成必然失败。
这也解释了为什么 8/19 之后主配置再没生效过。
一个我走的弯路
我第一个修法是:把 rule-set URL 重写到自建镜像。我从第一台设备的服务端配置里看到 {base}/api/geo/srs/geosite/cn.srs,就当成通用规律,重写到当前 endpoint 上。
结果第二台的 endpoint 是另一个 host,那个路径在它上面不存在——我把「不可达」换成了「404」,两者都是 fatal。
教训:基于一个样本推断出的 URL 规律不是规律。这种改动会把一个可诊断的失败换成另一个,看起来像修好了(错误信息变了),实际没有。
第二个弯路
接着我把 geosite-cn.srs 打包进 assets。原生层本来就有「存在 <tag>.srs 就改写成 type: local」的逻辑,所以这招立刻见效,start 回到 0.06s。
但用户一句话点破了:「还会有其他的 geosite,你要不先不要打进来,走 remote?」
对的。订阅可以引用任意 geosite-<code> / geoip-<code>,打包只覆盖固定几个,来个新 tag 照样走 remote、照样可能把隧道弄死。打包治不了根。
而且还有个我没料到的副作用:我从 SagerNet 拉的 geosite-cn.srs 是 55,615 字节,而他们服务端自己那份是 444,977 字节——差了 8 倍。等于悄悄把用户的分流数据换掉了。
最终修法:把下载挪出 start
正确的位置是在 bootstrap 隧道已经通、reload 之前下载:
Step 1 启 bootstrap 隧道(内嵌配置,无 remote rule-set → 0.04s 起)
Step 2 SSO 登录 → 拉主配置 ← 此时网络已经通了
★ 在这里把所有 remote rule-set 下下来写成 <tag>.srs
Step 3 reload 到主配置零原生改动——复用已有的 local 改写逻辑,而且任意 tag 都覆盖。实测同样冷启动条件下:
RuleSetPrefetch: geosite-cn cached locally (444977 bytes)
RuleSetPrefetch: geoip-cn cached locally (36934 bytes)
RuleSetPrefetch: geosite-google cached locally (7912 bytes)
sing-box started (0.08s) ← 28.38s → 0.08s几个必须要有的保护:
- 内容校验(最重要):binary 认
SRSmagic、source 认 JSON 可解析。把 404 的 HTML 页面写进<tag>.srs会让注入优先用它,而 sing-box 解析不了 rule-set 是 start 失败——比留着 remote 严格更糟。 - 失败即不写文件,注入保留
remote,退化成改之前的行为,绝不更差。 - 时间上限:单文件 8s、总共 15s,超了放弃继续 reload。不能把「优化启动速度」变成新的卡点。
- 原子写:临时文件 + rename,避免 sing-box 读到半个文件。
根因三:失败没人看见(这才是它能活两个月的原因)
前两个根因都是普通 bug。真正让它潜伏两个月的是这个:
} catch (e: Exception) {
Log.e(TAG, "reloadConfig: startOrReloadService failed: $e")
statusListener?.invoke("disconnected")
stopSelf()
return
}看起来有处理。但 reload 是上层通过 Intent 发的:
private fun reloadVpnConfig(config: String) {
startService(Intent(this, LbVpnService::class.java).apply {
action = LbVpnService.ACTION_RELOAD
putExtra(LbVpnService.EXTRA_CONFIG, config)
})
}
// 紧接着 result.success(null)fire-and-forget。 原生端抛异常、stopSelf() 把隧道拆了,上层照样打印:
mobile bootstrap: hot-reloaded to main config
Android VPN tunnel started所以两个月里,每一份用户日志都显示「热重载成功」。没人有理由去怀疑它。
修法两部分:
- 失败不再制造黑洞:把上一份成功装载过的配置重新 apply,保住一个能用的隧道。(注意不是「保留旧实例」——libbox 在 start 新实例前就把旧的关了,只能重新 apply。)
- 错误回传:原生侧记
lastError,通过 status 事件的error字段带给上层,UI 显示真实原因而不是一句disconnected。
这是本次最值得记的一条:一个失败路径如果上层收不到,它的存活期等于「有人愿意翻原始日志」的间隔。这个案例里是两个月。
顺手抓到的两个问题
tun fd 每次 reload 漏一个
openTun 原来是裸赋值:
vpnService.fileDescriptor = pfd // 直接覆盖,没关旧的而 stopVpn 只关当前那一个。所以每次 reload 漏一个 ParcelFileDescriptor + 一个 tun 设备。日志里能直接看到堆积:
networks=[dummy0, wlan0, lo]
networks=[dummy0, wlan0, lo, tun0]
networks=[dummy0, wlan0, tun1, lo, tun0]
networks=[tun3, dummy0, wlan0, tun1, lo, tun2, tun0]修的时候有个时序讲究:不能在 openTun 里立刻关旧 fd。libbox 是先给新实例开 tun、再拆旧实例,立刻关会让还在读它的旧实例拿到 EBADF。得先存起来,等 startOrReloadService 返回、旧实例确实关掉了再 close。
有意思的是上游没这个问题——因为他们根本不做 in-place reload(下一节)。
Doze 期间 sing-box 照跑
配置里 8 个 urltest 组、interval: 300s,最大的组一轮探 7 个节点。息屏进 Doze 之后这些全在跑,而那段时间没有任何用户流量。
libbox 有现成的 pause() / wake(),两端都没接。Android 监听 ACTION_DEVICE_IDLE_MODE_CHANGED,iOS 用 NEPacketTunnelProvider 的 sleep / wake 回调。
一个容易漏的细节:start 和 reload 成功后要重新施加一次当前状态。新建/reload 出来的实例默认是 awake 的,而后台拉起时设备可能已经在休眠里——只靠事件,这种情况永远不会 pause。
平台对比:为什么 iOS 没事
同一套订阅、同样的路由规则,iOS 完全正常(anytls[NODE-X] 56 条连接、block[block] 58 条)。差别在 reload 的实现方式:
iOS ——完整重建,且失败会上报:
service.close() // 显式关旧的
LibboxNewService(configContent, SingboxPlatformInterface(self), &e) // 全新实例 + 全新 platform interface
try newService.start() // 失败 return "start-failed"Android ——复用同一个 CommandServer 调 startOrReloadService,再补一句异步的接口推送。
更关键的是:那个会丢的状态只在 Android 存在。iOS 的 startDefaultInterfaceMonitor 是空实现,underNetworkExtension() 返回 true——NEPacketTunnelProvider 自己管底层路由,根本没有「默认网卡」这个会丢的东西。所以 no available network interface 这个故障模式 iOS 天然不存在。
一个反向的参考
翻上游的 Flutter 封装时发现:
override fun serviceReload() {
restart() // ← 改成整个服务重启
// runBlocking { serviceReload0() } // ← 原来用 startOrReloadService 的实现,注释掉了
}旁边留着「修复 serviceReload」的注释。他们也踩了 in-place reload 的坑,选择直接放弃、改成全量重启。
但我们不能照抄:全量重启会撤掉 VpnService、VPN 图标闪断、还可能重新弹权限,而「保住图标、不重新授权」正是这套 bootstrap 设计的目的。所以只能把 in-place reload 修对。
调试环境本身的坑
这部分占了实际时间的一大半,值得单独记。
签名与版本门禁。 设备上是 release 签名包,本地没有 keystore,debug 包签名不匹配无法覆盖安装;versionCode 由 versionName 推导(major*10000 + minor*100 + patch),debug 默认版本号更低直接被判 downgrade 拒绝。最后只能卸载重装——代价是用户那台设备的登录态、日志、audit DB 全没了。
打包参数。 我第一次用裸的 flutter build apk --debug,结果 app 报「获取不了配置文件」。日志说得很明确:
TenantService: enroll failed (decryptFailed) —
ENC1/ENC2 content requires a 64-hex decryption key, but none was configured租户信息是运行时远程拉取的(那步成功了),但内容是加密的,解密密钥要在编译期通过 --dart-define-from-file=<secrets>.json 注进去。构建脚本里本来就带着这个参数,我图省事绕过了脚本。
教训:项目有构建脚本就用它。绕过去省下的几秒钟,换来的是一次误诊 + 一次不必要的卸载。
启动一个未导出的服务。 想用指定配置测试,需要走 ACTION_START_FROM_CACHE(磁贴的后台启动路径)。但服务未导出,am start-foreground-service 被拒:
Error: Requires permission not exported from uid 10xxxcmd statusbar click-tile 在 MIUI 上不存在。最后的路子是:cmd statusbar add-tile 把磁贴加进快捷面板 → expand-settings 展开 → 截图定位图标坐标 → input tap。确认图标是哪个的办法是去仓库里看 ic_tile_vpn.xml(盾牌内嵌锁)。
测试配置被覆盖。 头两次测试都失败,因为点磁贴时 MIUI 顺带唤起了 app UI,Dart 侧自己跑了一遍「拉后端配置 → reload」,把测试配置换掉了。判断方法还是出站 tag——测试配置独有的 [MATCH]、[GROUP-C]、[GROUP-D] 对上后端独有的 [PROXY]、[AUTO]。解法是先 am force-stop,磁贴那条路只启服务不启 Activity,Dart 压根不会运行(grep -c "I/flutter" = 0 可验证)。
充电时永远不进 Doze。 用户手动息屏测省电,日志里一条 device idle 都没有。原因在系统状态里:
mState=ACTIVE mScreenOn=true mScreenLocked=false mCharging=trueadb 调试线在供电,Android 充电状态下不进 Doze。必须强制模拟:
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle # → device idle — sing-box paused
adb shell dumpsys deviceidle unforce # → device active — sing-box woken
adb shell dumpsys battery reset # 记得还原附带:CI artifact 上传超时
最后还撞上一个 CI 的问题:
Failed to CreateArtifact: Failed to make request after 5 attempts:
Request timeout: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact这是 actions/upload-artifact 的上传失败,不是编译失败——CreateArtifact 是上传字节之前建 artifact 的那次 API 调用,属于服务侧抖动,重跑基本就好。
但顺手发现 workflow 里一个真问题:上传 Libbox.xcframework 那步没有 if: 条件,而它前面的「装 Go」和「重编 xcframework」都有 cache-hit != 'true'。所以即使缓存命中、内容跟上次一模一样,还是会把整个 xcframework 重新上传一遍——白花时间,还多暴露一次在抖动里。而且它是个保留 3 天的调试产物,不该让它把 20 分钟的 iOS 构建拖挂(continue-on-error: true)。
顺带:uses: actions/upload-artifact@v4 是浮动大版本标签,已经自动拿最新 4.x 了,所以「升级到 4.6」是空操作。
给排查同类问题的人 — 经验清单
- 「连上了但打不开 X」先确认哪份配置在生效,而不是先查 X 的规则。出站 tag 的 histogram 是最快的指纹。
- 累积日志按天聚合,能直接定出回归时间点,比读代码找 bug 快得多。
- 注意日志级别的取样偏差。
error级只记录失败的连接,很容易让人以为「只有失败的那类流量走错了」。 operation was canceled/context canceled先看时间戳。落在生命周期事件(reload / stop)上的,是副产物不是故障。- fire-and-forget 的跨进程调用必须有回执。一个上层收不到的失败路径,存活期等于「有人愿意翻原始日志」的间隔。
- 不要基于单个样本推断 URL / 路径规律。把一种 fatal 换成另一种 fatal,比不改更难诊断。
- 兜底代码要检查时序,不只是存在性。
mainHandler.post在「调用方就在主线程」的场景下等于「等我返回后再说」。 - 看上游怎么做。上游放弃了 in-place reload 这个信息,本身就说明这块脆——即使你因为产品需求不能照抄。
- 别绕过项目的构建脚本。
- 测省电要强制模拟 Doze,插着调试线息屏是测不出来的。
- 改动要能证伪。本次每一项都有真机日志对照:
28.38s → 0.08s、closed 1 superseded tun fd(s)、device idle — sing-box paused。没跑到的路径(reload 失败回滚、iOS 休眠)就明确标注没验,不含糊过去。
还没有评论
欢迎留下你的观点,保持交流的清晰和友好。