- 决定上 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-setup | narrative(叙事/踩坑) | 为什么从 sing-box 换 mihomo?换的时候踩了什么坑? |
| proxying-in-practice | vision(宣言/世界观) | 整个翻墙方案是什么?为什么用 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 validate ≠ caddy 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
部署模式由两个开关决定:
| 模式 | ports 段 | EDGE_NETWORK_EXTERNAL |
|---|---|---|
| 本地(macOS) | 保留 127.0.0.1:3001 | false(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 处:
- sub-store 容器 env(
SUB_STORE_FRONTEND_BACKEND_PATH) - caddy entrypoint 的
DEFAULT_SK(自动重算BASIC_AUTH_HASH) - mihomo sops template 的
ME_SK(影响wildUrl渲染) - 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