clash-party Documentation

Electron: Another Mihomo GUI.

26,937 stars TypeScript TypeScript 4 files · ~20,010 tokens #clash#clash-meta#electron#mihomo
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy clash-party on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name clash-party -p 80:80 --restart always caddy:2
Configured Services & Ports
⚙️ caddy
image: caddy:2
🔌 Ports: 80:80, 443:443
⚙️ gateway
image: build locally
docker-compose.yml
yaml (docker-compose.yml)
services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
    environment:
      # Comma-separated domains Caddy serves; falls back to the single DOMAIN.
      DOMAINS: ${DOMAINS:-${DOMAIN:?set DOMAIN (or DOMAINS) in .env}}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - gateway

  gateway:
    build: .
    restart: unless-stopped
    env_file: .env
    environment:
      DB_PATH: /data/gateway.db
    volumes:
      - gateway_data:/data
    # Not published to the host — only reachable inside the compose network as gateway:8080.
    expose:
      - '8080'

volumes:
  caddy_data:
  caddy_config:
  gateway_data:

Technical Documentation: mihomo-party-org/clash-party

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

Another [Mihomo](https://github.com/MetaCubeX/mihomo) GUI

本项目认证稳定机场推荐:“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”

[狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)

  • 高性能海外机场,稳定首选,海外团队,无跑路风险
  • Clash Party专属8折优惠码:party,仅有500份
  • Party专属链接注册送 3 天,每天 1G 流量 免费试用
  • 优惠套餐每月仅需 15.8 元,160G 流量,年付 8 折
  • 全球首家支持Hysteria1/2 协议,集群负载均衡设计,高速专线,基于最新UDP quic技术,极低延迟,无视晚高峰,4K 秒开,配合Clash Party食用更省心!
  • 解锁流媒体及 ChatGPT
  • 官网:https://狗狗加速.com

特性

  • 一键 Smart Core 规则覆写,基于 AI 模型自动选择最优节点 详细介绍请看 这里
  • 开箱即用,无需服务模式的 Tun
  • 多种配色主题可选,UI 焕然一新
  • 支持大部分 Mihomo(Clash Meta) 常用配置修改
  • 内置 Smart内核 与 Mihomo(Clash Meta) 内核
  • 通过 WebDAV 一键备份和恢复配置
  • 强大的覆写功能,任意修订配置文件
  • 深度集成 Sub-Store,轻松管理订阅

安装/使用指南见 [官方文档](https://clashparty.org)


Overview (`deploy/gateway/README.md`)

cpx-gateway

机场插件 v2 的参考网关实现。适合先跑通协议的服务商直接部署测试和参考实现使用。

它提供:

  • OAuth authorize 登录页:/oauth/authorize
  • 网关发现文件:/.well-known/cpx-gateway
  • 网关接口:/enroll、/challenge、/config、/revoke
  • SQLite 账号、设备和订阅 URL 管理
  • Caddy 自动申请和续期 HTTPS 证书

实现只使用 Node.js 内置模块,包括 node:sqlite。镜像构建时不执行 npm install。

相关文档:


部署结构

默认部署使用一个公网域名,同时作为登录域名和网关域名。

text
Client browser
  -> https://<domain>/oauth/authorize
  -> Caddy
  -> gateway:8080

Client updater
  -> https://<domain>/.well-known/cpx-gateway
  -> https://<domain>/challenge
  -> https://<domain>/config
  -> Caddy
  -> gateway:8080
  -> hidden subscription origin

Compose 服务:

服务 作用
caddy 监听 80/443,申请 Let's Encrypt 证书,反代网关
gateway Node.js 网关进程,只暴露在 compose 内部网络

数据卷:

卷 内容
gateway_data SQLite 数据库 /data/gateway.db
caddy_data Caddy 证书和状态
caddy_config Caddy 运行配置

前置条件

  1. 一台公网 VPS,已安装 Docker 和 Docker Compose v2。
  2. 一个域名,A/AAAA 记录已经指向这台 VPS。
  3. VPS 防火墙和安全组放行 TCP 80、443。

客户端会校验 HTTPS、公网 host 和 well-known 文件。不要用 IP、localhost、内网域名或自签证书部署给真实用户。


部署

在 VPS 上进入本目录:

bash
cd deploy/gateway
./deploy.sh

首次运行时,脚本会复制 .env.example 为 .env,询问公网域名,并执行:

bash
docker compose up -d --build

等待 Caddy 申请证书后检查发现文件:

bash
curl https://<domain>/.well-known/cpx-gateway

正常响应类似:

json
{
  "spec": "cpx-plugin/2",
  "gateway": "https://<domain>",
  "gateways": ["https://<domain>"],
  "endpoints": {
    "enroll": "/enroll",
    "challenge": "/challenge",
    "config": "/config",
    "revoke": "/revoke"
  }
}

多网关域名(可选)

同一个网关进程可以用多个域名对外提供服务,客户端会在一个域名超时或不可达时自动切到下一个(见对接指南 §5 / §6):

  1. 在 .env 里用 DOMAINS=a.example.com, b-cdn.example.net 列出全部域名——逗号后要有空格:Caddy 把该值原样当作站点地址,a,b 会被拒绝(deploy.sh 会自动补空格,直接用 docker compose 则不会)。Caddy 会为每个域名申请证书;都必须解析到这台 VPS。
  2. 用 GATEWAY_ORIGINS=https://a.example.com,https://b-cdn.example.net(1..3 个)声明 well-known 里的 gateways;gateway 始终等于第一个。
  3. ./deploy.sh。

备用发现源(静态文件)

.cpx 可以用 discoveryUrls 列出备用发现源(对接指南第 3 / 5 节)。备用源只需要在 /.well-known/cpx-gateway 路径上提供同一份 JSON 文件,任何 CDN / 对象存储都可以。备用源不能与 loginUrl 同 origin(同一主机不构成冗余,gen-cpx.mjs 与客户端都会拒绝);最简单的做法是列出 DOMAINS 里的第二个网关域名——它已经提供该路径:

bash
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx \
  --discovery https://<second-domain> --discovery https://cdn.example.net

若用静态托管,把网关返回的文档原样放到 https://cdn.example.net/.well-known/cpx-gateway,并保证 gateway 等于 gateways[0]:

bash
curl https://<domain>/.well-known/cpx-gateway > cpx-gateway.json
# 上传为 cdn.example.net/.well-known/cpx-gateway,Content-Type: application/json

共享状态 / 单进程:authorize code、nonce、登录限流都保存在网关进程内存里,所有 gateways 必须落到同一个网关进程(本部署由 Caddy 把所有域名反代到同一个容器)。不支持多副本:客户端可能在 /challenge 用一个域名、/config 用另一个域名,跨进程 nonce 会直接失败。

查看容器状态:

bash
docker compose ps
docker compose logs -f gateway
docker compose logs -f caddy

账号管理

每个账号对应一个隐藏订阅 URL。该 URL 由网关在服务端请求,客户端不会拿到。

添加用户:

bash
docker compose exec gateway cpx-admin add-user alice 'https://origin.example.com/sub?token=xxxx' --limit 3

命令会提示输入密码。密码只保存 scrypt hash。

常用命令:

bash
docker compose exec gateway cpx-admin list-users
docker compose exec gateway cpx-admin list-users --show-sub
docker compose exec gateway cpx-admin set-sub alice 'https://origin.example.com/sub?token=yyyy'
docker compose exec gateway cpx-admin set-limit alice 5
docker compose exec gateway cpx-admin passwd alice
docker compose exec gateway cpx-admin list-devices alice
docker compose exec gateway cpx-admin revoke-device <deviceId>
docker compose exec gateway cpx-admin del-user alice

说明:

  • list-users 默认只显示订阅 URL 的 host。
  • list-users --show-sub 会打印完整订阅 URL,只在需要排障时使用。
  • revoke-device 删除设备绑定。客户端下次更新会进入重新登录流程。
  • del-user 会删除用户及其设备。

生成 `.cpx`

.cpx 是公开插件描述文件,不含用户信息、token、网关密钥或订阅 URL。所有用户可以使用同一份文件。

在仓库根目录运行:

bash
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx

分发 your-airport.cpx。用户在 Clash Party 中导入后,会通过系统浏览器打开登录页。登录成功后,客户端注册设备并拉取该账号绑定的 Clash YAML。


请求链路

首次登录:

  1. 客户端请求 https://<domain>/.well-known/cpx-gateway。
  2. 客户端生成 Ed25519 设备密钥和 deviceId。
  3. 系统浏览器打开 https://<domain>/oauth/authorize?...。
  4. 用户输入账号密码。
  5. 网关签发一次性 code,绑定 PKCE、redirect_uri、client_id,TTL 默认 60 秒。
  6. 客户端调用 /enroll,提交 code、PKCE verifier、设备公钥和 deviceId。
  7. 网关写入设备绑定。

订阅更新:

  1. 客户端调用 /challenge 领取 nonce。
  2. 客户端用设备私钥签名。
  3. 客户端调用 /config。
  4. 网关校验 nonce、时钟偏差和 Ed25519 签名。
  5. 网关用该账号的隐藏订阅 URL 拉取 Clash YAML。
  6. 网关把 YAML 返回给客户端。

删除插件:

  1. 客户端调用 /revoke。
  2. 网关验签后删除设备绑定。
  3. 客户端删除本地状态。

运维

升级:

bash
git pull
cd deploy/gateway
./deploy.sh

账号和设备数据保存在 gateway_data 卷中,升级容器不会删除。

备份数据库到当前目录:

bash
docker compose exec -T gateway cat /data/gateway.db > gateway.db.backup

恢复数据库:

bash
docker compose down
docker run --rm -i -v gateway_gateway_data:/data busybox sh -c 'cat > /data/gateway.db' < gateway.db.backup
docker compose up -d

签名发现文档(可选,对接指南第 5a 节):

私钥离线保存与签发,网关进程只读取签好的信封文件。在一台离线机器上:

bash
# 1. 生成密钥(seed 写入 0600 文件,只打印公钥)
node deploy/gateway/admin.mjs keygen --out ./provider.seed
# 2. 写 payload.json(gateways / endpoints 必须与网关实际提供的一致;每次变更 seq 递增)
cat > payload.json <<'JSON'
{ "spec": "cpx-plugin/2", "seq": 1,
  "gateways": ["https://<domain>"],
  "endpoints": { "enroll": "/enroll", "challenge": "/challenge", "config": "/config", "revoke": "/revoke" } }
JSON
# 3. 签发(seed 只经文件 / stdin 传入,不放命令行)
node deploy/gateway/admin.mjs sign-discovery payload.json --seed-file ./provider.seed --out discovery.signed

把 discovery.signed 放进容器(例如 gateway_data 卷)并设置 DISCOVERY_SIGNED_FILE,重启后 well-known 会带 signed、/config 会带 X-CPX-Discovery 头;网关启动时会校验信封里的 endpoints 与自己提供的路径一致。先发布签名文档,再分发带 --pubkey 的 .cpx:

bash
node scripts/plugin/gen-cpx.mjs https://<domain>/oauth/authorize "Your Airport" https://<domain> your-airport.cpx --pubkey <providerPubKey>

每个 .cpx 谱系使用独立密钥;本期不做密钥轮换,密钥泄露或丢失需要重新发放 .cpx。

给用户的错误说明(可选):

在容器内放一个 JSON 文件并用 MESSAGES_FILE 指向它,网关会把对应文案附在错误响应的 message 字段里,客户端卡片原样显示(客户端会去除控制字符并截断到 200 个字符):

json
{
  "device_revoked": "订阅已到期,续费后请重新登录。",
  "device_limit": "设备数已达上限,请在官网解绑旧设备。",
  "gateway_retired": "服务地址已更换,客户端会自动重新发现。"
}

只支持 JSON(参考网关零依赖,没有 YAML 解析)。

网关退役:

bash
if grep -q '^RETIRED=' .env; then
  sed -i.bak 's/^RETIRED=.*/RETIRED=true/' .env
else
  printf '\nRETIRED=true\n' >> .env
fi
docker compose up -d

退役后,/challenge 和 /config 返回 410 / gateway_retired。客户端会回到登录域名重新发现网关。


配置

配置文件为 .env。可参考 .env.example。

变量 默认值 说明
DOMAIN 无 公网域名,必填
DOMAINS $DOMAIN Caddy 服务的全部域名,逗号分隔(多网关域名时使用)
PUBLIC_ORIGIN https://$DOMAIN 写入 /.well-known/cpx-gateway 的 gateway origin
GATEWAY_ORIGINS $PUBLIC_ORIGIN well-known gateways 列表,逗号分隔 1..3 个 origin
DEVICE_LIMIT_DEFAULT 3 新用户默认设备数上限,可被 add-user --limit 覆盖
CLOCK_SKEW_MS 300000 /config、/revoke 签名时间戳允许偏差
CODE_TTL_MS 60000 authorize code 有效期
NONCE_TTL_MS 60000 challenge nonce 有效期
NONCE_POOL_MAX 8 单设备待用 nonce 数上限
LOGIN_MAX 10 单 IP 登录尝试次数上限
LOGIN_WINDOW_MS 60000 登录限流窗口
SUB_TIMEOUT_MS 30000 拉取隐藏订阅的超时
SUB_MAX_BYTES 10485760 隐藏订阅响应体上限
RETIRED false 设置为 true 时返回网关退役信号
MESSAGES_FILE 空 可选 JSON 文件,错误响应附带给用户看的 message
DISCOVERY_SIGNED_FILE 空 可选,离线签好的发现文档信封(对接指南第 5a 节)
ORIGIN_CA_FILE 空 订阅 origin 使用私有 CA 时,在容器内指定 CA 文件路径
PORT 8080 gateway 容器内监听端口
DB_PATH /data/gateway.db SQLite 数据库路径

如果登录域名和网关域名需要拆开,保持 .cpx 里的 loginUrl 指向登录域名,同时把登录域名上的 /.well-known/cpx-gateway 的 gateway / gateways 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。

参考网关只支持单进程部署:GATEWAY_ORIGINS 里的所有域名都必须反代到同一个 gateway 容器。


开发和自测

本目录不需要安装依赖。需要 Node.js >= 22.5.0。

bash
cd deploy/gateway
npm test
npm run check-vectors
npm start

说明:

  • npm test 使用 node:test 跑网关测试。
  • npm run check-vectors 使用客户端签名向量检查 Ed25519 互通。
  • npm start 在本地启动 HTTP 网关,默认监听 :8080。真实客户端接入仍需要公网 HTTPS 和合法 well-known。

安全边界

  • 隐藏订阅 URL 是管理员配置项。网关只要求 HTTPS,并设置超时和响应体大小上限;不拦截私网地址,便于把 origin 放在内网。
  • 密码使用 scrypt hash 保存。
  • authorize code 和 nonce 都是一次性短 TTL。
  • 登录接口按 IP 限流。
  • /config 和 /revoke 使用 Ed25519 设备签名和一次性 nonce 防重放。
  • 日志不要记录密码、完整订阅 URL、code、nonce、签名或完整 Clash YAML。
  • Clash 节点内容最终会返回客户端,这是客户端运行 Mihomo 的必要输入。本实现保护的是订阅 URL、origin host 和服务端 API。

Plugin PROVIDER INTEGRATION V2 (`docs/plugin/PROVIDER_INTEGRATION_v2.md`)

Clash Party Provider Integration v2

Client spec: cpx-plugin/2.

Chinese reference document:
机场服务端对接指南-v2.md.

Related files:

During 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.


1. Integration Model

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:

text
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.

2. Reference Gateway

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:

  1. A device binding table: user_id, device_id, device_pubkey, created_at.
  2. Pending nonce storage: Redis, memory, or database; short TTL; delete after use.
  3. An OAuth authorize endpoint backed by the existing login system.
  4. /.well-known/cpx-gateway.
  5. Four gateway endpoints that call existing user-status and subscription-generation logic.

3. `.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.

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"
  }
}

Field rules:

Field Rule
magic String "CPXF"
v Number 2
spec String "cpx-plugin/2"
Top-level keys Only magic, v, spec, loginUrl, provider, discoveryUrls, providerPubKey
loginUrl HTTPS URL; no query, fragment, or userinfo; host must not be private, loopback, localhost, or *.localhost
discoveryUrls Optional. 1..8 public HTTPS origins (no path/query/fragment/userinfo), deduplicated, none equal to the loginUrl origin. Backup discovery sources, see §5. Only clients from the release that added it accept this key; older clients reject the file
providerPubKey Optional. Ed25519 raw 32-byte public key, standard base64 with padding. Once present the client requires a signed discovery document (§5a) and rejects unsigned ones. Use one key per .cpx lineage. Older clients reject the file
provider Object; only name, icon, site, description
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
provider.description Optional string shown to the user; sanitized like error message (§6) and capped at 500 code points. Older clients reject the file

loginUrl is the OAuth authorize endpoint, not a generic login page. The client appends OAuth parameters to it.

Generator:

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

--discovery may be repeated; each value becomes one entry of discoveryUrls.

Distribution Options

  • File download: Distribute the .cpx file directly. Clients with the file association registered can launch the app by double-clicking the file and will see the plugin preview and confirmation page.

  • Deep link: Host the same .cpx file at a public HTTPS URL, then use the following link in a web button or QR code:

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

    mihomo:// is equivalent to clash://. URL-encode the url parameter. The client downloads and validates the file, installs the plugin, and opens the system browser for login. The download URL must use public HTTPS, redirects are rejected, and the file must not exceed 1 MiB.


4. OAuth Authorize

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:

  1. Allow loopback redirect URI http://127.0.0.1:<random-port>/callback. The port changes per login.
  2. After successful login, redirect to redirect_uri?code=...&state=....
  3. The issued code must be one-time and expire in no more than 60 seconds.
  4. Store user_id, redirect_uri, client_id, and code_challenge with the code.
  5. /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.


5. Gateway Discovery

The client requests the exact host from loginUrl:

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

Response:

json
{
  "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"
  }
}

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. Must equal the normalized gateways[0] when gateways is present
gateways Optional. 1..3 HTTPS origins with the same rules as gateway, deduplicated. Absent → the client uses [gateway]. Any invalid entry, an empty list, or more than 3 entries invalidates the whole document
endpoints Must contain enroll, challenge, config, revoke; each value is a relative path starting with /; no absolute URL, ?, #, or backslash. The same endpoints apply to every gateway

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.

Old clients only read gateway; new clients read gateways and fall back to [gateway]. Generate both from the same list so they never disagree.

Multiple gateways must share state. All gateways must point at the same backend state (authorize codes, devices, nonces): the client may take a nonce from /challenge on one gateway and post /config on another after a timeout. The reference deployment terminates TLS for every gateway domain in Caddy and forwards them all to one gateway process; it does not support multiple replicas.

Gateway Rotation and Switching

To rotate gateways, update /.well-known/cpx-gateway.

Within one client operation (a complete business action such as challenge + config), the client tries the cached gateways in order — the last gateway that worked first, then the rest — and moves to the next candidate when the current one returns:

  • HTTP 410 or JSON {"error":"gateway_retired"};
  • a network-level failure: DNS failure, connection failure, TLS handshake failure;
  • a timeout (no HTTP response at all).

Any HTTP response other than the retired marker stops the operation: plain 5xx, 429, or revoked are never reasons to try another gateway. Only when every cached gateway failed in the switch-worthy way does the client rediscover once and try the new list, skipping targets it already tried in the same operation (same origin and same endpoints). If that still fails the operation ends as transient and backs off.

Each gateway origin is also routed independently: the client may reach one gateway directly and another through its local proxy (see §6).

Rediscovery requires a discovery source to be reachable (the login host, and any discoveryUrls from the descriptor).

Multiple Discovery Sources

The login host is a single point of failure for rediscovery: if it is blocked, an enrolled device can no longer learn about a new gateway. The descriptor may therefore list backup sources in discoveryUrls (§3). They carry the same trust as loginUrl — both are static roots the user accepted at import time.

Discovery order is [origin of loginUrl, ...discoveryUrls]; every source is asked for https://<origin>/.well-known/cpx-gateway. Any failure at one source — network error, non-2xx, invalid JSON, invalid fields, or a client-side guard refusal — moves on to the next source; a backup may be a plain static file on a CDN, so 404 simply means "not provided here". When every source fails, the last error wins. Each source is its own origin, so route selection (§6) is independent per source.

A backup source only needs to serve the JSON document at that path over public HTTPS. Any CDN or object storage works, and the gateway itself already serves it, so listing a gateway origin in discoveryUrls is the simplest option. Backup sources help already-enrolled devices recover; a new login still needs the login host, because the OAuth page opens in the system browser.

5a. Signed Discovery Document

With providerPubKey in the descriptor the trust root moves from a host name to a key: a discovery document is accepted from anywhere — the login host, any gateway, a static CDN file, or the /config response — as long as it verifies under that key and its sequence number is not older than what the client already accepted. This makes the login host replaceable and lets gateways, endpoints, loginUrl and discoveryUrls rotate without touching the .cpx file.

Envelope. One string ".<sigB64>":

  • payloadB64: the UTF-8 bytes of the payload JSON, standard base64 with padding.
  • sigB64: the Ed25519 signature (64 bytes) over "CPX2-DISCOVERY\0" || payloadBytes — the ASCII prefix followed by a NUL byte, then the exact payload bytes — standard base64 with padding. The prefix separates discovery documents from any other message signed with the same key.
  • Exactly one .. Both halves must be canonical base64 (decoding and re-encoding reproduces the input). The payload is at most 4 KiB; after base64 and signature the envelope is about 5.6 KiB, inside the usual 8 KiB per-header limit of CDNs and reverse proxies.

The signer serializes the payload itself and signs those bytes; the client verifies the received bytes before parsing them. No canonicalization (JCS or similar) is involved on either side.

Payload.

json
{
  "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"]
}
Field Rule
spec "cpx-plugin/2"
seq Integer, 1 ≤ seq ≤ 2^53−1. Strictly increase it on every change
gateways Same rules as §5 (1..3 public HTTPS origins)
endpoints Same rules as §5; an optional bootstrap path is reserved for a later phase
loginUrl Optional; same rules as the descriptor field. When accepted it replaces the stored login URL; the next login opens the new one
discoveryUrls Optional; absent = unchanged, [] = clear, otherwise the descriptor rules (1..8). When the payload also carries loginUrl, the list must not contain that URL's origin — the whole document is invalid otherwise, so remove it before signing. When the payload omits loginUrl, the client drops entries equal to its currently stored login origin at apply time

No other keys are allowed.

Where it is served — two places, same format, same verification:

  1. The well-known document gains an optional top-level signed. Keep gateway / gateways / endpoints for unkeyed and older clients; a keyed client compares them with the payload and rejects the whole source if they disagree, so generate both from the same payload.
  2. A successful /config response may carry the header X-CPX-Discovery: .<sigB64>. Send it as a single header value; the client ignores repeated headers.

What the client does.

providerPubKey in .cpx signed in well-known Behaviour
no any unsigned path (§5); signed and X-CPX-Discovery are ignored
yes no this source fails (downgrade protection); the next source is tried
yes yes verify → parse → sequence check → apply

The client stores the last accepted seq together with the SHA-256 of the payload bytes. For an incoming document: no stored value → accept; seq higher → accept; same seq and same digest → accept as an idempotent re-application; same seq but a different digest → reject (two different documents with one number, e.g. inconsistent CDN copies); lower seq → reject. A rejected well-known source is skipped like any other discovery failure. A rejected or malformed X-CPX-Discovery header is only logged: the authenticated /config response is never discarded because of it.

The sequence number protects against the network replaying an older document; it is not a defense against someone with write access to the client's own files.

Applying an accepted document. Gateways and endpoints go into the client's encrypted cache first; then loginUrl, discoveryUrls, seq and the digest are written to the plugin record in one step. The sequence number is the commit marker: if anything fails in between, the marker does not advance and the next arrival of the same document repairs the record idempotently. On a fresh login the discovery document is applied before the browser opens, so a rotated loginUrl takes effect immediately and a cancelled login cannot be talked back into an older document afterwards.

Key management. Sign offline. The gateway process holds no private key; it only serves a pre-signed envelope file (DISCOVERY_SIGNED_FILE in the reference gateway, used both for signed and for the header). cpx-admin keygen and cpx-admin sign-discovery (or scripts/plugin/sign-discovery.mjs) read the seed from a file or stdin, never from the command line. Use a separate key for every .cpx lineage: documents signed with the same key are interchangeable between the plugins that share it. Key rotation is not part of this version — a lost or leaked key means issuing a new .cpx.

Publish order. Serve the signed well-known first, then distribute the .cpx containing providerPubKey. In the other order every keyed client fails discovery until signed appears.


6. Gateway Common Rules

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"} Try the next cached gateway; rediscover once when all are exhausted
revoked JSON {"error":"revoked"} or {"error":"device_revoked"} Mark as needs re-authentication
unreachable DNS failure, connection failure, TLS handshake failure Try the next route, then the next gateway; rediscover once when exhausted
transient Other non-2xx (5xx, 429, bare 401/403), or a timeout Timeout: next route / next gateway. Any HTTP response: back off and retry
blocked The gateway host resolved to a private/loopback address (client-side guard, nothing is sent) Back off; the user must fix the target or explicitly choose proxy mode
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.

Optional message. Any error JSON may add a human-readable message that the client shows on the plugin card next to its own fixed status text:

json
{
  "error": "revoked",
  "message": "Your subscription expired on 2026-09-01. Renew and log in again."
}

The client only reads message when it is a string; it trims it, strips control characters except newline (U+000A), truncates to 200 code points, and treats an empty result as absent. It is rendered as plain text (no Markdown, no links). The message is stored with the plugin until the next successful operation clears it. The same sanitizing rules apply to the static provider.description in .cpx (cap 500 code points), which is shown on the install page and on the card.

Routes. Each request leaves the client either directly or through the user's local proxy. In the default auto mode the client remembers the route that last worked for a plugin, tries it first, and switches to the other one only on a network-level failure or a timeout — never on an HTTP response. Stickiness is per origin: once a gateway origin has answered on a route, the rest of the operation keeps using that route for it. Before any request goes through the proxy, the client resolves the gateway host locally and refuses hosts that resolve to private addresses (blocked). Two residual risks remain, both shared with the explicit proxy mode: DNS rebinding between that check and the proxy's own lookup, and hosts that fail to resolve locally are still allowed through the proxy.

/enroll is never replayed. The authorize code is consumed by the first request that reaches the gateway. The client therefore only tries another route or another gateway for /enroll when the request provably never left the client (connection refused / DNS / TLS failure before the request was written). A timeout, a connection reset after sending, or any HTTP response ends the login; the user simply logs in again for a fresh code.


7. `POST {gateway}/enroll`

Purpose: exchange an authorize code for a device binding. This is similar to OAuth token exchange, but no bearer token is returned.

Request:

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>"
}

Server steps:

  1. Find code; verify it is unexpired and unused.
  2. Verify PKCE: BASE64URL(SHA256(code_verifier)) == code_challenge.
  3. Compare redirect_uri and client_id with the stored values byte-for-byte.
  4. Resolve user_id from the code.
  5. Store (user_id, deviceId, devicePubKey).
  6. Mark the code as used.
  7. Return 2xx, for example {"ok":true}.

Notes:

  • deviceId is client-generated. Do not replace it.
  • A user may have multiple devices. Apply a device-count limit or cleanup policy.
  • If enroll succeeds but the first config fetch fails, keep the device binding.
  • Re-login creates a new key pair and a new device binding. The client then revokes the device it replaced (see §10), so a user who logs in repeatedly on one machine does not accumulate bindings.

8. `POST {gateway}/challenge`

Purpose: issue a one-time nonce for a device.

Request:

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

Success response:

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

Rules:

  • Keep a pending-nonce pool per deviceId.
  • Allow several pending nonces for concurrency.
  • nonce is 32 cryptographically random bytes.
  • TTL should be no more than 60 seconds.
  • Delete nonce after use; clean expired nonces.
  • Limit pending nonces per device, for example 8.
  • 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:

json
{ "error": "revoked" }

9. `POST {gateway}/config`

Purpose: verify the device signature and return the user's Clash YAML.

Request:

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

Server steps:

  1. Look up the pending nonce by deviceId and nonceId.
  2. Verify request nonce matches the stored value.
  3. Verify the nonce is unexpired and unconsumed.
  4. Check clock skew: abs(now_ms - ts) <= 300000.
  5. Load devicePubKey for the device.
  6. Build the canonical sign input from section 11 with op=1.
  7. Verify the Ed25519 signature.
  8. Consume the nonce.
  9. Resolve user_id from deviceId.
  10. Generate the subscription internally or fetch it from a hidden origin.
  11. Return HTTP 200 with Clash YAML as the response body.

Successful /config response is not JSON. The client parses it as Clash YAML and requires an object containing at least proxies or proxy-providers.

A successful response may additionally carry the header X-CPX-Discovery: .<sigB64> (§5a). The client reads it only for plugins with providerPubKey, only as a single header value, and a failed verification is logged and ignored — the YAML body is still applied.

Do not return the subscription URL, origin API host, or origin token.


10. `POST {gateway}/revoke`

Purpose: unbind a device. The client calls it best-effort in two situations:

  • the user deletes the plugin — for the current device and any device still waiting in the list below;
  • a re-login replaced the device — for the previous device, signed with the previous key, right after the login completes. If that call fails, the client keeps the old credentials and retries after later successful /config fetches, one device per attempt.

A /challenge answer of revoked / device_revoked for the old device counts as "already unbound" and ends the retries. Nothing new is required of the gateway beyond §7 and the idempotency rule below; the only visible change is that /revoke now also arrives after re-logins, for a device that is no longer the account's newest one — unbind that deviceId only.

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.


11. Device Signature

Canonical sign input:

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

Signature:

text
sig = Ed25519_sign(devicePrivKey, SignInput)

sig is 64 raw bytes on input to standard base64.

Implementation notes:

  • Decode nonce from base64 before adding it to SignInput.
  • Encode 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.

12. Wire Encoding

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-._~]
providerPubKey Ed25519 public key, 32 raw bytes, standard base64 with padding
signed / X-CPX-Discovery .<sigB64>; both halves canonical standard base64 with padding

Only PKCE fields use base64url without padding. Binary protocol fields use standard base64 with padding.


13. PHP Snippet

PKCE:

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

Signature verification:

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 */ }

// 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:

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

Signed discovery document (§5a), done offline:

php
$payload = json_encode($doc, JSON_UNESCAPED_SLASHES); // sign exactly these bytes
$sig     = sodium_crypto_sign_detached("CPX2-DISCOVERY\0" . $payload, $secretKey);
$signed  = base64_encode($payload) . '.' . base64_encode($sig);
// providerPubKey for the .cpx: base64_encode(sodium_crypto_sign_publickey($keypair))

14. Test Vectors

File:

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

Each vector contains:

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

Verify:

  1. Your canonical input hex equals inputHex.
  2. sigB64 verifies under pubKeyB64.

Regenerate vectors:

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

Signed discovery vectors (§5a):

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

Each entry contains seedB64, pubKeyB64, payloadJson, payloadB64, signInputHex (prefix + payload), sigB64, signed and digestHex. Verify that signing signInputHex with the seed reproduces sigB64, that signed verifies under pubKeyB64, and that SHA-256 of the payload bytes equals digestHex. Regenerate with node scripts/plugin/gen-discovery-vectors.mjs.


15. Launch Checklist

Descriptor:

  • .cpx contains only valid v2 fields.
  • loginUrl is an HTTPS authorize endpoint with no query, fragment, or userinfo.
  • Optional icon uses an allowed data URI format and size.

Login host:

  • /.well-known/cpx-gateway returns valid JSON.
  • authorize accepts http://127.0.0.1:<random-port>/callback.
  • successful login redirects with code and original state.
  • code is one-time and TTL <= 60 seconds.
  • code stores redirect_uri, client_id, and code_challenge.

Gateway:

  • gateway is a public HTTPS origin.
  • if gateways is present it lists 1..3 public HTTPS origins and gateway equals gateways[0].
  • every listed gateway reaches the same backend state (codes, devices, nonces); no independent replicas.
  • if the .cpx carries providerPubKey: the well-known already serves signed, the top-level fields match the payload, and the signed document went live before the .cpx was distributed.
  • endpoint paths are relative and contain no backslash, query, or fragment.
  • /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.
  • account/device revocation returns {"error":"revoked"} or {"error":"device_revoked"}.
  • gateway retirement returns HTTP 410 or {"error":"gateway_retired"}.

Compatibility:

  • devicePubKey, sig, and nonce use standard base64 with padding.
  • PKCE uses base64url without padding.
  • sign input uses raw nonce bytes, not the base64 string.
  • ts is encoded as uint64 big-endian.
  • implementation passes sign-vectors.json.

16. Logging

Do not log:

  • authorize code
  • code_verifier
  • nonce or nonceId
  • user password or raw login form
  • subscription URL, origin token, or full Clash YAML

Safe operational fields include user_id, deviceId, endpoint name, status code, duration, and gateway version.


Plugin 机场服务端对接指南 V2 (`docs/plugin/机场服务端对接指南-v2.md`)

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

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

相关文件:

开发过程中曾有一版 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

端到端流程:

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.mjs enroll/challenge/config/revoke
deploy/gateway/src/crypto.mjs PKCE、Ed25519、签名输入
deploy/gateway/src/origin.mjs 拉取隐藏订阅 origin

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

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

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

生成脚本:

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

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

分发方式

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

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

    text
    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. 用户登录成功后,302 到 redirect_uri?code=...&state=...。
  3. code 必须一次性、短 TTL(不超过 60 秒)。
  4. 签发 code 时记录 user_id、redirect_uri、client_id、code_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",
  "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、userinfo;host 必须公网可达。有 gateways 时必须等于归一化后的 gateways[0]
gateways 可选。1..3 个 HTTPS origin,规则同 gateway,按归一化结果去重。缺失时客户端按 [gateway] 处理。任一项非法、空列表、超过 3 个都会使整份文档无效
endpoints 必须包含 enroll、challenge、config、revoke;每个值是以 / 开头的相对路径,不能是绝对 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. 签名发现文档

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

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

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

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

payload。

json
{
  "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^53−1。每次变更必须严格递增
gateways 规则同第 5 节(1..3 个公网 HTTPS origin)
endpoints 规则同第 5 节;可选的 bootstrap 路径留给后续阶段
loginUrl 可选;规则同 .cpx 字段。接受后替换已保存的登录地址,下次登录打开新地址
discoveryUrls 可选;缺失 = 不改,[] = 清空,非空时规则同 .cpx(1..8)。payload 同时给出 loginUrl 时,列表不得包含其 origin,否则整份文档无效,签发前须移除;未给出 loginUrl 时,客户端在应用阶段按已保存的登录地址去除同源项

不允许其他键。

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

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

客户端行为。

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

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

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

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

密钥管理。 离线签发。网关进程不持有私钥,只读取预先签好的信封文件(参考网关的 DISCOVERY_SIGNED_FILE,同时用于 signed 与响应头)。cpx-admin keygen 与 cpx-admin sign-discovery (或 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 其它非 2xx(5xx、429、空 401/403),或超时 超时:换路由 / 换网关。任何 HTTP 响应:退避重试
blocked 网关 host 在客户端本地解析到私网/环回地址(不会发出任何请求) 退避;用户需修正目标或显式选择代理模式
成功 2xx 且没有错误标记 正常处理

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

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

json
{ "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。

请求体:

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_verifier:BASE64URL(SHA256(code_verifier)) 必须等于签发 code 时保存的 code_challenge。
  3. 校验 redirect_uri、client_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。

请求体:

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. 按 deviceId、nonceId 查找待用 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 文本。

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

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

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


10. `POST {gateway}/revoke`

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

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

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

请求体同 /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 字节无符号大端整数编码。
  • 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-._~]
providerPubKey Ed25519 公钥,32 原始字节,标准 base64,带 padding
signed / X-CPX-Discovery .<sigB64>;两段都是规范的标准 base64,带 padding

除 PKCE 的 code_challenge、code_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']);

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

php
$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 的 providerPubKey:base64_encode(sodium_crypto_sign_publickey($keypair))

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

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

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

每条包含 seedB64、pubKeyB64、payloadJson、payloadB64、signInputHex(前缀 + payload)、sigB64、signed、digestHex。验证:用 seed 对 signInputHex 签名得到 sigB64;signed 能用 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:

  • 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。
  • 如提供 gateways:1..3 个公网 HTTPS origin,且 gateway 等于 gateways[0]。
  • 列出的每个网关都落到同一份后端状态(code / 设备 / nonce),没有独立副本。
  • .cpx 带 providerPubKey 时:well-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"}。

编码和兼容:

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

16. 常见问题

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

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

json
{ "error": "revoked" }

或:

json
{ "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,也不要包含反斜杠。

验签失败

按顺序检查:

  1. nonce 是否先 base64 解码为 32 原始字节。
  2. ts 是否按 uint64 大端编码。
  3. op 是否正确:config=1,revoke=2。
  4. deviceId、nonceId 的长度前缀是否是 1 字节。
  5. devicePubKey、sig 是否分别解码为 32/64 字节。
  6. 生成的 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、端点名、错误码、请求耗时、网关版本等运维字段。


--- METRICS ---