背景
挂着两三家机场 + 一台自建节点,每家命名规则不一样、节点重复、规则集要在每个客户端各贴一份。机场换链接、加节点、改分流,所有设备都要重订阅一轮。
要的是:所有原始订阅塞进一个聚合器,自动去重 / 改名 / 分组 / 套规则,输出一条稳定 URL,客户端只订阅这条。本文用 Sub-Store + subconverter + Docker Compose 实现。
架构与暴露策略
三层各暴露一个稳定 URL,上下游互不影响。链路单向:客户端 → subconverter 公网 URL → 容器内拉 Sub-Store → Sub-Store 拉机场。中间任一层挂掉只影响下次订阅刷新,已下载的配置继续可用。
机场订阅 · 自建节点
↓
Sub-Store · 合并 / 去重 / 改名 / 过滤 / 分组
↓ 纯节点订阅
subconverter (+ sub-web GUI) · 套 ACL4SSR · 转目标格式
↓
客户端 · Mihomo / Surge / sing-box
只有 subconverter 必须公网(客户端要拉订阅),其余关进 SSH 隧道:
| 层 | 服务 | 容器端口 | 宿主机绑定 | 对外 |
|---|---|---|---|---|
| 2 | sub-store | 3001 | 127.0.0.1:3001 | 仅 SSH 隧道 |
| 3 | subconverter | 25500 | 127.0.0.1:25500 | 公网域名 + Caddy |
| 3 | sub-web | 80 | 127.0.0.1:25580 | 仅 SSH 隧道 |
subconverter 公网入口叠两层防御:Caddy UA 白名单(藏 banner,挡裸扫描器)+ api_access_token(每条 URL 必带 token,真访问控制)。
前置准备
- VPS 一台(1C/512M 起步),Debian 12 / Ubuntu 22.04+
- 域名
sub.example.com,A 记录指向 VPS(只 subconverter 用) - 本机 SSH 公钥已加进 VPS
环境初始化
apt update && apt upgrade -y && apt install -y curl ca-certificates
timedatectl set-timezone Asia/Shanghai
adduser evan && usermod -aG sudo evan
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
usermod -aG docker evan
部署核心栈
sudo -iu evan
mkdir -p ~/sherpa/{sub-store-data,subconverter/{snippets,rules}}
cd ~/sherpa
docker-compose.yml
x-logging: &log
driver: json-file
options: { max-size: "10m", max-file: "3" }
services:
sub-store:
image: xream/sub-store:latest
container_name: sub-store
restart: unless-stopped
ports: ["127.0.0.1:3001:3001"]
volumes: [./sub-store-data:/opt/app/data]
environment:
- "SUB_STORE_FRONTEND_BACKEND_PATH=${SUB_STORE_FRONTEND_BACKEND_PATH:?missing in .env}"
- SUB_STORE_FRONTEND_HOST=0.0.0.0
- SUB_STORE_FRONTEND_PORT=3001
- SUB_STORE_DATA_BASE_PATH=/opt/app/data
networks: [sherpa-net]
logging: *log
# MetaCubeX fork 跟进 Hysteria2 / TUIC / Reality;tindy2013 原版更新慢
subconverter:
image: ghcr.io/metacubex/subconverter:latest
container_name: subconverter
restart: unless-stopped
ports: ["127.0.0.1:25500:25500"]
volumes:
- ./subconverter/pref.toml:/base/pref.toml:ro
- ./subconverter/snippets:/base/snippets:ro
- ./subconverter/rules:/base/rules:ro
networks: [sherpa-net]
logging: *log
sub-web:
image: careywong/subweb:latest
container_name: sub-web
restart: unless-stopped
environment: [API_URL=https://${SUB_DOMAIN}]
ports: ["127.0.0.1:25580:80"]
depends_on: [subconverter]
networks: [sherpa-net]
logging: *log
# 显式 name,Caddy 子项目 external 引用;容器之间用服务名通信,不走宿主机 loopback
networks:
sherpa-net:
driver: bridge
name: sherpa-net
.env
cat > .env <<EOF
SUB_DOMAIN=sub.example.com
SUB_STORE_FRONTEND_BACKEND_PATH=/api-$(openssl rand -hex 16)
SSH_HOST=evan@<vps-ip>
EOF
chmod 600 .env
SUB_STORE_FRONTEND_BACKEND_PATH 是 Sub-Store「路径即密钥」的访问控制 —— 后端 API 挂到 /<random>/api/...,没前缀直接 404。即便不暴露公网也必填(前端代码写死要带)。
subconverter/pref.toml
cat > subconverter/pref.toml <<EOF
[common]
api_mode = true
api_access_token = "$(openssl rand -hex 32)"
[node_pref]
udp_flag = true
filter_deprecated_nodes = true
append_sub_userinfo = true
clash_use_new_field_name = true
[managed_config]
write_managed_config = true
# 必须真实公网域名,客户端按这个 URL 自动刷新;写 localhost 客户端更新不了
managed_config_prefix = "https://sub.example.com"
[emojis]
add_emoji = true
remove_old_emoji = true
[ruleset]
enabled = true
overwrite_original_rules = true
EOF
chmod 600 subconverter/pref.toml
启动 + 自检
docker compose up -d
curl -s http://127.0.0.1:25500/version # subconverter vx.x.x backend
curl -sI http://127.0.0.1:3001/ | head -1 # HTTP/1.1 200 OK
公网入口:Caddy 反代
宿主机已有 Caddy,/etc/caddy/Caddyfile 加一段,systemctl reload caddy:
sub.example.com {
encode gzip
@blocked not header User-Agent *Clash* *Quantumult* *Surge* *Shadowrocket* *Stash* *Loon* *Mihomo* *sing-box* *curl* *wget*
respond @blocked 403
reverse_proxy 127.0.0.1:25500
}
没 Caddy 就跑容器版子项目 caddy/:
# caddy/docker-compose.yml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [caddy-net, sherpa-net]
networks:
caddy-net: { driver: bridge, name: caddy-net }
sherpa-net: { external: true, name: sherpa-net } # 引用主项目同名网络
volumes: { caddy_data: {}, caddy_config: {} }
caddy/Caddyfile 跟上面一样,把 reverse_proxy 改成走容器网络的服务名:reverse_proxy subconverter:25500。
自检:
curl -i https://sub.example.com/version # 403,UA 被挡
curl -iA "Mihomo" https://sub.example.com/version # 200
UA 白名单不是安全机制(改 UA 太容易),作用是把 zgrab / nmap 扫描里的 banner 干掉。真正的访问控制是
api_access_token。
本机访问:SSH 隧道
Sub-Store / sub-web 只绑 loopback,靠 SSH 隧道把端口转回本机。配 ~/.ssh/config:
Host sherpa
HostName <vps-ip>
User evan
IdentityFile ~/.ssh/id_ed25519
LocalForward 3001 127.0.0.1:3001
LocalForward 25580 127.0.0.1:25580
ServerAliveInterval 60
ExitOnForwardFailure yes
简单粗暴:ssh -fN sherpa。坏处是要关只能 pgrep + kill。幂等管理用 ControlMaster,开 / 关 / 查对着同一个 socket:
SOCK=/tmp/sherpa-tunnel-$(id -u).sock
ssh -S "$SOCK" -O check sherpa 2>/dev/null || \
ssh -fN -M -S "$SOCK" sherpa # 启动(已有则跳过)
ssh -S "$SOCK" -O check sherpa # 查
ssh -S "$SOCK" -O exit sherpa # 关
长期保活:autossh -M 0 -fN sherpa(-M 0 关掉 autossh 自带 echo 探测,完全依赖 SSH 的 ServerAliveInterval)。
隧道起来后:
- Sub-Store 后台
http://localhost:3001/?api=http://localhost:3001/api-xxxxxxxx(api=填.env的SUB_STORE_FRONTEND_BACKEND_PATH,浏览器记 localStorage) - sub-web GUI
http://localhost:25580,「后端地址」填https://sub.example.com
手机端 SSH 隧道不友好,VPS 和手机都加 Tailscale tailnet,手机直连
http://<tailscale-ip>:3001/...。这部分单独写。
第 1 层:节点源
- 机场订阅:复制后台的
https://airport.example.com/api/v1/client/subscribe?token=xxxURL,多家就准备多条 - 自建节点(可选):另一台 VPS 上跑 mack-a/v2ray-agent 一键脚本,协议选 VLESS-Reality(抗封锁最强,免域名证书)。生成的
vless://...当一条「单节点订阅」喂给 Sub-Store。独立 VPS,管理面挂了不影响出口流量
第 2 层:Sub-Store 编排
两个抽象:订阅(单源)+ 组合订阅(多源合并 + 节点操作流水线)。典型用法:每家机场 / 每条自建节点建一条单订阅 → 全部加进主组合 all → 客户端只订阅主组合。
「组合订阅 → all」加节点操作流水线,顺序重要:
| # | 操作 | 用途 |
|---|---|---|
| 1 | 类型过滤 | 砍掉客户端不支持的协议 |
| 2 | 正则过滤 | 关键字 剩余|到期|官网|订阅|流量|套餐,砍伪节点 |
| 3 | 倍率过滤(脚本) | 砍倍率 ≥ 2 的高耗节点 |
| 4 | 正则改名 | 🇭🇰 香港 01 / HK-01 / Hong Kong-01 统一格式 |
| 5 | 正则排序 | 按国家分组 |
| 6 | 去重 | 按 server + port + uuid |
all 最终拿到一个 URL,视场景换 host:
| 视角 | URL | 用途 |
|---|---|---|
| 容器内 | http://sub-store:3001/api-xxx/download/collection/all?target=ClashMeta | ✅ 填进 sub-web |
| 隧道 | http://localhost:3001/api-xxx/download/collection/all?target=ClashMeta | 本地调试看节点 |
容器内用服务名 sub-store(同 sherpa-net,Docker DNS 解析)。这条 URL 会出现在客户端订阅链接的 url= 参数里,但 hostname sub-store 只在 docker 网络内可解析,公网拿到也访问不了。
第 3 层:subconverter 串接
subconverter URL 模板:
https://sub.example.com/sub
?target=clash
&url=<URL-encoded 第 2 层 URL>
&config=<URL-encoded 远程 config>
&access_token=<pref.toml 的 api_access_token>
&emoji=true&list=false&udp=true
手拼太累,用 sub-web GUI(http://localhost:25580)填表:
| 字段 | 填法 |
|---|---|
| 后端地址 | https://sub.example.com |
| 订阅链接 | 容器内视角 http://sub-store:3001/api-xxx/download/collection/all |
| 客户端 | Clash |
| 远程配置 | ACL4SSR 模板(完整 / 精简 / 含广告拦截,按需选) |
| 高级 → access_token | pref.toml 里的那个 |
远程 config 默认走 GitHub 原始链接,怕限流就 clone 到本地:
git clone --depth=1 https://github.com/ACL4SSR/ACL4SSR.git ~/sherpa/subconverter/rules/ACL4SSR
# config 改成 config/ACL4SSR/Clash/config/ACL4SSR_Online_Full.ini
上下游联动
三层架构最舒服的地方:改一处不影响其他。
| 想做 | 改哪层 | 客户端 URL |
|---|---|---|
| 机场换链接 | 第 2 层单订阅 | ❌ 不变 |
| 加自建节点 | 第 2 层加单订阅 + 进组合 | ❌ 不变 |
| 砍某地区节点 | 第 2 层组合过滤规则 | ❌ 不变 |
| 换分流规则 | 第 3 层 config / pref.toml | ✅ 换新 URL |
| 加 sing-box 客户端 | 第 3 层 target=singbox | ✅ 给 sing-box 新 URL |
下一节的短链方案能把所有 ✅ 都变 ❌。
短链固定订阅 URL
上一节的 subconverter URL 又长又会变:换 token / 调规则 / 加 target 都要重订一遍。家里多设备 / 共享家人就翻车。
套一层短链:客户端订阅短链,短链指真实 URL。改后端时只改短链目标,客户端无感。推荐 short.io —— 免费版支持自定义域名 + API,不挂广告中转页(bit.ly / tinyurl 免费版会跳广告,部分客户端识别为非订阅响应直接报错)。
注册 → 加自己的域名 s.example.com(按界面提示加 CNAME)→ 每种格式 / 每个家人各建一条:
| 短链 | 目标 |
|---|---|
s.example.com/clash | clash 格式 |
s.example.com/sing | sing-box 格式 |
s.example.com/mom | 给妈用的(可叠 token / 限节点) |
注意:
access_token暴露给 short.io —— 介意就自托管 Shlink / YOURLS 跟主栈放一起- 每人独立短链 —— 想踢人 / 限速只动一条目标 URL
- 免费版每月 1k 跳转 —— 6 小时刷一次,五口之家月 120 次够用
客户端
| 平台 | 推荐 |
|---|---|
| macOS / Linux | Mihomo Party · Clash Verge Rev · Surge |
| iOS | Shadowrocket · Stash |
| Android | Clash Meta for Android |
桌面优先 Mihomo 内核(规则兼容性最好)。sing-box 用 target=singbox,配置精简但规则生态没 Mihomo 完整。
节点诊断
| 工具 | 用途 |
|---|---|
| https://www.whatismyip.com/ | 出口 IP + ISP 归属 |
| https://ping.pe/8.8.8.8 | 全球多点 ping 延迟 / 丢包 |
| https://lg.dmit.sh/ | 多家机房 Looking Glass,反测 VPS 网络质量 |
| https://apimobile.meituan.com/locate/v2/ip/loc?rgeo=true&ip=8.8.8.8 | 美团 IP 库定位,判断 IP 在国内 App 眼里属哪个城市 |
| https://apimobile.meituan.com/group/v1/city/latlng/21.7,110.9?tag=0 | 经纬度反查城市,配合上一条交叉验证 |
| https://ip.skk.moe/ | IP 信息 + 流媒体解锁 + IP 类型(家宽 / 机房 / 移动)一站式 |
挑节点优先看「IP 类型 = 家宽 (Residential)」,机房 IP 越来越多被 Netflix / ChatGPT 风控。
维护
- 数据:业务数据在
~/sherpa/sub-store-data/+~/sherpa/subconverter/+.env,整个~/sherpa/打 tar 异地。备份策略复用 [[migrate-from-1password-to-vaultwarden|Vaultwarden 那篇]] 的 restic + B2,差异是可以把 restic 也跑 Docker(crontab 写进容器)跟主栈解耦 - 升级:
docker compose pull && docker compose up -d,三个latest都滚动,介意稳定性锁 tag - 日志:
docker compose logs -f sub-store / subconverter / sub-web - 隧道掉了:客户端无影响(已下载的 config 继续可用),只是后台进不去,重连
ssh -fN sherpa - token 泄漏:改
.env的SUB_STORE_FRONTEND_BACKEND_PATH或pref.toml的api_access_token,docker compose up -d重启。前者要重填浏览器api=参数,后者要重生成所有订阅 URL(用短链就只改短链目标) - 证书续签自动,但 80/443 被挡会失败,到期前 30 天看下
caddy-logs
– EOF –