docs/plugin/机场服务端对接指南-v2.md
本文面向机场/服务商的服务端开发人员。v2 协议对应客户端 spec:cpx-plugin/2。
相关文件:
deploy/gateway/PROVIDER_INTEGRATION_v2.mdsrc/main/resolve/plugin/__fixtures__/sign-vectors.json开发过程中曾有一版 v1 方案(账号密码 + 加密容器)。该方案已弃用,当前代码库未包含 v1 实现。
v2 不再把真实订阅 URL 或 API 域名写入客户端文件。用户导入的 .cpx 只包含登录入口和服务商展示信息;登录通过系统浏览器完成;客户端本地生成 Ed25519 设备密钥;后续订阅更新通过网关的 challenge/config 流程完成。
服务端需要提供三类能力:
| 模块 | 所在域名 | 用途 |
|---|---|---|
| OAuth authorize 登录页 | 登录域名,即 .cpx 中 loginUrl 的 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,然后删除本地记录
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 |
已有面板接入时,通常只需要新增:
user_id、device_id、device_pubkey、created_at 等。/.well-known/cpx-gateway:返回当前网关配置。.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" |
| 顶层字段 | 只能包含 magic、v、spec、loginUrl、provider |
loginUrl | HTTPS URL;不能包含 query、fragment、userinfo;host 不能是私网、环回、localhost、*.localhost |
provider | 对象,只能包含 name、icon、site |
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 参数。
生成脚本:
node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output]
登录流程使用 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 端点要求:
http://127.0.0.1:<random-port>/callback 形式的回环重定向。端口每次登录随机,不能按固定端口白名单处理。302 到 redirect_uri?code=...&state=...。code 必须一次性、短 TTL(不超过 60 秒)。code 时记录 user_id、redirect_uri、client_id、code_challenge。/enroll 时逐字节比对。原生应用的 loopback redirect 允许使用 HTTP,依据 RFC 8252。不要因为 redirect_uri 不是 HTTPS 而拒绝。
客户端按 loginUrl 的精确 host 请求:
GET https://<login-host>/.well-known/cpx-gateway
响应体:
{
"spec": "cpx-plugin/2",
"gateway": "https://gw.front.example.net",
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
字段要求:
| 字段 | 要求 |
|---|---|
spec | 字符串 "cpx-plugin/2" |
gateway | HTTPS origin,仅包含 scheme、host、可选 port;不能有 path、query、fragment、userinfo;host 必须公网可达 |
endpoints | 必须包含 enroll、challenge、config、revoke;每个值是以 / 开头的相对路径,不能是绝对 URL,不能包含 ?、#、反斜杠 |
客户端请求 /.well-known/cpx-gateway 时只走 HTTPS,不跟随重定向,响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。
替换网关时,更新 /.well-known/cpx-gateway 中的 gateway 即可。客户端在下列情况会回到登录域名重新发现,并重试一次:
410;{"error":"gateway_retired"};普通 5xx、429、超时不会触发重新发现,客户端按瞬时故障退避重试。
重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。
四个端点均为 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"} | 标记为需要重新登录 |
transient | 其它非 2xx、超时、网络错误 | 退避重试,不改变登录状态 |
| 成功 | 2xx 且没有错误标记 | 正常处理 |
账号到期、设备被踢、用户被禁用时,返回体必须包含 revoked 或 device_revoked。单独返回空的 401/403 会被客户端当作瞬时失败处理。
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>"
}
服务端处理:
code,确认未过期、未使用。code_verifier:BASE64URL(SHA256(code_verifier)) 必须等于签发 code 时保存的 code_challenge。redirect_uri、client_id 与签发 code 时保存的值逐字节一致。code 关联到 user_id。(user_id, deviceId, devicePubKey)。code 标记为已使用。{"ok":true}。注意:
deviceId 由客户端生成,服务端不要替换。POST {gateway}/challenge作用:为指定设备发放一次性 nonce。
请求体:
{ "deviceId": "<UUIDv4>" }
成功响应:
{
"nonceId": "<opaque id>",
"nonce": "<base64 32 bytes>",
"exp": 60
}
要求:
deviceId 维护待用 nonce 池,允许并发存在多个 nonce。nonceId 是不透明句柄,可见 ASCII,长度不超过 64,不能包含空格或控制字符。nonce 使用标准 base64,带 = padding;解码后必须正好 32 字节。exp 只是提示,客户端不强依赖。如果设备不存在、账号到期或设备已被服务端吊销,返回 JSON 错误:
{ "error": "revoked" }
POST {gateway}/config作用:验证设备签名,并返回该用户的 Clash YAML。
请求体:
{
"deviceId": "<UUIDv4>",
"nonceId": "<challenge nonceId>",
"nonce": "<challenge nonce>",
"ts": 1700000000000,
"sig": "<base64 Ed25519 signature>"
}
服务端处理:
deviceId、nonceId 查找待用 nonce。nonce 与服务端保存值一致。abs(now_ms - ts) <= 300000。devicePubKey。op=1,验证 Ed25519 签名。deviceId 关联到 user_id,内部调用现有订阅生成逻辑或隐藏 origin。成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 proxies 或 proxy-providers 之一。
订阅 URL、origin API、内部鉴权 token 不应下发给客户端。
POST {gateway}/revoke作用:解绑设备。客户端删除插件时会尽力调用该端点。
请求体同 /config:
{
"deviceId": "<UUIDv4>",
"nonceId": "<challenge nonceId>",
"nonce": "<challenge nonce>",
"ts": 1700000000000,
"sig": "<base64 Ed25519 signature>"
}
处理流程同 /config,但签名输入中的 op=2。验签通过后删除该 deviceId 的绑定记录,并消费 nonce。
/revoke 必须幂等。设备已经不存在时,仍可返回 2xx。
签名输入是确定性字节串:
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 字节无符号大端整数编码。deviceId、nonceId 长度前缀为 1 字节无符号整数。/config 使用 op=1,/revoke 使用 op=2。| 字段 | 编码 |
|---|---|
devicePubKey | Ed25519 公钥,32 原始字节,标准 base64,带 padding |
sig | Ed25519 签名,64 原始字节,标准 base64,带 padding |
nonce | 32 原始字节,标准 base64,带 padding,通常 44 字符 |
deviceId | UUIDv4,36 字符,小写带连字符;服务端上限不超过 64 |
nonceId | 服务端生成的不透明可见 ASCII,长度不超过 64 |
ts | 整数,Unix 毫秒 |
op | uint8;config=1,revoke=2 |
code | authorize 签发的不透明字符串,长度不超过 2048 |
code_challenge / code_verifier | RFC 7636 base64url,无 padding;verifier 长度 43-128,字符集 [A-Za-z0-9-._~] |
除 PKCE 的 code_challenge、code_verifier 外,其余二进制字段全部使用标准 base64,带 = padding。不要混用 base64url。
以下片段只展示关键校验。生产代码仍需补齐参数校验、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']);
测试向量文件:
src/main/resolve/plugin/__fixtures__/sign-vectors.json
每条向量包含:
opdeviceIdnonceIdprivSeedB64pubKeyB64nonceB64tsinputHexsigB64服务端实现至少验证两项:
inputHex 完全一致。pubKeyB64 验证 sigB64 可以通过。如果 inputHex 不一致,先检查 nonce 是否用了原始字节、ts 是否按 uint64 大端编码、op 是否正确。
重新生成向量:
node scripts/plugin/gen-sign-vectors.mjs
仓库中另有 scripts/plugin/example-gateway.mjs,仅用于查看协议形状。它使用明文 HTTP 和 loopback origin,会被真实客户端的 HTTPS 校验拒绝;真实联调用 deploy/gateway/。
.cpx:
magic、v、spec、loginUrl、provider 字段符合第 3 节。loginUrl 是 HTTPS authorize 端点,不带 query、fragment、userinfo。provider.icon 如有提供,使用允许的 data URI 格式,大小不超过限制。登录域名:
https://<login-host>/.well-known/cpx-gateway 返回合法 JSON。http://127.0.0.1:<random-port>/callback。code 和原始 state。code 一次性、TTL 不超过 60 秒。code 记录了 redirect_uri、client_id、code_challenge。网关:
gateway 是公网 HTTPS origin,无 path、query、fragment、userinfo。/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"}。410 或 {"error":"gateway_retired"}。编码和兼容:
devicePubKey、sig、nonce 使用标准 base64,带 padding。ts 使用 uint64 大端。sign-vectors.json 做过互通测试。通常是服务端只返回了 401 或 403。需要在 JSON 响应体中返回:
{ "error": "revoked" }
或:
{ "error": "device_revoked" }
检查 nonce 是否为标准 base64、是否带 padding、解码后是否正好 32 字节。签名输入中使用的是解码后的原始字节,不是 base64 字符串。
redirect_uri 是 http://127.0.0.1:<random-port>/callback,端口随机。按 scheme、host、path 校验即可,不要固定端口,也不要要求 HTTPS。
检查 gateway 是否为公网 HTTPS origin,不能写成 https://gw.example.com/base,也不能使用 localhost、私网 IP、环回 IP。
endpoints.* 必须是 /enroll 这类相对路径。不要写绝对 URL、//host/path,也不要包含反斜杠。
按顺序检查:
nonce 是否先 base64 解码为 32 原始字节。ts 是否按 uint64 大端编码。op 是否正确:config=1,revoke=2。deviceId、nonceId 的长度前缀是否是 1 字节。devicePubKey、sig 是否分别解码为 32/64 字节。inputHex 是否与测试向量一致。/config 返回后客户端仍然更新失败成功响应必须是 Clash YAML。客户端要求 YAML 可解析为对象,并且包含 proxies 或 proxy-providers。返回 JSON、HTML、空 body 或非 Clash 配置都会被视为瞬时失败。
服务端日志不要记录以下内容:
codecode_verifier可记录 user_id、deviceId、端点名、错误码、请求耗时、网关版本等运维字段。