Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:
- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
persistence commit per operation; per-plugin lock → vault lock hierarchy;
tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
invalid local-proxy ports are refused instead of falling back to :80; the
core's inbound credentials are carried to the local proxy; NAT64 and
site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
against the current override set before it replaces the profile, inside
the profile write critical section (profile.yaml and override.yaml share
one write queue); schedule fields are read at write time; the first
subscription is activated through the real switch flow; profile deletion
removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
vault (staleDevices) and retires it after the login, after later
successful fetches and on removal; enroll compensation restores the old
vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
(backend name + ciphertext-prefix canary); otherwise it stays in memory.
A cache-miss read releases the caller at the budget while the lock is held
until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
same-origin rule, /revoke after re-login); https-proxy-agent dropped.
Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cpx-gateway
机场插件 v2 的参考网关实现。适合先跑通协议的服务商直接部署测试和参考实现使用。
它提供:
- OAuth authorize 登录页:
/oauth/authorize - 网关发现文件:
/.well-known/cpx-gateway - 网关接口:
/enroll、/challenge、/config、/revoke - SQLite 账号、设备和订阅 URL 管理
- Caddy 自动申请和续期 HTTPS 证书
实现只使用 Node.js 内置模块,包括 node:sqlite。镜像构建时不执行 npm install。
相关文档:
部署结构
默认部署使用一个公网域名,同时作为登录域名和网关域名。
Client browser
-> https://<domain>/oauth/authorize
-> Caddy
-> gateway:8080
Client updater
-> https://<domain>/.well-known/cpx-gateway
-> https://<domain>/challenge
-> https://<domain>/config
-> Caddy
-> gateway:8080
-> hidden subscription origin
Compose 服务:
| 服务 | 作用 |
|---|---|
caddy |
监听 80/443,申请 Let's Encrypt 证书,反代网关 |
gateway |
Node.js 网关进程,只暴露在 compose 内部网络 |
数据卷:
| 卷 | 内容 |
|---|---|
gateway_data |
SQLite 数据库 /data/gateway.db |
caddy_data |
Caddy 证书和状态 |
caddy_config |
Caddy 运行配置 |
前置条件
- 一台公网 VPS,已安装 Docker 和 Docker Compose v2。
- 一个域名,A/AAAA 记录已经指向这台 VPS。
- VPS 防火墙和安全组放行 TCP 80、443。
客户端会校验 HTTPS、公网 host 和 well-known 文件。不要用 IP、localhost、内网域名或自签证书部署给真实用户。
部署
在 VPS 上进入本目录:
cd deploy/gateway
./deploy.sh
首次运行时,脚本会复制 .env.example 为 .env,询问公网域名,并执行:
docker compose up -d --build
等待 Caddy 申请证书后检查发现文件:
curl https://<domain>/.well-known/cpx-gateway
正常响应类似:
{
"spec": "cpx-plugin/2",
"gateway": "https://<domain>",
"gateways": ["https://<domain>"],
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
多网关域名(可选)
同一个网关进程可以用多个域名对外提供服务,客户端会在一个域名超时或不可达时自动切到下一个(见对接指南 §5 / §6):
- 在
.env里用DOMAINS=a.example.com, b-cdn.example.net列出全部域名——逗号后要有空格:Caddy 把该值原样当作站点地址,a,b会被拒绝(deploy.sh会自动补空格,直接用 docker compose 则不会)。Caddy 会为每个域名申请证书;都必须解析到这台 VPS。 - 用
GATEWAY_ORIGINS=https://a.example.com,https://b-cdn.example.net(1..3 个)声明 well-known 里的gateways;gateway始终等于第一个。 ./deploy.sh。
备用发现源(静态文件)
.cpx 可以用 discoveryUrls 列出备用发现源(对接指南第 3 / 5 节)。备用源只需要在 /.well-known/cpx-gateway 路径上提供同一份 JSON 文件,任何 CDN / 对象存储都可以。备用源不能与 loginUrl 同 origin(同一主机不构成冗余,gen-cpx.mjs 与客户端都会拒绝);最简单的做法是列出 DOMAINS 里的第二个网关域名——它已经提供该路径:
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx \
--discovery https://<second-domain> --discovery https://cdn.example.net
若用静态托管,把网关返回的文档原样放到 https://cdn.example.net/.well-known/cpx-gateway,并保证 gateway 等于 gateways[0]:
curl https://<domain>/.well-known/cpx-gateway > cpx-gateway.json
# 上传为 cdn.example.net/.well-known/cpx-gateway,Content-Type: application/json
共享状态 / 单进程:authorize code、nonce、登录限流都保存在网关进程内存里,所有 gateways 必须落到同一个网关进程(本部署由 Caddy 把所有域名反代到同一个容器)。不支持多副本:客户端可能在 /challenge 用一个域名、/config 用另一个域名,跨进程 nonce 会直接失败。
查看容器状态:
docker compose ps
docker compose logs -f gateway
docker compose logs -f caddy
账号管理
每个账号对应一个隐藏订阅 URL。该 URL 由网关在服务端请求,客户端不会拿到。
添加用户:
docker compose exec gateway cpx-admin add-user alice 'https://origin.example.com/sub?token=xxxx' --limit 3
命令会提示输入密码。密码只保存 scrypt hash。
常用命令:
docker compose exec gateway cpx-admin list-users
docker compose exec gateway cpx-admin list-users --show-sub
docker compose exec gateway cpx-admin set-sub alice 'https://origin.example.com/sub?token=yyyy'
docker compose exec gateway cpx-admin set-limit alice 5
docker compose exec gateway cpx-admin passwd alice
docker compose exec gateway cpx-admin list-devices alice
docker compose exec gateway cpx-admin revoke-device <deviceId>
docker compose exec gateway cpx-admin del-user alice
说明:
list-users默认只显示订阅 URL 的 host。list-users --show-sub会打印完整订阅 URL,只在需要排障时使用。revoke-device删除设备绑定。客户端下次更新会进入重新登录流程。del-user会删除用户及其设备。
生成 .cpx
.cpx 是公开插件描述文件,不含用户信息、token、网关密钥或订阅 URL。所有用户可以使用同一份文件。
在仓库根目录运行:
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx
分发 your-airport.cpx。用户在 Clash Party 中导入后,会通过系统浏览器打开登录页。登录成功后,客户端注册设备并拉取该账号绑定的 Clash YAML。
请求链路
首次登录:
- 客户端请求
https://<domain>/.well-known/cpx-gateway。 - 客户端生成 Ed25519 设备密钥和
deviceId。 - 系统浏览器打开
https://<domain>/oauth/authorize?...。 - 用户输入账号密码。
- 网关签发一次性
code,绑定 PKCE、redirect_uri、client_id,TTL 默认 60 秒。 - 客户端调用
/enroll,提交code、PKCE verifier、设备公钥和deviceId。 - 网关写入设备绑定。
订阅更新:
- 客户端调用
/challenge领取 nonce。 - 客户端用设备私钥签名。
- 客户端调用
/config。 - 网关校验 nonce、时钟偏差和 Ed25519 签名。
- 网关用该账号的隐藏订阅 URL 拉取 Clash YAML。
- 网关把 YAML 返回给客户端。
删除插件:
- 客户端调用
/revoke。 - 网关验签后删除设备绑定。
- 客户端删除本地状态。
运维
升级:
git pull
cd deploy/gateway
./deploy.sh
账号和设备数据保存在 gateway_data 卷中,升级容器不会删除。
备份数据库到当前目录:
docker compose exec -T gateway cat /data/gateway.db > gateway.db.backup
恢复数据库:
docker compose down
docker run --rm -i -v gateway_gateway_data:/data busybox sh -c 'cat > /data/gateway.db' < gateway.db.backup
docker compose up -d
签名发现文档(可选,对接指南第 5a 节):
私钥离线保存与签发,网关进程只读取签好的信封文件。在一台离线机器上:
# 1. 生成密钥(seed 写入 0600 文件,只打印公钥)
node deploy/gateway/admin.mjs keygen --out ./provider.seed
# 2. 写 payload.json(gateways / endpoints 必须与网关实际提供的一致;每次变更 seq 递增)
cat > payload.json <<'JSON'
{ "spec": "cpx-plugin/2", "seq": 1,
"gateways": ["https://<domain>"],
"endpoints": { "enroll": "/enroll", "challenge": "/challenge", "config": "/config", "revoke": "/revoke" } }
JSON
# 3. 签发(seed 只经文件 / stdin 传入,不放命令行)
node deploy/gateway/admin.mjs sign-discovery payload.json --seed-file ./provider.seed --out discovery.signed
把 discovery.signed 放进容器(例如 gateway_data 卷)并设置 DISCOVERY_SIGNED_FILE,重启后 well-known 会带 signed、/config 会带 X-CPX-Discovery 头;网关启动时会校验信封里的 endpoints 与自己提供的路径一致。先发布签名文档,再分发带 --pubkey 的 .cpx:
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx --pubkey <providerPubKey>
每个 .cpx 谱系使用独立密钥;本期不做密钥轮换,密钥泄露或丢失需要重新发放 .cpx。
给用户的错误说明(可选):
在容器内放一个 JSON 文件并用 MESSAGES_FILE 指向它,网关会把对应文案附在错误响应的 message 字段里,客户端卡片原样显示(客户端会去除控制字符并截断到 200 个字符):
{
"device_revoked": "订阅已到期,续费后请重新登录。",
"device_limit": "设备数已达上限,请在官网解绑旧设备。",
"gateway_retired": "服务地址已更换,客户端会自动重新发现。"
}
只支持 JSON(参考网关零依赖,没有 YAML 解析)。
网关退役:
if grep -q '^RETIRED=' .env; then
sed -i.bak 's/^RETIRED=.*/RETIRED=true/' .env
else
printf '\nRETIRED=true\n' >> .env
fi
docker compose up -d
退役后,/challenge 和 /config 返回 410 / gateway_retired。客户端会回到登录域名重新发现网关。
配置
配置文件为 .env。可参考 .env.example。
| 变量 | 默认值 | 说明 |
|---|---|---|
DOMAIN |
无 | 公网域名,必填 |
DOMAINS |
$DOMAIN |
Caddy 服务的全部域名,逗号分隔(多网关域名时使用) |
PUBLIC_ORIGIN |
https://$DOMAIN |
写入 /.well-known/cpx-gateway 的 gateway origin |
GATEWAY_ORIGINS |
$PUBLIC_ORIGIN |
well-known gateways 列表,逗号分隔 1..3 个 origin |
DEVICE_LIMIT_DEFAULT |
3 |
新用户默认设备数上限,可被 add-user --limit 覆盖 |
CLOCK_SKEW_MS |
300000 |
/config、/revoke 签名时间戳允许偏差 |
CODE_TTL_MS |
60000 |
authorize code 有效期 |
NONCE_TTL_MS |
60000 |
challenge nonce 有效期 |
NONCE_POOL_MAX |
8 |
单设备待用 nonce 数上限 |
LOGIN_MAX |
10 |
单 IP 登录尝试次数上限 |
LOGIN_WINDOW_MS |
60000 |
登录限流窗口 |
SUB_TIMEOUT_MS |
30000 |
拉取隐藏订阅的超时 |
SUB_MAX_BYTES |
10485760 |
隐藏订阅响应体上限 |
RETIRED |
false |
设置为 true 时返回网关退役信号 |
MESSAGES_FILE |
空 | 可选 JSON 文件,错误响应附带给用户看的 message |
DISCOVERY_SIGNED_FILE |
空 | 可选,离线签好的发现文档信封(对接指南第 5a 节) |
ORIGIN_CA_FILE |
空 | 订阅 origin 使用私有 CA 时,在容器内指定 CA 文件路径 |
PORT |
8080 |
gateway 容器内监听端口 |
DB_PATH |
/data/gateway.db |
SQLite 数据库路径 |
如果登录域名和网关域名需要拆开,保持 .cpx 里的 loginUrl 指向登录域名,同时把登录域名上的 /.well-known/cpx-gateway 的 gateway / gateways 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。
参考网关只支持单进程部署:GATEWAY_ORIGINS 里的所有域名都必须反代到同一个 gateway 容器。
开发和自测
本目录不需要安装依赖。需要 Node.js >= 22.5.0。
cd deploy/gateway
npm test
npm run check-vectors
npm start
说明:
npm test使用node:test跑网关测试。npm run check-vectors使用客户端签名向量检查 Ed25519 互通。npm start在本地启动 HTTP 网关,默认监听:8080。真实客户端接入仍需要公网 HTTPS 和合法 well-known。
安全边界
- 隐藏订阅 URL 是管理员配置项。网关只要求 HTTPS,并设置超时和响应体大小上限;不拦截私网地址,便于把 origin 放在内网。
- 密码使用 scrypt hash 保存。
- authorize code 和 nonce 都是一次性短 TTL。
- 登录接口按 IP 限流。
/config和/revoke使用 Ed25519 设备签名和一次性 nonce 防重放。- 日志不要记录密码、完整订阅 URL、code、nonce、签名或完整 Clash YAML。
- Clash 节点内容最终会返回客户端,这是客户端运行 Mihomo 的必要输入。本实现保护的是订阅 URL、origin host 和服务端 API。