Skip to main content

sub-store + mihomo 本地优先架构决策

TLDR
  • 决定上 sub-store,但只用于 wild 节点;self 节点继续走 file provider,不进 sub-store。
  • mihomo client 拆 proxies 为 self (file) + wild (http) 两个 provider,proxy-groups 重构为 Manual / Self / Wild / Auto 四组语义。
  • admin path 用 sops ME_SK(== DEFAULT_SK,与 axonhub 同源)注入,wildUrl__ADMIN_PATH__ 模板占位避免 secret 进 /nix/store
  • "本地优先"= self 不依赖 sub-store;sub-store 只服务"打野进来的、需要测速过滤的"wild 池。

0. 这篇文档的位置

我的 docs/proxy 下已经有两类文档:

文档类型回答什么问题
mihomo-setupnarrative(叙事/踩坑)为什么从 sing-box 换 mihomo?换的时候踩了什么坑?
proxying-in-practicevision(宣言/世界观)整个翻墙方案是什么?为什么用 sub-store 这种东西?

本文是第三类——ADR(Architecture Decision Record):sub-store + mihomo 这条路线已经定了,具体怎么落、为什么这么落

适用对象:未来回头追问"当初为什么这么做"的我自己。落地过程中做的几个有意偏离原始设想的决策(比如 self 不走 sub-store)目前只散落在 commit message 里,半年后很容易产生"是不是漏做了"的误判。这篇就是把那些决策显式记下来

1. 现状基线

代码已落地(commits 7450b88 / 5503ff7 / 0d1c674),拓扑长这样:

                                    ┌────────────────┐
providers/self.yaml ────►────│ │
(由 nix selfProviderContent │ mihomo │
经 sops template 渲染落盘) │ (macos-ws) │────► TUN
│ │
┌────►────│ │
│ └────────────────┘
│ http (wild URL)

┌─────────┴─────────┐
│ xream/sub-store │
│ container │
└─────────┬─────────┘

本地: 127.0.0.1:3001 (直暴露绕 caddy)
生产: 走 edge network

┌─────────┴─────────┐
│ caddy │ basic_auth(luck, hash)
│ sub.lucc.dev │ reverse_proxy sub-store:3001
└───────────────────┘

四个关键代码位置:

  • lib/mihomo/client-config.nix:93-118 —— proxy-providers.{self,wild} 拆分定义
  • hosts/macos-ws/default.nix:8-11 —— wildUrl__ADMIN_PATH__ 占位
  • .cntr/sub-store/compose.yml —— 双模式 compose(本地 / 生产)
  • .cntr/caddy/compose.yml:21-30 —— entrypoint wrapper 现场算 bcrypt hash

下文逐个讲为什么。


2. 决策一:sub-store 只服务 wild,self 走 file provider

起点:原本设想

proxying-in-practice.md 行 89-100 描述的"理想态"是把所有节点(self + 打野)都进 sub-store 做测速 / 过滤 / 排序 / 格式转换,多端 client 直接吃同一个订阅 URL。原话:

打野/机场 和 自建 互为灾备、互为冗余……自动扔进 sub-store 做测速(测速、筛选节点、删除无效节点等),自动按照 latency 排序,给我下发订阅 URL(之后多端的 singbox 直接拉 URL,本地不需要任何操作)。

实际选择:self=file / wild=http

# lib/mihomo/client-config.nix:93
proxy-providers = {
self = {
type = "file";
path = "providers/self.yaml"; # 由 nix selfProviderContent 经 sops template 渲染
};
wild = {
type = "http";
url = lib.replaceStrings ["__ADMIN_PATH__"] [config.sops.placeholder.ME_SK] wildUrl;
interval = 1800;
};
};

为什么背离原始设想?4 条理由

① 鸡生蛋问题

sub-store 跑在 self 节点所在的 HK VPS 上(proxying-in-practice.md:94 原话:"sub-store 就在这台机器上跑")。如果 macos-ws 的 mihomo 启动时需要拉 self URL,它必须先连到 HK——但此时本地一个节点都没有,请求要么直连出去(CN 网络不稳)、要么卡死。

走 file provider 直接绕开:mihomo 一启动就有 self 节点可用,sub-store 容器即使没起也不影响 self 链路。

② "本地优先"的语义本身要求 self 不依赖 sub-store

commit 5503ff7 标题是 "sub-store + mihomo 本地优先架构"。self 是最受控、最可信、最不该有外部依赖的部分(自建 VPS、密码自己写的、SLA 自己保证)。让它放弃"零依赖直接可用"的属性、去换"被 sub-store 测速排序"那点边际收益,不划算。

wild 走 sub-store 是值得的——wild 本质就是"打野收集来的、参差不齐、需要测速筛选"的池子,sub-store 的 pipeline 操作正好对应这种数据形态。self 没有这些痛点,节点数量小且稳定,mihomo 自带的 url-test group + health-check 就够了。

③ 当前没有多端异构客户端要养

proxying-in-practice.md 行 658-682 论证 sub-store 价值时强调的"一个真源,多端多格式输出"——这个卖点要求有 mihomo / sing-box / Shadowrocket 等多种 client 并存。本次任务只动 macos-ws 一个 mihomo client,多端格式转换的痛点不在当前 scope。为还没发生的需求做架构,是过早抽象。

④ self 节点的 url-test 由 mihomo 客户端做就够了

{
name = "Self";
type = "url-test";
use = ["self"];
url = "https://cp.cloudflare.com/generate_204";
interval = 300;
}

mihomo 自带 health-check(provider 层 300s 一次)+ url-test group 自动选最快节点,对 self 这种 < 10 个节点的小池子,再加一层 sub-store pipeline 是过度工程。

什么时候应当撤销这个决策?

写下"反向触发条件",是为了把"何时重做这件事"的判断力预先存进文档,避免半年后再次评估时已经忘掉原始约束。

触发条件原因
加入 iOS Shadowrocket / 不能跑 nix 的 client需要 sub-store 做格式转换
self 节点数膨胀到 >10 个且需要细粒度筛选/改名/分区域nix selfProviderContent 不方便表达 pipeline 操作
sub-store 上加了别人也能订阅的多用户场景必须出 URL 才能分发

反向触发时的正确做法不是"替换",是"双轨"

即使将来触发,也不要把 self 改成 http provider 替代 file,而是 "file 兜底 + URL 旁路"双轨

  • mihomo 仍然 file provider 读本地 providers/self.yaml(保持鸡生蛋的避免)
  • 另外把 self.yaml 给 sub-store(推送方向:本地 → sub-store,而非拉取)
  • sub-store 上把 self.yaml 作为分发源,对外提供 URL 给其他 client 消费

这样既保留"本地优先"的零依赖属性,又获得"多端统一"的收益。


3. 决策二:admin path 用 sops ME_SK(== DEFAULT_SK)同源

sub-store 的安全模型是 "不可猜路径"作为第一道防线:所有 admin 操作都在 /${SUB_STORE_FRONTEND_BACKEND_PATH} 这个路径下。这个路径本身就是 secret,泄露=完全失陷。

# .cntr/sub-store/compose.yml
environment:
SUB_STORE_FRONTEND_BACKEND_PATH: /${DEFAULT_SK:?DEFAULT_SK is required}

为什么不另起一个 secret,而是复用 DEFAULT_SK

axonhub 已经在用 DEFAULT_SK(见 .cntr/axonhub/compose.yml),再生一个 sub-store 专用 secret 会让 sops 文件多一条 key,运维心智多一个对象。

ME_SK(== DEFAULT_SK)的语义是"我自己的根密钥"——admin path、axonhub user token 都是"只有我自己有权访问"的入口,复用同源符合人脑模型。复用不是为了省力,是为了概念聚类

代价:一旦 DEFAULT_SK 泄露,axonhub + sub-store 同时失陷。但这两个服务的访问边界本来就一致(都是我自己用),单独保护其中一个并不增强整体安全性。

为什么走 sops template 而非 nix pkgs.writeText / pkgs.writeYAML

如果用 pkgs.writeText,最终生成的 mihomo-config.yaml 会进 /nix/store——一个全局可读的目录。admin path 写进去等于 secret 写进 store,任何能登录这台机器的用户(甚至 nix daemon 的其他消费者)都能读到。

走 sops template:

# modules/darwin/mihomo-client.nix:38-40
sops.templates."mihomo-client.json".content = client.templatesContent;
sops.templates."mihomo-self-provider.json".content = client.selfProviderContent;

sops-nix 会把模板渲染到 /run/secrets-rendered/(权限受控),并且只在系统激活时用 sops 解密注入真值。

为什么用 __ADMIN_PATH__ 占位符?

# lib/mihomo/client-config.nix:109
url = lib.replaceStrings ["__ADMIN_PATH__"] [config.sops.placeholder.ME_SK] wildUrl;

config.sops.placeholder.ME_SK 在 nix evaluation 阶段是一个字面字符串占位符(不是真值),sops-nix 在 template 渲染阶段才把它替换成解密后的实值。这是 sops-nix 社区标准模式。

__ADMIN_PATH__ 这种醒目的双下划线包裹,是为了让 hosts/macos-ws/default.nix 里的 wildUrl = "http://127.0.0.1:3001/__ADMIN_PATH__/download/..." 一眼可读——hosts 配置不需要知道 sops placeholder 的具体形态,只需要约定一个标记。


4. 决策三:caddy basic_auth hash 在 entrypoint 现场算

caddy 的 basic_auth 要的是 bcrypt hash,不是明文密码。常见做法是预先caddy hash-password 算好,把 hash 字符串塞进 secret store。我没这么做:

# .cntr/caddy/compose.yml:21-30
command:
- sh
- -c
- |
export BASIC_AUTH_HASH="$$(caddy hash-password --plaintext "$$DEFAULT_SK")"
echo "[entrypoint] BASIC_AUTH_HASH generated, starting caddy..."
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile

Caddyfile 里只引 env:

sub.lucc.dev {
basic_auth {
luck {env.BASIC_AUTH_HASH}
}
reverse_proxy sub-store:3001
}

为什么不预先 hash 写进 secret?

  • 多一份产物要维护:每次 DEFAULT_SK 旋转,都要手动重算 hash 同步进去
  • caddy 的 bcrypt cost 升级时要手动跑:现场算每次启动自动用当前 caddy 版本的算法
  • sops 多管一个对象:只管 DEFAULT_SK 一个,少一条 key

为什么不在 Caddyfile 写 {$BASIC_AUTH_HASH:dummy_hash} 默认值?

这是个安全反模式。caddy 有两套 env placeholder:

写法时机是否支持默认值
{env.X}runtime不支持
{$X:default}parse-time支持

如果用 {$BASIC_AUTH_HASH:dummy_hash}:真实 secret 缺失时 caddy 会拿 dummy_hash 静默启动,basic_auth 就被一个写死的 dummy 密码"通过"了——形同虚设。

正确做法是让 caddy 启动失败,而非降级到不安全状态。{env.BASIC_AUTH_HASH} 在 env 为空时会失败,这是我们想要的。

配套:pre-commit hook 为何降级 caddy adapt(不是 caddy validate

# .pre-commit-config.yaml
- id: caddy-adapt
name: caddy-adapt
language: system
pass_filenames: false
entry: caddy adapt --config .cntr/caddy/Caddyfile

caddy validatecaddy adapt

  • adapt:Caddyfile → JSON,只做语法 + adapter pipeline 转换
  • validate:adapt + 跑所有模块 Provision(),执行 runtime 级校验(密码非空、证书可读、上游可解析等)

basic_auth 的 Provision() 会拒绝空密码。pre-commit 进程里 BASIC_AUTH_HASH 不可能有值(runtime secret 不该在 hook 进程里出现)——所以 validate 必然假阳报错 account 0: username and password are required

降级为 adapt 后,pre-commit 只做离线可确定的事(语法/结构),runtime 校验留给真实 compose 环境时的容器启动。这符合"pre-commit 不该依赖运行时 secret"的通用原则。


5. 决策四:sub-store compose 双模式(本地 vs 生产)

# .cntr/sub-store/compose.yml
ports:
- "127.0.0.1:3001:3001" # 本地直暴露;生产模式删掉这段
networks:
- default
- edge
...
networks:
edge:
name: edge
external: ${EDGE_NETWORK_EXTERNAL:-false} # 本地 false / 生产 true

部署模式由两个开关决定:

模式portsEDGE_NETWORK_EXTERNAL
本地(macOS)保留 127.0.0.1:3001false(compose 自建私网)
生产(VPS)删除true(复用 caddy compose 创建的 edge)

为什么同一份 compose + 环境变量切换,而非两份 compose?

参考 .cntr/axonhub/compose.yml:130-136 的同模式约定。两份 compose 一定会漂移——某次只改了其中一份,另一份就过期了。一份 compose + 开关,所有改动只能在一处发生

代价:本地切生产时需要手动删 ports 段。这是有意的摩擦——避免脑子还没切就把本地直暴露的端口带到 VPS 上(生产应该全部走 caddy + basic_auth)。


6. unknown unknowns

这些是"代码已经写完但我可能没意识到的二阶后果"。写下来是为了给未来的自己留 trace,遇到现象时能反向定位。

6.1 mihomo health-check 探针带宽

self + wild 两个 provider 各自 300s / 600s 健康检查(url = https://cp.cloudflare.com/generate_204)。节点数大时探针流量可观——每节点每周期至少一次握手 + TLS。

监控点:mihomo log 里 health-check 失败行的频率突增。 应对:调长 interval 或换成 lazy mode(mihomo 支持)。

6.2 wild URL fallback 行为掩盖告警

sub-store 没起时,mihomo 的 http provider 会回退到上次缓存的 providers/wild.yaml

  • 好处:冷启动可用,sub-store 临时挂掉不会全断
  • 坏处:过期节点持续出现在 group 里,且没有可见告警——你以为在用最新订阅,其实在用一周前的快照

监控点:mihomo log 里 fetch provider failed 行。 应对:加一个 cron 检查 providers/wild.yaml 的 mtime,超过 1h 触发告警。

6.3 sub-store 自动备份 cron 实际是空操作

SUB_STORE_BACKEND_UPLOAD_CRON: "17 4 * * *"

cron 配了,但没配 upload 目标(gist token 之类)。当前等于空操作。要么补 token,要么删这行——避免"以为有备份"的虚假安全感。

6.4 caddy entrypoint 算 hash 的时序假设

当前依赖 docker compose 在 command 执行时 ${DEFAULT_SK} 已经在 env 里。这没问题。

未来切到声明式 secret(doppler / vault / k8s secret)时,必须确认:先注入 DEFAULT_SK 再启动 entrypoint。如果顺序反了,caddy hash-password --plaintext "" 会算出一个空串的 bcrypt hash——而 basic_auth 校验时空密码 + 空 hash 反而可能匹配,相当于无密码可登。

6.5 admin path 的泄露面

admin path 同时是:

  • mihomo 拉 wild 的 URL 路径片段
  • web 端访问 sub-store 后台的路径

一处泄露双倍杠——日志、错误回显、浏览器 history 都会捕获。

当前 Caddyfile 的 log 段:

log {
output stdout
format console
}

console 格式默认会记录完整 URL,包括路径——admin path 会进 caddy 的 stdout,被 docker logs 持久化。

应对:要么改 log format 显式 redact path 段,要么把 sub-store 的 admin path 路由也 mute(caddy 的 log 配置支持 log_skip)。这条当前没做,列为后续 follow-up。

6.6 DEFAULT_SK 旋转的联动链

一旦旋转,需同时同步 4 处

  1. sub-store 容器 env(SUB_STORE_FRONTEND_BACKEND_PATH
  2. caddy entrypoint 的 DEFAULT_SK(自动重算 BASIC_AUTH_HASH
  3. mihomo sops template 的 ME_SK(影响 wildUrl 渲染)
  4. axonhub 容器 env(DEFAULT_SK

缺一处就 401。这条联动链当前没写在任何运维 checklist 里,旋转操作要重新读这一节确认。

6.7 docker edge network 命名的全局唯一性

caddy compose 创建 edge,sub-store 生产模式声明 external: true 复用同名网络。如果未来第三个服务也命名 edge 但配 external: false,docker compose 会自作主张创建一个私网,导致服务间不通——且现象很迷惑(健康检查通过、curl 不通)。

约定:edge 是全站共享名,任何新服务要进 edge 必须 external: true


7. 验证清单

部署后按顺序跑一遍:

# 1) sub-store 容器起来
docker compose -f .cntr/sub-store/compose.yml up -d
docker compose -f .cntr/sub-store/compose.yml ps
curl -s http://127.0.0.1:3001/${DEFAULT_SK} | head -c 200 # 应返回 sub-store 前端 HTML

# 2) mihomo 拿到 self / wild 两个 provider
darwin-rebuild switch
# 然后开 mihomo dashboard (metacubexd) 看 Proxy Providers 页
# 应看到 self / wild 两条,updated 时间合理,节点数 > 0

# 3) Self / Wild / Auto group 各能选出节点
# dashboard 进 Proxies 页,依次点 Self / Wild / Auto 看延迟数字

# 4) 生产模式下 caddy basic_auth 真生效
# 不带 -u 直接 curl 应返回 401
curl -I https://sub.lucc.dev/
# 带正确 basic_auth 应到 sub-store 登录前端
curl -I -u luck:${DEFAULT_SK} https://sub.lucc.dev/${DEFAULT_SK}

# 5) 检查 admin path 没出现在 caddy log 明文里
docker compose -f .cntr/caddy/compose.yml logs caddy | grep -c "${DEFAULT_SK}"
# 期望 0,否则触发 6.5 的 follow-up

文档不是代码,真正的"verification"是半年后回来还能读懂

  • 能回答"当初为什么 self 不走 sub-store"吗?→ 第 2 节
  • 能回答"DEFAULT_SK 旋转要同步哪几处"吗?→ 第 6.6 节
  • 能回答"pre-commit hook 为什么不是 validate"吗?→ 第 4 节末段
  • 能回答"加 iOS 客户端时该怎么改"吗?→ 第 2 节反向触发条件 + 双轨方案

如果某条问不出对应章节,说明这一节没写够清晰,回来补。

打野

野王轮流坐,今天到你啦 - 开发调优 / 开发调优, Lv1 - LINUX DO

全自动获取免费机场节点/订阅方法分享【立即实现代理节点自由】 - 开发调优 / 开发调优, Lv1 - LINUX DO