Back to Clash Party

机场服务端对接指南(v2)

docs/plugin/机场服务端对接指南-v2.md

2.0.023.2 KB
Original Source

机场服务端对接指南(v2)

本文面向机场/服务商的服务端开发人员。v2 协议对应客户端 spec:cpx-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

端到端流程:

text
安装:
  用户导入 .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.mjsenroll/challenge/config/revoke
deploy/gateway/src/crypto.mjsPKCE、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。所有用户可以使用同一份文件。

json
{
  "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"
顶层字段只能包含 magicvspecloginUrlprovider
loginUrlHTTPS URL;不能包含 query、fragment、userinfo;host 不能是私网、环回、localhost*.localhost
provider对象,只能包含 nameiconsite
provider.name必填,非空字符串
provider.icon可选;仅支持 data:image/png;base64,data:image/jpeg;base64,data:image/webp;base64,;总长度不超过 65536
provider.site可选;HTTPS URL;host 约束同 loginUrl,可包含路径

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

生成脚本:

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

4. OAuth authorize

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

客户端请求参数:

参数
response_typecode
client_idmihomo-party
redirect_urihttp://127.0.0.1:<random-port>/callback
code_challengeBASE64URL(SHA256(code_verifier)),无 padding
code_challenge_methodS256
state随机串,服务端必须原样带回
scopesubscribe

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 请求:

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

响应体:

json
{
  "spec": "cpx-plugin/2",
  "gateway": "https://gw.front.example.net",
  "endpoints": {
    "enroll": "/enroll",
    "challenge": "/challenge",
    "config": "/config",
    "revoke": "/revoke"
  }
}

字段要求:

字段要求
spec字符串 "cpx-plugin/2"
gatewayHTTPS origin,仅包含 scheme、host、可选 port;不能有 path、query、fragment、userinfo;host 必须公网可达
endpoints必须包含 enrollchallengeconfigrevoke;每个值是以 / 开头的相对路径,不能是绝对 URL,不能包含 ?#、反斜杠

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

网关轮换

替换网关时,更新 /.well-known/cpx-gateway 中的 gateway 即可。客户端在下列情况会回到登录域名重新发现,并重试一次:

  • 当前网关返回 HTTP 410
  • 响应 JSON 为 {"error":"gateway_retired"}
  • 当前网关发生网络层不可达,例如 DNS 失败、连接失败、TLS 握手失败。

普通 5xx、429、超时不会触发重新发现,客户端按瞬时故障退避重试。

重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。


6. 网关公共约定

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

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

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

客户端判定条件客户端行为
retiredHTTP 410,或 JSON {"error":"gateway_retired"}重新发现网关并重试一次
revokedJSON {"error":"revoked"}{"error":"device_revoked"}标记为需要重新登录
transient其它非 2xx、超时、网络错误退避重试,不改变登录状态
成功2xx 且没有错误标记正常处理

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


7. POST {gateway}/enroll

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

请求体:

json
{
  "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 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。
  • 用户重新登录会生成新设备密钥,也会产生新的设备绑定。

8. POST {gateway}/challenge

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

请求体:

json
{ "deviceId": "<UUIDv4>" }

成功响应:

json
{
  "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 错误:

json
{ "error": "revoked" }

9. POST {gateway}/config

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

请求体:

json
{
  "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 200,body 为 Clash YAML 文本。

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

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


10. POST {gateway}/revoke

作用:解绑设备。客户端删除插件时会尽力调用该端点。

请求体同 /config

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

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

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


11. 设备签名

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

text
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

签名算法:

text
sig = Ed25519_sign(devicePrivKey, SignInput)

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

实现要点:

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

12. 编码规则

字段编码
devicePubKeyEd25519 公钥,32 原始字节,标准 base64,带 padding
sigEd25519 签名,64 原始字节,标准 base64,带 padding
nonce32 原始字节,标准 base64,带 padding,通常 44 字符
deviceIdUUIDv4,36 字符,小写带连字符;服务端上限不超过 64
nonceId服务端生成的不透明可见 ASCII,长度不超过 64
ts整数,Unix 毫秒
opuint8;config=1,revoke=2
codeauthorize 签发的不透明字符串,长度不超过 2048
code_challenge / code_verifierRFC 7636 base64url,无 padding;verifier 长度 43-128,字符集 [A-Za-z0-9-._~]

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


13. PHP 参考代码

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

PKCE:

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

签名输入和验签:

php
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。

吊销响应:

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

14. 签名自测

测试向量文件:

text
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 是否正确。

重新生成向量:

bash
node scripts/plugin/gen-sign-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。
  • 四个 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 响应体中返回:

json
{ "error": "revoked" }

或:

json
{ "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、端点名、错误码、请求耗时、网关版本等运维字段。