// LATEST

不 fork 内核,怎么用 mihomo / sing-box 封一个自己的 VPN 客户端

ChatGPT Image Jun 4 2026 from VPN 客户端架构分享.png

一句话定位

我们没有 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 子进程,只能把内核编成库链进来。
  • AndroidVpnService 前台服务,同样是把内核作为库链进来最干净。

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.newService

connect() 的每一步都对应一个可观测的 stage,失败会打点:

  1. ensureAuthenticated() — KeyCloak PKCE + xboard 二次认证拿 token
  2. getMihomoConfig() — 用 token 向 xboard 拉 clash.meta YAML
  3. 移动端:ClashToSingbox.convert(yaml) → sing-box JSON
  4. 分平台把配置交给内核
  5. 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 必须带 typeaddressservergeoip: inline 字段移除改用 rule_set)全靠这层转换扛下来。

原生层的二次注入

Dart 转好 JSON 后,原生层在 Libbox.newService 之前还要注入「只有设备本地才知道」的东西:日志路径、clash_api.external_controller(让 Flutter 像桌面那样抓 per-connection 数据)、以及把 rule_settype: 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。
这一节是全文最值钱的部分。fake-ip 行为差异是桌面 / 移动封装最大的不对称点——mihomo 的 fake-ip 相对宽容,sing-box 1.12 严格得多,配置稍错就是「连上了但打不开网页」。桌面能跑,不代表移动能跑。

其它跨端一致性问题

  • 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 域名本身可能就被墙,得先有代理才能登录。解法是登录阶段先起一个独立的轻量内核隧道,登录完立刻关掉。

给想走同样路线的人 — 经验清单

  1. 不要 fork 内核,当黑盒进程 / 库用,跟上游升级。定制全走「配置补丁 + REST API + 原生注入」。
  2. 内核选型跟着部署形态走:桌面能跑子进程就用 mihomo;移动只能进程内跑库,sing-box 的 gomobile 封装是现成答案。
  3. 统一入口、显式 stage、第一版就埋可观测性,否则线上排错是黑盒。
  4. 订阅配置永远要打补丁再用:钉端口、关 IPv6、注入规则、屏蔽 QUIC、护栏 REJECT。
  5. 进程生命周期是脏活累活:僵尸清理、Windows .exe 文件锁、退出等待、DNS 缓存 flush、意外退出监听,一个都不能省。
  6. fake-ip / DNS 是移动端最大的不对称点,顺序错一条就「连上打不开」。
  7. 配置生成放客户端:服务端只维护一种格式,内核 schema 变化由客户端吸收。
  8. 离线可用要兜底:geo 数据、bootstrap 代理、本地 rule_set,首连不能依赖「先联网下载」。

还没有评论

欢迎留下你的观点,保持交流的清晰和友好。

写下评论