docs/plugin/PROVIDER_INTEGRATION_v2.md
Client spec: cpx-plugin/2.
Chinese reference document:
机场服务端对接指南-v2.md.
Related files:
deploy/gateway/src/main/resolve/plugin/__fixtures__/sign-vectors.jsonDuring development there was an earlier v1 design (password + encrypted container). That design has been retired, and the current codebase does not include a v1 implementation.
The client must not receive the real subscription URL, API host, or origin token.
The user imports a public .cpx descriptor. Login happens in the system browser. The client generates an Ed25519 device key pair locally. Subscription updates then use a gateway challenge/config flow.
Provider-side components:
| Component | Host | Purpose |
|---|---|---|
| OAuth authorize endpoint | Login host from .cpx loginUrl | User login and one-time code issuance |
/.well-known/cpx-gateway | Same host and port as loginUrl | Current gateway origin and endpoint paths |
| Gateway endpoints | Gateway host; may differ from login host | Device enrollment, nonce, config, revocation |
The login host is the trust root and is fixed in distributed .cpx files. The gateway host is discovered at runtime and can be rotated by updating the well-known document.
Failure behavior:
| Failed host | Result |
|---|---|
| Gateway host | Client can rediscover through the login host |
| Login host | Existing devices can keep using the cached gateway; new login, re-login, and rediscovery fail |
| Both | Client is disconnected; redistribute a new .cpx |
Flow:
Install:
Import .cpx
Validate descriptor
Create local record; no network request yet
Login:
1. GET https://<login-host>/.well-known/cpx-gateway
2. Generate Ed25519 device key pair and deviceId(UUIDv4)
3. Open system browser:
loginUrl?response_type=code&code_challenge=...&state=...
4. Provider login page redirects to:
http://127.0.0.1:<port>/callback?code=...&state=...
5. POST {gateway}/enroll with code and PKCE verifier
6. POST {gateway}/challenge
7. POST {gateway}/config with Ed25519 signature
8. Store returned Clash YAML as a normal profile
Update:
Repeat challenge -> config. No browser is opened.
Re-login:
Gateway returns {"error":"revoked"} or {"error":"device_revoked"}.
Client marks the profile as needs re-authentication.
Delete:
Client best-effort calls /revoke, then deletes local state.
deploy/gateway/ is the reference implementation. It includes Docker deployment, a SQLite account store, and cpx-admin.
Important files:
| File | Purpose |
|---|---|
deploy/gateway/src/auth.mjs | authorize page and one-time codes |
deploy/gateway/src/gateway.mjs | enroll/challenge/config/revoke |
deploy/gateway/src/crypto.mjs | PKCE, Ed25519, canonical sign input |
deploy/gateway/src/origin.mjs | hidden-origin subscription fetch |
For an existing panel, the usual additions are:
user_id, device_id, device_pubkey, created_at./.well-known/cpx-gateway..cpx Descriptor.cpx is public JSON. Use one file for all users. It must not contain user data, tokens, API hosts, gateway hosts, or subscription URLs.
{
"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"
}
}
Field rules:
| Field | Rule |
|---|---|
magic | String "CPXF" |
v | Number 2 |
spec | String "cpx-plugin/2" |
| Top-level keys | Only magic, v, spec, loginUrl, provider |
loginUrl | HTTPS URL; no query, fragment, or userinfo; host must not be private, loopback, localhost, or *.localhost |
provider | Object; only name, icon, site |
provider.name | Required non-empty string |
provider.icon | Optional data URI; only PNG/JPEG/WEBP; total string length <= 65536 |
provider.site | Optional HTTPS URL; same host restrictions as loginUrl; path is allowed |
loginUrl is the OAuth authorize endpoint, not a generic login page. The client appends OAuth parameters to it.
Generator:
node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output]
Use OAuth 2.0 Authorization Code + PKCE(S256). The client opens the system browser. Credentials are submitted only to the provider page.
Client query parameters:
| Parameter | Value |
|---|---|
response_type | code |
client_id | mihomo-party |
redirect_uri | http://127.0.0.1:<random-port>/callback |
code_challenge | BASE64URL(SHA256(code_verifier)), no padding |
code_challenge_method | S256 |
state | Random string; echo unchanged |
scope | subscribe |
Authorize endpoint requirements:
http://127.0.0.1:<random-port>/callback. The port changes per login.redirect_uri?code=...&state=....code must be one-time and expire in no more than 60 seconds.user_id, redirect_uri, client_id, and code_challenge with the code./enroll must compare stored redirect_uri and client_id byte-for-byte.Loopback redirect uses HTTP for native apps; do not reject it for not being HTTPS. See RFC 8252.
The client requests the exact host from loginUrl:
GET https://<login-host>/.well-known/cpx-gateway
Response:
{
"spec": "cpx-plugin/2",
"gateway": "https://gw.front.example.net",
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
Field rules:
| Field | Rule |
|---|---|
spec | String "cpx-plugin/2" |
gateway | HTTPS origin only: scheme, host, optional port; no path, query, fragment, or userinfo; public host |
endpoints | Must contain enroll, challenge, config, revoke; each value is a relative path starting with /; no absolute URL, ?, #, or backslash |
The discovery request uses HTTPS only, does not follow redirects, and caps the response body at 64 KiB. Non-2xx, invalid JSON, or invalid fields are discovery failures.
To rotate the gateway, update /.well-known/cpx-gateway.
The client rediscovers and retries once when the cached gateway returns:
410;{"error":"gateway_retired"};Plain 5xx, 429, and timeouts are treated as transient failures. They do not trigger rediscovery.
Rediscovery requires the login host to be reachable.
All gateway endpoints are POST with JSON request bodies. The client uses HTTPS only, does not follow redirects, and caps responses at 10 MiB.
No Authorization header is used. No bearer token is issued. Device identity is based on deviceId, the stored Ed25519 public key, and request signatures.
Client error classification:
| Class | Condition | Client action |
|---|---|---|
retired | HTTP 410, or JSON {"error":"gateway_retired"} | Rediscover gateway and retry once |
revoked | JSON {"error":"revoked"} or {"error":"device_revoked"} | Mark as needs re-authentication |
transient | Other non-2xx, timeout, network error | Back off and retry; login state unchanged |
| success | 2xx without an error marker | Continue |
For expired accounts, disabled users, or revoked devices, include revoked or device_revoked in the JSON body. A bare 401 or 403 is treated as transient.
POST {gateway}/enrollPurpose: exchange an authorize code for a device binding. This is similar to OAuth token exchange, but no bearer token is returned.
Request:
{
"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>"
}
Server steps:
code; verify it is unexpired and unused.BASE64URL(SHA256(code_verifier)) == code_challenge.redirect_uri and client_id with the stored values byte-for-byte.user_id from the code.(user_id, deviceId, devicePubKey).{"ok":true}.Notes:
deviceId is client-generated. Do not replace it.POST {gateway}/challengePurpose: issue a one-time nonce for a device.
Request:
{ "deviceId": "<UUIDv4>" }
Success response:
{
"nonceId": "<opaque id>",
"nonce": "<base64 32 bytes>",
"exp": 60
}
Rules:
deviceId.nonce is 32 cryptographically random bytes.nonceId is an opaque visible ASCII handle, length <= 64, with no spaces or control characters.nonce uses standard base64 with = padding; decoded length must be exactly 32 bytes.exp is informational.For an unknown device, expired account, or revoked device, return:
{ "error": "revoked" }
POST {gateway}/configPurpose: verify the device signature and return the user's Clash YAML.
Request:
{
"deviceId": "<UUIDv4>",
"nonceId": "<challenge nonceId>",
"nonce": "<challenge nonce>",
"ts": 1700000000000,
"sig": "<base64 Ed25519 signature>"
}
Server steps:
deviceId and nonceId.nonce matches the stored value.abs(now_ms - ts) <= 300000.devicePubKey for the device.op=1.user_id from deviceId.Successful /config response is not JSON. The client parses it as Clash YAML and requires an object containing at least proxies or proxy-providers.
Do not return the subscription URL, origin API host, or origin token.
POST {gateway}/revokePurpose: unbind a device. The client calls this best-effort when the user deletes the plugin.
Request body is the same as /config.
Use the same verification flow as /config, but build the sign input with op=2. After successful verification, remove the deviceId binding and consume the nonce.
/revoke must be idempotent. Return 2xx even if the device is already absent.
Canonical sign input:
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
Signature:
sig = Ed25519_sign(devicePrivKey, SignInput)
sig is 64 raw bytes on input to standard base64.
Implementation notes:
nonce from base64 before adding it to SignInput.ts as an 8-byte unsigned big-endian integer.deviceId and nonceId length prefixes are one unsigned byte./config uses op=1; /revoke uses op=2.| Field | Encoding |
|---|---|
devicePubKey | Ed25519 public key, 32 raw bytes, standard base64 with padding |
sig | Ed25519 signature, 64 raw bytes, standard base64 with padding |
nonce | 32 raw bytes, standard base64 with padding, usually 44 chars |
deviceId | UUIDv4, 36 lowercase chars with hyphens; server cap <= 64 |
nonceId | Server-generated opaque visible ASCII, length <= 64 |
ts | Integer, Unix milliseconds |
op | uint8; config=1, revoke=2 |
code | Opaque authorize code, length <= 2048 |
code_challenge / code_verifier | RFC 7636 base64url, no padding; verifier length 43-128, charset [A-Za-z0-9-._~] |
Only PKCE fields use base64url without padding. Binary protocol fields use standard base64 with padding.
PKCE:
function pkce_ok(string $verifier, string $challenge): bool {
$calc = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return hash_equals($challenge, $calc);
}
Signature verification:
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 */ }
// Also check nonceId/nonce belongs to deviceId and is unexpired/unconsumed.
$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 */ }
// Consume nonce after successful verification.
Revocation response:
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'revoked']);
File:
src/main/resolve/plugin/__fixtures__/sign-vectors.json
Each vector contains:
opdeviceIdnonceIdprivSeedB64pubKeyB64nonceB64tsinputHexsigB64Verify:
inputHex.sigB64 verifies under pubKeyB64.Regenerate vectors:
node scripts/plugin/gen-sign-vectors.mjs
Descriptor:
.cpx contains only valid v2 fields.loginUrl is an HTTPS authorize endpoint with no query, fragment, or userinfo.Login host:
/.well-known/cpx-gateway returns valid JSON.http://127.0.0.1:<random-port>/callback.code and original state.redirect_uri, client_id, and code_challenge.Gateway:
gateway is a public HTTPS origin./enroll verifies PKCE, code, redirect URI, and client ID./challenge issues 32-byte standard-base64 nonce values with short TTL and pool limits./config verifies nonce, clock skew, signature, consumes nonce, and returns Clash YAML./revoke verifies with op=2, consumes nonce, and idempotently unbinds the device.{"error":"revoked"} or {"error":"device_revoked"}.410 or {"error":"gateway_retired"}.Compatibility:
devicePubKey, sig, and nonce use standard base64 with padding.ts is encoded as uint64 big-endian.sign-vectors.json.Do not log:
codecode_verifierSafe operational fields include user_id, deviceId, endpoint name, status code, duration, and gateway version.