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

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/mihomo-party-org/clash-party)

### Overview (`README.md`)

<h3 align="center">
  <img height='48px' src='./images/icon-white.png#gh-dark-mode-only'>
  <img height='48px' src='./images/icon-black.png#gh-light-mode-only'>
</h3>

<h3 align="center">Another <a href="https://github.com/MetaCubeX/mihomo">Mihomo</a> GUI</h3>

<p align="center">
  <a href="https://github.com/mihomo-party-org/clash-party/releases">
    <img src="https://img.shields.io/github/release/mihomo-party-org/clash-party/all.svg">
  </a>
  <a href="https://t.me/mihomo_party_group">
    <img src="https://img.shields.io/badge/Telegram-Group-blue?logo=telegram">
  </a>
</p>
<div align='center'>
<img width='90%' src="./images/preview.jpg">
</div>

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

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

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

### 特性

- [x] 一键 Smart Core 规则覆写，基于 AI 模型自动选择最优节点 详细介绍请看 [这里](https://clashparty.org/docs/guide/smart-core)
- [x] 开箱即用，无需服务模式的 Tun
- [x] 多种配色主题可选，UI 焕然一新
- [x] 支持大部分 Mihomo(Clash Meta) 常用配置修改
- [x] 内置 Smart内核 与 Mihomo(Clash Meta) 内核
- [x] 通过 WebDAV 一键备份和恢复配置
- [x] 强大的覆写功能，任意修订配置文件
- [x] 深度集成 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`。

相关文档：

- 中文对接指南：[`docs/plugin/机场服务端对接指南-v2.md`](../../docs/plugin/机场服务端对接指南-v2.md)
- 英文协议文档：[`docs/plugin/PROVIDER_INTEGRATION_v2.md`](../../docs/plugin/PROVIDER_INTEGRATION_v2.md)

---

## 部署结构

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

```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`](.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`](机场服务端对接指南-v2.md).

Related files:

- Reference gateway: [`deploy/gateway/`](../../deploy/gateway/)
- Sign test vectors:
  [`src/main/resolve/plugin/__fixtures__/sign-vectors.json`](../../src/main/resolve/plugin/__fixtures__/sign-vectors.json)

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/`](../../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`](../../deploy/gateway/src/auth.mjs)       | authorize page and one-time codes   |
| [`deploy/gateway/src/gateway.mjs`](../../deploy/gateway/src/gateway.mjs) | enroll/challenge/config/revoke      |
| [`deploy/gateway/src/crypto.mjs`](../../deploy/gateway/src/crypto.mjs)   | PKCE, Ed25519, canonical sign input |
| [`deploy/gateway/src/origin.mjs`](../../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 `"<payloadB64>.<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: <payloadB64>.<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 <payload.json>` (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: <payloadB64>.<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`       | `<payloadB64>.<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`。

相关文件：

- 参考服务端：[`deploy/gateway/`](../../deploy/gateway/)
- 英文版：[`PROVIDER_INTEGRATION_v2.md`](PROVIDER_INTEGRATION_v2.md)
- 签名测试向量：[`src/main/resolve/plugin/__fixtures__/sign-vectors.json`](../../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`                                |

端到端流程：

```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/`](../../deploy/gateway/) 是完整参考实现，包含 Docker 部署、SQLite 账号库和 `cpx-admin` 管理命令。新接入建议先跑通这套服务，再接入自己的面板。

主要文件：

| 文件                                                                     | 说明                           |
| ------------------------------------------------------------------------ | ------------------------------ |
| [`deploy/gateway/src/auth.mjs`](../../deploy/gateway/src/auth.mjs)       | 登录页、authorize、一次性 code |
| [`deploy/gateway/src/gateway.mjs`](../../deploy/gateway/src/gateway.mjs) | enroll/challenge/config/revoke |
| [`deploy/gateway/src/crypto.mjs`](../../deploy/gateway/src/crypto.mjs)   | PKCE、Ed25519、签名输入        |
| [`deploy/gateway/src/origin.mjs`](../../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` 的情况下轮换。

**信封。** 一个字符串 `"<payloadB64>.<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: <payloadB64>.<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 <payload.json>`（或 `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: <payloadB64>.<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`       | `<payloadB64>.<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`](../../scripts/plugin/example-gateway.mjs)，仅用于查看协议形状。它使用明文 HTTP 和 loopback origin，会被真实客户端的 HTTPS 校验拒绝；真实联调用 [`deploy/gateway/`](../../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 ---
- Files Extracted: 4
- Estimated Token Budget: ~19921 tokens
- Canonical Reference: https://codewiki.google/github.com/mihomo-party-org/clash-party
