背景

挂着两三家机场 + 一台自建节点,每家命名规则不一样、节点重复、规则集要在每个客户端各贴一份。机场换链接、加节点、改分流,所有设备都要重订阅一轮。

要的是:所有原始订阅塞进一个聚合器,自动去重 / 改名 / 分组 / 套规则,输出一条稳定 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 隧道:

服务容器端口宿主机绑定对外
2sub-store3001127.0.0.1:3001仅 SSH 隧道
3subconverter25500127.0.0.1:25500公网域名 + Caddy
3sub-web80127.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-xxxxxxxxapi=.envSUB_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=xxx URL,多家就准备多条
  • 自建节点(可选):另一台 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_tokenpref.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/clashclash 格式
s.example.com/singsing-box 格式
s.example.com/mom给妈用的(可叠 token / 限节点)

注意:

  • access_token 暴露给 short.io —— 介意就自托管 Shlink / YOURLS 跟主栈放一起
  • 每人独立短链 —— 想踢人 / 限速只动一条目标 URL
  • 免费版每月 1k 跳转 —— 6 小时刷一次,五口之家月 120 次够用

客户端

平台推荐
macOS / LinuxMihomo Party · Clash Verge Rev · Surge
iOSShadowrocket · Stash
AndroidClash Meta for Android

桌面优先 Mihomo 内核(规则兼容性最好)。sing-boxtarget=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 泄漏:改 .envSUB_STORE_FRONTEND_BACKEND_PATHpref.tomlapi_access_tokendocker compose up -d 重启。前者要重填浏览器 api= 参数,后者要重生成所有订阅 URL(用短链就只改短链目标)
  • 证书续签自动,但 80/443 被挡会失败,到期前 30 天看下 caddy-logs

– EOF –