
一句话定位
我们没有 fork mihomo 改源码,而是把未改动的内核当成黑盒用:桌面 fork 一个 mihomo 子进程,移动把 sing-box 编成 gomobile 库塞进 VPN 扩展。内核只负责「按配置转发流量」,UI、认证、配置转换、生命周期管理全在外面那层 Flutter 壳里。
好处是内核能跟着上游升级(mihomo 1.19.x / sing-box 1.12.x 直接换),不背 fork 的维护债。代价是所有控制都隔着进程 / FFI 边界——本文大半在讲这层边界怎么处理干净。
为什么桌面用 mihomo、移动用 sing-box
| 桌面 (macOS/Windows) | 移动 (iOS/Android) | |
|---|---|---|
| 内核 | mihomo (clash.meta) | sing-box |
| 形态 | 独立子进程 | 进程内的gomobile 库 |
| 为什么这样选 | 桌面能随意 fork/exec | 移动端根本不能跑子进程 |
移动端不用 mihomo,不是性能问题,是运行形态不允许跑子进程:
- iOS 的 VPN 必须由
NEPacketTunnelProvider(NetworkExtension)实现,它是 system-managed 的独立扩展进程,内存受限、不能 fork 子进程,只能把内核编成库链进来。 - Android 走
VpnService前台服务,同样是把内核作为库链进来最干净。
sing-box 用 Go 写,官方支持 gomobile bind 产出 Libbox.xcframework(iOS)和 libbox.aar(Android),可以直接 Libbox.newService(json) 在进程内起隧道。mihomo 没有同等成熟的 gomobile 封装,所以移动端选了 sing-box。
内核选型是被部署形态逼出来的,不是性能对比的结果。 这也带来一个甜蜜的副作用——配置转换那一节会讲。
整体架构:统一入口,按平台分支
核心是一个 VpnService,对上提供统一的 connect(),对下按平台分流:
VpnService.connect()
│
┌───────────────────┼────────────────────┐
desktop iOS Android
│ │ │
MihomoService.start(yaml) VpnManager.connect(json) startService(json)
子进程 + REST API → PacketTunnelProvider → LbVpnService
→ Libbox.newService → Libbox.newServiceconnect() 的每一步都对应一个可观测的 stage,失败会打点:
ensureAuthenticated()— KeyCloak PKCE + xboard 二次认证拿 tokengetMihomoConfig()— 用 token 向 xboard 拉 clash.meta YAML- 移动端:
ClashToSingbox.convert(yaml)→ sing-box JSON - 分平台把配置交给内核
state = connected
把「认证 / 拉配置 / 转换 / 起内核」拆成显式 stage,是后来做监控的基础——线上能直接看到「android 连接失败 70% 卡在 login,macOS 卡在 config_fetch」这种结论。封装内核时,连接流程的可观测性要在第一版就埋好,事后补很痛。
桌面端:把 mihomo 当子进程管
封装一个外部内核进程,绕不开下面这几件事。
二进制的分发与版本管理
二进制随 app 打包进 assets/bin/,首次运行按 CPU 架构抽取到 App Support 目录并 chmod +x。关键是用版本 marker 文件判断要不要重抽取,而不是「文件在就跳过」。
!= 已安装版本」来判断是否覆盖。结果 app 内「手动更新内核」拉了个新版后,下次启动又被 app 包里的旧版回滚了。正确做法是只在「打包版本严格新于已安装版本」时才覆盖。Windows 的 .exe 文件锁
Windows 不能删除 / 覆盖正在运行的 .exe(errno 5/32)。处理办法:升级前若进程还活着就推迟到下次冷启动再换;真要覆盖时先把旧的 rename 成 .old(Windows 允许 rename 被锁文件),写新的,再 best-effort 删 .old。退出 app 前 kill() 后必须等进程真正退出,否则文件句柄没释放,紧接着的操作会失败。
订阅配置永远要打补丁再用
服务器下发的 clash YAML 不能直接喂给 mihomo,要先打补丁:
- 钉死 REST API 端口,清空
secret——否则订阅写了别的端口 / 密钥,壳就控制不了内核。 - 关 IPv6(纯 IPv4 部署,避免 AAAA 带来的 DNS 麻烦)。
- 注入自定义规则到
rules:最前面(首条匹配即停 = 优先级最高)。这里有个隐蔽坑:要探测已有数组项的缩进再注入,订阅有的顶格、有的缩进两格,混用会让 YAML 把注入行当成另一个被忽略的列表——表现为「规则写进文件了但不生效」。 - 引擎级屏蔽 QUIC:插一条
AND,((NETWORK,udp),(DST-PORT,443)),REJECT,QUIC 会绕过域名路由,统一 REJECT 让客户端回落 TCP。
配置文件 chmod 600,mihomo 读完后立即删掉(节点凭证明文);另存一份 last_mihomo_config.yaml 供用户验证规则到底注入没。
热重载优先,REST API 做精细控制
进程还活着就走 mihomo REST API PUT /configs 热重载(换订阅、改规则都不重启进程);失败再 fallback 冷启动。冷启动前先 pkill / taskkill 清僵尸进程,并 flush 系统 DNS 缓存,否则上次的 fake-ip 脏记录会让新会话报「fake DNS record missing」。
external-controller 这个 REST API 是金矿。除了切节点、拉节点列表,最实用的是加规则后只精准关掉命中该规则的连接(GET /connections + DELETE /connections/{id})——因为 mihomo 只在 TCP 建连时评估规则,不给在途连接重新路由,不 flush 的话新规则看起来「不生效」直到用户手动重连。比一刀切断所有 socket 温柔得多。
两种代理模式与提权
- 系统代理:mihomo 监听 mixed port,用
networksetup(macOS)/ 注册表(Windows)把系统流量指过去,不需要提权。 - TUN 模式:建
utun*虚拟网卡捕获全部流量,需要 root。macOS 通过 XPC Helper 只给二进制打 setuid,不用整个 app 跑 root;Windows 需要 UAC。
进程级故障要监听
mihomo 把 dial 失败 / DNS 失败写 stdout(warning),进程级 error 才写 stderr,两路都要监听:喂给失败提取器、特判 :53 address already in use(端口被占不会自愈,直接 kill 报错)、监听 exitCode(意外退出要立刻清 TUN/DNS 并通知 UI)。
移动端:把 sing-box 编成库塞进 VPN 扩展
iOS / Android 架构对称,区别只在原生 API:iOS 是 PacketTunnelProvider + Libbox.xcframework,Android 是 LbVpnService + libbox.aar;配置分别经 App Group 文件和 Intent extra 交付;状态通过同名 EventChannel 回传。
配置转换:clash YAML → sing-box JSON
这是移动端最重的一块。订阅统一下发 clash 格式(桌面 mihomo 直接吃),移动端在 Dart 侧转成 sing-box JSON,支持 VMess / VLess / SS / Trojan / Hysteria(2) / TUIC 等协议,RULE-SET 引用会把对应 provider 下载内联进路由。
为什么放客户端转、不在服务端转? 一是服务端只维护一种格式;二是 sing-box 升级导致的 schema 变化由客户端版本吸收,后端零改动。sing-box 1.12 的破坏性变更(DNS server 必须带 type、address→server、geoip: inline 字段移除改用 rule_set)全靠这层转换扛下来。
原生层的二次注入
Dart 转好 JSON 后,原生层在 Libbox.newService 之前还要注入「只有设备本地才知道」的东西:日志路径、clash_api.external_controller(让 Flutter 像桌面那样抓 per-connection 数据)、以及把 rule_set 的 type: remote 改成 type: local + 本地 .srs 路径——这样首次连接不需要联网下载 geoip / geosite。配置生成放 Dart(可测、可复用),设备本地路径留原生层最后注入,两层职责分清。
DNS / fake-ip:移动端最深的坑
sing-box 的 DNS 规则顺序敏感、first-match-wins。最终顺序:
0. HTTPS / SVCB 查询 → reject // 防 RFC 9460 绕过域名路由
1. 代理服务器域名 → dns-direct // 防 fakeip,否则 trojan 拨号到假 IP
2. A 查询 → dns-fakeip // 应用层域名 → 198.18.x.x
3. outbound:[direct] → dns-direct // 直连出口需要真实 DNS
final: dns-direct // 非 A 查询(AAAA/MX/...)兜底走直连四个非踩不可的坑:
- HTTPS/SVCB 必须 reject:RFC 9460 让客户端读记录里的
ipv4hint直连真实 IP,绕过 fake-ip。症状是 iOS Safari 打开站点 A 查询拿到 fake-ip,但并行的 HTTPS RR 返回真 IP,Safari 直接拨真 IP → 命中 IP 规则而非域名规则 → 路由到错误出口 → 被区域封锁。 - 代理服务器域名不能进 fakeip:vmess/trojan 出口要解析自己的 server 域名,命中 fakeip 拿到 198.18.x.x 就会去 dial 假 IP 当出口,sing-box 报
DNS query loopback,整条链死。 - 只让 A 走 fakeip,AAAA 别碰:纯 IPv4 部署不配
inet6_range,AAAA 命中 fakeip 直接报错,Android resolver 并行查 A/AAAA 会一直 retry。 final用 dns-direct 不用 dns-proxy:非 A 查询走代理会再次触发 loopback。
其它跨端一致性问题
- TLS 指纹(uTLS):服务端拒绝裸 Go 标准库 TLS。桌面 mihomo 一个
client-fingerprint: chrome全局生效;移动 sing-box 没有全局开关,必须 per-outbound 配,转换层会自动把指纹注入到每个 TLS 出口,保证两端一致。 - Geo 数据离线可用:启动时把内置 geo 文件同步到平台目录,桌面侧还会做自愈(损坏 / 被写成 HTML 错误页时重新下载,再 fallback 到内置 asset)——没有有效 geo 文件 mihomo 直接 exit 1,这层兜底是硬需求。
- Bootstrap proxy 悖论:登录前 Google / KeyCloak 域名本身可能就被墙,得先有代理才能登录。解法是登录阶段先起一个独立的轻量内核隧道,登录完立刻关掉。
给想走同样路线的人 — 经验清单
- 不要 fork 内核,当黑盒进程 / 库用,跟上游升级。定制全走「配置补丁 + REST API + 原生注入」。
- 内核选型跟着部署形态走:桌面能跑子进程就用 mihomo;移动只能进程内跑库,sing-box 的 gomobile 封装是现成答案。
- 统一入口、显式 stage、第一版就埋可观测性,否则线上排错是黑盒。
- 订阅配置永远要打补丁再用:钉端口、关 IPv6、注入规则、屏蔽 QUIC、护栏 REJECT。
- 进程生命周期是脏活累活:僵尸清理、Windows .exe 文件锁、退出等待、DNS 缓存 flush、意外退出监听,一个都不能省。
- fake-ip / DNS 是移动端最大的不对称点,顺序错一条就「连上打不开」。
- 配置生成放客户端:服务端只维护一种格式,内核 schema 变化由客户端吸收。
- 离线可用要兜底:geo 数据、bootstrap 代理、本地 rule_set,首连不能依赖「先联网下载」。
还没有评论
欢迎留下你的观点,保持交流的清晰和友好。
写下评论