Files
clash-party/docs/plugin/机场服务端对接指南-v2.md
ezequielnick a092eb50c3 feat(plugin): CPX v2 availability hardening (squash of cpx-v2-availability-hardening)
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>
2026-09-08 22:44:13 +08:00

40 KiB
Raw Blame History

机场服务端对接指南v2

本文面向机场/服务商的服务端开发人员。v2 协议对应客户端 speccpx-plugin/2

相关文件:

开发过程中曾有一版 v1 方案(账号密码 + 加密容器)。该方案已弃用,当前代码库未包含 v1 实现。


1. 接入模型

v2 不再把真实订阅 URL 或 API 域名写入客户端文件。用户导入的 .cpx 只包含登录入口和服务商展示信息;登录通过系统浏览器完成;客户端本地生成 Ed25519 设备密钥;后续订阅更新通过网关的 challenge/config 流程完成。

服务端需要提供三类能力:

模块 所在域名 用途
OAuth authorize 登录页 登录域名,即 .cpxloginUrl 的 host 用户登录,签发一次性 code
/.well-known/cpx-gateway 同登录域名、同端口 返回当前网关 origin 和端点路径
网关端点 网关域名,可与登录域名不同 设备注册、发放 nonce、拉订阅、解绑设备

登录域名是信任根,会固化在已经发出的 .cpx 文件中;网关域名由 /.well-known/cpx-gateway 动态发现,可以替换。

故障位置 影响
网关域名 客户端可经登录域名重新发现新网关
登录域名 已登录设备可继续访问现有网关;无法新登录、重登,也无法重新发现网关
两者都故障 客户端失联,需要重新分发新的 .cpx

端到端流程:

安装:
  用户导入 .cpx
  客户端校验描述文件,创建本地记录,不联网

登录:
  1. GET https://<login-host>/.well-known/cpx-gateway
  2. 客户端生成 Ed25519 设备密钥和 deviceId(UUIDv4)
  3. 系统浏览器打开 loginUrl?response_type=code&code_challenge=...&state=...
  4. 用户在服务端页面登录,服务端 302 回 http://127.0.0.1:<port>/callback?code=...&state=...
  5. POST {gateway}/enroll用 code 和 PKCE verifier 注册设备
  6. POST {gateway}/challenge领取一次性 nonce
  7. POST {gateway}/config签名后拉取 Clash YAML

更新:
  定时重复 challenge -> config不再打开浏览器

重新登录:
  服务端返回 {"error":"revoked"} 或 {"error":"device_revoked"}
  客户端标记为需要重新登录,用户重新走登录流程

删除:
  客户端尽力调用 /revoke然后删除本地记录

2. 参考服务端

deploy/gateway/ 是完整参考实现,包含 Docker 部署、SQLite 账号库和 cpx-admin 管理命令。新接入建议先跑通这套服务,再接入自己的面板。

主要文件:

文件 说明
deploy/gateway/src/auth.mjs 登录页、authorize、一次性 code
deploy/gateway/src/gateway.mjs enroll/challenge/config/revoke
deploy/gateway/src/crypto.mjs PKCE、Ed25519、签名输入
deploy/gateway/src/origin.mjs 拉取隐藏订阅 origin

已有面板接入时,通常只需要新增:

  1. 设备绑定表:user_iddevice_iddevice_pubkeycreated_at 等。
  2. nonce 暂存Redis、内存或数据库均可短 TTL用完即删。
  3. OAuth authorize 登录页:复用现有账号密码校验。
  4. /.well-known/cpx-gateway:返回当前网关配置。
  5. 四个网关端点:内部调用现有用户状态和订阅生成逻辑。

3. .cpx 文件

.cpx 是公开 JSON 文件不包含用户信息、token、API host 或订阅 URL。所有用户可以使用同一份文件。

{
  "magic": "CPXF",
  "v": 2,
  "spec": "cpx-plugin/2",
  "loginUrl": "https://panel.example.com/oauth/authorize",
  "provider": {
    "name": "Example 机场",
    "icon": "data:image/png;base64,iVBORw0K...",
    "site": "https://example.com"
  }
}

字段要求:

字段 要求
magic 字符串 "CPXF"
v 数字 2
spec 字符串 "cpx-plugin/2"
顶层字段 只能包含 magicvspecloginUrlproviderdiscoveryUrlsproviderPubKey
loginUrl HTTPS URL不能包含 query、fragment、userinfohost 不能是私网、环回、localhost*.localhost
discoveryUrls 可选。1..8 个公网 HTTPS origin无 path/query/fragment/userinfo去重不得与 loginUrl 同 origin。备用发现源见第 5 节。只有加入该字段的版本之后的客户端才接受此键,旧客户端会拒绝导入
providerPubKey 可选。Ed25519 原始 32 字节公钥,标准 base64 带 padding。一旦存在客户端强制要求签名发现文档(第 5a 节),拒绝未签名文档。每个 .cpx 谱系使用独立密钥。旧客户端会拒绝导入
provider 对象,只能包含 nameiconsitedescription
provider.name 必填,非空字符串
provider.icon 可选;仅支持 data:image/png;base64,data:image/jpeg;base64,data:image/webp;base64,;总长度不超过 65536
provider.site 可选HTTPS URLhost 约束同 loginUrl,可包含路径
provider.description 可选;给用户看的说明文字,清洗规则同错误 message(第 6 节),上限 500 码点。旧客户端会拒绝导入

loginUrl 必须是 authorize 端点,不是普通登录首页。客户端会在该 URL 后拼接 OAuth 参数。

生成脚本:

node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output] [--discovery <origin>]...

--discovery 可重复,每个值对应 discoveryUrls 的一项。

分发方式

  • 文件分发:直接提供 .cpx 下载。已注册文件关联的客户端支持双击 .cpx 启动应用,并打开插件预览确认页。

  • Deep link:将同一份 .cpx 托管在公网 HTTPS 地址,再把以下链接用于网页按钮或二维码:

    clash://install-plugin?url=https%3A%2F%2Fprovider.example.com%2Fapp.cpx
    

    mihomo://clash:// 等价。url 参数应进行 URL 编码;客户端下载并校验文件后会安装插件并打开系统浏览器登录。下载地址必须使用公网 HTTPS、不允许重定向文件大小不超过 1 MiB。


4. OAuth authorize

登录流程使用 OAuth 2.0 Authorization Code + PKCE(S256)。客户端打开系统浏览器访问 loginUrl,密码只提交到服务商页面,客户端不接触密码。

客户端请求参数:

参数
response_type code
client_id mihomo-party
redirect_uri http://127.0.0.1:<random-port>/callback
code_challenge BASE64URL(SHA256(code_verifier)),无 padding
code_challenge_method S256
state 随机串,服务端必须原样带回
scope subscribe

authorize 端点要求:

  1. 允许 http://127.0.0.1:<random-port>/callback 形式的回环重定向。端口每次登录随机,不能按固定端口白名单处理。
  2. 用户登录成功后,302redirect_uri?code=...&state=...
  3. code 必须一次性、短 TTL不超过 60 秒)。
  4. 签发 code 时记录 user_idredirect_uriclient_idcode_challenge/enroll 时逐字节比对。

原生应用的 loopback redirect 允许使用 HTTP依据 RFC 8252。不要因为 redirect_uri 不是 HTTPS 而拒绝。


5. 网关发现

客户端按 loginUrl 的精确 host 请求:

GET https://<login-host>/.well-known/cpx-gateway

响应体:

{
  "spec": "cpx-plugin/2",
  "gateway": "https://gw.front.example.net",
  "gateways": ["https://gw.front.example.net", "https://gw2-cdn.example.com"],
  "endpoints": {
    "enroll": "/enroll",
    "challenge": "/challenge",
    "config": "/config",
    "revoke": "/revoke"
  }
}

字段要求:

字段 要求
spec 字符串 "cpx-plugin/2"
gateway HTTPS origin仅包含 scheme、host、可选 port不能有 path、query、fragment、userinfohost 必须公网可达。gateways 时必须等于归一化后的 gateways[0]
gateways 可选。1..3 个 HTTPS origin规则同 gateway,按归一化结果去重。缺失时客户端按 [gateway] 处理。任一项非法、空列表、超过 3 个都会使整份文档无效
endpoints 必须包含 enrollchallengeconfigrevoke;每个值是以 / 开头的相对路径,不能是绝对 URL不能包含 ?#、反斜杠。所有网关共用同一组 endpoints

客户端请求 /.well-known/cpx-gateway 时只走 HTTPS不跟随重定向响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。

旧客户端只读 gateway;新客户端读 gateways,缺失时退回 [gateway]。两者请从同一个列表生成,保证一致。

多网关必须共享状态。 所有 gateways 必须指向同一份后端状态authorize code / 设备 / nonce客户端可能在一个网关上 /challenge 拿到 nonce超时后到另一个网关上 /config。参考部署由 Caddy 为所有网关域名终止 TLS 并转发到同一个网关进程,不支持多副本

网关轮换与切换

替换网关时,更新 /.well-known/cpx-gateway 即可。

在一次客户端操作(一个完整业务动作,如 challenge + config客户端按缓存列表逐个尝试——上次成功的网关优先其余按原序——在当前网关出现下列情况时换到下一个

  • HTTP 410 或 JSON {"error":"gateway_retired"}
  • 网络层不可达DNS 失败、连接失败、TLS 握手失败;
  • 超时(完全没有 HTTP 响应)。

除退役标记外的任何 HTTP 响应都会结束本次操作:普通 5xx、429、revoked 都不会换网关。只有当全部缓存网关都以上述可切换方式失败时,客户端才重新发现一次并尝试新列表,且跳过本次操作已试过的目标(同 origin 同 endpoints。仍失败则按瞬时故障退避。

每个网关 origin 的出口路由也是独立选择的:客户端可能直连一个网关、经本地代理访问另一个网关(见第 6 节)。

重新发现依赖至少一个发现源可用(登录域名,以及 .cpx 里的 discoveryUrls)。

多发现源

登录域名是重新发现的单点:它被封后,已登录设备就再也拿不到新网关。因此 .cpx 可以在 discoveryUrls(第 3 节)里列出备用发现源,信任级别与 loginUrl 相同——都是用户导入时接受的静态信任根。

发现顺序为 [loginUrl 的 origin, …discoveryUrls],每个源请求 https://<origin>/.well-known/cpx-gateway。某个源的任何失败——网络错误、非 2xx、JSON 非法、字段非法、客户端本地 guard 拒绝——都会换到下一个源;备用源可能只是 CDN 上的一个静态文件,404 只表示“此处不提供”。全部失败时以最后一个错误为准。每个源是独立 origin出口路由第 6 节)也各自选择。

备用源只需在该路径上通过公网 HTTPS 提供同一份 JSON 文件,任何 CDN / 对象存储都可以;网关本身已经提供该路径,因此把网关 origin 列进 discoveryUrls 是最省事的做法。备用源只帮助已登录设备自愈;新登录仍然依赖登录域名,因为 OAuth 页面是在系统浏览器里打开的。

5a. 签名发现文档

.cpxproviderPubKey 后,信任根从"主机名"换成"密钥":发现文档从哪拿到都无所谓——登录域名、任一网关、静态 CDN 文件、/config 响应——只要能用该密钥验签、且序号不早于客户端已接受的版本即可。登录域名不再是单点,gatewaysendpointsloginUrldiscoveryUrls 都可以在不动 .cpx 的情况下轮换。

信封。 一个字符串 "<payloadB64>.<sigB64>"

  • payloadB64payload JSON 的 UTF-8 字节,标准 base64 带 padding。
  • sigB64Ed25519 对 "CPX2-DISCOVERY\0" || payloadBytes 的 64 字节签名——ASCII 前缀、一个 NUL 字节、然后是 payload 原始字节——标准 base64 带 padding。前缀提供域分离防止同一密钥签出的其他类型消息被冒用。
  • 恰好一个 .。两段都必须是规范 base64解码后重新编码必须与输入完全一致。payload 最多 4 KiB;经 base64 与签名后约 5.6 KiB在常见 CDN / 反向代理 8 KiB 单头限制内。

签发方自己序列化 payload 并对那串字节签名;客户端验的是收到的那串字节,验过之后才解析。两边都不需要规范化(不用 JCS

payload。

{
  "spec": "cpx-plugin/2",
  "seq": 12,
  "gateways": ["https://gw1.example.net", "https://gw2-cdn.example.com"],
  "endpoints": {
    "enroll": "/enroll",
    "challenge": "/challenge",
    "config": "/config",
    "revoke": "/revoke"
  },
  "loginUrl": "https://panel-new.example.com/oauth/authorize",
  "discoveryUrls": ["https://gw2-cdn.example.com"]
}
字段 要求
spec "cpx-plugin/2"
seq 整数,1 ≤ seq ≤ 2^531。每次变更必须严格递增
gateways 规则同第 5 节1..3 个公网 HTTPS origin
endpoints 规则同第 5 节;可选的 bootstrap 路径留给后续阶段
loginUrl 可选;规则同 .cpx 字段。接受后替换已保存的登录地址,下次登录打开新地址
discoveryUrls 可选;缺失 = 不改,[] = 清空,非空时规则同 .cpx1..8。payload 同时给出 loginUrl 时,列表不得包含其 origin否则整份文档无效签发前须移除未给出 loginUrl 时,客户端在应用阶段按已保存的登录地址去除同源项

不允许其他键。

投放位置——两处,同一格式、同一校验:

  1. well-known 文档新增可选顶层 signed。旧字段 gateway / gateways / endpoints 照旧保留给无密钥与旧客户端;有密钥客户端会把它们与 payload 比较,不一致则整个源无效,因此两者请从同一份 payload 生成。
  2. /config 成功响应可带响应头 X-CPX-Discovery: <payloadB64>.<sigB64>。只发一个头值;重复头会被客户端忽略。

客户端行为。

.cpxproviderPubKey well-known 有 signed 行为
任意 走无签名路径(第 5 节);忽略 signedX-CPX-Discovery
该源发现失败(降级攻击防护),试下一个源
验签 → 解析 → 序号校验 → 应用

客户端保存最后接受的 seq 以及 payload 字节的 SHA-256。对新到达的文档未存过 → 接受;seq 更大 → 接受;seq 相同且摘要相同 → 作为幂等重放接受;seq 相同但摘要不同 → 拒绝(同一序号下的两份不同文档,例如多 CDN 副本不一致);seq 更小 → 拒绝。被拒绝的 well-known 源和其他发现失败一样跳过。被拒绝或畸形的 X-CPX-Discovery 头只记日志:已认证成功的 /config 响应绝不因此作废。

序号防的是网络侧重放更旧的文档;它不防有本机文件系统权限的攻击者。

接受后的应用顺序。 网关列表与端点先写入客户端的加密缓存;然后 loginUrldiscoveryUrlsseq 与摘要一步写入插件记录。序号是提交标记:中间任何一步失败都不推进标记,下一次同一文档到达时幂等修复。新登录时发现文档在打开浏览器之前就应用,因此轮换后的 loginUrl 立即生效,取消登录之后也不会被拉回更旧的文档。

密钥管理。 离线签发。网关进程不持有私钥,只读取预先签好的信封文件(参考网关的 DISCOVERY_SIGNED_FILE,同时用于 signed 与响应头)。cpx-admin keygencpx-admin sign-discovery <payload.json>(或 scripts/plugin/sign-discovery.mjs)从文件或 stdin 读取 seed绝不放在命令行参数里。每个 .cpx 谱系使用独立密钥:同一密钥签出的文档在共用该密钥的插件之间可以互相冒用。本期不做密钥轮换——密钥泄露或丢失都需要重新发放 .cpx

发布顺序。 先上线带 signed 的 well-known再分发含 providerPubKey.cpx。顺序反了,所有有密钥的客户端在 signed 出现之前都会发现失败。


6. 网关公共约定

四个端点均为 POST,请求体为 JSON。客户端使用加固 HTTPS 客户端访问:只走 HTTPS不跟随重定向响应体上限 10 MiB。

不使用 Authorization header不下发 bearer token。设备身份由 deviceId、设备公钥和 Ed25519 签名确认。

错误信号按以下优先级处理:

客户端判定 条件 客户端行为
retired HTTP 410,或 JSON {"error":"gateway_retired"} 换下一个缓存网关;全部用尽后重新发现一次
revoked JSON {"error":"revoked"}{"error":"device_revoked"} 标记为需要重新登录
unreachable DNS 失败、连接失败、TLS 握手失败 换下一条路由,再换下一个网关;全部用尽后重新发现一次
transient 其它非 2xx5xx、429、空 401/403或超时 超时:换路由 / 换网关。任何 HTTP 响应:退避重试
blocked 网关 host 在客户端本地解析到私网/环回地址(不会发出任何请求) 退避;用户需修正目标或显式选择代理模式
成功 2xx 且没有错误标记 正常处理

账号到期、设备被踢、用户被禁用时,返回体必须包含 revokeddevice_revoked。单独返回空的 401/403 会被客户端当作瞬时失败处理。

可选的 message 任何错误 JSON 都可以附带给用户看的 message,客户端会显示在插件卡片上、固定状态文案之下:

{ "error": "revoked", "message": "订阅已于 2026-09-01 到期,续费后请重新登录。" }

客户端只在 message 是字符串时读取trim、去除除换行U+000A外的控制字符、按码点截断到 200、清洗后为空视为无。渲染为纯文本不做 Markdown、不识别链接。该消息随插件保存直到下一次操作成功时清空。.cpx 里的静态 provider.description 使用同一套清洗规则(上限 500 码点),显示在安装确认页与卡片上。

路由。 每个请求要么直连、要么经用户本地代理发出。默认的 自动 模式下,客户端记住该插件上次成功的路由并优先使用,只在网络层失败或超时时换另一条——任何 HTTP 响应都不会换路由。粘性按 origin 计:某个网关 origin 一旦在某条路由上有响应,本次操作对它的后续请求都走这条路由。经代理发出任何请求之前,客户端先在本地解析网关 host解析到私网地址即拒绝blocked)。剩余两条风险与显式代理模式相同:预检与代理侧解析之间的 DNS rebinding本地解析失败的 host 仍会放行经代理。

/enroll 不会重放。 authorize code 会被第一个到达网关的请求消费。因此客户端只在请求确定没有离开本机时(写入请求前的连接拒绝 / DNS / TLS 失败)才为 /enroll 换路由或换网关;超时、发送后的连接重置、任何 HTTP 响应都会直接结束本次登录,用户重新登录取新 code 即可。


7. POST {gateway}/enroll

作用:用 authorize 阶段签发的 code 注册设备,相当于 OAuth token exchange但不返回 bearer token。

请求体:

{
  "code": "<authorize code>",
  "code_verifier": "<PKCE verifier>",
  "redirect_uri": "http://127.0.0.1:<port>/callback",
  "client_id": "mihomo-party",
  "devicePubKey": "<base64 Ed25519 public key>",
  "deviceId": "<UUIDv4>"
}

服务端处理:

  1. 查找 code,确认未过期、未使用。
  2. 校验 code_verifierBASE64URL(SHA256(code_verifier)) 必须等于签发 code 时保存的 code_challenge
  3. 校验 redirect_uriclient_id 与签发 code 时保存的值逐字节一致。
  4. code 关联到 user_id
  5. 保存设备绑定:(user_id, deviceId, devicePubKey)
  6. code 标记为已使用。
  7. 返回 2xx例如 {"ok":true}

注意:

  • deviceId 由客户端生成,服务端不要替换。
  • 一个用户可绑定多台设备,建议设置设备数上限和清理策略。
  • enroll 成功后,即使首次 config 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。
  • 用户重新登录会生成新设备密钥,也会产生新的设备绑定;随后客户端会注销被替换的旧设备(见第 10 节),同一台电脑反复登录不会堆积绑定。

8. POST {gateway}/challenge

作用:为指定设备发放一次性 nonce。

请求体:

{ "deviceId": "<UUIDv4>" }

成功响应:

{
  "nonceId": "<opaque id>",
  "nonce": "<base64 32 bytes>",
  "exp": 60
}

要求:

  • deviceId 维护待用 nonce 池,允许并发存在多个 nonce。
  • nonce 为 32 字节安全随机数。
  • TTL 建议不超过 60 秒。
  • nonce 用完即删;过期定期清理。
  • 每个设备的待用 nonce 数应设置上限,例如 8 个。
  • nonceId 是不透明句柄,可见 ASCII长度不超过 64不能包含空格或控制字符。
  • nonce 使用标准 base64= padding解码后必须正好 32 字节。
  • exp 只是提示,客户端不强依赖。

如果设备不存在、账号到期或设备已被服务端吊销,返回 JSON 错误:

{ "error": "revoked" }

9. POST {gateway}/config

作用:验证设备签名,并返回该用户的 Clash YAML。

请求体:

{
  "deviceId": "<UUIDv4>",
  "nonceId": "<challenge nonceId>",
  "nonce": "<challenge nonce>",
  "ts": 1700000000000,
  "sig": "<base64 Ed25519 signature>"
}

服务端处理:

  1. deviceIdnonceId 查找待用 nonce。
  2. 校验请求中的 nonce 与服务端保存值一致。
  3. 校验 nonce 未过期、未消费。
  4. 校验时钟偏差:abs(now_ms - ts) <= 300000
  5. 读取该设备绑定的 devicePubKey
  6. 按第 11 节构造签名输入,op=1,验证 Ed25519 签名。
  7. 消费 nonce。
  8. deviceId 关联到 user_id,内部调用现有订阅生成逻辑或隐藏 origin。
  9. 返回 HTTP 200body 为 Clash YAML 文本。

成功响应还可以附带响应头 X-CPX-Discovery: <payloadB64>.<sigB64>(第 5a 节)。客户端只对带 providerPubKey 的插件读取它、只接受单一头值校验失败只记日志并忽略YAML 正文照常应用。

成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 proxiesproxy-providers 之一。

订阅 URL、origin API、内部鉴权 token 不应下发给客户端。


10. POST {gateway}/revoke

作用:解绑设备。客户端在两种情况下尽力调用该端点:

  • 用户删除插件——注销当前设备,以及下文"待回收"名单里的设备;
  • 重新登录换了新设备——登录完成后立即用设备的密钥注销旧设备。若这次调用失败,客户端保留旧密钥,在之后每次 /config 拉取成功后重试,每次只处理一台。

旧设备的 /challenge 返回 revoked / device_revoked 即视为"已解绑",客户端停止重试。除第 7 节的错误码约定与下文的幂等要求外,网关不需要新增任何东西;唯一可见的变化是 /revoke 现在也会在重新登录之后到来,针对的是账号里已不是最新的那台设备——只解绑该 deviceId 即可。

请求体同 /config

{
  "deviceId": "<UUIDv4>",
  "nonceId": "<challenge nonceId>",
  "nonce": "<challenge nonce>",
  "ts": 1700000000000,
  "sig": "<base64 Ed25519 signature>"
}

处理流程同 /config,但签名输入中的 op=2。验签通过后删除该 deviceId 的绑定记录,并消费 nonce。

/revoke 必须幂等。设备已经不存在时,仍可返回 2xx。


11. 设备签名

签名输入是确定性字节串:

SignInput = "CPX2"                              // 4 bytes ASCII
          | uint8(op)                           // config=1, revoke=2
          | uint8(len(deviceId)) | deviceId     // UTF-8 bytes
          | uint8(len(nonceId))  | nonceId      // UTF-8 bytes
          | nonce                               // 32 raw bytes
          | uint64_be(ts)                       // Unix milliseconds

签名算法:

sig = Ed25519_sign(devicePrivKey, SignInput)

sig 为 64 原始字节,在线路中使用标准 base64 编码。

实现要点:

  • nonce 放入签名输入前必须先 base64 解码,使用 32 原始字节。
  • ts 是 Unix 毫秒时间戳,用 8 字节无符号大端整数编码。
  • deviceIdnonceId 长度前缀为 1 字节无符号整数。
  • /config 使用 op=1/revoke 使用 op=2

12. 编码规则

字段 编码
devicePubKey Ed25519 公钥32 原始字节,标准 base64带 padding
sig Ed25519 签名64 原始字节,标准 base64带 padding
nonce 32 原始字节,标准 base64带 padding通常 44 字符
deviceId UUIDv436 字符,小写带连字符;服务端上限不超过 64
nonceId 服务端生成的不透明可见 ASCII长度不超过 64
ts 整数Unix 毫秒
op uint8config=1revoke=2
code authorize 签发的不透明字符串,长度不超过 2048
code_challenge / code_verifier RFC 7636 base64url无 paddingverifier 长度 43-128字符集 [A-Za-z0-9-._~]
providerPubKey Ed25519 公钥32 原始字节,标准 base64带 padding
signed / X-CPX-Discovery <payloadB64>.<sigB64>;两段都是规范的标准 base64带 padding

除 PKCE 的 code_challengecode_verifier 外,其余二进制字段全部使用标准 base64= padding。不要混用 base64url。


13. PHP 参考代码

以下片段只展示关键校验。生产代码仍需补齐参数校验、nonce 状态、用户状态和错误处理。

PKCE

function pkce_ok(string $verifier, string $challenge): bool {
    $calc = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
    return hash_equals($challenge, $calc);
}

签名输入和验签:

function build_sign_input(int $op, string $deviceId, string $nonceId, string $nonceRaw, int $tsMs): string {
    return "CPX2"
        . chr($op)
        . chr(strlen($deviceId)) . $deviceId
        . chr(strlen($nonceId)) . $nonceId
        . $nonceRaw
        . pack('J', $tsMs); // uint64 big-endian
}

$nonceRaw = base64_decode($req['nonce'], true);
$pub      = base64_decode($devicePubKeyB64, true);
$sig      = base64_decode($req['sig'], true);
$ts       = (int)$req['ts'];

if ($nonceRaw === false || $pub === false || $sig === false) { /* 400 */ }
if (strlen($nonceRaw) !== 32 || strlen($pub) !== 32 || strlen($sig) !== 64) { /* 400 */ }
if (abs((int)(microtime(true) * 1000) - $ts) > 300000) { /* 400 */ }

// 还需要校验 nonceId/nonce 属于该 deviceId且未过期、未消费。

$op    = ($endpoint === 'config') ? 1 : 2;
$input = build_sign_input($op, $req['deviceId'], $req['nonceId'], $nonceRaw, $ts);
$ok    = sodium_crypto_sign_verify_detached($sig, $input, $pub);
if (!$ok) { /* 401 or 400 */ }

// 验签通过后消费 nonce。

吊销响应:

http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'revoked']);

签名发现文档(第 5a 节),离线执行:

$payload = json_encode($doc, JSON_UNESCAPED_SLASHES); // 签的就是这串字节
$sig     = sodium_crypto_sign_detached("CPX2-DISCOVERY\0" . $payload, $secretKey);
$signed  = base64_encode($payload) . '.' . base64_encode($sig);
// .cpx 的 providerPubKeybase64_encode(sodium_crypto_sign_publickey($keypair))

14. 签名自测

测试向量文件:

src/main/resolve/plugin/__fixtures__/sign-vectors.json

每条向量包含:

  • op
  • deviceId
  • nonceId
  • privSeedB64
  • pubKeyB64
  • nonceB64
  • ts
  • inputHex
  • sigB64

服务端实现至少验证两项:

  1. 按本协议构造出的签名输入,其十六进制与 inputHex 完全一致。
  2. 使用 pubKeyB64 验证 sigB64 可以通过。

如果 inputHex 不一致,先检查 nonce 是否用了原始字节、ts 是否按 uint64 大端编码、op 是否正确。

重新生成向量:

node scripts/plugin/gen-sign-vectors.mjs

签名发现文档向量(第 5a 节):

src/main/resolve/plugin/__fixtures__/discovery-vectors.json

每条包含 seedB64pubKeyB64payloadJsonpayloadB64signInputHex(前缀 + payloadsigB64signeddigestHex。验证:用 seed 对 signInputHex 签名得到 sigB64signed 能用 pubKeyB64 验过payload 字节的 SHA-256 等于 digestHex。重新生成:node scripts/plugin/gen-discovery-vectors.mjs

仓库中另有 scripts/plugin/example-gateway.mjs,仅用于查看协议形状。它使用明文 HTTP 和 loopback origin会被真实客户端的 HTTPS 校验拒绝;真实联调用 deploy/gateway/


15. 上线检查

.cpx

  • magicvspecloginUrlprovider 字段符合第 3 节。
  • loginUrl 是 HTTPS authorize 端点,不带 query、fragment、userinfo。
  • provider.icon 如有提供,使用允许的 data URI 格式,大小不超过限制。

登录域名:

  • https://<login-host>/.well-known/cpx-gateway 返回合法 JSON。
  • authorize 允许 http://127.0.0.1:<random-port>/callback
  • 登录成功后 302 回调,带 code 和原始 state
  • code 一次性、TTL 不超过 60 秒。
  • code 记录了 redirect_uriclient_idcode_challenge

网关:

  • gateway 是公网 HTTPS origin无 path、query、fragment、userinfo。
  • 如提供 gateways1..3 个公网 HTTPS origingateway 等于 gateways[0]
  • 列出的每个网关都落到同一份后端状态code / 设备 / nonce没有独立副本。
  • .cpxproviderPubKeywell-known 已含 signed、顶层字段与 payload 一致,且签名文档先于 .cpx 发布。
  • 四个 endpoint 都是相对路径不含反斜杠、query、fragment。
  • /enroll 校验 PKCE、code、redirect_uri、client_id写入设备绑定。
  • /challenge 发放 32 字节随机 nonce标准 base64短 TTL待用池有上限。
  • /config 校验 nonce、时钟、签名消费 nonce返回 Clash YAML。
  • /revoke 使用 op=2 验签,消费 nonce幂等解绑。
  • 账号到期、设备吊销返回 {"error":"revoked"}{"error":"device_revoked"}
  • 网关退役返回 HTTP 410{"error":"gateway_retired"}

编码和兼容:

  • devicePubKeysignonce 使用标准 base64带 padding。
  • PKCE 字段使用 base64url无 padding。
  • 签名输入中的 nonce 是解码后的 32 原始字节。
  • ts 使用 uint64 大端。
  • 已用 sign-vectors.json 做过互通测试。

16. 常见问题

客户端不提示重新登录,只是一直重试

通常是服务端只返回了 401403。需要在 JSON 响应体中返回:

{ "error": "revoked" }

或:

{ "error": "device_revoked" }

nonce 校验失败

检查 nonce 是否为标准 base64、是否带 padding、解码后是否正好 32 字节。签名输入中使用的是解码后的原始字节,不是 base64 字符串。

authorize 拒绝回环重定向

redirect_urihttp://127.0.0.1:<random-port>/callback,端口随机。按 scheme、host、path 校验即可,不要固定端口,也不要要求 HTTPS。

网关发现失败

检查 gateway 是否为公网 HTTPS origin不能写成 https://gw.example.com/base,也不能使用 localhost、私网 IP、环回 IP。

endpoint 拼接失败

endpoints.* 必须是 /enroll 这类相对路径。不要写绝对 URL、//host/path,也不要包含反斜杠。

验签失败

按顺序检查:

  1. nonce 是否先 base64 解码为 32 原始字节。
  2. ts 是否按 uint64 大端编码。
  3. op 是否正确:config=1revoke=2
  4. deviceIdnonceId 的长度前缀是否是 1 字节。
  5. devicePubKeysig 是否分别解码为 32/64 字节。
  6. 生成的 inputHex 是否与测试向量一致。

/config 返回后客户端仍然更新失败

成功响应必须是 Clash YAML。客户端要求 YAML 可解析为对象,并且包含 proxiesproxy-providers。返回 JSON、HTML、空 body 或非 Clash 配置都会被视为瞬时失败。


17. 日志

服务端日志不要记录以下内容:

  • authorize code
  • code_verifier
  • nonce 和 nonceId
  • 设备私钥(服务端本不应持有)
  • 订阅 URL、origin token、完整 Clash YAML
  • 用户密码或登录表单原文

可记录 user_iddeviceId、端点名、错误码、请求耗时、网关版本等运维字段。