23 KiB
机场服务端对接指南(v2)
本文面向机场/服务商的服务端开发人员。v2 协议对应客户端 spec:cpx-plugin/2。
相关文件:
- 参考服务端:
deploy/gateway/ - 英文版:
PROVIDER_INTEGRATION_v2.md - 签名测试向量:
src/main/resolve/plugin/__fixtures__/sign-vectors.json
开发过程中曾有一版 v1 方案(账号密码 + 加密容器)。该方案已弃用,当前代码库未包含 v1 实现。
1. 接入模型
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,然后删除本地记录
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 |
已有面板接入时,通常只需要新增:
- 设备绑定表:
user_id、device_id、device_pubkey、created_at等。 - nonce 暂存:Redis、内存或数据库均可,短 TTL,用完即删。
- OAuth authorize 登录页:复用现有账号密码校验。
/.well-known/cpx-gateway:返回当前网关配置。- 四个网关端点:内部调用现有用户状态和订阅生成逻辑。
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" |
| 顶层字段 | 只能包含 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]
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 端点要求:
- 允许
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 而拒绝。
5. 网关发现
客户端按 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 即可。客户端在下列情况会回到登录域名重新发现,并重试一次:
- 当前网关返回 HTTP
410; - 响应 JSON 为
{"error":"gateway_retired"}; - 当前网关发生网络层不可达,例如 DNS 失败、连接失败、TLS 握手失败。
普通 5xx、429、超时不会触发重新发现,客户端按瞬时故障退避重试。
重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。
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"} |
标记为需要重新登录 |
transient |
其它非 2xx、超时、网络错误 | 退避重试,不改变登录状态 |
| 成功 | 2xx 且没有错误标记 | 正常处理 |
账号到期、设备被踢、用户被禁用时,返回体必须包含 revoked 或 device_revoked。单独返回空的 401/403 会被客户端当作瞬时失败处理。
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>"
}
服务端处理:
- 查找
code,确认未过期、未使用。 - 校验
code_verifier:BASE64URL(SHA256(code_verifier))必须等于签发 code 时保存的code_challenge。 - 校验
redirect_uri、client_id与签发 code 时保存的值逐字节一致。 - 从
code关联到user_id。 - 保存设备绑定:
(user_id, deviceId, devicePubKey)。 - 将
code标记为已使用。 - 返回 2xx,例如
{"ok":true}。
注意:
deviceId由客户端生成,服务端不要替换。- 一个用户可绑定多台设备,建议设置设备数上限和清理策略。
- enroll 成功后,即使首次 config 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。
- 用户重新登录会生成新设备密钥,也会产生新的设备绑定。
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>"
}
服务端处理:
- 按
deviceId、nonceId查找待用 nonce。 - 校验请求中的
nonce与服务端保存值一致。 - 校验 nonce 未过期、未消费。
- 校验时钟偏差:
abs(now_ms - ts) <= 300000。 - 读取该设备绑定的
devicePubKey。 - 按第 11 节构造签名输入,
op=1,验证 Ed25519 签名。 - 消费 nonce。
- 由
deviceId关联到user_id,内部调用现有订阅生成逻辑或隐藏 origin。 - 返回 HTTP 200,body 为 Clash YAML 文本。
成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 proxies 或 proxy-providers 之一。
订阅 URL、origin API、内部鉴权 token 不应下发给客户端。
10. 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。
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 字节无符号大端整数编码。deviceId、nonceId长度前缀为 1 字节无符号整数。/config使用op=1,/revoke使用op=2。
12. 编码规则
| 字段 | 编码 |
|---|---|
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。
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']);
14. 签名自测
测试向量文件:
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/。
15. 上线检查
.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。- authorize 允许
http://127.0.0.1:<random-port>/callback。 - 登录成功后 302 回调,带
code和原始state。 code一次性、TTL 不超过 60 秒。code记录了redirect_uri、client_id、code_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"}。
编码和兼容:
devicePubKey、sig、nonce使用标准 base64,带 padding。- PKCE 字段使用 base64url,无 padding。
- 签名输入中的 nonce 是解码后的 32 原始字节。
ts使用 uint64 大端。- 已用
sign-vectors.json做过互通测试。
16. 常见问题
客户端不提示重新登录,只是一直重试
通常是服务端只返回了 401 或 403。需要在 JSON 响应体中返回:
{ "error": "revoked" }
或:
{ "error": "device_revoked" }
nonce 校验失败
检查 nonce 是否为标准 base64、是否带 padding、解码后是否正好 32 字节。签名输入中使用的是解码后的原始字节,不是 base64 字符串。
authorize 拒绝回环重定向
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。
endpoint 拼接失败
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 配置都会被视为瞬时失败。
17. 日志
服务端日志不要记录以下内容:
- authorize
code code_verifier- nonce 和 nonceId
- 设备私钥(服务端本不应持有)
- 订阅 URL、origin token、完整 Clash YAML
- 用户密码或登录表单原文
可记录 user_id、deviceId、端点名、错误码、请求耗时、网关版本等运维字段。