feat: airport plugin

This commit is contained in:
ezequielnick
2026-07-01 08:27:12 +08:00
parent e26f3effe9
commit 0c891030df
96 changed files with 7651 additions and 73 deletions

0
.githooks/pre-commit Normal file → Executable file
View File

1
.gitignore vendored
View File

@@ -12,4 +12,3 @@ party.md
CLAUDE.md
agent.md
tsconfig.node.tsbuildinfo
docs

View File

@@ -1,5 +1,6 @@
out
dist
.claude
pnpm-lock.yaml
LICENSE.md
tsconfig.json

View File

@@ -1,3 +1,9 @@
# 2.0.0
## 新功能 (Feat)
- 机场插件机制,服务商请参考: [`docs/plugin/机场服务端对接指南-v2.md`](../../docs/plugin/机场服务端对接指南-v2.md)
# 1.9.6
## 新功能 (Feat)
@@ -50,47 +56,3 @@
- 优化流量时间范围显示格式
- 更新依赖
- 使用最新发布的 sysproxy-rs
# 1.9.5
## 新功能 (Feat)
- 更新 mihomo 内核
- 新增 WebDAV 备份文件名包含设备名称
- 新增单个日志文件大小限制和截断机制
- 新增每个订阅独立的 User-Agent 配置
- 新增配置开关和覆写保存时使用 Mihomo 热重载生效
- 新增热重载切换配置后的连接关闭选项
- 新增自定义托盘图标支持及裁剪选择器
- 新增托盘策略组延迟测试入口
- 新增网络延迟测试自定义目标地址
- 新增 fake-ip-filter 规则模式支持
- 新增删除配置文件的确认弹窗
- 使用 mshta 在 Electron 初始化前同步检测 PowerShell 版本
## 修复 (Fix)
- 修复 Win7 兼容性问题
- 修复日志清理正则表达式以正确匹配带前缀的文件名
- 修复渲染端和配置相关的类型安全回归
- 修复订阅超时时间为空时未回退到全局超时时间的问题
- 修复空 `lan-allowed-ips` 导致局域网访问异常的问题
- 修复智能覆写在规则覆写前应用导致配置结果异常的问题
- 修复 `mihomoHotReloadConfig` IPC 调用白名单缺失并稳定 TUN 保存流程
- 修复受保护配置工作目录删除失败的问题
- 修复连接表中国旗表情未正确显示的问题
- 修复订阅自动更新在异常间隔或错误配置下可能触发高频刷新请求的问题
- 修复 nameserver-policy 未正确保存和应用的问题
- 修复托盘图标裁剪弹窗的暗色模式显示异常
- 修复 Windows TUN 模式下的自代理循环问题
## 性能优化 (Performance)
- 优化连接页渲染并减少代理组轮询
- 优化软件启动和退出速度
## 其他 (Chore)
- 新增提交前格式、lint 和类型检查钩子
- 重构 rule-item 额外字段处理逻辑并补充类型定义
- 更新依赖

View File

@@ -0,0 +1,14 @@
# Keep tests, fixtures (incl. the self-signed test cert), and dev-only tooling out of the image.
**/*.test.mjs
src/__fixtures__
check-vectors.mjs
.env
.env.bak
*.db
*.db-*
node_modules
README.md
Dockerfile
docker-compose.yml
Caddyfile
deploy.sh

View File

@@ -0,0 +1,24 @@
# Public domain. An A/AAAA DNS record MUST point at this VPS before deploying —
# Caddy uses it to obtain a Let's Encrypt certificate. Required.
DOMAIN=gw.example.com
# Default per-user device limit for `cpx-admin add-user` (override per user with --limit).
DEVICE_LIMIT_DEFAULT=3
# ---- Advanced (sensible defaults; uncomment only to override) ----
# gateway origin written into /.well-known/cpx-gateway (defaults to https://$DOMAIN)
# PUBLIC_ORIGIN=https://gw.example.com
# Allowed client/server clock skew for signed requests (ms)
# CLOCK_SKEW_MS=300000
# Upstream subscription fetch timeout (ms) and max size (bytes)
# SUB_TIMEOUT_MS=30000
# SUB_MAX_BYTES=10485760
# Pending nonces kept per device
# NONCE_POOL_MAX=8
# Login attempts allowed per IP per window
# LOGIN_MAX=10
# LOGIN_WINDOW_MS=60000
# Set to "true" to retire this gateway: /challenge and /config return 410 gateway_retired
# RETIRED=false
# Path (inside the container) to a private CA cert for the subscription origin
# ORIGIN_CA_FILE=

8
deploy/gateway/Caddyfile Normal file
View File

@@ -0,0 +1,8 @@
# Caddy obtains and renews a Let's Encrypt certificate for {$DOMAIN} automatically.
# It terminates TLS and reverse-proxies to the gateway container on the internal network.
# To receive cert-expiry notices, add a global block above: { email you@example.com }
{$DOMAIN} {
encode gzip
reverse_proxy gateway:8080
}

19
deploy/gateway/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-slim
WORKDIR /app
# Zero runtime dependencies — the gateway uses only Node built-ins (including node:sqlite).
# No `npm install` needed. Copy sources and expose the admin CLI on PATH.
COPY package.json ./
COPY src ./src
COPY admin.mjs ./
RUN chmod +x /app/admin.mjs \
&& ln -sf /app/admin.mjs /usr/local/bin/cpx-admin \
&& mkdir -p /data
ENV NODE_ENV=production
EXPOSE 8080
# Run node as PID 1 so it receives stop signals directly.
CMD ["node", "--experimental-sqlite", "--disable-warning=ExperimentalWarning", "src/server.mjs"]

285
deploy/gateway/README.md Normal file
View File

@@ -0,0 +1,285 @@
# 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>",
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
```
查看容器状态:
```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
```
网关退役:
```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` | 无 | 公网域名,必填 |
| `PUBLIC_ORIGIN` | `https://$DOMAIN` | 写入 `/.well-known/cpx-gateway` 的 gateway 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` 时返回网关退役信号 |
| `ORIGIN_CA_FILE` | 空 | 订阅 origin 使用私有 CA 时,在容器内指定 CA 文件路径 |
| `PORT` | `8080` | gateway 容器内监听端口 |
| `DB_PATH` | `/data/gateway.db` | SQLite 数据库路径 |
如果登录域名和网关域名需要拆开,保持 `.cpx` 里的 `loginUrl` 指向登录域名,同时把登录域名上的 `/.well-known/cpx-gateway``gateway` 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。
---
## 开发和自测
本目录不需要安装依赖。需要 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。

19
deploy/gateway/admin.mjs Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env -S node --experimental-sqlite --disable-warning=ExperimentalWarning
// cpx-admin CLI entrypoint. Wires the real account DB + a no-echo password reader into
// the tested command logic (src/admin.mjs).
import { loadConfig } from './src/config.mjs'
import { openDb } from './src/db.mjs'
import { runAdmin } from './src/admin.mjs'
import { readPassword } from './src/prompt.mjs'
const config = loadConfig()
const db = openDb(config.dbPath)
const code = await runAdmin(process.argv.slice(2), {
db,
deviceLimitDefault: config.deviceLimitDefault,
readPassword,
out: (s) => console.log(s),
err: (s) => console.error(s)
})
db.close()
process.exit(code)

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Interop proof: the gateway's crypto reproduces the client's recorded sign vectors
// byte-for-byte and verifies the recorded signatures. If this passes, signatures the
// app produces with device.ts will verify here. Dev-only — reads the repo fixture by
// relative path and is NOT shipped in the container image.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import assert from 'node:assert/strict'
import { buildSignInput, verifySignature } from './src/crypto.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const fixture = join(here, '../../src/main/resolve/plugin/__fixtures__/sign-vectors.json')
const vectors = JSON.parse(readFileSync(fixture, 'utf-8'))
assert.ok(Array.isArray(vectors) && vectors.length > 0, 'no vectors found')
for (const v of vectors) {
const nonce = Buffer.from(v.nonceB64, 'base64')
const input = buildSignInput(v.op, v.deviceId, v.nonceId, nonce, v.ts)
assert.equal(input.toString('hex'), v.inputHex, `canonical input mismatch (${v.deviceId})`)
assert.equal(
verifySignature(v.pubKeyB64, input, v.sigB64),
true,
`signature did not verify (${v.deviceId})`
)
}
console.log(`check-vectors: OK — ${vectors.length} client vectors verified against gateway crypto`)

48
deploy/gateway/deploy.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# One-click deploy for the cpx-gateway reference server (Caddy auto-HTTPS + gateway).
set -euo pipefail
cd "$(dirname "$0")"
command -v docker >/dev/null 2>&1 || {
echo "Docker is required. Install Docker, then re-run: https://docs.docker.com/engine/install/" >&2
exit 1
}
docker compose version >/dev/null 2>&1 || {
echo "Docker Compose v2 is required (the 'docker compose' subcommand)." >&2
exit 1
}
if [ ! -f .env ]; then
cp .env.example .env
read -rp "Public domain (its DNS A/AAAA record must already point at this VPS), e.g. gw.example.com: " DOMAIN
[ -n "$DOMAIN" ] || { echo "A domain is required." >&2; exit 1; }
sed -i.bak "s|^DOMAIN=.*|DOMAIN=${DOMAIN}|" .env && rm -f .env.bak
echo "Wrote .env (DOMAIN=${DOMAIN})."
fi
DOMAIN=$(grep -E '^DOMAIN=' .env | cut -d= -f2-)
echo "Building and starting containers..."
docker compose up -d --build
cat <<EOF
✅ Deployed. DOMAIN=${DOMAIN}
Next steps:
1) Wait ~30s for Caddy to obtain the TLS certificate, then verify discovery:
curl https://${DOMAIN}/.well-known/cpx-gateway
2) Add an account (you'll be prompted for a password):
docker compose exec gateway cpx-admin add-user <name> '<hidden-subscription-url>' --limit 3
3) Generate the .cpx plugin file for your users (run from the repository root):
node scripts/plugin/gen-cpx.mjs https://${DOMAIN}/oauth/authorize "Your Airport" https://${DOMAIN} your-airport.cpx
4) Distribute your-airport.cpx. Users import it in Clash Party, log in via the
system browser with the account you created, and the subscription loads automatically.
Manage: docker compose exec gateway cpx-admin list-users | list-devices <name> | revoke-device <id>
Logs: docker compose logs -f gateway
Update: git pull && ./deploy.sh (account data persists in the gateway_data volume)
EOF

View File

@@ -0,0 +1,32 @@
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- '80:80'
- '443:443'
environment:
DOMAIN: ${DOMAIN:?set DOMAIN 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:

View File

@@ -0,0 +1,18 @@
{
"name": "cpx-gateway",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Reference provider gateway for the clash-party airport-plugin v2 browser-login model",
"bin": {
"cpx-admin": "./admin.mjs"
},
"scripts": {
"start": "node --experimental-sqlite --disable-warning=ExperimentalWarning src/server.mjs",
"test": "node --experimental-sqlite --disable-warning=ExperimentalWarning --test",
"check-vectors": "node --disable-warning=ExperimentalWarning check-vectors.mjs"
},
"engines": {
"node": ">=22.5.0"
}
}

View File

@@ -0,0 +1,129 @@
// Admin command logic, decoupled from I/O so it is testable. The bin wrapper
// (../admin.mjs) supplies a real db, a no-echo password reader, and console writers.
import { hashPassword } from './crypto.mjs'
const USAGE = `Usage: cpx-admin <command> ...
add-user <username> <subUrl> [--limit N]
set-sub <username> <subUrl>
passwd <username>
set-limit <username> <N>
del-user <username>
list-users [--show-sub]
list-devices <username>
revoke-device <deviceId>`
function parse(rest) {
const pos = []
const flags = {}
for (let i = 0; i < rest.length; i++) {
const t = rest[i]
if (t === '--limit') flags.limit = rest[++i]
else if (t === '--show-sub') flags.showSub = true
else if (t.startsWith('--')) flags[t.slice(2)] = true
else pos.push(t)
}
return { pos, flags }
}
function hostOf(url) {
try {
return new URL(url).host
} catch {
return '(invalid url)'
}
}
export async function runAdmin(args, deps) {
const { db, readPassword, out, err } = deps
const defaultLimit = deps.deviceLimitDefault ?? 3
const [cmd, ...rest] = args
const { pos, flags } = parse(rest)
const need = (ok, msg) => {
if (!ok) err(msg)
return ok
}
const requireUser = (name) => {
if (db.getUser(name)) return true
err(`user "${name}" not found`)
return false
}
switch (cmd) {
case 'add-user': {
const [username, subUrl] = pos
if (!need(username && subUrl, 'add-user requires <username> <subUrl>')) return 1
if (db.getUser(username)) {
err(`user "${username}" already exists`)
return 1
}
const password = await readPassword('Password: ')
if (!need(password, 'empty password')) return 1
const deviceLimit = flags.limit ? Number(flags.limit) : defaultLimit
db.addUser({ username, pwdHash: hashPassword(password), subUrl, deviceLimit })
out(`added user "${username}" (device limit ${deviceLimit})`)
return 0
}
case 'set-sub': {
const [username, subUrl] = pos
if (!need(username && subUrl, 'set-sub requires <username> <subUrl>')) return 1
if (!requireUser(username)) return 1
db.setSub(username, subUrl)
out(`updated subscription for "${username}"`)
return 0
}
case 'set-limit': {
const [username, n] = pos
if (!need(username && n, 'set-limit requires <username> <N>')) return 1
if (!requireUser(username)) return 1
db.setLimit(username, Number(n))
out(`set device limit ${Number(n)} for "${username}"`)
return 0
}
case 'passwd': {
const [username] = pos
if (!need(username, 'passwd requires <username>')) return 1
if (!requireUser(username)) return 1
const password = await readPassword('Password: ')
if (!need(password, 'empty password')) return 1
db.setPwd(username, hashPassword(password))
out(`changed password for "${username}"`)
return 0
}
case 'del-user': {
const [username] = pos
if (!need(username, 'del-user requires <username>')) return 1
if (!requireUser(username)) return 1
db.delUser(username)
out(`deleted user "${username}" and its devices`)
return 0
}
case 'list-users': {
for (const u of db.listUsers()) {
const sub = flags.showSub ? u.subUrl : hostOf(u.subUrl)
out(
`${u.username}\tdevices=${u.deviceCount}/${u.deviceLimit}\t${sub}\t${new Date(u.created).toISOString()}`
)
}
return 0
}
case 'list-devices': {
const [username] = pos
if (!need(username, 'list-devices requires <username>')) return 1
if (!requireUser(username)) return 1
for (const d of db.listDevices(username)) {
out(`${d.deviceId}\t${new Date(d.created).toISOString()}`)
}
return 0
}
case 'revoke-device': {
const [deviceId] = pos
if (!need(deviceId, 'revoke-device requires <deviceId>')) return 1
db.delDevice(deviceId)
out(`revoked device ${deviceId}`)
return 0
}
default:
err(USAGE)
return 1
}
}

View File

@@ -0,0 +1,95 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { openDb } from './db.mjs'
import { verifyPassword } from './crypto.mjs'
import { runAdmin } from './admin.mjs'
function harness({ password = 'pw' } = {}) {
const lines = []
const db = openDb(':memory:')
const deps = {
db,
readPassword: async () => password,
out: (s) => lines.push(String(s)),
err: (s) => lines.push('ERR:' + s)
}
return { db, deps, lines, text: () => lines.join('\n') }
}
test('add-user creates a user with a hashed password and a device limit', async () => {
const h = harness({ password: 's3cret' })
const code = await runAdmin(
['add-user', 'alice', 'https://o.example/sub?t=1', '--limit', '5'],
h.deps
)
assert.equal(code, 0)
const u = h.db.getUser('alice')
assert.equal(u.subUrl, 'https://o.example/sub?t=1')
assert.equal(u.deviceLimit, 5)
assert.equal(verifyPassword('s3cret', u.pwdHash), true)
})
test('add-user rejects a duplicate username with a non-zero exit', async () => {
const h = harness()
await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps)
const code = await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps)
assert.notEqual(code, 0)
assert.match(h.text(), /exists/i)
})
test('set-sub / set-limit / passwd update an existing user', async () => {
const h = harness({ password: 'new-pw' })
await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps)
await runAdmin(['set-sub', 'alice', 'https://o/sub2'], h.deps)
await runAdmin(['set-limit', 'alice', '9'], h.deps)
await runAdmin(['passwd', 'alice'], h.deps)
const u = h.db.getUser('alice')
assert.equal(u.subUrl, 'https://o/sub2')
assert.equal(u.deviceLimit, 9)
assert.equal(verifyPassword('new-pw', u.pwdHash), true)
})
test('del-user removes the user and its devices', async () => {
const h = harness()
await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps)
h.db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K' })
const code = await runAdmin(['del-user', 'alice'], h.deps)
assert.equal(code, 0)
assert.equal(h.db.getUser('alice'), undefined)
assert.equal(h.db.getDevice('d1'), undefined)
})
test('list-users hides the full subUrl by default and shows it with --show-sub', async () => {
const h = harness()
await runAdmin(['add-user', 'alice', 'https://secret.example/sub?token=XYZ'], h.deps)
await runAdmin(['list-users'], h.deps)
assert.match(h.text(), /secret\.example/)
assert.doesNotMatch(h.text(), /token=XYZ/)
await runAdmin(['list-users', '--show-sub'], h.deps)
assert.match(h.text(), /token=XYZ/)
})
test('list-devices and revoke-device manage bindings', async () => {
const h = harness()
await runAdmin(['add-user', 'alice', 'https://o/sub'], h.deps)
h.db.upsertDevice({ deviceId: 'dev-abc', username: 'alice', pubKey: 'K' })
await runAdmin(['list-devices', 'alice'], h.deps)
assert.match(h.text(), /dev-abc/)
const code = await runAdmin(['revoke-device', 'dev-abc'], h.deps)
assert.equal(code, 0)
assert.equal(h.db.getDevice('dev-abc'), undefined)
})
test('operating on a missing user is a non-zero exit with a clear message', async () => {
const h = harness()
const code = await runAdmin(['set-sub', 'ghost', 'https://o/sub'], h.deps)
assert.notEqual(code, 0)
assert.match(h.text(), /not found/i)
})
test('an unknown command returns non-zero and prints usage', async () => {
const h = harness()
const code = await runAdmin(['frobnicate'], h.deps)
assert.notEqual(code, 0)
assert.match(h.text(), /usage/i)
})

View File

@@ -0,0 +1,95 @@
// OAuth 2.0 authorize endpoint (the public login page). Renders a self-contained
// login form, validates credentials against the local account store, and issues a
// one-time authorization code bound to the PKCE challenge + loopback redirect.
import { verifyPassword } from './crypto.mjs'
import { sendHtml, redirect } from './http.mjs'
const LOOPBACK_REDIRECT = /^http:\/\/(127\.0\.0\.1|localhost):\d{1,5}\/callback$/
const OAUTH_FIELDS = [
'response_type',
'client_id',
'redirect_uri',
'code_challenge',
'code_challenge_method',
'state',
'scope'
]
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
// Returns null when valid, else a short reason string.
function validateParams(p) {
if (p.response_type !== 'code') return 'response_type must be code'
if (p.code_challenge_method !== 'S256') return 'code_challenge_method must be S256'
if (!p.code_challenge || !/^[A-Za-z0-9_-]{20,}$/.test(p.code_challenge))
return 'invalid code_challenge'
if (!p.redirect_uri || !LOOPBACK_REDIRECT.test(p.redirect_uri)) return 'invalid redirect_uri'
if (!p.state) return 'missing state'
if (!p.client_id) return 'missing client_id'
return null
}
function errorPage(reason) {
return `<!doctype html><meta charset="utf-8"><title>Login error</title>
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
<h2>无法开始登录 / Cannot start login</h2><p>${escapeHtml(reason)}</p></body>`
}
function loginPage(p, errorMsg) {
const hidden = OAUTH_FIELDS.map(
(f) => `<input type="hidden" name="${f}" value="${escapeHtml(p[f])}">`
).join('\n ')
const err = errorMsg ? `<p style="color:#c00">${escapeHtml(errorMsg)}</p>` : ''
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 / Sign in</title>
<body style="font-family:system-ui;max-width:24rem;margin:4rem auto;padding:0 1rem">
<h2>登录 / Sign in</h2>
${err}
<form method="post" action="/oauth/authorize">
${hidden}
<p><label>账号 / Username<br><input name="username" autocomplete="username" autofocus
style="width:100%;padding:.5rem;box-sizing:border-box"></label></p>
<p><label>密码 / Password<br><input name="password" type="password" autocomplete="current-password"
style="width:100%;padding:.5rem;box-sizing:border-box"></label></p>
<p><button type="submit" style="padding:.6rem 1.2rem">登录 / Sign in</button></p>
</form>
<p style="color:#666;font-size:.85rem">密码只输入在本页面(机场官网)。/ Your password is entered only here.</p>
</body>`
}
export function authorizeGet(query, res) {
const reason = validateParams(query)
if (reason) return sendHtml(res, 400, errorPage(reason))
sendHtml(res, 200, loginPage(query))
}
export function authorizePost(form, ip, res, deps) {
if (!deps.rateLimiter.hit(ip)) {
return sendHtml(res, 429, errorPage('尝试过于频繁,请稍后再试 / Too many attempts'))
}
const reason = validateParams(form)
if (reason) return sendHtml(res, 400, errorPage(reason))
const user = deps.db.getUser(form.username)
if (!user || !verifyPassword(form.password ?? '', user.pwdHash)) {
return sendHtml(res, 200, loginPage(form, '账号或密码错误 / Wrong username or password'))
}
const code = deps.codes.issue({
username: user.username,
redirect_uri: form.redirect_uri,
client_id: form.client_id,
code_challenge: form.code_challenge
})
const url = new URL(form.redirect_uri)
url.searchParams.set('code', code)
url.searchParams.set('state', form.state)
redirect(res, url.toString())
}

View File

@@ -0,0 +1,113 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { openDb } from './db.mjs'
import { createCodeStore } from './codes.mjs'
import { createRateLimiter } from './ratelimit.mjs'
import { hashPassword } from './crypto.mjs'
import { authorizeGet, authorizePost } from './auth.mjs'
const PARAMS = {
response_type: 'code',
client_id: 'mihomo-party',
redirect_uri: 'http://127.0.0.1:51000/callback',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
state: 'st-123',
scope: 'subscribe'
}
function deps() {
const db = openDb(':memory:')
db.addUser({
username: 'alice',
pwdHash: hashPassword('pw'),
subUrl: 'https://o/sub',
deviceLimit: 3
})
return {
db,
codes: createCodeStore(),
rateLimiter: createRateLimiter({ max: 5, windowMs: 1000 })
}
}
function mockRes() {
const r = { status: 0, headers: {}, body: '' }
r.writeHead = (s, h) => ((r.status = s), (r.headers = h || {}), r)
r.end = (b) => ((r.body = b ?? ''), undefined)
return r
}
test('authorizeGet renders a self-contained login form with the OAuth params', () => {
const res = mockRes()
authorizeGet(PARAMS, res)
assert.equal(res.status, 200)
assert.match(res.body, /<form[^>]*method="post"/i)
assert.match(res.body, /name="state"[^>]*value="st-123"/)
assert.match(res.body, /name="code_challenge"/)
assert.doesNotMatch(res.body, /<script/i) // no inline/external script
})
test('authorizeGet rejects a non-loopback redirect_uri', () => {
const res = mockRes()
authorizeGet({ ...PARAMS, redirect_uri: 'https://evil.example/callback' }, res)
assert.equal(res.status, 400)
})
test('authorizeGet rejects a non-S256 challenge method', () => {
const res = mockRes()
authorizeGet({ ...PARAMS, code_challenge_method: 'plain' }, res)
assert.equal(res.status, 400)
})
test('authorizePost with valid creds issues a bound code and redirects with state', () => {
const d = deps()
const res = mockRes()
authorizePost({ ...PARAMS, username: 'alice', password: 'pw' }, '1.2.3.4', res, d)
assert.equal(res.status, 302)
const loc = new URL(res.headers.location)
assert.equal(loc.origin + loc.pathname, 'http://127.0.0.1:51000/callback')
assert.equal(loc.searchParams.get('state'), 'st-123')
const code = loc.searchParams.get('code')
const bound = d.codes.consume(code)
assert.deepEqual(bound, {
username: 'alice',
redirect_uri: PARAMS.redirect_uri,
client_id: 'mihomo-party',
code_challenge: PARAMS.code_challenge
})
})
test('authorizePost with a wrong password re-renders the form (200) and issues no code', () => {
const d = deps()
const res = mockRes()
authorizePost({ ...PARAMS, username: 'alice', password: 'WRONG' }, '1.2.3.4', res, d)
assert.equal(res.status, 200)
assert.match(res.body, /<form/i)
assert.equal(d.codes.size(), 0)
})
test('authorizePost with an unknown user issues no code', () => {
const d = deps()
const res = mockRes()
authorizePost({ ...PARAMS, username: 'ghost', password: 'pw' }, '1.2.3.4', res, d)
assert.equal(res.status, 200)
assert.equal(d.codes.size(), 0)
})
test('authorizePost rate-limits repeated attempts from one IP', () => {
const d = { ...deps(), rateLimiter: createRateLimiter({ max: 1, windowMs: 1000 }) }
authorizePost({ ...PARAMS, username: 'alice', password: 'x' }, '9.9.9.9', mockRes(), d)
const res = mockRes()
authorizePost({ ...PARAMS, username: 'alice', password: 'x' }, '9.9.9.9', res, d)
assert.equal(res.status, 429)
})
test('authorizePost escapes a malicious state when re-rendering (no reflected script)', () => {
const d = deps()
const res = mockRes()
const evil = '"><script>alert(1)</script>'
authorizePost({ ...PARAMS, state: evil, username: 'alice', password: 'WRONG' }, '1.2.3.4', res, d)
assert.equal(res.status, 200)
assert.doesNotMatch(res.body, /<script>alert/i)
})

View File

@@ -0,0 +1,31 @@
// In-memory one-time authorization-code pool. Codes are short-lived (TTL <= 60s),
// consumed exactly once, and bound to the redirect_uri/client_id/code_challenge they
// were issued with. Not persisted: a restart simply invalidates in-flight logins.
import { randomBytes } from 'node:crypto'
export function createCodeStore({ ttlMs = 60000, now = Date.now } = {}) {
const codes = new Map() // code -> { ...payload, exp }
function issue(payload) {
const code = randomBytes(32).toString('base64url')
codes.set(code, { ...payload, exp: now() + ttlMs })
return code
}
function consume(code) {
const entry = codes.get(code)
if (!entry) return undefined
codes.delete(code) // one-time, even if expired
if (now() > entry.exp) return undefined
const { exp, ...payload } = entry
void exp
return payload
}
function sweep() {
const t = now()
for (const [code, entry] of codes) if (t > entry.exp) codes.delete(code)
}
return { issue, consume, sweep, size: () => codes.size }
}

View File

@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCodeStore } from './codes.mjs'
const payload = {
username: 'alice',
redirect_uri: 'http://127.0.0.1:5000/callback',
client_id: 'mihomo-party',
code_challenge: 'abc'
}
test('issue returns a high-entropy base64url code', () => {
const store = createCodeStore()
const code = store.issue(payload)
assert.match(code, /^[A-Za-z0-9_-]{40,}$/)
})
test('consume returns the payload exactly once (one-time)', () => {
const store = createCodeStore()
const code = store.issue(payload)
assert.deepEqual(store.consume(code), payload)
assert.equal(store.consume(code), undefined)
})
test('consume of an unknown code returns undefined', () => {
const store = createCodeStore()
assert.equal(store.consume('nope'), undefined)
})
test('an expired code is not consumable', () => {
let clock = 1000
const store = createCodeStore({ ttlMs: 60000, now: () => clock })
const code = store.issue(payload)
clock += 60001
assert.equal(store.consume(code), undefined)
})
test('a code just inside the TTL is still consumable', () => {
let clock = 1000
const store = createCodeStore({ ttlMs: 60000, now: () => clock })
const code = store.issue(payload)
clock += 59999
assert.deepEqual(store.consume(code), payload)
})
test('two issued codes are distinct', () => {
const store = createCodeStore()
assert.notEqual(store.issue(payload), store.issue(payload))
})

View File

@@ -0,0 +1,25 @@
// Load configuration from an environment object (injectable for tests). Pure: no I/O.
// The origin CA file, if configured, is read separately by server.mjs.
export function loadConfig(env = process.env) {
const num = (v, d) => {
const n = Number(v)
return Number.isFinite(n) ? n : d
}
const domain = env.DOMAIN || 'localhost'
return Object.freeze({
port: num(env.PORT, 8080),
dbPath: env.DB_PATH || '/data/gateway.db',
publicOrigin: env.PUBLIC_ORIGIN || `https://${domain}`,
deviceLimitDefault: num(env.DEVICE_LIMIT_DEFAULT, 3),
codeTtlMs: num(env.CODE_TTL_MS, 60000),
nonceTtlMs: num(env.NONCE_TTL_MS, 60000),
noncePoolMax: num(env.NONCE_POOL_MAX, 8),
clockSkewMs: num(env.CLOCK_SKEW_MS, 300000),
loginMax: num(env.LOGIN_MAX, 10),
loginWindowMs: num(env.LOGIN_WINDOW_MS, 60000),
subTimeoutMs: num(env.SUB_TIMEOUT_MS, 30000),
subMaxBytes: num(env.SUB_MAX_BYTES, 10 * 1024 * 1024),
retired: env.RETIRED === 'true',
originCaFile: env.ORIGIN_CA_FILE || ''
})
}

View File

@@ -0,0 +1,44 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { loadConfig } from './config.mjs'
test('applies sane defaults for an empty environment', () => {
const c = loadConfig({})
assert.equal(c.port, 8080)
assert.equal(c.dbPath, '/data/gateway.db')
assert.equal(c.deviceLimitDefault, 3)
assert.equal(c.codeTtlMs, 60000)
assert.equal(c.nonceTtlMs, 60000)
assert.equal(c.noncePoolMax, 8)
assert.equal(c.clockSkewMs, 300000)
assert.equal(c.subMaxBytes, 10485760)
assert.equal(c.retired, false)
})
test('parses numeric overrides as numbers', () => {
const c = loadConfig({ PORT: '9000', CLOCK_SKEW_MS: '120000', NONCE_POOL_MAX: '4' })
assert.strictEqual(c.port, 9000)
assert.strictEqual(c.clockSkewMs, 120000)
assert.strictEqual(c.noncePoolMax, 4)
})
test('RETIRED is true only for the literal "true"', () => {
assert.equal(loadConfig({ RETIRED: 'true' }).retired, true)
assert.equal(loadConfig({ RETIRED: 'false' }).retired, false)
assert.equal(loadConfig({ RETIRED: '1' }).retired, false)
})
test('derives publicOrigin from DOMAIN when PUBLIC_ORIGIN is unset', () => {
assert.equal(loadConfig({ DOMAIN: 'gw.example.com' }).publicOrigin, 'https://gw.example.com')
assert.equal(
loadConfig({ DOMAIN: 'gw.example.com', PUBLIC_ORIGIN: 'https://other.example' }).publicOrigin,
'https://other.example'
)
})
test('the returned config object is frozen', () => {
const c = loadConfig({})
assert.throws(() => {
c.port = 1
})
})

View File

@@ -0,0 +1,90 @@
// Crypto primitives for the gateway. Native node:crypto only — no dependencies.
// buildSignInput + verifySignature are byte-identical to the client's device.ts so
// signatures produced by the app verify here (see check-vectors.mjs for the proof).
import {
createHash,
createPublicKey,
randomBytes,
scryptSync,
timingSafeEqual,
verify
} from 'node:crypto'
export const OP_CONFIG = 1
export const OP_REVOKE = 2
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const KEYLEN = 32
// "scrypt$N$r$p$saltB64$hashB64" — self-describing so params can change without breaking old hashes.
export function hashPassword(plain) {
const salt = randomBytes(16)
const hash = scryptSync(plain, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P })
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString('base64')}$${hash.toString('base64')}`
}
export function verifyPassword(plain, stored) {
try {
const parts = String(stored).split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
const [, n, r, p, saltB64, hashB64] = parts
const expected = Buffer.from(hashB64, 'base64')
const actual = scryptSync(plain, Buffer.from(saltB64, 'base64'), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p)
})
return actual.length === expected.length && timingSafeEqual(actual, expected)
} catch {
return false
}
}
// PKCE S256: BASE64URL(SHA256(verifier)) === code_challenge (constant-time compare).
export function verifyPkce(verifier, challenge) {
try {
const computed = Buffer.from(createHash('sha256').update(String(verifier)).digest('base64url'))
const given = Buffer.from(String(challenge))
return computed.length === given.length && timingSafeEqual(computed, given)
} catch {
return false
}
}
// Canonical sign-input (client v2 design §7): "CPX2" | u8 op | u8 len+deviceId | u8 len+nonceId | 32B nonce | u64be ts
export function buildSignInput(op, deviceId, nonceId, nonce, ts) {
const did = Buffer.from(deviceId, 'utf-8')
const nid = Buffer.from(nonceId, 'utf-8')
if (did.length > 255 || nid.length > 255) throw new Error('deviceId/nonceId too long')
const tsB = Buffer.alloc(8)
tsB.writeBigUInt64BE(BigInt(ts))
return Buffer.concat([
Buffer.from('CPX2', 'ascii'),
Buffer.from([op & 0xff]),
Buffer.from([did.length]),
did,
Buffer.from([nid.length]),
nid,
nonce,
tsB
])
}
// Ed25519 verify. pubKey is the raw 32-byte point (standard base64), sig is raw 64 bytes (standard base64).
export function verifySignature(pubKeyB64, input, sigB64) {
try {
const raw = Buffer.from(pubKeyB64, 'base64')
if (raw.length !== 32) return false
const sig = Buffer.from(sigB64, 'base64')
if (sig.length !== 64) return false
const key = createPublicKey({
key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') },
format: 'jwk'
})
return verify(null, input, key, sig)
} catch {
return false
}
}

View File

@@ -0,0 +1,72 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import {
OP_CONFIG,
OP_REVOKE,
hashPassword,
verifyPassword,
verifyPkce,
buildSignInput,
verifySignature
} from './crypto.mjs'
// One recorded cross-language vector (mirrors the client's sign-vectors.json fixture).
const VEC = {
op: 1,
deviceId: '11111111-1111-4111-8111-111111111111',
nonceId: 'nonce-1',
pubKeyB64: 'iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=',
nonceB64: 'qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=',
ts: 1700000000000,
inputHex:
'43505832012431313131313131312d313131312d343131312d383131312d313131313131313131313131076e6f6e63652d31aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000018bcfe56800',
sigB64: 'to+fTc12+7n2enMcfUXeZRT4ro7KUQfvWe5GXQ+BzvLY1Baoo+9RFCMGVkkv0JH9pLMjKCb5ViBRzQ9pFe12Cg=='
}
test('op constants match the wire protocol', () => {
assert.equal(OP_CONFIG, 1)
assert.equal(OP_REVOKE, 2)
})
test('hashPassword/verifyPassword round-trips and rejects wrong password', () => {
const stored = hashPassword('s3cret-pw')
assert.match(stored, /^scrypt\$\d+\$\d+\$\d+\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/)
assert.equal(verifyPassword('s3cret-pw', stored), true)
assert.equal(verifyPassword('wrong', stored), false)
})
test('verifyPassword returns false on a malformed stored hash', () => {
assert.equal(verifyPassword('x', 'not-a-hash'), false)
assert.equal(verifyPassword('x', ''), false)
})
test('verifyPkce accepts BASE64URL(SHA256(verifier)) and rejects mismatch', () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
const challenge = createHash('sha256').update(verifier).digest('base64url')
assert.equal(verifyPkce(verifier, challenge), true)
assert.equal(verifyPkce(verifier, challenge + 'x'), false)
assert.equal(verifyPkce('different', challenge), false)
})
test('buildSignInput reproduces the recorded canonical byte string', () => {
const nonce = Buffer.from(VEC.nonceB64, 'base64')
const input = buildSignInput(VEC.op, VEC.deviceId, VEC.nonceId, nonce, VEC.ts)
assert.equal(input.toString('hex'), VEC.inputHex)
})
test('verifySignature accepts the recorded signature and rejects tampering', () => {
const nonce = Buffer.from(VEC.nonceB64, 'base64')
const input = buildSignInput(VEC.op, VEC.deviceId, VEC.nonceId, nonce, VEC.ts)
assert.equal(verifySignature(VEC.pubKeyB64, input, VEC.sigB64), true)
const tampered = buildSignInput(OP_REVOKE, VEC.deviceId, VEC.nonceId, nonce, VEC.ts)
assert.equal(verifySignature(VEC.pubKeyB64, tampered, VEC.sigB64), false)
})
test('verifySignature returns false on a garbage signature instead of throwing', () => {
const nonce = Buffer.from(VEC.nonceB64, 'base64')
const input = buildSignInput(VEC.op, VEC.deviceId, VEC.nonceId, nonce, VEC.ts)
assert.equal(verifySignature(VEC.pubKeyB64, input, 'not-base64-!!!'), false)
assert.equal(verifySignature('bad-pubkey', input, VEC.sigB64), false)
})

108
deploy/gateway/src/db.mjs Normal file
View File

@@ -0,0 +1,108 @@
// SQLite account/device store via the built-in node:sqlite (zero external deps).
// Columns are snake_case on disk; results are mapped to camelCase for the rest of the app.
import { DatabaseSync } from 'node:sqlite'
const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
pwd_hash TEXT NOT NULL,
sub_url TEXT NOT NULL,
device_limit INTEGER NOT NULL DEFAULT 3,
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
pub_key TEXT NOT NULL,
created INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(username);
`
const mapUser = (r) =>
r && {
username: r.username,
pwdHash: r.pwd_hash,
subUrl: r.sub_url,
deviceLimit: r.device_limit,
created: r.created
}
const mapDevice = (r) =>
r && { deviceId: r.device_id, username: r.username, pubKey: r.pub_key, created: r.created }
export function openDb(path) {
const db = new DatabaseSync(path)
db.exec('PRAGMA foreign_keys = ON')
db.exec('PRAGMA journal_mode = WAL')
db.exec(SCHEMA)
return {
addUser({ username, pwdHash, subUrl, deviceLimit = 3 }) {
db.prepare(
'INSERT INTO users (username, pwd_hash, sub_url, device_limit, created) VALUES (?,?,?,?,?)'
).run(username, pwdHash, subUrl, deviceLimit, Date.now())
},
getUser(username) {
return (
mapUser(db.prepare('SELECT * FROM users WHERE username = ?').get(username)) || undefined
)
},
setSub(username, subUrl) {
db.prepare('UPDATE users SET sub_url = ? WHERE username = ?').run(subUrl, username)
},
setLimit(username, n) {
db.prepare('UPDATE users SET device_limit = ? WHERE username = ?').run(n, username)
},
setPwd(username, pwdHash) {
db.prepare('UPDATE users SET pwd_hash = ? WHERE username = ?').run(pwdHash, username)
},
delUser(username) {
db.prepare('DELETE FROM users WHERE username = ?').run(username)
},
listUsers() {
return db
.prepare(
`SELECT u.username, u.sub_url, u.device_limit, u.created,
(SELECT COUNT(*) FROM devices d WHERE d.username = u.username) AS device_count
FROM users u ORDER BY u.created`
)
.all()
.map((r) => ({
username: r.username,
subUrl: r.sub_url,
deviceLimit: r.device_limit,
created: r.created,
deviceCount: r.device_count
}))
},
upsertDevice({ deviceId, username, pubKey }) {
db.prepare(
`INSERT INTO devices (device_id, username, pub_key, created) VALUES (?,?,?,?)
ON CONFLICT(device_id) DO UPDATE SET
pub_key = excluded.pub_key, username = excluded.username, created = excluded.created`
).run(deviceId, username, pubKey, Date.now())
},
getDevice(deviceId) {
return (
mapDevice(db.prepare('SELECT * FROM devices WHERE device_id = ?').get(deviceId)) ||
undefined
)
},
countDevices(username) {
return db.prepare('SELECT COUNT(*) AS n FROM devices WHERE username = ?').get(username).n
},
delDevice(deviceId) {
db.prepare('DELETE FROM devices WHERE device_id = ?').run(deviceId)
},
listDevices(username) {
return db
.prepare('SELECT device_id, created FROM devices WHERE username = ? ORDER BY created')
.all(username)
.map((r) => ({ deviceId: r.device_id, created: r.created }))
},
close() {
db.close()
}
}
}

View File

@@ -0,0 +1,95 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { openDb } from './db.mjs'
function freshDb() {
return openDb(':memory:')
}
const USER = {
username: 'alice',
pwdHash: 'scrypt$x',
subUrl: 'https://o.example/sub?t=1',
deviceLimit: 3
}
test('addUser/getUser round-trips; unknown user is undefined', () => {
const db = freshDb()
db.addUser(USER)
const u = db.getUser('alice')
assert.equal(u.username, 'alice')
assert.equal(u.subUrl, USER.subUrl)
assert.equal(u.deviceLimit, 3)
assert.ok(u.created > 0)
assert.equal(db.getUser('bob'), undefined)
db.close()
})
test('addUser rejects a duplicate username', () => {
const db = freshDb()
db.addUser(USER)
assert.throws(() => db.addUser(USER))
db.close()
})
test('setSub / setLimit / setPwd update the user', () => {
const db = freshDb()
db.addUser(USER)
db.setSub('alice', 'https://o.example/sub?t=2')
db.setLimit('alice', 5)
db.setPwd('alice', 'scrypt$y')
const u = db.getUser('alice')
assert.equal(u.subUrl, 'https://o.example/sub?t=2')
assert.equal(u.deviceLimit, 5)
assert.equal(u.pwdHash, 'scrypt$y')
db.close()
})
test('upsertDevice binds a device; re-upsert updates pubkey without growing the count', () => {
const db = freshDb()
db.addUser(USER)
db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K1' })
assert.equal(db.countDevices('alice'), 1)
assert.equal(db.getDevice('d1').pubKey, 'K1')
db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K2' })
assert.equal(db.countDevices('alice'), 1)
assert.equal(db.getDevice('d1').pubKey, 'K2')
db.close()
})
test('delDevice removes a single device', () => {
const db = freshDb()
db.addUser(USER)
db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K1' })
db.delDevice('d1')
assert.equal(db.getDevice('d1'), undefined)
assert.equal(db.countDevices('alice'), 0)
db.close()
})
test('delUser cascades to its devices', () => {
const db = freshDb()
db.addUser(USER)
db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K1' })
db.upsertDevice({ deviceId: 'd2', username: 'alice', pubKey: 'K2' })
db.delUser('alice')
assert.equal(db.getUser('alice'), undefined)
assert.equal(db.getDevice('d1'), undefined)
assert.equal(db.getDevice('d2'), undefined)
db.close()
})
test('listUsers reports device counts; listDevices lists a user devices', () => {
const db = freshDb()
db.addUser(USER)
db.addUser({ ...USER, username: 'bob' })
db.upsertDevice({ deviceId: 'd1', username: 'alice', pubKey: 'K1' })
const users = db.listUsers().sort((a, b) => a.username.localeCompare(b.username))
assert.equal(users.length, 2)
assert.equal(users[0].username, 'alice')
assert.equal(users[0].deviceCount, 1)
assert.equal(users[1].deviceCount, 0)
const devices = db.listDevices('alice')
assert.equal(devices.length, 1)
assert.equal(devices[0].deviceId, 'd1')
db.close()
})

View File

@@ -0,0 +1,104 @@
// The fixed gateway protocol: enroll / challenge / config / revoke. Mirrors the client
// v2 design §14. Handlers take (body, res, deps); deps = { db, codes, nonces, config,
// fetchSubscription }. Errors are JSON { error } with the status codes the client maps.
import { verifyPkce, verifySignature, buildSignInput, OP_CONFIG, OP_REVOKE } from './crypto.mjs'
import { sendJson, sendText } from './http.mjs'
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
function isB64Bytes(s, n) {
if (typeof s !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(s)) return false
return Buffer.from(s, 'base64').length === n
}
export function enroll(body, res, deps) {
const bound = deps.codes.consume(body?.code) // one-time, even on later failure
if (!bound) return sendJson(res, 400, { error: 'invalid_code' })
if (!verifyPkce(body.code_verifier, bound.code_challenge)) {
return sendJson(res, 400, { error: 'bad_pkce' })
}
if (body.redirect_uri !== bound.redirect_uri || body.client_id !== bound.client_id) {
return sendJson(res, 400, { error: 'binding_mismatch' })
}
if (typeof body.deviceId !== 'string' || !UUID_V4.test(body.deviceId)) {
return sendJson(res, 400, { error: 'bad_request' })
}
if (!isB64Bytes(body.devicePubKey, 32)) {
return sendJson(res, 400, { error: 'bad_request' })
}
const user = deps.db.getUser(bound.username)
if (!user) return sendJson(res, 400, { error: 'invalid_code' })
const existing = deps.db.getDevice(body.deviceId)
const isRebind = existing && existing.username === bound.username
if (!isRebind && deps.db.countDevices(bound.username) >= user.deviceLimit) {
return sendJson(res, 403, { error: 'device_limit' })
}
deps.db.upsertDevice({
deviceId: body.deviceId,
username: bound.username,
pubKey: body.devicePubKey
})
sendJson(res, 200, { ok: true })
}
export function challenge(body, res, deps) {
if (deps.config.retired) return sendJson(res, 410, { error: 'gateway_retired' })
const device = deps.db.getDevice(body?.deviceId)
if (!device) return sendJson(res, 403, { error: 'device_revoked' })
const issued = deps.nonces.issue(device.deviceId)
if (!issued) return sendJson(res, 429, { error: 'too_many_nonces' })
sendJson(res, 200, issued)
}
// Validate a signed request against an already-resolved device. Returns null on success
// (and consumes the nonce), else { status, error }. Caller verifies the device exists first.
function verifySignedRequest(body, op, device, deps) {
if (!deps.nonces.check(body?.deviceId, body?.nonceId, body?.nonce)) {
return { status: 401, error: 'bad_nonce' }
}
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > deps.config.clockSkewMs) {
return { status: 401, error: 'clock_skew' }
}
const input = buildSignInput(
op,
body.deviceId,
body.nonceId,
Buffer.from(body.nonce, 'base64'),
body.ts
)
if (!verifySignature(device.pubKey, input, body?.sig ?? '')) {
return { status: 403, error: 'bad_signature' }
}
deps.nonces.consume(body.nonceId)
return null
}
export async function config(body, res, deps) {
if (deps.config.retired) return sendJson(res, 410, { error: 'gateway_retired' })
const device = deps.db.getDevice(body?.deviceId)
if (!device) return sendJson(res, 403, { error: 'device_revoked' })
const bad = verifySignedRequest(body, OP_CONFIG, device, deps)
if (bad) return sendJson(res, bad.status, { error: bad.error })
const user = deps.db.getUser(device.username)
try {
const yaml = await deps.fetchSubscription(user.subUrl, {
timeoutMs: deps.config.subTimeoutMs,
maxBytes: deps.config.subMaxBytes,
ca: deps.config.originCa
})
sendText(res, 200, yaml, 'text/yaml; charset=utf-8')
} catch {
sendJson(res, 502, { error: 'upstream' })
}
}
export async function revoke(body, res, deps) {
const device = deps.db.getDevice(body?.deviceId)
if (!device) return sendJson(res, 200, { ok: true }) // idempotent: nothing to unbind
const bad = verifySignedRequest(body, OP_REVOKE, device, deps)
if (bad) return sendJson(res, bad.status, { error: bad.error })
deps.db.delDevice(device.deviceId)
sendJson(res, 200, { ok: true })
}

View File

@@ -0,0 +1,391 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { generateKeyPairSync, randomUUID, sign as edSign, createHash } from 'node:crypto'
import { openDb } from './db.mjs'
import { createCodeStore } from './codes.mjs'
import { createNonceStore } from './nonces.mjs'
import { buildSignInput, OP_CONFIG, OP_REVOKE } from './crypto.mjs'
import { enroll, challenge, config, revoke } from './gateway.mjs'
const CLASH = 'proxies:\n - {name: a, type: ss}\n'
const REDIRECT = 'http://127.0.0.1:51000/callback'
const CLIENT = 'mihomo-party'
function mockRes() {
const r = { status: 0, headers: {}, body: '' }
r.writeHead = (s, h) => ((r.status = s), (r.headers = h || {}), r)
r.end = (b) => ((r.body = b ?? ''), undefined)
return r
}
function jsonOf(res) {
return JSON.parse(res.body)
}
function newDevice() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const pubKey = Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64')
return {
deviceId: randomUUID(),
pubKey,
sign: (input) => edSign(null, input, privateKey).toString('base64')
}
}
function setup({ retired = false, fetchSubscription } = {}) {
const db = openDb(':memory:')
db.addUser({ username: 'alice', pwdHash: 'scrypt$x', subUrl: 'https://o/sub', deviceLimit: 2 })
return {
db,
codes: createCodeStore(),
nonces: createNonceStore(),
config: { clockSkewMs: 300000, retired, subTimeoutMs: 5000, subMaxBytes: 1024 },
fetchSubscription: fetchSubscription ?? (async () => CLASH)
}
}
function mintCode(deps, over = {}) {
const verifier = 'verifier-' + 'a'.repeat(40)
const code_challenge = createHash('sha256').update(verifier).digest('base64url')
const code = deps.codes.issue({
username: 'alice',
redirect_uri: REDIRECT,
client_id: CLIENT,
code_challenge,
...over
})
return { code, verifier }
}
// Bind a device the quick way (skip the enroll dance) for challenge/config/revoke tests.
function bind(deps) {
const dev = newDevice()
deps.db.upsertDevice({ deviceId: dev.deviceId, username: 'alice', pubKey: dev.pubKey })
return dev
}
function signedBody(deps, dev, op) {
const res = mockRes()
challenge({ deviceId: dev.deviceId }, res, deps)
const ch = jsonOf(res)
const ts = Date.now()
const input = buildSignInput(op, dev.deviceId, ch.nonceId, Buffer.from(ch.nonce, 'base64'), ts)
return { deviceId: dev.deviceId, nonceId: ch.nonceId, nonce: ch.nonce, ts, sig: dev.sign(input) }
}
// ---------- enroll ----------
test('enroll: valid request binds the device and returns ok', () => {
const deps = setup()
const { code, verifier } = mintCode(deps)
const dev = newDevice()
const res = mockRes()
enroll(
{
code,
code_verifier: verifier,
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
},
res,
deps
)
assert.equal(res.status, 200)
assert.deepEqual(jsonOf(res), { ok: true })
assert.equal(deps.db.getDevice(dev.deviceId).pubKey, dev.pubKey)
})
test('enroll: invalid/expired code → 400 invalid_code', () => {
const deps = setup()
const dev = newDevice()
const res = mockRes()
enroll(
{
code: 'nope',
code_verifier: 'v',
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
},
res,
deps
)
assert.equal(res.status, 400)
assert.equal(jsonOf(res).error, 'invalid_code')
})
test('enroll: PKCE mismatch → 400 bad_pkce', () => {
const deps = setup()
const { code } = mintCode(deps)
const dev = newDevice()
const res = mockRes()
enroll(
{
code,
code_verifier: 'WRONG',
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
},
res,
deps
)
assert.equal(res.status, 400)
assert.equal(jsonOf(res).error, 'bad_pkce')
})
test('enroll: redirect_uri/client_id binding mismatch → 400 binding_mismatch', () => {
const deps = setup()
const { code, verifier } = mintCode(deps)
const dev = newDevice()
const res = mockRes()
enroll(
{
code,
code_verifier: verifier,
redirect_uri: 'http://127.0.0.1:9/callback',
client_id: CLIENT,
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
},
res,
deps
)
assert.equal(res.status, 400)
assert.equal(jsonOf(res).error, 'binding_mismatch')
})
test('enroll: malformed deviceId or pubKey → 400 bad_request', () => {
const deps = setup()
const a = mintCode(deps)
const r1 = mockRes()
enroll(
{
code: a.code,
code_verifier: a.verifier,
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: newDevice().pubKey,
deviceId: 'not-a-uuid'
},
r1,
deps
)
assert.equal(r1.status, 400)
const b = mintCode(deps)
const r2 = mockRes()
enroll(
{
code: b.code,
code_verifier: b.verifier,
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: 'short',
deviceId: randomUUID()
},
r2,
deps
)
assert.equal(r2.status, 400)
})
test('enroll: device limit reached → 403, but re-enrolling the same deviceId is allowed', () => {
const deps = setup() // limit 2
bind(deps)
bind(deps) // now at 2
const over = mintCode(deps)
const dev = newDevice()
const res = mockRes()
enroll(
{
code: over.code,
code_verifier: over.verifier,
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
},
res,
deps
)
assert.equal(res.status, 403)
assert.equal(jsonOf(res).error, 'device_limit')
// existing device re-enroll (rotate key) is fine even at the cap
const existing = deps.db.listDevices('alice')[0].deviceId
const c = mintCode(deps)
const d2 = newDevice()
const res2 = mockRes()
enroll(
{
code: c.code,
code_verifier: c.verifier,
redirect_uri: REDIRECT,
client_id: CLIENT,
devicePubKey: d2.pubKey,
deviceId: existing
},
res2,
deps
)
assert.equal(res2.status, 200)
})
// ---------- challenge ----------
test('challenge: unknown device → 403 device_revoked', () => {
const deps = setup()
const res = mockRes()
challenge({ deviceId: randomUUID() }, res, deps)
assert.equal(res.status, 403)
assert.equal(jsonOf(res).error, 'device_revoked')
})
test('challenge: known device → nonceId + 32-byte nonce', () => {
const deps = setup()
const dev = bind(deps)
const res = mockRes()
challenge({ deviceId: dev.deviceId }, res, deps)
assert.equal(res.status, 200)
const c = jsonOf(res)
assert.equal(Buffer.from(c.nonce, 'base64').length, 32)
assert.ok(c.nonceId.length > 0)
})
test('challenge: retired gateway → 410 gateway_retired', () => {
const deps = setup({ retired: true })
const dev = bind(deps)
const res = mockRes()
challenge({ deviceId: dev.deviceId }, res, deps)
assert.equal(res.status, 410)
assert.equal(jsonOf(res).error, 'gateway_retired')
})
// ---------- config ----------
test('config: signed request returns the subscription yaml and consumes the nonce', async () => {
const deps = setup()
const dev = bind(deps)
const body = signedBody(deps, dev, OP_CONFIG)
const res = mockRes()
await config(body, res, deps)
assert.equal(res.status, 200)
assert.match(res.headers['content-type'], /yaml/)
assert.equal(res.body, CLASH)
// replay the same nonce → rejected
const res2 = mockRes()
await config(body, res2, deps)
assert.equal(res2.status, 401)
assert.equal(jsonOf(res2).error, 'bad_nonce')
})
test('config: bad signature → 403 bad_signature', async () => {
const deps = setup()
const dev = bind(deps)
const body = signedBody(deps, dev, OP_CONFIG)
body.sig = Buffer.alloc(64, 1).toString('base64')
const res = mockRes()
await config(body, res, deps)
assert.equal(res.status, 403)
assert.equal(jsonOf(res).error, 'bad_signature')
})
test('config: clock skew beyond limit → 401 clock_skew', async () => {
const deps = setup()
const dev = bind(deps)
const res0 = mockRes()
challenge({ deviceId: dev.deviceId }, res0, deps)
const ch = jsonOf(res0)
const ts = Date.now() - 400000 // > 300s
const input = buildSignInput(
OP_CONFIG,
dev.deviceId,
ch.nonceId,
Buffer.from(ch.nonce, 'base64'),
ts
)
const res = mockRes()
await config(
{ deviceId: dev.deviceId, nonceId: ch.nonceId, nonce: ch.nonce, ts, sig: dev.sign(input) },
res,
deps
)
assert.equal(res.status, 401)
assert.equal(jsonOf(res).error, 'clock_skew')
})
test('config: unknown device → 403 device_revoked', async () => {
const deps = setup()
const res = mockRes()
await config(
{
deviceId: randomUUID(),
nonceId: 'x',
nonce: Buffer.alloc(32).toString('base64'),
ts: Date.now(),
sig: 'x'
},
res,
deps
)
assert.equal(res.status, 403)
assert.equal(jsonOf(res).error, 'device_revoked')
})
test('config: retired gateway → 410', async () => {
const deps = setup({ retired: true })
const res = mockRes()
await config({ deviceId: randomUUID() }, res, deps)
assert.equal(res.status, 410)
})
test('config: upstream fetch failure → 502 upstream', async () => {
const deps = setup({
fetchSubscription: async () => {
throw new Error('boom')
}
})
const dev = bind(deps)
const body = signedBody(deps, dev, OP_CONFIG)
const res = mockRes()
await config(body, res, deps)
assert.equal(res.status, 502)
assert.equal(jsonOf(res).error, 'upstream')
})
// ---------- revoke ----------
test('revoke: signed op=2 unbinds the device', async () => {
const deps = setup()
const dev = bind(deps)
const body = signedBody(deps, dev, OP_REVOKE)
const res = mockRes()
await revoke(body, res, deps)
assert.equal(res.status, 200)
assert.deepEqual(jsonOf(res), { ok: true })
assert.equal(deps.db.getDevice(dev.deviceId), undefined)
})
test('revoke: already-removed device → 200 ok (idempotent)', async () => {
const deps = setup()
const res = mockRes()
await revoke(
{
deviceId: randomUUID(),
nonceId: 'x',
nonce: Buffer.alloc(32).toString('base64'),
ts: Date.now(),
sig: 'x'
},
res,
deps
)
assert.equal(res.status, 200)
assert.deepEqual(jsonOf(res), { ok: true })
})
test('revoke: bad signature on an existing device → 403 and device stays bound', async () => {
const deps = setup()
const dev = bind(deps)
const body = signedBody(deps, dev, OP_REVOKE)
body.sig = Buffer.alloc(64, 9).toString('base64')
const res = mockRes()
await revoke(body, res, deps)
assert.equal(res.status, 403)
assert.equal(jsonOf(res).error, 'bad_signature')
assert.ok(deps.db.getDevice(dev.deviceId))
})

View File

@@ -0,0 +1,50 @@
// Small HTTP helpers: bounded body read, body parsing, client IP, and response writers.
// TLS is terminated by Caddy in front, so the gateway speaks plain HTTP internally.
export async function readBody(req, maxBytes) {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > maxBytes) throw new Error('request body too large')
chunks.push(chunk)
}
return Buffer.concat(chunks).toString('utf-8')
}
export function parseForm(body) {
return Object.fromEntries(new URLSearchParams(body))
}
// Parse a JSON object body; returns undefined for invalid JSON or non-objects.
export function parseJson(body) {
try {
const v = JSON.parse(body)
return typeof v === 'object' && v !== null && !Array.isArray(v) ? v : undefined
} catch {
return undefined
}
}
export function clientIp(req) {
const xff = req.headers?.['x-forwarded-for']
if (xff) return String(xff).split(',')[0].trim()
return req.socket?.remoteAddress || 'unknown'
}
export function sendJson(res, status, obj) {
const body = JSON.stringify(obj)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }).end(body)
}
export function sendText(res, status, body, contentType = 'text/plain; charset=utf-8') {
res.writeHead(status, { 'content-type': contentType }).end(body)
}
export function sendHtml(res, status, html) {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' }).end(html)
}
export function redirect(res, location) {
res.writeHead(302, { location }).end()
}

View File

@@ -0,0 +1,54 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { Readable } from 'node:stream'
import { readBody, parseForm, parseJson, clientIp, sendJson } from './http.mjs'
test('parseForm decodes url-encoded fields', () => {
const o = parseForm('username=a%40b&password=p+q&state=xyz')
assert.deepEqual(o, { username: 'a@b', password: 'p q', state: 'xyz' })
})
test('parseJson returns the object or undefined on bad input', () => {
assert.deepEqual(parseJson('{"a":1}'), { a: 1 })
assert.equal(parseJson('{nope'), undefined)
assert.equal(parseJson('"scalar"'), undefined) // non-object
})
test('readBody returns the body under the cap', async () => {
const req = Readable.from([Buffer.from('hello '), Buffer.from('world')])
assert.equal(await readBody(req, 1024), 'hello world')
})
test('readBody rejects a body over the cap', async () => {
const req = Readable.from([Buffer.from('x'.repeat(100))])
await assert.rejects(readBody(req, 10), /too large/)
})
test('clientIp prefers the first X-Forwarded-For entry, else the socket', () => {
assert.equal(
clientIp({ headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, socket: {} }),
'9.9.9.9'
)
assert.equal(clientIp({ headers: {}, socket: { remoteAddress: '1.2.3.4' } }), '1.2.3.4')
})
test('sendJson writes status, json content-type, and the serialized body', () => {
const res = mockRes()
sendJson(res, 403, { error: 'x' })
assert.equal(res.status, 403)
assert.match(res.headers['content-type'], /application\/json/)
assert.deepEqual(JSON.parse(res.body), { error: 'x' })
})
function mockRes() {
const r = { status: 0, headers: {}, body: '' }
r.writeHead = (s, h) => {
r.status = s
r.headers = h || {}
return r
}
r.end = (b) => {
r.body = b ?? ''
}
return r
}

View File

@@ -0,0 +1,44 @@
// In-memory per-device nonce pool for challenge-response. Each nonce is 32 random
// bytes with a short TTL (<= 60s), consumed exactly once. A per-device cap (default 8)
// blocks challenge spam. Not persisted — restart drops in-flight challenges.
import { randomBytes, timingSafeEqual } from 'node:crypto'
export function createNonceStore({ ttlMs = 60000, poolMax = 8, now = Date.now } = {}) {
const pending = new Map() // nonceId -> { deviceId, nonce(b64), exp }
function sweep() {
const t = now()
for (const [id, e] of pending) if (t > e.exp) pending.delete(id)
}
function countFor(deviceId) {
const t = now()
let n = 0
for (const e of pending.values()) if (e.deviceId === deviceId && t <= e.exp) n++
return n
}
function issue(deviceId) {
sweep()
if (countFor(deviceId) >= poolMax) return null
const nonceId = randomBytes(16).toString('hex')
const nonce = randomBytes(32).toString('base64')
pending.set(nonceId, { deviceId, nonce, exp: now() + ttlMs })
return { nonceId, nonce, exp: Math.floor(ttlMs / 1000) }
}
// Validate without consuming (caller verifies the signature, then consumes).
function check(deviceId, nonceId, nonceB64) {
const e = pending.get(nonceId)
if (!e || e.deviceId !== deviceId || now() > e.exp) return false
const a = Buffer.from(e.nonce, 'base64')
const b = Buffer.from(String(nonceB64), 'base64')
return a.length === b.length && timingSafeEqual(a, b)
}
function consume(nonceId) {
pending.delete(nonceId)
}
return { issue, check, consume, sweep, pending: (id) => countFor(id) }
}

View File

@@ -0,0 +1,59 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createNonceStore } from './nonces.mjs'
test('issue returns an ascii nonceId and a 32-byte standard-base64 nonce', () => {
const store = createNonceStore()
const c = store.issue('dev-1')
assert.match(c.nonceId, /^[\x21-\x7e]{1,64}$/)
assert.equal(Buffer.from(c.nonce, 'base64').length, 32)
assert.equal(typeof c.exp, 'number')
})
test('check passes for a freshly issued nonce and fails after consume (one-time)', () => {
const store = createNonceStore()
const c = store.issue('dev-1')
assert.equal(store.check('dev-1', c.nonceId, c.nonce), true)
store.consume(c.nonceId)
assert.equal(store.check('dev-1', c.nonceId, c.nonce), false)
})
test('check fails on wrong device, wrong nonce, or unknown nonceId', () => {
const store = createNonceStore()
const c = store.issue('dev-1')
assert.equal(store.check('dev-2', c.nonceId, c.nonce), false)
assert.equal(store.check('dev-1', c.nonceId, Buffer.alloc(32, 9).toString('base64')), false)
assert.equal(store.check('dev-1', 'unknown', c.nonce), false)
})
test('an expired nonce does not pass check', () => {
let clock = 1000
const store = createNonceStore({ ttlMs: 60000, now: () => clock })
const c = store.issue('dev-1')
clock += 60001
assert.equal(store.check('dev-1', c.nonceId, c.nonce), false)
})
test('per-device pool cap: issue beyond poolMax returns null until one is freed', () => {
const store = createNonceStore({ poolMax: 2 })
const a = store.issue('dev-1')
store.issue('dev-1')
assert.equal(store.issue('dev-1'), null) // pool full
assert.notEqual(store.issue('dev-2'), null) // other device unaffected
store.consume(a.nonceId)
assert.notEqual(store.issue('dev-1'), null) // freed a slot
})
test('expired pending nonces do not count toward the pool cap', () => {
let clock = 1000
const store = createNonceStore({ ttlMs: 60000, poolMax: 1, now: () => clock })
store.issue('dev-1')
assert.equal(store.issue('dev-1'), null)
clock += 60001
assert.notEqual(store.issue('dev-1'), null)
})
test('consume of an unknown nonceId is a safe no-op', () => {
const store = createNonceStore()
assert.doesNotThrow(() => store.consume('nope'))
})

View File

@@ -0,0 +1,54 @@
// Fetch a user's hidden subscription from the upstream origin. https-only (refuses
// downgrade-on-redirect), size- and time-bounded. The sub_url is admin-configured and
// trusted, so no private-IP ban here (an operator may keep the origin internal). `ca`
// lets the origin use a private CA.
import https from 'node:https'
export async function fetchSubscription(
subUrl,
{ timeoutMs = 30000, maxBytes = 10 * 1024 * 1024, maxRedirects = 5, ca } = {}
) {
let url = new URL(subUrl)
for (let redirects = 0; ; redirects++) {
if (url.protocol !== 'https:') throw new Error('subscription origin must be https')
const res = await once(url, { timeoutMs, maxBytes, ca })
if (res.status >= 300 && res.status < 400 && res.location) {
if (redirects >= maxRedirects) throw new Error('subscription origin: too many redirects')
url = new URL(res.location, url)
continue
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`subscription origin status ${res.status}`)
}
return res.body
}
}
function once(url, { timeoutMs, maxBytes, ca }) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method: 'GET', timeout: timeoutMs, ca }, (res) => {
const status = res.statusCode ?? 0
if (status >= 300 && status < 400) {
res.resume()
resolve({ status, location: res.headers.location })
return
}
const chunks = []
let size = 0
res.on('data', (c) => {
size += c.length
if (size > maxBytes) {
res.destroy()
reject(new Error('subscription origin response too large'))
return
}
chunks.push(c)
})
res.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf-8') }))
res.on('error', reject)
})
req.on('timeout', () => req.destroy(new Error('subscription fetch timed out')))
req.on('error', reject)
req.end()
})
}

View File

@@ -0,0 +1,97 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import https from 'node:https'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fetchSubscription } from './origin.mjs'
// Generate an ephemeral self-signed localhost cert at test time (no key committed to the repo).
function makeCert() {
const dir = mkdtempSync(join(tmpdir(), 'cpxgw-cert-'))
const keyPath = join(dir, 'k.pem')
const certPath = join(dir, 'c.pem')
execFileSync('openssl', [
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-keyout',
keyPath,
'-out',
certPath,
'-subj',
'/CN=localhost',
'-addext',
'subjectAltName=DNS:localhost,IP:127.0.0.1'
])
return { dir, cert: readFileSync(certPath), key: readFileSync(keyPath) }
}
const CLASH = 'proxies:\n - {name: a, type: ss}\n'
let server
let base
let tls
before(async () => {
tls = makeCert()
const { cert, key } = tls
server = https.createServer({ cert, key }, (req, res) => {
const u = new URL(req.url, 'https://127.0.0.1')
if (u.pathname === '/sub')
return void res.writeHead(200, { 'content-type': 'text/yaml' }).end(CLASH)
if (u.pathname === '/big') return void res.writeHead(200).end('x'.repeat(100 * 1024))
if (u.pathname === '/err') return void res.writeHead(500).end('nope')
if (u.pathname === '/slow') return void setTimeout(() => res.writeHead(200).end(CLASH), 600)
if (u.pathname === '/redir') return void res.writeHead(302, { location: `${base}/sub` }).end()
if (u.pathname === '/downgrade')
return void res.writeHead(302, { location: 'http://127.0.0.1:1/sub' }).end()
res.writeHead(404).end()
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
base = `https://127.0.0.1:${server.address().port}`
})
after(() => {
server.close()
rmSync(tls.dir, { recursive: true, force: true })
})
const opts = () => ({ ca: tls.cert, timeoutMs: 2000, maxBytes: 10 * 1024 })
test('fetches a 200 body over https', async () => {
assert.equal(await fetchSubscription(`${base}/sub`, opts()), CLASH)
})
test('rejects a non-https subscription url', async () => {
await assert.rejects(fetchSubscription('http://127.0.0.1/sub', opts()), /https/)
})
test('throws on a non-2xx upstream', async () => {
await assert.rejects(fetchSubscription(`${base}/err`, opts()), /status 500/)
})
test('throws when the body exceeds maxBytes', async () => {
await assert.rejects(
fetchSubscription(`${base}/big`, { ca: tls.cert, timeoutMs: 2000, maxBytes: 1024 }),
/too large/
)
})
test('throws on timeout', async () => {
await assert.rejects(
fetchSubscription(`${base}/slow`, { ca: tls.cert, timeoutMs: 100, maxBytes: 10 * 1024 }),
/timed out/i
)
})
test('follows an https redirect', async () => {
assert.equal(await fetchSubscription(`${base}/redir`, opts()), CLASH)
})
test('rejects a redirect that downgrades to http', async () => {
await assert.rejects(fetchSubscription(`${base}/downgrade`, opts()), /https/)
})

View File

@@ -0,0 +1,68 @@
// No-echo password prompt. On a TTY it reads two lines with echo off and confirms they
// match; when stdin is piped (scripts/CI) it reads stdin: one line is used for both,
// two lines must match. I/O glue — exercised by the container smoke, not unit tests.
import { stdin, stdout } from 'node:process'
// Control codes by ordinal to avoid embedding raw control bytes in source.
const LF = 10
const CR = 13
const EOT = 4 // Ctrl-D / end of transmission
const ETX = 3 // Ctrl-C
const DEL = 127
const BS = 8
function readLineNoEcho(prompt) {
return new Promise((resolve) => {
stdout.write(prompt)
stdin.setRawMode(true)
stdin.resume()
let buf = ''
const onData = (chunk) => {
for (const c of chunk.toString('utf-8')) {
const code = c.charCodeAt(0)
if (code === LF || code === CR || code === EOT) {
stdin.setRawMode(false)
stdin.removeListener('data', onData)
stdin.pause()
stdout.write('\n')
return resolve(buf)
}
if (code === ETX) {
stdin.setRawMode(false)
stdout.write('\n')
process.exit(130)
}
if (code === DEL || code === BS) buf = buf.slice(0, -1)
else buf += c
}
}
stdin.on('data', onData)
})
}
function readAllStdin() {
return new Promise((resolve) => {
let data = ''
stdin.setEncoding('utf-8')
stdin.on('data', (c) => (data += c))
stdin.on('end', () => resolve(data))
stdin.resume()
})
}
export async function readPassword(prompt = 'Password: ') {
let a, b
if (stdin.isTTY) {
a = await readLineNoEcho(prompt)
b = await readLineNoEcho('Confirm: ')
} else {
const lines = (await readAllStdin()).split(/\r?\n/)
a = lines[0] ?? ''
b = lines.length > 1 && lines[1] !== '' ? lines[1] : a
}
if (a !== b) {
stdout.write('Passwords do not match.\n')
return ''
}
return a
}

View File

@@ -0,0 +1,23 @@
// Fixed-window in-memory rate limiter. hit(key) returns true if allowed, false if over
// the cap for the current window. Good enough for one-instance login throttling.
export function createRateLimiter({ max = 10, windowMs = 60000, now = Date.now } = {}) {
const windows = new Map() // key -> { count, resetAt }
function hit(key) {
const t = now()
let w = windows.get(key)
if (!w || t >= w.resetAt) {
w = { count: 0, resetAt: t + windowMs }
windows.set(key, w)
}
w.count++
return w.count <= max
}
function sweep() {
const t = now()
for (const [k, w] of windows) if (t >= w.resetAt) windows.delete(k)
}
return { hit, sweep }
}

View File

@@ -0,0 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createRateLimiter } from './ratelimit.mjs'
test('allows up to max hits then blocks within the window', () => {
let clock = 0
const rl = createRateLimiter({ max: 3, windowMs: 1000, now: () => clock })
assert.equal(rl.hit('ip'), true)
assert.equal(rl.hit('ip'), true)
assert.equal(rl.hit('ip'), true)
assert.equal(rl.hit('ip'), false) // 4th in window
})
test('resets after the window elapses', () => {
let clock = 0
const rl = createRateLimiter({ max: 1, windowMs: 1000, now: () => clock })
assert.equal(rl.hit('ip'), true)
assert.equal(rl.hit('ip'), false)
clock += 1001
assert.equal(rl.hit('ip'), true)
})
test('tracks keys independently', () => {
const rl = createRateLimiter({ max: 1, windowMs: 1000, now: () => 0 })
assert.equal(rl.hit('a'), true)
assert.equal(rl.hit('b'), true)
assert.equal(rl.hit('a'), false)
})

View File

@@ -0,0 +1,198 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import {
generateKeyPairSync,
randomUUID,
randomBytes,
createHash,
sign as edSign
} from 'node:crypto'
import { openDb } from './db.mjs'
import { createCodeStore } from './codes.mjs'
import { createNonceStore } from './nonces.mjs'
import { createRateLimiter } from './ratelimit.mjs'
import { hashPassword, buildSignInput, OP_CONFIG, OP_REVOKE } from './crypto.mjs'
import { createServer } from './server.mjs'
const CLASH = 'proxies:\n - {name: a, type: ss}\n'
const REDIRECT = 'http://127.0.0.1:51000/callback'
let server
let base
before(async () => {
const db = openDb(':memory:')
db.addUser({
username: 'alice',
pwdHash: hashPassword('pw'),
subUrl: 'https://o/sub',
deviceLimit: 3
})
const deps = {
db,
codes: createCodeStore(),
nonces: createNonceStore(),
rateLimiter: createRateLimiter({ max: 100, windowMs: 1000 }),
fetchSubscription: async () => CLASH,
config: {
publicOrigin: 'https://gw.test',
clockSkewMs: 300000,
retired: false,
subTimeoutMs: 5000,
subMaxBytes: 4096
}
}
server = createServer(deps)
await new Promise((r) => server.listen(0, '127.0.0.1', r))
base = `http://127.0.0.1:${server.address().port}`
})
after(() => server.close())
function request(method, path, { json, form } = {}) {
return new Promise((resolve, reject) => {
let body
const headers = {}
if (json !== undefined) {
body = JSON.stringify(json)
headers['content-type'] = 'application/json'
} else if (form !== undefined) {
body = new URLSearchParams(form).toString()
headers['content-type'] = 'application/x-www-form-urlencoded'
}
const req = http.request(new URL(path, base), { method, headers }, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () =>
resolve({
status: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf-8')
})
)
})
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}
function newDevice() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const pubKey = Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64')
return {
deviceId: randomUUID(),
pubKey,
sign: (i) => edSign(null, i, privateKey).toString('base64')
}
}
async function signedConfigBody(deviceId, sign, op) {
const ch = JSON.parse((await request('POST', '/challenge', { json: { deviceId } })).body)
const ts = Date.now()
const input = buildSignInput(op, deviceId, ch.nonceId, Buffer.from(ch.nonce, 'base64'), ts)
return { deviceId, nonceId: ch.nonceId, nonce: ch.nonce, ts, sig: sign(input) }
}
test('full flow: well-known → authorize → enroll → challenge → config → revoke', async () => {
// 1. discovery
const wk = await request('GET', '/.well-known/cpx-gateway')
assert.equal(wk.status, 200)
const wkBody = JSON.parse(wk.body)
assert.equal(wkBody.gateway, 'https://gw.test')
assert.equal(wkBody.endpoints.config, '/config')
// 2. PKCE login params
const verifier = randomBytes(32).toString('base64url')
const challengeParam = createHash('sha256').update(verifier).digest('base64url')
const params = {
response_type: 'code',
client_id: 'mihomo-party',
redirect_uri: REDIRECT,
code_challenge: challengeParam,
code_challenge_method: 'S256',
state: 'st-1',
scope: 'subscribe'
}
// authorize GET renders a form
const getForm = await request('GET', '/oauth/authorize?' + new URLSearchParams(params))
assert.equal(getForm.status, 200)
assert.match(getForm.body, /<form/i)
// authorize POST logs in → 302 with code
const login = await request('POST', '/oauth/authorize', {
form: { ...params, username: 'alice', password: 'pw' }
})
assert.equal(login.status, 302)
const loc = new URL(login.headers.location)
assert.equal(loc.searchParams.get('state'), 'st-1')
const code = loc.searchParams.get('code')
assert.ok(code)
// 3. enroll
const dev = newDevice()
const enrollRes = await request('POST', '/enroll', {
json: {
code,
code_verifier: verifier,
redirect_uri: REDIRECT,
client_id: 'mihomo-party',
devicePubKey: dev.pubKey,
deviceId: dev.deviceId
}
})
assert.equal(enrollRes.status, 200)
assert.deepEqual(JSON.parse(enrollRes.body), { ok: true })
// 4. config (challenge → sign → fetch)
const cfgBody = await signedConfigBody(dev.deviceId, dev.sign, OP_CONFIG)
const cfg = await request('POST', '/config', { json: cfgBody })
assert.equal(cfg.status, 200)
assert.match(cfg.headers['content-type'], /yaml/)
assert.equal(cfg.body, CLASH)
// 5. revoke, then a challenge must report the device as revoked
const revBody = await signedConfigBody(dev.deviceId, dev.sign, OP_REVOKE)
const rev = await request('POST', '/revoke', { json: revBody })
assert.equal(rev.status, 200)
const after = await request('POST', '/challenge', { json: { deviceId: dev.deviceId } })
assert.equal(after.status, 403)
assert.equal(JSON.parse(after.body).error, 'device_revoked')
})
test('a re-used authorization code cannot enroll twice', async () => {
const verifier = randomBytes(32).toString('base64url')
const challengeParam = createHash('sha256').update(verifier).digest('base64url')
const params = {
response_type: 'code',
client_id: 'mihomo-party',
redirect_uri: REDIRECT,
code_challenge: challengeParam,
code_challenge_method: 'S256',
state: 's',
scope: 'subscribe'
}
const login = await request('POST', '/oauth/authorize', {
form: { ...params, username: 'alice', password: 'pw' }
})
const code = new URL(login.headers.location).searchParams.get('code')
const enrollOnce = (deviceId, pubKey) =>
request('POST', '/enroll', {
json: {
code,
code_verifier: verifier,
redirect_uri: REDIRECT,
client_id: 'mihomo-party',
devicePubKey: pubKey,
deviceId
}
})
const d1 = newDevice()
assert.equal((await enrollOnce(d1.deviceId, d1.pubKey)).status, 200)
const d2 = newDevice()
const second = await enrollOnce(d2.deviceId, d2.pubKey)
assert.equal(second.status, 400)
assert.equal(JSON.parse(second.body).error, 'invalid_code')
})

View File

@@ -0,0 +1,87 @@
// HTTP wiring. Caddy terminates TLS in front and reverse-proxies here over plain HTTP.
import http from 'node:http'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadConfig } from './config.mjs'
import { openDb } from './db.mjs'
import { createCodeStore } from './codes.mjs'
import { createNonceStore } from './nonces.mjs'
import { createRateLimiter } from './ratelimit.mjs'
import { fetchSubscription } from './origin.mjs'
import { readBody, parseForm, parseJson, clientIp, sendJson } from './http.mjs'
import { authorizeGet, authorizePost } from './auth.mjs'
import { enroll, challenge, config as configHandler, revoke } from './gateway.mjs'
const BODY_MAX = 64 * 1024
const ENDPOINTS = {
enroll: '/enroll',
challenge: '/challenge',
config: '/config',
revoke: '/revoke'
}
export function createHandler(deps) {
return async (req, res) => {
try {
const url = new URL(req.url, 'http://gateway')
const path = url.pathname
const method = req.method
if (method === 'GET' && path === '/.well-known/cpx-gateway') {
return sendJson(res, 200, {
spec: 'cpx-plugin/2',
gateway: deps.config.publicOrigin,
endpoints: ENDPOINTS
})
}
if (path === '/oauth/authorize') {
if (method === 'GET') return authorizeGet(Object.fromEntries(url.searchParams), res)
if (method === 'POST') {
const form = parseForm(await readBody(req, BODY_MAX))
return authorizePost(form, clientIp(req), res, deps)
}
}
if (method === 'POST' && Object.values(ENDPOINTS).includes(path)) {
const body = parseJson(await readBody(req, BODY_MAX)) ?? {}
if (path === ENDPOINTS.enroll) return enroll(body, res, deps)
if (path === ENDPOINTS.challenge) return challenge(body, res, deps)
if (path === ENDPOINTS.config) return configHandler(body, res, deps)
if (path === ENDPOINTS.revoke) return revoke(body, res, deps)
}
sendJson(res, 404, { error: 'not_found' })
} catch (e) {
if (e?.message && /too large/.test(e.message))
return sendJson(res, 413, { error: 'too_large' })
sendJson(res, 500, { error: 'server_error' })
}
}
}
export function buildDeps(config) {
const originCa = config.originCaFile ? readFileSync(config.originCaFile) : undefined
return {
db: openDb(config.dbPath),
codes: createCodeStore({ ttlMs: config.codeTtlMs }),
nonces: createNonceStore({ ttlMs: config.nonceTtlMs, poolMax: config.noncePoolMax }),
rateLimiter: createRateLimiter({ max: config.loginMax, windowMs: config.loginWindowMs }),
fetchSubscription,
config: { ...config, originCa }
}
}
export function createServer(deps) {
return http.createServer(createHandler(deps))
}
function main() {
const config = loadConfig()
const deps = buildDeps(config)
createServer(deps).listen(config.port, '0.0.0.0', () => {
console.log(`cpx-gateway listening on :${config.port} (public origin ${config.publicOrigin})`)
})
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main()

View File

@@ -0,0 +1,540 @@
# 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)
The old v1 document (`docs/机场插件服务端对接指南.md`) described the password/encrypted-container model. Do not use it for new integrations.
---
## 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` |
| `loginUrl` | HTTPS URL; no query, fragment, or userinfo; host must not be private, loopback, `localhost`, or `*.localhost` |
| `provider` | Object; only `name`, `icon`, `site` |
| `provider.name` | Required non-empty string |
| `provider.icon` | Optional data URI; only PNG/JPEG/WEBP; total string length <= 65536 |
| `provider.site` | Optional HTTPS URL; same host restrictions as `loginUrl`; path is allowed |
`loginUrl` is the OAuth authorize endpoint, not a generic login page. The client appends OAuth parameters to it.
Generator:
```bash
node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output]
```
---
## 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",
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
```
Field rules:
| Field | Rule |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `spec` | String `"cpx-plugin/2"` |
| `gateway` | HTTPS origin only: scheme, host, optional port; no path, query, fragment, or userinfo; public host |
| `endpoints` | Must contain `enroll`, `challenge`, `config`, `revoke`; each value is a relative path starting with `/`; no absolute URL, `?`, `#`, or backslash |
The discovery request uses HTTPS only, does not follow redirects, and caps the response body at 64 KiB. Non-2xx, invalid JSON, or invalid fields are discovery failures.
### Gateway Rotation
To rotate the gateway, update `/.well-known/cpx-gateway`.
The client rediscovers and retries once when the cached gateway returns:
- HTTP `410`;
- JSON `{"error":"gateway_retired"}`;
- network-level failure, such as DNS failure, connection failure, or TLS handshake failure.
Plain 5xx, 429, and timeouts are treated as transient failures. They do not trigger rediscovery.
Rediscovery requires the login host to be reachable.
---
## 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"}` | Rediscover gateway and retry once |
| `revoked` | JSON `{"error":"revoked"}` or `{"error":"device_revoked"}` | Mark as needs re-authentication |
| `transient` | Other non-2xx, timeout, network error | Back off and retry; login state unchanged |
| success | 2xx without an error marker | Continue |
For expired accounts, disabled users, or revoked devices, include `revoked` or `device_revoked` in the JSON body. A bare `401` or `403` is treated as transient.
---
## 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.
---
## 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`.
Do not return the subscription URL, origin API host, or origin token.
---
## 10. `POST {gateway}/revoke`
Purpose: unbind a device. The client calls this best-effort when the user deletes the plugin.
Request body is the same as `/config`.
Use the same verification flow as `/config`, but build the sign input with `op=2`. After successful verification, remove the `deviceId` binding and consume the nonce.
`/revoke` must be idempotent. Return 2xx even if the device is already absent.
---
## 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-._~]` |
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']);
```
---
## 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
```
---
## 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.
- [ ] 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.

View File

@@ -0,0 +1,589 @@
# 机场服务端对接指南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)
旧版 `docs/机场插件服务端对接指南.md` 是 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` |
| `loginUrl` | HTTPS URL不能包含 query、fragment、userinfohost 不能是私网、环回、`localhost``*.localhost` |
| `provider` | 对象,只能包含 `name``icon``site` |
| `provider.name` | 必填,非空字符串 |
| `provider.icon` | 可选;仅支持 `data:image/png;base64,``data:image/jpeg;base64,``data:image/webp;base64,`;总长度不超过 65536 |
| `provider.site` | 可选HTTPS URLhost 约束同 `loginUrl`,可包含路径 |
`loginUrl` 必须是 authorize 端点,不是普通登录首页。客户端会在该 URL 后拼接 OAuth 参数。
生成脚本:
```bash
node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [output]
```
---
## 4. OAuth authorize
登录流程使用 OAuth 2.0 Authorization Code + PKCE(S256)。客户端打开系统浏览器访问 `loginUrl`,密码只提交到服务商页面,客户端不接触密码。
客户端请求参数:
| 参数 | 值 |
| ----------------------- | ---------------------------------------------- |
| `response_type` | `code` |
| `client_id` | `mihomo-party` |
| `redirect_uri` | `http://127.0.0.1:<random-port>/callback` |
| `code_challenge` | `BASE64URL(SHA256(code_verifier))`,无 padding |
| `code_challenge_method` | `S256` |
| `state` | 随机串,服务端必须原样带回 |
| `scope` | `subscribe` |
authorize 端点要求:
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",
"endpoints": {
"enroll": "/enroll",
"challenge": "/challenge",
"config": "/config",
"revoke": "/revoke"
}
}
```
字段要求:
| 字段 | 要求 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `spec` | 字符串 `"cpx-plugin/2"` |
| `gateway` | HTTPS origin仅包含 scheme、host、可选 port不能有 path、query、fragment、userinfohost 必须公网可达 |
| `endpoints` | 必须包含 `enroll``challenge``config``revoke`;每个值是以 `/` 开头的相对路径,不能是绝对 URL不能包含 `?``#`、反斜杠 |
客户端请求 `/.well-known/cpx-gateway` 时只走 HTTPS不跟随重定向响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。
### 网关轮换
替换网关时,更新 `/.well-known/cpx-gateway` 中的 `gateway` 即可。客户端在下列情况会回到登录域名重新发现,并重试一次:
- 当前网关返回 HTTP `410`
- 响应 JSON 为 `{"error":"gateway_retired"}`
- 当前网关发生网络层不可达,例如 DNS 失败、连接失败、TLS 握手失败。
普通 5xx、429、超时不会触发重新发现客户端按瞬时故障退避重试。
重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。
---
## 6. 网关公共约定
四个端点均为 `POST`,请求体为 JSON。客户端使用加固 HTTPS 客户端访问:只走 HTTPS不跟随重定向响应体上限 10 MiB。
不使用 Authorization header不下发 bearer token。设备身份由 `deviceId`、设备公钥和 Ed25519 签名确认。
错误信号按以下优先级处理:
| 客户端判定 | 条件 | 客户端行为 |
| ----------- | ---------------------------------------------------------- | ------------------------ |
| `retired` | HTTP `410`,或 JSON `{"error":"gateway_retired"}` | 重新发现网关并重试一次 |
| `revoked` | JSON `{"error":"revoked"}``{"error":"device_revoked"}` | 标记为需要重新登录 |
| `transient` | 其它非 2xx、超时、网络错误 | 退避重试,不改变登录状态 |
| 成功 | 2xx 且没有错误标记 | 正常处理 |
账号到期、设备被踢、用户被禁用时,返回体必须包含 `revoked``device_revoked`。单独返回空的 `401`/`403` 会被客户端当作瞬时失败处理。
---
## 7. `POST {gateway}/enroll`
作用:用 authorize 阶段签发的 code 注册设备,相当于 OAuth token exchange但不返回 bearer token。
请求体:
```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 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。
- 用户重新登录会生成新设备密钥,也会产生新的设备绑定。
---
## 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 200body 为 Clash YAML 文本。
成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 `proxies``proxy-providers` 之一。
订阅 URL、origin API、内部鉴权 token 不应下发给客户端。
---
## 10. `POST {gateway}/revoke`
作用:解绑设备。客户端删除插件时会尽力调用该端点。
请求体同 `/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` | UUIDv436 字符,小写带连字符;服务端上限不超过 64 |
| `nonceId` | 服务端生成的不透明可见 ASCII长度不超过 64 |
| `ts` | 整数Unix 毫秒 |
| `op` | `uint8`config=1revoke=2 |
| `code` | authorize 签发的不透明字符串,长度不超过 2048 |
| `code_challenge` / `code_verifier` | RFC 7636 base64url无 paddingverifier 长度 43-128字符集 `[A-Za-z0-9-._~]` |
除 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']);
```
---
## 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
```
仓库中另有 [`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。
- [ ] 四个 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`、端点名、错误码、请求耗时、网关版本等运维字段。

View File

@@ -7,6 +7,8 @@
"author": "mihomo-party-org",
"homepage": "https://clashparty.org",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --list-different .",
"hooks:install": "node scripts/install-git-hooks.mjs",
@@ -49,6 +51,8 @@
"file-icon": "^6.0.0",
"file-icon-info": "^1.1.1",
"flag-icons": "^7.5.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"i18next": "^26.3.1",
"iconv-lite": "^0.7.2",
"js-yaml": "^4.2.0",

6
pnpm-lock.yaml generated
View File

@@ -50,6 +50,12 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
http-proxy-agent:
specifier: ^7.0.2
version: 7.0.2
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
i18next:
specifier: ^26.3.1
version: 26.3.1(typescript@5.9.3)

View File

@@ -0,0 +1,106 @@
// Minimal demo gateway — PROTOCOL REFERENCE ONLY, not runnable against the real client.
// It serves plain HTTP on loopback and its /.well-known advertises an https://127.0.0.1 origin,
// both of which the hardened client deliberately REFUSES (https-only + no private/loopback host).
// Use it to read the exact request/response shapes and the Ed25519 verify logic; for an end-to-end
// demo against the client you must front it with real HTTPS on a public host.
// NOT production — no real OAuth, in-memory device store.
// Run (shape inspection / curl only): node scripts/plugin/example-gateway.mjs (127.0.0.1:8788)
import http from 'http'
import { randomBytes, createPublicKey, verify } from 'crypto'
const devices = new Map() // deviceId -> pubKey(raw base64)
const nonces = new Map() // nonceId -> { deviceId, nonce(b64), exp }
const CLASH =
'proxies:\n - {name: demo, type: ss, server: 1.1.1.1, port: 8388, cipher: aes-128-gcm, password: x}\n'
function pubKeyFromRaw(b64) {
const x = Buffer.from(b64, 'base64').toString('base64url')
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' })
}
function buildInput(op, deviceId, nonceId, nonce, ts) {
const did = Buffer.from(deviceId, 'utf-8')
const nid = Buffer.from(nonceId, 'utf-8')
const tsB = Buffer.alloc(8)
tsB.writeBigUInt64BE(BigInt(ts))
return Buffer.concat([
Buffer.from('CPX2', 'ascii'),
Buffer.from([op]),
Buffer.from([did.length]),
did,
Buffer.from([nid.length]),
nid,
nonce,
tsB
])
}
function readBody(req) {
return new Promise((resolve) => {
const c = []
req.on('data', (d) => c.push(d))
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(c).toString('utf-8')))
} catch {
resolve({})
}
})
})
}
function verifySigned(op, b) {
const rec = nonces.get(b.nonceId)
if (!rec || rec.deviceId !== b.deviceId || rec.nonce !== b.nonce || Date.now() > rec.exp)
return false
const pub = devices.get(b.deviceId)
if (!pub) return false
const ok = verify(
null,
buildInput(op, b.deviceId, b.nonceId, Buffer.from(b.nonce, 'base64'), b.ts),
pubKeyFromRaw(pub),
Buffer.from(b.sig, 'base64')
)
if (ok) nonces.delete(b.nonceId) // consume
return ok
}
const server = http.createServer(async (req, res) => {
const json = (code, obj) => {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(obj))
}
if (req.url === '/.well-known/cpx-gateway') {
return json(200, {
spec: 'cpx-plugin/2',
gateway: 'https://127.0.0.1:8788',
endpoints: {
enroll: '/enroll',
challenge: '/challenge',
config: '/config',
revoke: '/revoke'
}
})
}
const b = await readBody(req)
if (req.url === '/enroll') {
devices.set(b.deviceId, b.devicePubKey) // demo: trust the code, skip real PKCE check
return json(200, { ok: true })
}
if (req.url === '/challenge') {
const nonceId = randomBytes(8).toString('hex')
const nonce = randomBytes(32).toString('base64')
nonces.set(nonceId, { deviceId: b.deviceId, nonce, exp: Date.now() + 60000 })
return json(200, { nonceId, nonce, exp: 60 })
}
if (req.url === '/config') {
if (!verifySigned(1, b)) return json(403, { error: 'bad signature' })
res.writeHead(200, { 'content-type': 'text/yaml' })
return res.end(CLASH)
}
if (req.url === '/revoke') {
if (!verifySigned(2, b)) return json(403, { error: 'bad signature' })
devices.delete(b.deviceId)
return json(200, { ok: true })
}
json(404, { error: 'not found' })
})
server.listen(8788, '127.0.0.1', () => console.log('demo gateway on http://127.0.0.1:8788'))

View File

@@ -0,0 +1,24 @@
// Usage: node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [out.cpx]
import { writeFileSync } from 'fs'
const [loginUrl, name, site, out = 'plugin.cpx'] = process.argv.slice(2)
if (!loginUrl || !name) {
console.error(
'Usage: node gen-cpx.mjs <loginUrl https authorize> <providerName> [site] [out.cpx]'
)
process.exit(1)
}
const u = new URL(loginUrl)
if (u.protocol !== 'https:' || u.search || u.hash) {
console.error('loginUrl must be https with no query/fragment')
process.exit(1)
}
const descriptor = {
magic: 'CPXF',
v: 2,
spec: 'cpx-plugin/2',
loginUrl,
provider: { name, ...(site ? { site } : {}) }
}
writeFileSync(out, JSON.stringify(descriptor, null, 2) + '\n')
console.log('wrote', out)

View File

@@ -0,0 +1,85 @@
import { writeFileSync, mkdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { createPrivateKey, createPublicKey, sign } from 'crypto'
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
function keyFromSeed(seed) {
return createPrivateKey({
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
format: 'der',
type: 'pkcs8'
})
}
function pubRaw(priv) {
const jwk = createPublicKey(priv).export({ format: 'jwk' })
return Buffer.from(jwk.x, 'base64url')
}
function buildInput(op, deviceId, nonceId, nonce, ts) {
const did = Buffer.from(deviceId, 'utf-8')
const nid = Buffer.from(nonceId, 'utf-8')
const tsB = Buffer.alloc(8)
tsB.writeBigUInt64BE(BigInt(ts))
return Buffer.concat([
Buffer.from('CPX2', 'ascii'),
Buffer.from([op]),
Buffer.from([did.length]),
did,
Buffer.from([nid.length]),
nid,
nonce,
tsB
])
}
const cases = [
{
op: 1,
deviceId: '11111111-1111-4111-8111-111111111111',
nonceId: 'nonce-1',
seedByte: 1,
nonceByte: 0xaa,
ts: 1700000000000
},
{
op: 2,
deviceId: '22222222-2222-4222-8222-222222222222',
nonceId: 'nonce-2',
seedByte: 2,
nonceByte: 0xbb,
ts: 1700000001234
}
]
const out = cases.map((c) => {
const seed = Buffer.alloc(32, c.seedByte)
const nonce = Buffer.alloc(32, c.nonceByte)
const priv = keyFromSeed(seed)
const input = buildInput(c.op, c.deviceId, c.nonceId, nonce, c.ts)
return {
op: c.op,
deviceId: c.deviceId,
nonceId: c.nonceId,
privSeedB64: seed.toString('base64'),
pubKeyB64: pubRaw(priv).toString('base64'),
nonceB64: nonce.toString('base64'),
ts: c.ts,
inputHex: input.toString('hex'),
sigB64: sign(null, input, priv).toString('base64')
}
})
const dir = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'src',
'main',
'resolve',
'plugin',
'__fixtures__'
)
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'sign-vectors.json'), JSON.stringify(out, null, 2) + '\n')
console.log('wrote', out.length, 'sign vectors')

View File

@@ -0,0 +1,66 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
let TMP = ''
vi.mock('../utils/dirs', () => ({ pluginConfigPath: () => join(TMP, 'plugin.yaml') }))
import {
getPluginConfig,
addPluginItem,
getPluginItem,
updatePluginItem,
removePluginItem
} from './plugin'
function item(id: string): IPluginItem {
return {
id,
name: 'X',
loginUrl: 'https://panel.x.com/oauth/authorize',
spec: 'cpx-plugin/2',
profileId: `prof-${id}`,
status: 'active',
created: 1,
updated: 1
}
}
beforeEach(() => {
TMP = mkdtempSync(join(tmpdir(), 'cpxcfg-'))
})
afterEach(() => rmSync(TMP, { recursive: true, force: true }))
describe('plugin config CRUD', () => {
it('starts empty', async () => {
expect((await getPluginConfig(true)).items).toEqual([])
})
it('adds and reads back an item', async () => {
await addPluginItem(item('a'))
expect((await getPluginItem('a'))?.name).toBe('X')
expect((await getPluginConfig(true)).items).toHaveLength(1)
})
it('updates an item', async () => {
await addPluginItem(item('a'))
await updatePluginItem({ ...item('a'), status: 'needs-reauth' })
expect((await getPluginItem('a'))?.status).toBe('needs-reauth')
})
it('removes an item', async () => {
await addPluginItem(item('a'))
await removePluginItem('a')
expect(await getPluginItem('a')).toBeUndefined()
})
it('does not poison the write queue when update throws', async () => {
await expect(updatePluginItem(item('missing'))).rejects.toThrow()
await addPluginItem(item('after'))
expect((await getPluginItem('after'))?.id).toBe('after')
})
it('addPluginItem upserts an existing id', async () => {
await addPluginItem(item('dup'))
await addPluginItem({ ...item('dup'), name: 'renamed' })
const cfg = await getPluginConfig(true)
expect(cfg.items.filter((i) => i.id === 'dup')).toHaveLength(1)
expect((await getPluginItem('dup'))?.name).toBe('renamed')
})
})

64
src/main/config/plugin.ts Normal file
View File

@@ -0,0 +1,64 @@
import { readFile, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { pluginConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
let pluginConfig: IPluginConfig | undefined
let writeQueue: Promise<void> = Promise.resolve()
export async function getPluginConfig(force = false): Promise<IPluginConfig> {
if (force || !pluginConfig) {
if (existsSync(pluginConfigPath())) {
const data = await readFile(pluginConfigPath(), 'utf-8')
pluginConfig = parse<IPluginConfig>(data)
} else {
pluginConfig = { items: [] }
}
if (typeof pluginConfig !== 'object' || pluginConfig === null) pluginConfig = { items: [] }
if (!Array.isArray(pluginConfig.items)) pluginConfig.items = []
}
return JSON.parse(JSON.stringify(pluginConfig)) as IPluginConfig
}
async function update(updater: (c: IPluginConfig) => IPluginConfig): Promise<void> {
const run = writeQueue.then(async () => {
const current = await getPluginConfig(true)
const next = updater(current)
pluginConfig = next
await writeFile(pluginConfigPath(), stringify(next), 'utf-8')
})
// Keep the queue chain settled so a rejected op doesn't poison later writes,
// but still surface this op's error/result to the caller via `run`.
writeQueue = run.catch(() => {})
await run
}
export async function getPluginItem(id: string): Promise<IPluginItem | undefined> {
const { items } = await getPluginConfig()
return items.find((i) => i.id === id)
}
export async function addPluginItem(newItem: IPluginItem): Promise<void> {
await update((c) => {
const idx = c.items.findIndex((i) => i.id === newItem.id)
if (idx === -1) c.items.push(newItem)
else c.items[idx] = newItem
return c
})
}
export async function updatePluginItem(newItem: IPluginItem): Promise<void> {
await update((c) => {
const idx = c.items.findIndex((i) => i.id === newItem.id)
if (idx === -1) throw new Error('Plugin not found')
c.items[idx] = newItem
return c
})
}
export async function removePluginItem(id: string): Promise<void> {
await update((c) => {
c.items = c.items.filter((i) => i.id !== id)
return c
})
}

View File

@@ -224,7 +224,9 @@ export async function removeProfileItem(id: string): Promise<void> {
await removeProfileUpdater(id)
let shouldRestart = false
let removedItem: IProfileItem | undefined
await updateProfileConfig((config) => {
removedItem = config.items?.find((item) => item.id === id)
config.items = config.items?.filter((item) => item.id !== id)
if (config.current === id) {
shouldRestart = true
@@ -240,6 +242,18 @@ export async function removeProfileItem(id: string): Promise<void> {
await restartCore()
}
await removeProfileWorkDir(id)
if (removedItem?.type === 'plugin' && removedItem.pluginId) {
const { removePluginItem } = await import('./plugin')
const { removeVault } = await import('../resolve/plugin/vault')
const { revokePluginDevice } = await import('../resolve/plugin')
const { mainWindow } = await import('../window')
// best-effort 通知服务端解绑设备(需 vault故在 removeVault 之前);失败不阻塞删除
await revokePluginDevice(removedItem.pluginId)
await removePluginItem(removedItem.pluginId)
await removeVault(removedItem.pluginId)
mainWindow?.webContents.send('pluginConfigUpdated')
}
}
export async function getCurrentProfileItem(): Promise<IProfileItem> {
@@ -682,3 +696,46 @@ export async function convertMrsRuleset(filePath: string, behavior: string): Pro
throw error
}
}
// 插件 profile内容已由 plugin 网关取得,这里只写内容 + 维护 profile item不走远程 URL 下载
export async function upsertPluginProfile(
meta: {
profileId: string
pluginId: string
name: string
interval?: number
autoUpdate?: boolean
},
content: string
): Promise<void> {
await setProfileStr(meta.profileId, content)
let isNew = false
await updateProfileConfig((config) => {
const idx = config.items.findIndex((i) => i.id === meta.profileId)
const item: IProfileItem = {
id: meta.profileId,
type: 'plugin',
name: meta.name,
pluginId: meta.pluginId,
interval: meta.interval ?? 0,
autoUpdate: meta.autoUpdate ?? false,
updated: Date.now()
}
if (idx === -1) {
isNew = true
config.items.push(item)
} else {
config.items[idx] = { ...config.items[idx], ...item }
}
if (!config.current) config.current = meta.profileId
return config
})
if (isNew) {
const created = await getProfileItem(meta.profileId)
if (created) await addProfileUpdater(created)
}
}
export async function removePluginProfileContent(profileId: string): Promise<void> {
await removeProfileItem(profileId)
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
auditPluginVault: vi.fn(),
updatePluginProfile: vi.fn()
}))
vi.mock('../config', () => ({
getProfileConfig: vi.fn(async () => ({
current: 'default',
items: [
{
id: 'profile-plugin',
type: 'plugin',
name: 'Demo',
pluginId: 'plugin-id',
autoUpdate: false,
interval: 0
}
]
})),
getCurrentProfileItem: vi.fn(async () => ({
id: 'default',
type: 'local',
name: 'Empty'
})),
getProfileItem: vi.fn(),
addProfileItem: vi.fn()
}))
vi.mock('../resolve/plugin', () => ({
auditPluginVault: mocks.auditPluginVault,
updatePluginProfile: mocks.updatePluginProfile
}))
vi.mock('../utils/logger', () => ({
logger: { warn: vi.fn() }
}))
describe('initProfileUpdater', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('audits plugin vault availability on startup even when auto update is disabled', async () => {
const { initProfileUpdater } = await import('./profileUpdater')
await initProfileUpdater()
expect(mocks.auditPluginVault).toHaveBeenCalledWith('plugin-id')
expect(mocks.updatePluginProfile).not.toHaveBeenCalled()
})
})

View File

@@ -29,12 +29,25 @@ async function updateProfile(id: string): Promise<void> {
const item = await getProfileItem(id)
if (item && item.type === 'remote') {
await addProfileItem(item)
} else if (item && item.type === 'plugin' && item.pluginId) {
const { updatePluginProfile } = await import('../resolve/plugin')
await updatePluginProfile(item.pluginId)
}
} finally {
updatingProfileIds.delete(id)
}
}
async function auditPluginProfileVault(item: IProfileItem): Promise<void> {
if (item.type !== 'plugin' || !item.pluginId) return
try {
const { auditPluginVault } = await import('../resolve/plugin')
await auditPluginVault(item.pluginId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to audit plugin vault ${item.pluginId}:`, e)
}
}
function updateTask(itemId: string, logLabel: string): () => Promise<void> {
return async () => {
try {
@@ -46,7 +59,8 @@ function updateTask(itemId: string, logLabel: string): () => Promise<void> {
}
function scheduleProfileUpdate(item: IProfileItem): void {
if (item.type !== 'remote' || !item.autoUpdate || !item.interval) return
if ((item.type !== 'remote' && item.type !== 'plugin') || !item.autoUpdate || !item.interval)
return
const itemId = item.id
const logLabel = `profile ${itemId}`
@@ -92,6 +106,8 @@ export async function initProfileUpdater(): Promise<void> {
const currentItem = await getCurrentProfileItem()
for (const item of items.filter((i) => i.id !== current)) {
await auditPluginProfileVault(item)
if (item.type === 'remote' && item.autoUpdate && item.interval) {
await addProfileUpdater(item)
@@ -101,8 +117,14 @@ export async function initProfileUpdater(): Promise<void> {
await logger.warn(`[ProfileUpdater] Failed to init profile ${item.name}:`, e)
}
}
if (item.type === 'plugin' && item.autoUpdate && item.interval) {
await addProfileUpdater(item)
}
}
await auditPluginProfileVault(currentItem)
if (currentItem?.type === 'remote' && currentItem.autoUpdate && currentItem.interval) {
const currentId = currentItem.id
await addProfileUpdater(currentItem)
@@ -116,6 +138,15 @@ export async function initProfileUpdater(): Promise<void> {
const latestCurrentItem = (await getProfileItem(currentId)) ?? currentItem
scheduleDelayedCurrentUpdate(latestCurrentItem)
}
if (
currentItem?.type === 'plugin' &&
currentItem.autoUpdate &&
currentItem.interval &&
currentItem.id !== 'default'
) {
await addProfileUpdater(currentItem)
}
}
export async function addProfileUpdater(item: IProfileItem): Promise<void> {

View File

@@ -0,0 +1,24 @@
[
{
"op": 1,
"deviceId": "11111111-1111-4111-8111-111111111111",
"nonceId": "nonce-1",
"privSeedB64": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
"pubKeyB64": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=",
"nonceB64": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=",
"ts": 1700000000000,
"inputHex": "43505832012431313131313131312d313131312d343131312d383131312d313131313131313131313131076e6f6e63652d31aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000018bcfe56800",
"sigB64": "to+fTc12+7n2enMcfUXeZRT4ro7KUQfvWe5GXQ+BzvLY1Baoo+9RFCMGVkkv0JH9pLMjKCb5ViBRzQ9pFe12Cg=="
},
{
"op": 2,
"deviceId": "22222222-2222-4222-8222-222222222222",
"nonceId": "nonce-2",
"privSeedB64": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=",
"pubKeyB64": "gTl3Dqh9F19Wo1Rmw0x+zMuNipG07jeiXfYPW4/Js5Q=",
"nonceB64": "u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7s=",
"ts": 1700000001234,
"inputHex": "43505832022432323232323232322d323232322d343232322d383232322d323232323232323232323232076e6f6e63652d32bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0000018bcfe56cd2",
"sigB64": "ViShvtnURXfzVrnmUFEV5S1B+HQLo880ZXtDKjaTO9e+EhEZswZ5iyDecgsMOfsv3TiYIzF3DH54vv9eAelQBQ=="
}
]

View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest'
import { computeBackoff } from './backoff'
const FIVE_MIN = 5 * 60 * 1000
const DAY = 24 * 60 * 60 * 1000
describe('computeBackoff', () => {
it('uses 5m * 2^failureCount with no jitter when rand=0', () => {
expect(computeBackoff(0, 1000, () => 0).nextRetryAt).toBe(1000 + FIVE_MIN)
expect(computeBackoff(2, 0, () => 0).nextRetryAt).toBe(FIVE_MIN * 4)
})
it('caps the base at 24h', () => {
expect(computeBackoff(20, 0, () => 0).nextRetryAt).toBe(DAY)
})
it('adds up to 30% jitter when rand=1', () => {
expect(computeBackoff(0, 0, () => 1).nextRetryAt).toBe(FIVE_MIN + FIVE_MIN * 0.3)
})
})

View File

@@ -0,0 +1,17 @@
const FIVE_MIN = 5 * 60 * 1000
const ONE_DAY = 24 * 60 * 60 * 1000
export interface BackoffResult {
failureCount: number
nextRetryAt: number
}
export function computeBackoff(
failureCount: number,
now: number,
rand: () => number = Math.random
): BackoffResult {
const base = Math.min(FIVE_MIN * 2 ** failureCount, ONE_DAY)
const jitter = base * 0.3 * rand()
return { failureCount, nextRetryAt: now + base + jitter }
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { parseDescriptor } from './descriptor'
const PNG = 'data:image/png;base64,iVBOR'
function file(over: Record<string, unknown> = {}): string {
return JSON.stringify({
magic: 'CPXF',
v: 2,
spec: 'cpx-plugin/2',
loginUrl: 'https://panel.xx.com/oauth/authorize',
provider: { name: 'XX', icon: PNG, site: 'https://xx.com' },
...over
})
}
describe('parseDescriptor', () => {
it('accepts a valid descriptor', () => {
const d = parseDescriptor(file())
expect(d.loginUrl).toBe('https://panel.xx.com/oauth/authorize')
expect(d.provider.name).toBe('XX')
})
it('rejects non-https loginUrl', () => {
expect(() => parseDescriptor(file({ loginUrl: 'http://panel.xx.com/a' }))).toThrow()
})
it('rejects loginUrl with query', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://panel.xx.com/a?x=1' }))).toThrow()
})
it('rejects loginUrl with fragment', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://panel.xx.com/a#f' }))).toThrow()
})
it('rejects loginUrl with a loopback IP literal', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://127.0.0.1/oauth' }))).toThrow()
})
it('rejects loginUrl with a private IP literal', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://10.0.0.5/oauth' }))).toThrow()
})
it('rejects loginUrl with an IPv6 loopback literal', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://[::1]/oauth' }))).toThrow()
})
it('rejects loginUrl pointing at localhost', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://localhost/oauth' }))).toThrow()
})
it('rejects loginUrl pointing at a *.localhost name', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://foo.localhost/oauth' }))).toThrow()
})
it('rejects loginUrl pointing at localhost. (trailing dot)', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://localhost./oauth' }))).toThrow()
})
it('rejects loginUrl with userinfo', () => {
expect(() => parseDescriptor(file({ loginUrl: 'https://u:p@panel.xx.com/oauth' }))).toThrow()
})
it('rejects a private IP literal in provider.site', () => {
expect(() =>
parseDescriptor(file({ provider: { name: 'X', site: 'https://192.168.1.1' } }))
).toThrow()
})
it('rejects unknown top-level fields', () => {
expect(() => parseDescriptor(file({ extra: 1 }))).toThrow()
})
it('rejects svg icon', () => {
expect(() =>
parseDescriptor(file({ provider: { name: 'X', icon: 'data:image/svg+xml,<svg/>' } }))
).toThrow()
})
it('rejects external icon url', () => {
expect(() =>
parseDescriptor(file({ provider: { name: 'X', icon: 'https://x.com/a.png' } }))
).toThrow()
})
it('rejects oversized icon', () => {
const big = 'data:image/png;base64,' + 'A'.repeat(64 * 1024 + 1)
expect(() => parseDescriptor(file({ provider: { name: 'X', icon: big } }))).toThrow()
})
it('rejects non-https site', () => {
expect(() =>
parseDescriptor(file({ provider: { name: 'X', site: 'http://xx.com' } }))
).toThrow()
})
it('rejects wrong spec', () => {
expect(() => parseDescriptor(file({ spec: 'cpx-plugin/1' }))).toThrow()
})
it('gives a specific error for a v1 file', () => {
expect(() => parseDescriptor(JSON.stringify({ magic: 'CPXF', v: 1 }))).toThrow(/v1/)
})
it('rejects invalid JSON', () => {
expect(() => parseDescriptor('{not json')).toThrow()
})
it('rejects non-object JSON (e.g. a string)', () => {
expect(() => parseDescriptor('"just a string"')).toThrow()
})
it('rejects wrong magic', () => {
expect(() => parseDescriptor(file({ magic: 'OOPS' }))).toThrow()
})
it('rejects unknown provider fields', () => {
expect(() => parseDescriptor(file({ provider: { name: 'X', extra: 1 } }))).toThrow()
})
it('rejects missing/empty provider.name', () => {
expect(() => parseDescriptor(file({ provider: { name: '' } }))).toThrow()
})
})

View File

@@ -0,0 +1,79 @@
import { isForbiddenHost } from './net-guard'
const ICON_MAX_LEN = 64 * 1024
const ICON_PREFIXES = [
'data:image/png;base64,',
'data:image/jpeg;base64,',
'data:image/webp;base64,'
]
function fail(msg: string): never {
throw new Error(`Invalid plugin descriptor: ${msg}`)
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function assertOnlyKeys(obj: Record<string, unknown>, allowed: string[], where: string): void {
for (const k of Object.keys(obj)) {
if (!allowed.includes(k)) fail(`unexpected field "${k}" in ${where}`)
}
}
function validateIcon(icon: unknown): void {
if (icon === undefined) return
if (typeof icon !== 'string') fail('provider.icon must be a string')
if (icon.length > ICON_MAX_LEN) fail('provider.icon too large')
if (!ICON_PREFIXES.some((p) => icon.startsWith(p))) {
fail('provider.icon must be a small png/jpeg/webp data uri (no svg, no external url)')
}
}
function assertHttpsUrl(v: unknown, where: string): URL {
if (typeof v !== 'string') fail(`${where} must be a string`)
let u: URL
try {
u = new URL(v)
} catch {
fail(`${where} must be a valid URL`)
}
if (u.protocol !== 'https:') fail(`${where} must be https`)
if (u.username || u.password) fail(`${where} must not contain userinfo`)
// 拒绝字面私网/环回/保留 IP 与 localhost/*.localhost无需 DNS代理模式下同样生效
// 域名解析到私网的拦截走加固客户端的 guarded lookup直连模式代理模式由 spec §11 标注为安全降级。
if (isForbiddenHost(u.hostname)) fail(`${where} must be a public host`)
return u
}
export function parseDescriptor(jsonText: string): IPluginDescriptor {
let raw: unknown
try {
raw = JSON.parse(jsonText)
} catch {
fail('not valid JSON')
}
if (!isObject(raw)) fail('must be an object')
if (raw.magic !== 'CPXF') fail('magic must be "CPXF"')
if (raw.v === 1) {
throw new Error(
'Plugin file format is outdated (v1); please obtain the new file from your provider'
)
}
if (raw.v !== 2) fail('v must be 2')
if (raw.spec !== 'cpx-plugin/2') fail('spec must be "cpx-plugin/2"')
assertOnlyKeys(raw, ['magic', 'v', 'spec', 'loginUrl', 'provider'], 'descriptor')
const loginUrl = assertHttpsUrl(raw.loginUrl, 'loginUrl')
if (loginUrl.search || loginUrl.hash) fail('loginUrl must not contain query or fragment')
if (!isObject(raw.provider)) fail('provider must be an object')
assertOnlyKeys(raw.provider, ['name', 'icon', 'site'], 'provider')
if (typeof raw.provider.name !== 'string' || raw.provider.name.length === 0) {
fail('provider.name required')
}
validateIcon(raw.provider.icon)
if (raw.provider.site !== undefined) assertHttpsUrl(raw.provider.site, 'provider.site')
return raw as unknown as IPluginDescriptor
}

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest'
import {
generateDevice,
buildSignInput,
signRequest,
verifyRequest,
OP_CONFIG,
OP_REVOKE
} from './device'
describe('device', () => {
it('generates a UUID deviceId and 32-byte raw keys (base64)', () => {
const d = generateDevice()
expect(d.deviceId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
expect(Buffer.from(d.privKeyB64, 'base64')).toHaveLength(32)
expect(Buffer.from(d.pubKeyB64, 'base64')).toHaveLength(32)
})
it('sign/verify round-trips', () => {
const d = generateDevice()
const nonce = Buffer.alloc(32, 7)
const input = buildSignInput(OP_CONFIG, d.deviceId, 'n1', nonce, 1700000000000)
const sig = signRequest(d.privKeyB64, input)
expect(Buffer.from(sig, 'base64')).toHaveLength(64)
expect(verifyRequest(d.pubKeyB64, input, sig)).toBe(true)
})
it('verify fails on tampered input', () => {
const d = generateDevice()
const nonce = Buffer.alloc(32, 7)
const a = buildSignInput(OP_CONFIG, d.deviceId, 'n1', nonce, 1700000000000)
const b = buildSignInput(OP_REVOKE, d.deviceId, 'n1', nonce, 1700000000000)
const sig = signRequest(d.privKeyB64, a)
expect(verifyRequest(d.pubKeyB64, b, sig)).toBe(false)
})
it('builds the canonical input deterministically (spec §7)', () => {
const nonce = Buffer.alloc(32, 0xab)
const input = buildSignInput(OP_CONFIG, 'dev', 'nid', nonce, 1)
// "CPX2" | op(1) | len(3)+"dev" | len(3)+"nid" | 32B nonce | uint64_be(1)
const expected = Buffer.concat([
Buffer.from('CPX2', 'ascii'),
Buffer.from([1]),
Buffer.from([3]),
Buffer.from('dev', 'utf-8'),
Buffer.from([3]),
Buffer.from('nid', 'utf-8'),
nonce,
Buffer.from([0, 0, 0, 0, 0, 0, 0, 1])
])
expect(input.equals(expected)).toBe(true)
})
})

View File

@@ -0,0 +1,68 @@
import {
createPrivateKey,
createPublicKey,
generateKeyPairSync,
randomUUID,
sign,
verify
} from 'crypto'
export const OP_CONFIG = 1
export const OP_REVOKE = 2
// Fixed DER prefix for an Ed25519 PKCS#8 key; the 32-byte raw seed follows.
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
export interface DeviceKeys {
deviceId: string
privKeyB64: string // raw 32-byte seed, base64
pubKeyB64: string // raw 32-byte public key, base64
}
export function generateDevice(): DeviceKeys {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }) as Buffer
const seed = privDer.subarray(privDer.length - 32)
const pubJwk = publicKey.export({ format: 'jwk' }) as { x: string }
return {
deviceId: randomUUID(),
privKeyB64: seed.toString('base64'),
pubKeyB64: Buffer.from(pubJwk.x, 'base64url').toString('base64')
}
}
export function buildSignInput(
op: number,
deviceId: string,
nonceId: string,
nonce: Buffer,
ts: number
): Buffer {
const did = Buffer.from(deviceId, 'utf-8')
const nid = Buffer.from(nonceId, 'utf-8')
if (did.length > 255 || nid.length > 255) throw new Error('deviceId/nonceId too long')
const tsB = Buffer.alloc(8)
tsB.writeBigUInt64BE(BigInt(ts))
return Buffer.concat([
Buffer.from('CPX2', 'ascii'),
Buffer.from([op]),
Buffer.from([did.length]),
did,
Buffer.from([nid.length]),
nid,
nonce,
tsB
])
}
export function signRequest(privKeyB64: string, input: Buffer): string {
const der = Buffer.concat([ED25519_PKCS8_PREFIX, Buffer.from(privKeyB64, 'base64')])
const key = createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })
return sign(null, input, key).toString('base64')
}
export function verifyRequest(pubKeyB64: string, input: Buffer, sigB64: string): boolean {
const x = Buffer.from(pubKeyB64, 'base64').toString('base64url')
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' })
return verify(null, input, key, Buffer.from(sigB64, 'base64'))
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const requestOnce = vi.fn()
vi.mock('./http-client', () => ({ requestOnce: (...a: unknown[]) => requestOnce(...a) }))
import { discoverGateway } from './discovery'
const OK = {
spec: 'cpx-plugin/2',
gateway: 'https://gw.front.com',
endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' }
}
function reply(body: unknown, status = 200): void {
requestOnce.mockResolvedValueOnce({ status, headers: {}, body: JSON.stringify(body) })
}
const NET = { timeout: 5000 }
beforeEach(() => requestOnce.mockReset())
describe('discoverGateway', () => {
it('fetches the exact loginUrl host well-known and returns parsed gateway', async () => {
reply(OK)
const wk = await discoverGateway('https://panel.xx.com/oauth/authorize', NET)
expect(requestOnce).toHaveBeenCalledWith(
'https://panel.xx.com/.well-known/cpx-gateway',
expect.objectContaining({ method: 'GET' })
)
expect(wk.gateway).toBe('https://gw.front.com')
expect(wk.endpoints.config).toBe('/config')
})
it('rejects non-https gateway', async () => {
reply({ ...OK, gateway: 'http://gw.front.com' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects gateway with a path', async () => {
reply({ ...OK, gateway: 'https://gw.front.com/base' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects gateway with query/fragment', async () => {
reply({ ...OK, gateway: 'https://gw.front.com/?x=1' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects a private-IP gateway literal', async () => {
reply({ ...OK, gateway: 'https://127.0.0.1' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects a localhost gateway', async () => {
reply({ ...OK, gateway: 'https://localhost' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects a *.localhost gateway', async () => {
reply({ ...OK, gateway: 'https://gw.localhost' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects a gateway with userinfo', async () => {
reply({ ...OK, gateway: 'https://u:p@gw.front.com' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects an absolute endpoint url', async () => {
reply({ ...OK, endpoints: { ...OK.endpoints, config: 'https://evil.com/c' } })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects an endpoint not starting with /', async () => {
reply({ ...OK, endpoints: { ...OK.endpoints, config: 'config' } })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects wrong spec', async () => {
reply({ ...OK, spec: 'cpx-plugin/1' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects malformed JSON body', async () => {
requestOnce.mockResolvedValueOnce({ status: 200, headers: {}, body: 'nope' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
it('rejects non-2xx status', async () => {
requestOnce.mockResolvedValueOnce({ status: 404, headers: {}, body: '{}' })
await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow()
})
})

View File

@@ -0,0 +1,72 @@
import type { LookupFunction } from 'net'
import { createGuardedLookup } from './net-guard'
import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url'
import { requestOnce } from './http-client'
const MAX_BYTES = 64 * 1024
export interface DiscoverOpts {
timeout: number
lookup?: LookupFunction
proxy?: { host: string; port: number }
}
function fail(msg: string): never {
throw new Error(`Invalid gateway discovery: ${msg}`)
}
function assertHttpsOrigin(v: unknown, where: string): string {
const origin = parseGatewayOrigin(v)
if (!origin) fail(`${where} must be a public https origin with no path/query/fragment/userinfo`)
return origin
}
function assertRelPath(v: unknown, where: string): string {
if (!isValidEndpointPath(v)) {
fail(`${where} must be a relative path starting with "/" (no scheme/host/query/fragment)`)
}
return v
}
export async function discoverGateway(
loginUrl: string,
opts: DiscoverOpts
): Promise<IGatewayWellKnown> {
const host = new URL(loginUrl).host
const url = `https://${host}/.well-known/cpx-gateway`
const lookup = opts.proxy ? undefined : (opts.lookup ?? createGuardedLookup())
const res = await requestOnce(url, {
method: 'GET',
timeout: opts.timeout,
maxBytes: MAX_BYTES,
lookup,
proxy: opts.proxy
})
if (res.status < 200 || res.status >= 300) {
const err = new Error(`Discovery failed: status ${res.status}`) as Error & { status?: number }
err.status = res.status
throw err
}
let raw: unknown
try {
raw = JSON.parse(res.body)
} catch {
fail('not valid JSON')
}
if (typeof raw !== 'object' || raw === null) fail('must be an object')
const obj = raw as Record<string, unknown>
if (obj.spec !== 'cpx-plugin/2') fail('spec must be "cpx-plugin/2"')
const gateway = assertHttpsOrigin(obj.gateway, 'gateway')
if (typeof obj.endpoints !== 'object' || obj.endpoints === null) fail('endpoints required')
const e = obj.endpoints as Record<string, unknown>
return {
spec: 'cpx-plugin/2',
gateway,
endpoints: {
enroll: assertRelPath(e.enroll, 'endpoints.enroll'),
challenge: assertRelPath(e.challenge, 'endpoints.challenge'),
config: assertRelPath(e.config, 'endpoints.config'),
revoke: assertRelPath(e.revoke, 'endpoints.revoke')
}
}
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url'
describe('parseGatewayOrigin', () => {
it('accepts a plain https origin and returns it normalized', () => {
expect(parseGatewayOrigin('https://gw.front.com')).toBe('https://gw.front.com')
expect(parseGatewayOrigin('https://gw.front.com:8443')).toBe('https://gw.front.com:8443')
expect(parseGatewayOrigin('https://gw.front.com/')).toBe('https://gw.front.com')
})
it('rejects non-https', () => {
expect(parseGatewayOrigin('http://gw.front.com')).toBeNull()
})
it('rejects path/query/fragment', () => {
expect(parseGatewayOrigin('https://gw.front.com/base')).toBeNull()
expect(parseGatewayOrigin('https://gw.front.com/?x=1')).toBeNull()
expect(parseGatewayOrigin('https://gw.front.com/#f')).toBeNull()
})
it('rejects userinfo', () => {
expect(parseGatewayOrigin('https://u:p@gw.front.com')).toBeNull()
})
it('rejects private IP, loopback IP, and localhost names', () => {
expect(parseGatewayOrigin('https://127.0.0.1')).toBeNull()
expect(parseGatewayOrigin('https://10.0.0.5')).toBeNull()
expect(parseGatewayOrigin('https://[::1]')).toBeNull()
expect(parseGatewayOrigin('https://localhost')).toBeNull()
expect(parseGatewayOrigin('https://foo.localhost')).toBeNull()
expect(parseGatewayOrigin('https://localhost.')).toBeNull()
})
it('rejects non-string', () => {
expect(parseGatewayOrigin(123)).toBeNull()
expect(parseGatewayOrigin(undefined)).toBeNull()
})
})
describe('isValidEndpointPath', () => {
it('accepts a relative path starting with /', () => {
expect(isValidEndpointPath('/config')).toBe(true)
expect(isValidEndpointPath('/v2/config')).toBe(true)
})
it('rejects protocol-relative, absolute, query/fragment, non-string', () => {
expect(isValidEndpointPath('//evil.example/config')).toBe(false)
expect(isValidEndpointPath('https://evil.example/config')).toBe(false)
expect(isValidEndpointPath('/config?x=1')).toBe(false)
expect(isValidEndpointPath('/config#f')).toBe(false)
expect(isValidEndpointPath('config')).toBe(false)
expect(isValidEndpointPath(42)).toBe(false)
})
it('rejects backslash host-escape (WHATWG treats \\ as / under https)', () => {
// new URL('/\\evil.example/config', 'https://gw') === https://evil.example/config
expect(isValidEndpointPath('/\\evil.example/config')).toBe(false)
expect(isValidEndpointPath('/\\localhost/config')).toBe(false)
expect(isValidEndpointPath('/foo\\bar')).toBe(false)
})
})

View File

@@ -0,0 +1,37 @@
import { isForbiddenHost } from './net-guard'
// 网关 origin / 端点 path 的字面校验(无 DNS、无网络。discovery解析 .well-known
// vault解密缓存的网关共用避免两处校验语义分叉。
// 校验并归一化网关 origin必须 https、无 userinfo、无 path/query/fragment、host 非私网/环回/localhost。
// 合法返回 origin 字符串scheme + host[+port]),非法返回 null。
export function parseGatewayOrigin(v: unknown): string | null {
if (typeof v !== 'string') return null
let u: URL
try {
u = new URL(v)
} catch {
return null
}
if (u.protocol !== 'https:') return null
if (u.username || u.password) return null
if (u.search || u.hash || (u.pathname && u.pathname !== '/')) return null
if (isForbiddenHost(u.hostname)) return null
return u.origin
}
// 端点必须是以 '/' 开头的相对 path不得为协议相对//host、不得含 scheme/host/query/fragment
// 也不得含反斜杠 —— WHATWG URL 在 http(s) 下把 '\' 当作 '/',故 '/\evil/x' 会逃逸到另一个 host。
export function isValidEndpointPath(v: unknown): v is string {
if (typeof v !== 'string' || !v.startsWith('/')) return false
if (
v.startsWith('//') ||
v.includes('\\') ||
v.includes('?') ||
v.includes('#') ||
/^[a-z][a-z0-9+.-]*:/i.test(v)
) {
return false
}
return true
}

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest'
import { challenge } from './gateway'
// No http-client mock here: the real hardened client + guarded lookup must refuse a private gateway.
describe('gateway network hardening (real client)', () => {
it('refuses a loopback gateway via guarded lookup', async () => {
const target = {
gateway: 'https://localhost',
endpoints: { enroll: '/e', challenge: '/c', config: '/cfg', revoke: '/r' }
}
await expect(challenge(target, 'DID', { timeout: 2000 })).rejects.toMatchObject({
kind: 'transient'
})
})
})

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const requestOnce = vi.fn()
vi.mock('./http-client', () => ({ requestOnce: (...a: unknown[]) => requestOnce(...a) }))
import { enroll, challenge, fetchConfig, revoke, GatewayError } from './gateway'
import { generateDevice, buildSignInput, verifyRequest, OP_CONFIG, OP_REVOKE } from './device'
const TARGET = {
gateway: 'https://gw.front.com',
endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' }
}
const NET = { timeout: 5000 }
const CLASH =
'proxies:\n - {name: a, type: ss, server: 1.1.1.1, port: 8388, cipher: aes-128-gcm, password: x}\n'
function jsonReply(body: unknown, status = 200): void {
requestOnce.mockResolvedValueOnce({ status, headers: {}, body: JSON.stringify(body) })
}
function rawReply(body: string, status = 200): void {
requestOnce.mockResolvedValueOnce({ status, headers: {}, body })
}
function lastBody(): any {
const call = requestOnce.mock.calls[requestOnce.mock.calls.length - 1]
return JSON.parse((call[1] as { body: string }).body)
}
beforeEach(() => requestOnce.mockReset())
describe('gateway.enroll', () => {
it('posts code+verifier+redirect+client+pubKey+deviceId, resolves on ok', async () => {
jsonReply({ ok: true })
await enroll(
TARGET,
{
code: 'C',
code_verifier: 'V',
redirect_uri: 'http://127.0.0.1:5/callback',
client_id: 'mihomo-party',
devicePubKey: 'PUB',
deviceId: 'DID'
},
NET
)
expect(requestOnce).toHaveBeenCalledWith('https://gw.front.com/enroll', expect.any(Object))
expect(lastBody()).toMatchObject({ code: 'C', code_verifier: 'V', deviceId: 'DID' })
})
it('maps explicit revoked to GatewayError(revoked)', async () => {
jsonReply({ error: 'revoked' }, 403)
await expect(enroll(TARGET, {} as never, NET)).rejects.toMatchObject({ kind: 'revoked' })
})
})
describe('gateway.challenge', () => {
it('returns nonceId/nonce/exp', async () => {
jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 })
const c = await challenge(TARGET, 'DID', NET)
expect(c.nonceId).toBe('N1')
expect(Buffer.from(c.nonce, 'base64')).toHaveLength(32)
})
it('rejects a nonce that is not 32 bytes as transient', async () => {
jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(16, 1).toString('base64'), exp: 60 })
await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ kind: 'transient' })
})
it('rejects a non-base64 nonce as transient', async () => {
jsonReply({ nonceId: 'N1', nonce: 'not base64 !!!', exp: 60 })
await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ kind: 'transient' })
})
it('rejects a nonceId with control/whitespace chars as transient', async () => {
jsonReply({ nonceId: 'bad\nid', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 })
await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ kind: 'transient' })
})
})
describe('gateway.fetchConfig', () => {
it('challenge→signed config; signature verifies against device pubkey; returns YAML', async () => {
const dev = generateDevice()
const nonceBuf = Buffer.alloc(32, 9)
jsonReply({ nonceId: 'N1', nonce: nonceBuf.toString('base64'), exp: 60 })
rawReply(CLASH)
const yaml = await fetchConfig(
TARGET,
{ deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 },
NET
)
expect(yaml).toBe(CLASH)
const body = lastBody()
expect(body).toMatchObject({ deviceId: dev.deviceId, nonceId: 'N1' })
const input = buildSignInput(OP_CONFIG, dev.deviceId, 'N1', nonceBuf, body.ts)
expect(verifyRequest(dev.pubKeyB64, input, body.sig)).toBe(true)
})
it('rejects a non-clash config body as transient', async () => {
const dev = generateDevice()
jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32).toString('base64'), exp: 60 })
rawReply('just text')
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'transient' })
})
it('maps 410 to GatewayError(retired)', async () => {
const dev = generateDevice()
jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32).toString('base64'), exp: 60 })
rawReply('', 410)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'retired' })
})
it('maps gateway_retired json marker to retired', async () => {
const dev = generateDevice()
jsonReply({ error: 'gateway_retired' }, 200)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'retired' })
})
it('maps 5xx to transient', async () => {
const dev = generateDevice()
jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32).toString('base64'), exp: 60 })
rawReply('', 503)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'transient' })
})
it('maps a DNS failure (ENOTFOUND) to unreachable', async () => {
const dev = generateDevice()
requestOnce.mockRejectedValueOnce(
Object.assign(new Error('getaddrinfo ENOTFOUND gw.front.com'), { code: 'ENOTFOUND' })
)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'unreachable' })
})
it('maps a connection refused (ECONNREFUSED) to unreachable', async () => {
const dev = generateDevice()
requestOnce.mockRejectedValueOnce(
Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' })
)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'unreachable' })
})
it('maps a TLS failure code to unreachable', async () => {
const dev = generateDevice()
requestOnce.mockRejectedValueOnce(
Object.assign(new Error('certificate has expired'), { code: 'CERT_HAS_EXPIRED' })
)
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'unreachable' })
})
it('maps a timeout/generic error (no network code) to transient', async () => {
const dev = generateDevice()
requestOnce.mockRejectedValueOnce(new Error('Request timed out'))
await expect(
fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
).rejects.toMatchObject({ kind: 'transient' })
})
})
describe('gateway.revoke', () => {
it('signs op=revoke and posts; idempotent ok', async () => {
const dev = generateDevice()
const nonceBuf = Buffer.alloc(32, 3)
jsonReply({ nonceId: 'N1', nonce: nonceBuf.toString('base64'), exp: 60 })
jsonReply({ ok: true })
await revoke(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
const body = lastBody()
const input = buildSignInput(OP_REVOKE, dev.deviceId, 'N1', nonceBuf, body.ts)
expect(verifyRequest(dev.pubKeyB64, input, body.sig)).toBe(true)
})
})
describe('gateway encoding/timestamp', () => {
it('nonce is echoed back as the same base64 string; ts is integer ms', async () => {
const dev = generateDevice()
const nonceB64 = Buffer.alloc(32, 5).toString('base64')
jsonReply({ nonceId: 'N1', nonce: nonceB64, exp: 60 })
rawReply(CLASH)
const before = Date.now()
await fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET)
const body = lastBody()
expect(body.nonce).toBe(nonceB64)
expect(Number.isInteger(body.ts)).toBe(true)
expect(body.ts).toBeGreaterThanOrEqual(before)
})
})
describe('gateway urlOf host-escape defense', () => {
it('refuses an endpoint that escapes the gateway origin (backslash) before any request', async () => {
const evil = {
gateway: 'https://gw.front.com',
endpoints: { enroll: '/e', challenge: '/\\evil.example/c', config: '/cfg', revoke: '/r' }
}
requestOnce.mockClear()
await expect(challenge(evil, 'DID', NET)).rejects.toMatchObject({ kind: 'transient' })
expect(requestOnce).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,220 @@
import type { LookupFunction } from 'net'
import { parse } from '../../utils/yaml'
import { createGuardedLookup } from './net-guard'
import { requestOnce } from './http-client'
import { buildSignInput, signRequest, OP_CONFIG, OP_REVOKE } from './device'
const MAX_BYTES = 10 * 1024 * 1024
export interface GatewayNet {
timeout: number
lookup?: LookupFunction
proxy?: { host: string; port: number }
}
export interface GatewayTarget {
gateway: string
endpoints: IGatewayEndpoints
}
export type GatewayErrorKind = 'revoked' | 'retired' | 'unreachable' | 'transient'
export class GatewayError extends Error {
kind: GatewayErrorKind
status?: number
constructor(kind: GatewayErrorKind, message: string, status?: number) {
super(message)
this.name = 'GatewayError'
this.kind = kind
this.status = status
}
}
interface RawResult {
status: number
json: Record<string, unknown> | undefined
text: string
}
function urlOf(t: GatewayTarget, ep: keyof IGatewayEndpoints): string {
const u = new URL(t.endpoints[ep], t.gateway)
// 第二道防线:拼出的 URL 必须仍落在网关 origin 上(防端点逃逸到其它 host如反斜杠/编码技巧)。
if (u.origin !== new URL(t.gateway).origin) {
throw new GatewayError('transient', 'endpoint escaped gateway origin')
}
return u.toString()
}
function lookupFor(net: GatewayNet): LookupFunction | undefined {
return net.proxy ? undefined : (net.lookup ?? createGuardedLookup())
}
// DNS 解析失败 / 连接拒绝 / TLS 失败 → 缓存网关“不可达/已退役”信号spec §5交由编排层重新发现。
// 超时('Request timed out' / ETIMEDOUT、5xx、429、SSRF/重定向/大小拦截仍按瞬时失败退避,不在此列。
const UNREACHABLE_CODES = new Set([
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'EHOSTDOWN',
'ENETDOWN',
'EPIPE',
'EPROTO'
])
function isUnreachable(e: NodeJS.ErrnoException): boolean {
const code = e.code ?? ''
if (UNREACHABLE_CODES.has(code)) return true
// Node 的 TLS/证书错误 code 形如 ERR_TLS_*, ERR_SSL_*, CERT_*, SELF_SIGNED_*, UNABLE_TO_*, DEPTH_ZERO_*
return /^(ERR_TLS|ERR_SSL|CERT_|SELF_SIGNED_|UNABLE_TO_|DEPTH_ZERO_)/.test(code)
}
async function postJson(url: string, body: unknown, net: GatewayNet): Promise<RawResult> {
let res: { status: number; body: string }
try {
res = await requestOnce(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
timeout: net.timeout,
maxBytes: MAX_BYTES,
lookup: lookupFor(net),
proxy: net.proxy
})
} catch (e) {
const err = e as NodeJS.ErrnoException
throw new GatewayError(isUnreachable(err) ? 'unreachable' : 'transient', err.message)
}
let json: Record<string, unknown> | undefined
try {
const parsed = JSON.parse(res.body)
json =
typeof parsed === 'object' && parsed !== null
? (parsed as Record<string, unknown>)
: undefined
} catch {
json = undefined
}
return { status: res.status, json, text: res.body }
}
function classify(r: RawResult): GatewayError | null {
if (r.status === 410 || r.json?.error === 'gateway_retired') {
return new GatewayError('retired', 'gateway retired', r.status)
}
if (r.json?.error === 'revoked' || r.json?.error === 'device_revoked') {
return new GatewayError('revoked', 'device revoked', r.status)
}
if (r.status < 200 || r.status >= 300) {
return new GatewayError('transient', `gateway status ${r.status}`, r.status)
}
return null
}
// 不透明 ASCII token可见 ASCII无空白/控制字符),长度 1..max
function isAsciiToken(s: string, max: number): boolean {
return s.length > 0 && s.length <= max && /^[\x21-\x7e]+$/.test(s)
}
// 标准 base64带 padding且解码后恰为 n 字节、再编码可还原(拒非规范编码)
function isB64Bytes(s: string, n: number): boolean {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(s)) return false
const buf = Buffer.from(s, 'base64')
return buf.length === n && buf.toString('base64') === s
}
function isClashConfig(yamlText: string): boolean {
let parsed: unknown
try {
parsed = parse(yamlText)
} catch {
return false
}
if (typeof parsed !== 'object' || parsed === null) return false
const obj = parsed as Record<string, unknown>
return Boolean(obj['proxies'] || obj['proxy-providers'])
}
export interface EnrollBody {
code: string
code_verifier: string
redirect_uri: string
client_id: string
devicePubKey: string
deviceId: string
}
export async function enroll(t: GatewayTarget, body: EnrollBody, net: GatewayNet): Promise<void> {
const r = await postJson(urlOf(t, 'enroll'), body, net)
const err = classify(r)
if (err) throw err
}
export async function challenge(
t: GatewayTarget,
deviceId: string,
net: GatewayNet
): Promise<{ nonceId: string; nonce: string; exp: number }> {
const r = await postJson(urlOf(t, 'challenge'), { deviceId }, net)
const err = classify(r)
if (err) throw err
const j = r.json
// 结构校验spec §7nonceId 为不透明 ASCII ≤64nonce 必须是 32 raw bytes 的标准 base64。
// 坏数据按瞬时失败处理,避免畸形 nonce 进入签名串。
if (
!j ||
typeof j.nonceId !== 'string' ||
typeof j.nonce !== 'string' ||
!isAsciiToken(j.nonceId, 64) ||
!isB64Bytes(j.nonce, 32)
) {
throw new GatewayError('transient', 'bad challenge response', r.status)
}
return { nonceId: j.nonceId, nonce: j.nonce, exp: Number(j.exp) || 0 }
}
interface DeviceCred {
deviceId: string
privKeyB64: string
}
async function signedPost(
t: GatewayTarget,
ep: 'config' | 'revoke',
op: number,
dev: DeviceCred,
net: GatewayNet
): Promise<RawResult> {
const ch = await challenge(t, dev.deviceId, net)
const nonceBuf = Buffer.from(ch.nonce, 'base64')
const ts = Date.now()
const input = buildSignInput(op, dev.deviceId, ch.nonceId, nonceBuf, ts)
const sig = signRequest(dev.privKeyB64, input)
return postJson(
urlOf(t, ep),
{ deviceId: dev.deviceId, nonceId: ch.nonceId, nonce: ch.nonce, ts, sig },
net
)
}
export async function fetchConfig(
t: GatewayTarget,
dev: DeviceCred,
net: GatewayNet
): Promise<string> {
const r = await signedPost(t, 'config', OP_CONFIG, dev, net)
const err = classify(r)
if (err) throw err
if (!isClashConfig(r.text)) {
throw new GatewayError('transient', 'subscription is not a valid clash config', r.status)
}
return r.text
}
export async function revoke(t: GatewayTarget, dev: DeviceCred, net: GatewayNet): Promise<void> {
const r = await signedPost(t, 'revoke', OP_REVOKE, dev, net)
const err = classify(r)
if (err) throw err
}

View File

@@ -0,0 +1,137 @@
import http from 'http'
import { describe, it, expect, afterEach } from 'vitest'
import { requestOnce } from './http-client'
let server: http.Server | undefined
afterEach(() => server?.close())
function start(handler: http.RequestListener): Promise<string> {
return new Promise((resolve) => {
server = http.createServer(handler)
server.listen(0, '127.0.0.1', () => {
const addr = server!.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
resolve(`http://127.0.0.1:${port}`)
})
})
}
describe('requestOnce', () => {
it('performs a GET and returns status + body', async () => {
const url = await start((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"ok":true}')
})
const r = await requestOnce(url + '/x', { method: 'GET', timeout: 5000, maxBytes: 1024 })
expect(r.status).toBe(200)
expect(r.body).toBe('{"ok":true}')
})
it('sends a POST body', async () => {
const url = await start((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
res.writeHead(200)
res.end(Buffer.concat(chunks).toString('utf-8'))
})
})
const r = await requestOnce(url + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"email":"a"}',
timeout: 5000,
maxBytes: 1024
})
expect(r.body).toBe('{"email":"a"}')
})
it('rejects redirects instead of following', async () => {
const url = await start((_req, res) => {
res.writeHead(302, { location: 'https://evil.example' })
res.end()
})
await expect(
requestOnce(url + '/r', { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toThrow(/redirect/i)
})
it('rejects oversized responses', async () => {
const url = await start((_req, res) => {
res.writeHead(200)
res.end('x'.repeat(5000))
})
await expect(
requestOnce(url + '/big', { method: 'GET', timeout: 5000, maxBytes: 1000 })
).rejects.toThrow(/too large/i)
})
it('rejects forbidden headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: { Host: 'evil' },
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/forbidden/i)
})
it('rejects too many request headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`X-Test-${i}`, 'v'])),
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/headers/i)
})
it('rejects oversized request headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: { 'X-Large': 'x'.repeat(16 * 1024 + 1) },
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/headers/i)
})
it('times out slow responses', async () => {
const url = await start((_req, res) => {
setTimeout(() => res.end('late'), 200)
})
await expect(
requestOnce(url + '/slow', { method: 'GET', timeout: 50, maxBytes: 1024 })
).rejects.toThrow(/timed out/i)
})
it('routes the request through the configured proxy when proxy is set', async () => {
const seen: string[] = []
const proxy = http.createServer((req, res) => {
seen.push(req.url ?? '')
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('via-proxy')
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const proxyPort = (proxy.address() as { port: number }).port
try {
// target.invalid 永不可解析:没走代理就连不上,证明请求确实经由代理发出
const res = await requestOnce('http://target.invalid/getSub', {
method: 'GET',
timeout: 5000,
maxBytes: 4096,
proxy: { host: '127.0.0.1', port: proxyPort }
})
expect(res.body).toBe('via-proxy')
expect(seen).toContain('http://target.invalid/getSub')
} finally {
proxy.close()
}
})
})

View File

@@ -0,0 +1,121 @@
import http from 'http'
import https from 'https'
import type { LookupFunction } from 'net'
import { HttpProxyAgent } from 'http-proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent'
export interface PluginRequestOptions {
method: 'GET' | 'POST'
headers?: Record<string, string>
body?: string
timeout: number
maxBytes: number
lookup?: LookupFunction
// 走代理时由代理负责解析/连接目标,本地 SSRF guarded lookup 不再适用(安全保证降级)
proxy?: { host: string; port: number }
}
export interface PluginResponse {
status: number
headers: http.IncomingHttpHeaders
body: string
}
const FORBIDDEN_HEADERS = new Set(['host', 'content-length', 'connection', 'transfer-encoding'])
const MAX_HEADERS = 32
const MAX_HEADER_NAME_LEN = 128
const MAX_HEADER_VALUE_LEN = 4096
const MAX_HEADER_BYTES = 16 * 1024
function validateHeaders(input: Record<string, string>): Record<string, string> {
const entries = Object.entries(input)
if (entries.length > MAX_HEADERS) throw new Error('Request headers too large')
const headers: Record<string, string> = {}
let total = 0
for (const [k, v] of entries) {
if (FORBIDDEN_HEADERS.has(k.toLowerCase())) {
throw new Error(`Forbidden header: ${k}`)
}
const nameBytes = Buffer.byteLength(k, 'utf-8')
const valueBytes = Buffer.byteLength(v, 'utf-8')
if (nameBytes > MAX_HEADER_NAME_LEN || valueBytes > MAX_HEADER_VALUE_LEN) {
throw new Error('Request headers too large')
}
total += nameBytes + valueBytes
headers[k] = v
}
if (total > MAX_HEADER_BYTES) throw new Error('Request headers too large')
return headers
}
export function requestOnce(urlStr: string, opts: PluginRequestOptions): Promise<PluginResponse> {
return new Promise((resolve, reject) => {
let url: URL
try {
url = new URL(urlStr)
} catch {
reject(new Error('Invalid URL'))
return
}
const mod = url.protocol === 'https:' ? https : url.protocol === 'http:' ? http : null
if (!mod) {
reject(new Error(`Unsupported protocol: ${url.protocol}`))
return
}
let headers: Record<string, string>
try {
headers = validateHeaders(opts.headers ?? {})
} catch (e) {
reject(e)
return
}
if (opts.body !== undefined) headers['Content-Length'] = String(Buffer.byteLength(opts.body))
// 代理模式:连接打到本地代理,目标由代理解析;不再注入 guarded lookup。
const proxyUrl = opts.proxy ? `http://${opts.proxy.host}:${opts.proxy.port}` : undefined
const agent = proxyUrl
? url.protocol === 'https:'
? new HttpsProxyAgent(proxyUrl)
: new HttpProxyAgent(proxyUrl)
: undefined
const req = mod.request(
url,
{
method: opts.method,
headers,
agent,
lookup: proxyUrl ? undefined : opts.lookup,
timeout: opts.timeout
},
(res) => {
const status = res.statusCode ?? 0
if (status >= 300 && status < 400) {
res.destroy()
reject(new Error(`Refusing to follow redirect (status ${status})`))
return
}
const chunks: Buffer[] = []
let size = 0
res.on('data', (c: Buffer) => {
size += c.length
if (size > opts.maxBytes) {
res.destroy()
reject(new Error('Response too large'))
return
}
chunks.push(c)
})
res.on('end', () =>
resolve({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf-8') })
)
res.on('error', reject)
}
)
req.on('timeout', () => req.destroy(new Error('Request timed out')))
req.on('error', reject)
if (opts.body !== undefined) req.write(opts.body)
req.end()
})
}

View File

@@ -0,0 +1,387 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
const profiles: Record<string, string> = {}
const pluginItems: Record<string, IPluginItem> = {}
const vaults: Record<string, IPluginVault> = {}
vi.mock('./vault', () => ({
writeVault: vi.fn(async (id: string, v: IPluginVault) => {
vaults[id] = v
}),
readVault: vi.fn(async (id: string) => vaults[id]),
removeVault: vi.fn(async (id: string) => {
delete vaults[id]
}),
isVaultPersistent: () => true
}))
vi.mock('../../config/plugin', () => ({
getPluginItem: vi.fn(async (id: string) => pluginItems[id]),
addPluginItem: vi.fn(async (i: IPluginItem) => {
pluginItems[i.id] = i
}),
updatePluginItem: vi.fn(async (i: IPluginItem) => {
pluginItems[i.id] = i
}),
removePluginItem: vi.fn(async (id: string) => {
delete pluginItems[id]
}),
getPluginConfig: vi.fn(async () => ({ items: Object.values(pluginItems) }))
}))
vi.mock('../../config/profile', () => ({
upsertPluginProfile: vi.fn(async (meta: { profileId: string }, content: string) => {
profiles[meta.profileId] = content
}),
removePluginProfileContent: vi.fn(async (pid: string) => {
delete profiles[pid]
})
}))
vi.mock('../../config/app', () => ({
getAppConfig: vi.fn(async () => ({ subscriptionTimeout: 5000 }))
}))
vi.mock('../../window', () => ({ mainWindow: null }))
const discoverGateway = vi.fn()
vi.mock('./discovery', () => ({ discoverGateway: (...a: unknown[]) => discoverGateway(...a) }))
const browserLogin = vi.fn()
vi.mock('./oauth', () => ({
browserLogin: (...a: unknown[]) => browserLogin(...a),
CLIENT_ID: 'mihomo-party'
}))
const enroll = vi.fn()
const fetchConfig = vi.fn()
const revoke = vi.fn()
vi.mock('./gateway', async (importOriginal) => {
const real = await importOriginal<typeof import('./gateway')>()
return {
GatewayError: real.GatewayError,
enroll: (...a: unknown[]) => enroll(...a),
fetchConfig: (...a: unknown[]) => fetchConfig(...a),
revoke: (...a: unknown[]) => revoke(...a)
}
})
import { GatewayError } from './gateway'
import {
previewPlugin,
installPlugin,
loginPlugin,
updatePluginProfile,
auditPluginVault,
removePlugin
} from './index'
const CLASH =
'proxies:\n - {name: a, type: ss, server: 1.1.1.1, port: 8388, cipher: aes-128-gcm, password: x}\n'
const WK = {
spec: 'cpx-plugin/2',
gateway: 'https://gw.front.com',
endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' }
}
function file(): string {
return Buffer.from(
JSON.stringify({
magic: 'CPXF',
v: 2,
spec: 'cpx-plugin/2',
loginUrl: 'https://panel.xx.com/oauth/authorize',
provider: { name: 'XX', site: 'https://xx.com' }
}),
'utf-8'
).toString('base64')
}
beforeEach(() => {
for (const k of Object.keys(profiles)) delete profiles[k]
for (const k of Object.keys(pluginItems)) delete pluginItems[k]
for (const k of Object.keys(vaults)) delete vaults[k]
discoverGateway.mockReset().mockResolvedValue(WK)
browserLogin.mockReset().mockResolvedValue({
code: 'C',
verifier: 'V',
redirectUri: 'http://127.0.0.1:1/callback'
})
enroll.mockReset().mockResolvedValue(undefined)
fetchConfig.mockReset().mockResolvedValue(CLASH)
revoke.mockReset().mockResolvedValue(undefined)
})
describe('previewPlugin', () => {
it('returns the display subset without creating records or touching network', async () => {
const p = await previewPlugin(file())
expect(p.name).toBe('XX')
expect(p.loginUrl).toBe('https://panel.xx.com/oauth/authorize')
expect(Object.keys(pluginItems)).toHaveLength(0)
expect(discoverGateway).not.toHaveBeenCalled()
})
it('rejects an invalid file', async () => {
await expect(previewPlugin(Buffer.from('{bad', 'utf-8').toString('base64'))).rejects.toThrow()
})
})
describe('installPlugin', () => {
it('creates a needs-login record with no profileId and no network', async () => {
const item = await installPlugin(file())
expect(item.status).toBe('needs-login')
expect(item.profileId).toBeUndefined()
expect(item.loginUrl).toBe('https://panel.xx.com/oauth/authorize')
expect(discoverGateway).not.toHaveBeenCalled()
expect(browserLogin).not.toHaveBeenCalled()
})
})
describe('loginPlugin', () => {
it('discovers, generates device, browser-logs-in, enrolls, writes vault, fetches profile, active', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
const rec = pluginItems[item.id]
expect(rec.status).toBe('active')
expect(rec.profileId).toBeDefined()
expect(profiles[rec.profileId!]).toBe(CLASH)
const vault = vaults[item.id]
expect(Buffer.from(vault.devicePrivKey, 'base64')).toHaveLength(32)
expect(vault.gateway.gateway).toBe('https://gw.front.com')
expect(enroll).toHaveBeenCalledWith(
expect.objectContaining({ gateway: 'https://gw.front.com' }),
expect.objectContaining({ code: 'C', code_verifier: 'V', client_id: 'mihomo-party' }),
expect.any(Object)
)
})
it('re-login (reauth) after restart works from the persisted loginUrl alone (no reimport)', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
delete vaults[item.id]
pluginItems[item.id] = { ...pluginItems[item.id], status: 'needs-reauth' }
await loginPlugin(item.id)
expect(pluginItems[item.id].status).toBe('active')
expect(vaults[item.id]).toBeDefined()
})
// 设备复用仅限 needs-login 的“孤儿设备”(上次 enroll 成功但首份订阅拉取失败)
it('reuses an orphaned device (needs-login + vault) without browser/enroll', async () => {
const item = await installPlugin(file()) // needs-login, no vault
const dev = {
devicePrivKey: Buffer.alloc(32, 1).toString('base64'),
deviceId: '11111111-1111-4111-8111-111111111111'
}
vaults[item.id] = { ...dev, gateway: { gateway: WK.gateway, endpoints: WK.endpoints } }
browserLogin.mockClear()
enroll.mockClear()
discoverGateway.mockClear()
await loginPlugin(item.id)
expect(browserLogin).not.toHaveBeenCalled()
expect(enroll).not.toHaveBeenCalled()
expect(discoverGateway).not.toHaveBeenCalled()
expect(fetchConfig).toHaveBeenCalled()
expect(vaults[item.id].devicePrivKey).toBe(dev.devicePrivKey)
expect(pluginItems[item.id].status).toBe('active')
})
it('falls back to browser login + new device if the orphaned device is revoked', async () => {
const item = await installPlugin(file())
const dev = {
devicePrivKey: Buffer.alloc(32, 2).toString('base64'),
deviceId: '22222222-2222-4222-8222-222222222222'
}
vaults[item.id] = { ...dev, gateway: { gateway: WK.gateway, endpoints: WK.endpoints } }
browserLogin.mockClear()
enroll.mockClear()
fetchConfig
.mockRejectedValueOnce(new GatewayError('revoked', 'revoked')) // reuse attempt
.mockResolvedValueOnce(CLASH) // full-flow first fetch
await loginPlugin(item.id)
expect(browserLogin).toHaveBeenCalled()
expect(enroll).toHaveBeenCalled()
expect(pluginItems[item.id].status).toBe('active')
expect(vaults[item.id].devicePrivKey).not.toBe(dev.devicePrivKey)
})
// spec §9显式重新登录needs-reauth必须再走浏览器登录 + 新设备,即便 vault 仍在也不复用
it('explicit re-login (needs-reauth) does a fresh browser login + new device even with a vault', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id) // active, vault written
const devKeyBefore = vaults[item.id].devicePrivKey
pluginItems[item.id] = { ...pluginItems[item.id], status: 'needs-reauth' } // vault still present
browserLogin.mockClear()
enroll.mockClear()
fetchConfig.mockClear()
await loginPlugin(item.id)
expect(browserLogin).toHaveBeenCalled()
expect(enroll).toHaveBeenCalled()
expect(pluginItems[item.id].status).toBe('active')
expect(vaults[item.id].devicePrivKey).not.toBe(devKeyBefore)
})
it('sanitizes login errors (no gateway host / network detail leaks to the caller)', async () => {
const item = await installPlugin(file())
fetchConfig.mockRejectedValueOnce(
new GatewayError('unreachable', 'getaddrinfo ENOTFOUND gw.secret.host')
)
const err = (await loginPlugin(item.id).catch((e) => e)) as Error
expect(err.message).toBe('PLUGIN_LOGIN_NETWORK')
expect(err.message).not.toContain('gw.secret.host')
})
})
describe('updatePluginProfile', () => {
it('signed silent fetch refreshes the profile and clears failure state', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
fetchConfig.mockResolvedValueOnce(
'proxies: [{name: b, type: ss, server: 2.2.2.2, port: 1, cipher: aes-128-gcm, password: y}]\n'
)
await updatePluginProfile(item.id)
expect(pluginItems[item.id].status).toBe('active')
expect(pluginItems[item.id].failureCount ?? 0).toBe(0)
})
it('revoked → needs-reauth (no backoff)', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
fetchConfig.mockRejectedValueOnce(new GatewayError('revoked', 'revoked'))
await updatePluginProfile(item.id)
expect(pluginItems[item.id].status).toBe('needs-reauth')
})
it('transient failure keeps old profile + sets backoff', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
const before = profiles[pluginItems[item.id].profileId!]
fetchConfig.mockRejectedValueOnce(new GatewayError('transient', 'timeout'))
await updatePluginProfile(item.id)
expect(pluginItems[item.id].status).toBe('active')
expect(pluginItems[item.id].failureCount).toBe(1)
expect(pluginItems[item.id].nextRetryAt).toBeGreaterThan(Date.now())
expect(profiles[pluginItems[item.id].profileId!]).toBe(before)
})
it('backoff success clears failure state', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
pluginItems[item.id] = {
...pluginItems[item.id],
failureCount: 2,
nextRetryAt: Date.now() - 1,
lastUpdateErrorType: 'transient'
}
await updatePluginProfile(item.id)
expect(pluginItems[item.id].failureCount).toBe(0)
expect(pluginItems[item.id].lastUpdateErrorType).toBeUndefined()
})
it('respects nextRetryAt unless forced', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
pluginItems[item.id] = { ...pluginItems[item.id], nextRetryAt: Date.now() + 60000 }
fetchConfig.mockClear()
await updatePluginProfile(item.id)
expect(fetchConfig).not.toHaveBeenCalled()
await updatePluginProfile(item.id, true)
expect(fetchConfig).toHaveBeenCalled()
})
it('vault missing → needs-reauth', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
delete vaults[item.id]
await updatePluginProfile(item.id)
expect(pluginItems[item.id].status).toBe('needs-reauth')
})
it('corrupt active record (no profileId) → needs-reauth, never writes undefined.yaml', async () => {
const item = await installPlugin(file())
pluginItems[item.id] = { ...pluginItems[item.id], status: 'active', profileId: undefined }
fetchConfig.mockClear()
await updatePluginProfile(item.id)
expect(pluginItems[item.id].status).toBe('needs-reauth')
expect(fetchConfig).not.toHaveBeenCalled()
})
it('re-discovers and retries once on a retired gateway', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
discoverGateway.mockClear()
fetchConfig
.mockRejectedValueOnce(new GatewayError('retired', 'gone'))
.mockResolvedValueOnce(CLASH)
discoverGateway.mockResolvedValueOnce({
...WK,
gateway: 'https://gw2.front.com'
})
await updatePluginProfile(item.id)
expect(discoverGateway).toHaveBeenCalledTimes(1)
expect(vaults[item.id].gateway.gateway).toBe('https://gw2.front.com')
expect(pluginItems[item.id].status).toBe('active')
})
it('re-discovers and retries once on an unreachable gateway (dead/retired domain)', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
discoverGateway.mockClear()
fetchConfig
.mockRejectedValueOnce(new GatewayError('unreachable', 'ENOTFOUND'))
.mockResolvedValueOnce(CLASH)
discoverGateway.mockResolvedValueOnce({ ...WK, gateway: 'https://gw3.front.com' })
await updatePluginProfile(item.id)
expect(discoverGateway).toHaveBeenCalledTimes(1)
expect(vaults[item.id].gateway.gateway).toBe('https://gw3.front.com')
expect(pluginItems[item.id].status).toBe('active')
})
it('does nothing for needs-login / needs-reauth statuses', async () => {
const item = await installPlugin(file())
fetchConfig.mockClear()
await updatePluginProfile(item.id)
expect(fetchConfig).not.toHaveBeenCalled()
})
})
describe('auditPluginVault', () => {
it('marks an active plugin with a missing vault as needs-reauth', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
delete vaults[item.id]
await auditPluginVault(item.id)
expect(pluginItems[item.id].status).toBe('needs-reauth')
})
it('leaves a needs-login plugin alone', async () => {
const item = await installPlugin(file())
await auditPluginVault(item.id)
expect(pluginItems[item.id].status).toBe('needs-login')
})
})
describe('removePlugin', () => {
it('best-effort revokes then removes profile + record + vault', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
const pid = pluginItems[item.id].profileId!
await removePlugin(item.id)
expect(revoke).toHaveBeenCalled()
expect(pluginItems[item.id]).toBeUndefined()
expect(vaults[item.id]).toBeUndefined()
expect(profiles[pid]).toBeUndefined()
})
it('completes local deletion even if revoke fails', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
revoke.mockRejectedValueOnce(new GatewayError('transient', 'net'))
await removePlugin(item.id)
expect(pluginItems[item.id]).toBeUndefined()
expect(vaults[item.id]).toBeUndefined()
})
it('re-discovers and revokes against the new gateway when the cached one is retired', async () => {
const item = await installPlugin(file())
await loginPlugin(item.id)
discoverGateway.mockClear()
revoke
.mockRejectedValueOnce(new GatewayError('retired', 'gone'))
.mockResolvedValueOnce(undefined)
discoverGateway.mockResolvedValueOnce({ ...WK, gateway: 'https://gw4.front.com' })
await removePlugin(item.id)
expect(discoverGateway).toHaveBeenCalledTimes(1)
expect(revoke).toHaveBeenCalledTimes(2)
expect(pluginItems[item.id]).toBeUndefined()
expect(vaults[item.id]).toBeUndefined()
})
})

View File

@@ -0,0 +1,332 @@
import { randomUUID } from 'crypto'
import {
getPluginItem,
addPluginItem,
updatePluginItem,
removePluginItem
} from '../../config/plugin'
import { upsertPluginProfile, removePluginProfileContent } from '../../config/profile'
import { getAppConfig } from '../../config/app'
import { mainWindow } from '../../window'
import { parseDescriptor } from './descriptor'
import { discoverGateway } from './discovery'
import { browserLogin, CLIENT_ID } from './oauth'
import { generateDevice } from './device'
import { enroll, fetchConfig, revoke, GatewayError, type GatewayTarget } from './gateway'
import { writeVault, readVault, removeVault } from './vault'
import { computeBackoff } from './backoff'
const DEFAULT_PLUGIN_INTERVAL_MIN = 1440 // 24h
const MAX_PLUGIN_FILE_BYTES = 1024 * 1024
function notifyRenderer(): void {
mainWindow?.webContents.send('pluginConfigUpdated')
mainWindow?.webContents.send('profileConfigUpdated')
}
interface NetOpts {
timeout: number
proxy?: { host: string; port: number }
}
async function netOpts(): Promise<NetOpts> {
const { subscriptionTimeout = 30000, pluginUseProxy } = await getAppConfig()
if (!pluginUseProxy) return { timeout: subscriptionTimeout }
const { getControledMihomoConfig } = await import('../../config/controledMihomo')
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
return { timeout: subscriptionTimeout, proxy: { host: '127.0.0.1', port } }
}
function readDescriptor(fileBytesB64: string): IPluginDescriptor {
if (Buffer.byteLength(fileBytesB64, 'base64') > MAX_PLUGIN_FILE_BYTES) {
throw new Error('Plugin file too large')
}
const text = Buffer.from(fileBytesB64, 'base64').toString('utf-8')
return parseDescriptor(text)
}
// 预览:仅解析 + 校验,返回安装确认页展示子集。不建记录、不落盘、不联网。
export async function previewPlugin(fileBytesB64: string): Promise<IPluginDescriptorPreview> {
const d = readDescriptor(fileBytesB64)
return {
name: d.provider.name,
icon: d.provider.icon,
site: d.provider.site,
loginUrl: d.loginUrl,
spec: d.spec
}
}
// 安装:解析 + 建 needs-login 记录(无 profileId、不联网
export async function installPlugin(fileBytesB64: string): Promise<IPluginItem> {
const d = readDescriptor(fileBytesB64)
const now = Date.now()
const record: IPluginItem = {
id: randomUUID(),
name: d.provider.name,
icon: d.provider.icon,
site: d.provider.site,
loginUrl: d.loginUrl,
spec: d.spec,
status: 'needs-login',
interval: DEFAULT_PLUGIN_INTERVAL_MIN,
autoUpdate: true,
created: now,
updated: now
}
await addPluginItem(record)
notifyRenderer()
return record
}
// 写订阅 profile + 回填 profileId + 置 active + 清失败状态(首次登录与复用设备登录共用)
async function finishLogin(id: string, record: IPluginItem, content: string): Promise<void> {
const profileId = record.profileId ?? randomUUID()
await upsertPluginProfile(
{
profileId,
pluginId: id,
name: record.name,
interval: record.interval ?? DEFAULT_PLUGIN_INTERVAL_MIN,
autoUpdate: record.autoUpdate ?? true
},
content
)
await updatePluginItem({
...record,
profileId,
status: 'active',
updated: Date.now(),
failureCount: 0,
lastUpdateErrorType: undefined,
lastUpdateErrorAt: undefined,
nextRetryAt: undefined
})
notifyRenderer()
}
// 把登录过程中的底层错误映射为脱敏类别,避免网关 host / DNS / TLS 细节经 IPC 泄露到 rendererspec §8/§13
function sanitizeLoginError(e: unknown): Error {
if (e instanceof GatewayError) {
return new Error(e.kind === 'revoked' ? 'PLUGIN_LOGIN_REVOKED' : 'PLUGIN_LOGIN_NETWORK')
}
return new Error('PLUGIN_LOGIN_FAILED')
}
// 登录(首次登录与重新认证同一入口)。对外抛错经 sanitizeLoginError 脱敏。
export async function loginPlugin(id: string): Promise<void> {
try {
await runLogin(id)
} catch (e) {
throw sanitizeLoginError(e)
}
}
// 设备复用仅限「needs-login 且已有 vault」这一种情形上次 enroll 成功但首份订阅拉取失败留下的
// “孤儿设备”,重拉即可,避免每次重试都 enroll 新设备、消耗服务端设备数上限。
// 其它情形——needs-reauth显式重新登录、active刷新、无 vault首装/换机/Linux 无 safeStorage——
// 一律走全新浏览器登录 + 新设备,与 spec §9「reauth = 再走一次 login 流程、新设备密钥」一致。
async function runLogin(id: string): Promise<void> {
const record = await getPluginItem(id)
if (!record) throw new Error('Plugin not found')
const net = await netOpts()
const existing = await readVault(id)
if (existing && record.status === 'needs-login') {
try {
const content = await fetchWithRediscovery(id, record, existing, net)
await finishLogin(id, record, content)
return
} catch (e) {
// 孤儿设备已被吊销 → 丢弃旧 vault落到下面的全新浏览器登录 + 新设备
if (!(e instanceof GatewayError && e.kind === 'revoked')) throw e
await removeVault(id)
}
}
const wk = await discoverGateway(record.loginUrl, net)
const target: GatewayTarget = { gateway: wk.gateway, endpoints: wk.endpoints }
const dev = generateDevice()
const oauth = await browserLogin(record.loginUrl)
await enroll(
target,
{
code: oauth.code,
code_verifier: oauth.verifier,
redirect_uri: oauth.redirectUri,
client_id: CLIENT_ID,
devicePubKey: dev.pubKeyB64,
deviceId: dev.deviceId
},
net
)
await writeVault(id, {
devicePrivKey: dev.privKeyB64,
deviceId: dev.deviceId,
gateway: target
})
const content = await fetchConfig(
target,
{ deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 },
net
)
await finishLogin(id, record, content)
}
// 对一次网关操作做“缓存网关 retired/unreachable410、gateway_retired或 DNS/连接/TLS 失败)时,
// 用 loginUrl 重新发现并重试一次”的包装支撑可轮换网关、旧域名退役自愈spec §5
// 拉订阅与 revoke 共用,确保网关轮换后删除插件仍能解绑服务端设备。
async function withGatewayRediscovery<T>(
id: string,
loginUrl: string,
vault: IPluginVault,
net: NetOpts,
op: (target: GatewayTarget) => Promise<T>
): Promise<T> {
try {
return await op(vault.gateway)
} catch (e) {
if (e instanceof GatewayError && (e.kind === 'retired' || e.kind === 'unreachable')) {
const wk = await discoverGateway(loginUrl, net)
const target: GatewayTarget = { gateway: wk.gateway, endpoints: wk.endpoints }
await writeVault(id, { ...vault, gateway: target })
return await op(target)
}
throw e
}
}
function fetchWithRediscovery(
id: string,
record: IPluginItem,
vault: IPluginVault,
net: NetOpts
): Promise<string> {
const cred = { deviceId: vault.deviceId, privKeyB64: vault.devicePrivKey }
return withGatewayRediscovery(id, record.loginUrl, vault, net, (target) =>
fetchConfig(target, cred, net)
)
}
// 自动/手动更新(静默,不弹浏览器)
export async function updatePluginProfile(id: string, force = false): Promise<void> {
const record = await getPluginItem(id)
if (!record) return
if (record.status === 'needs-login' || record.status === 'needs-reauth') return
// active/needs-reauth 态必须有 profileIdspec §10。损坏/迁移异常导致 active 无 profileId 时,
// 标 needs-reauth 而非用 undefined 拼出 profiles/undefined.yaml。
if (!record.profileId) {
await updatePluginItem({
...record,
status: 'needs-reauth',
updated: Date.now(),
nextRetryAt: undefined
})
notifyRenderer()
return
}
if (!force && record.nextRetryAt && Date.now() < record.nextRetryAt) return
const vault = await readVault(id)
if (!vault) {
await updatePluginItem({
...record,
status: 'needs-reauth',
updated: Date.now(),
nextRetryAt: undefined
})
notifyRenderer()
return
}
const net = await netOpts()
try {
const content = await fetchWithRediscovery(id, record, vault, net)
await upsertPluginProfile(
{
profileId: record.profileId!,
pluginId: id,
name: record.name,
interval: record.interval ?? DEFAULT_PLUGIN_INTERVAL_MIN,
autoUpdate: record.autoUpdate ?? true
},
content
)
await updatePluginItem({
...record,
status: 'active',
updated: Date.now(),
failureCount: 0,
lastUpdateErrorType: undefined,
lastUpdateErrorAt: undefined,
nextRetryAt: undefined
})
} catch (e) {
const now = Date.now()
if (e instanceof GatewayError && e.kind === 'revoked') {
await updatePluginItem({
...record,
status: 'needs-reauth',
lastUpdateErrorType: 'auth',
lastUpdateErrorAt: now,
nextRetryAt: undefined
})
} else {
const failureCount = (record.failureCount ?? 0) + 1
const { nextRetryAt } = computeBackoff(failureCount, now)
await updatePluginItem({
...record,
lastUpdateErrorType: 'transient',
lastUpdateErrorAt: now,
failureCount,
nextRetryAt
})
}
}
notifyRenderer()
}
// 启动审计active 但 vault 缺失(如 Linux 无 safeStorage 重启)→ needs-reauth
export async function auditPluginVault(id: string): Promise<void> {
const record = await getPluginItem(id)
if (!record || record.status !== 'active') return
if (await readVault(id)) return
await updatePluginItem({
...record,
status: 'needs-reauth',
updated: Date.now(),
nextRetryAt: undefined
})
notifyRenderer()
}
// best-effort 通知服务端解绑设备。删除插件的两个入口——插件管理 removePlugin 与 profiles 列表
// 删除profile.ts removeProfileItem 级联)——都经此函数,避免服务端设备绑定残留。失败不抛。
export async function revokePluginDevice(id: string): Promise<void> {
const vault = await readVault(id)
if (!vault) return
const record = await getPluginItem(id)
const cred = { deviceId: vault.deviceId, privKeyB64: vault.devicePrivKey }
const net = await netOpts()
try {
// 网关轮换后旧 gateway 可能 retired/unreachable用 loginUrl 重新发现再 revoke避免设备绑定残留
if (record) {
await withGatewayRediscovery(id, record.loginUrl, vault, net, (target) =>
revoke(target, cred, net)
)
} else {
await revoke(vault.gateway, cred, net)
}
} catch {
// best-effort: 服务端解绑失败不阻塞本地删除
}
}
export async function removePlugin(id: string): Promise<void> {
const record = await getPluginItem(id)
// 先 revoke需要 vault再删 vault之后才删 profile使级联里的 revoke 成为无 vault 的 no-op
// 避免对同一设备重复 revoke。
await revokePluginDevice(id)
await removeVault(id)
if (record?.profileId) await removePluginProfileContent(record.profileId)
await removePluginItem(id)
notifyRenderer()
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest'
import { isPrivateIp, isForbiddenHost, createGuardedLookup } from './net-guard'
describe('isForbiddenHost', () => {
it('forbids localhost and its variants', () => {
expect(isForbiddenHost('localhost')).toBe(true)
expect(isForbiddenHost('LocalHost')).toBe(true)
expect(isForbiddenHost('localhost.')).toBe(true)
expect(isForbiddenHost('foo.localhost')).toBe(true)
expect(isForbiddenHost('foo.localhost.')).toBe(true)
})
it('forbids private/loopback IP literals (incl. bracketed IPv6)', () => {
expect(isForbiddenHost('127.0.0.1')).toBe(true)
expect(isForbiddenHost('10.0.0.5')).toBe(true)
expect(isForbiddenHost('[::1]')).toBe(true)
expect(isForbiddenHost('')).toBe(true)
})
it('allows ordinary public hostnames (incl. lookalikes)', () => {
expect(isForbiddenHost('panel.xx.com')).toBe(false)
expect(isForbiddenHost('gw.front.com')).toBe(false)
expect(isForbiddenHost('localhost.com')).toBe(false)
expect(isForbiddenHost('mylocalhost')).toBe(false)
})
})
describe('isPrivateIp', () => {
it('flags loopback/private/link-local/metadata/ula/mapped', () => {
for (const ip of [
'127.0.0.1',
'10.0.0.5',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'169.254.169.254',
'0.0.0.0',
'100.64.0.1',
'192.0.2.1',
'198.18.0.1',
'198.51.100.1',
'203.0.113.1',
'::1',
'::',
'100::1',
'2001:db8::1',
'2002:c0a8:0101::1',
'fe80::1',
'fd00::1',
'ff02::1',
'::ffff:127.0.0.1',
// hex IPv4-mapped forms (SSRF bypass vectors)
'::ffff:7f00:1', // 127.0.0.1 in hex
'::ffff:0a00:1', // 10.0.0.1 in hex
'::ffff:c0a8:101', // 192.168.1.1 in hex
'::ffff:a9fe:a9fe', // 169.254.169.254 (metadata) in hex
// full fe80::/10 link-local (not just fe80 prefix)
'febf::1',
// fully-expanded loopback
'0:0:0:0:0:0:0:1'
]) {
expect(isPrivateIp(ip), ip).toBe(true)
}
})
it('allows public addresses', () => {
for (const ip of ['1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700:4700::1111']) {
expect(isPrivateIp(ip), ip).toBe(false)
}
})
it('treats invalid input as unsafe', () => {
expect(isPrivateIp('not-an-ip')).toBe(true)
})
it('fails closed on malformed IPv6', () => {
expect(isPrivateIp(':::1'), ':::1').toBe(true)
expect(isPrivateIp('gg::1'), 'gg::1').toBe(true)
})
})
function callLookup(
lookup: ReturnType<typeof createGuardedLookup>,
host: string
): Promise<{ err: unknown; address: string }> {
return new Promise((resolve) => {
lookup(host, {}, (err, address) => resolve({ err, address: address as string }))
})
}
describe('createGuardedLookup', () => {
it('returns first address when all public', async () => {
const lookup = createGuardedLookup(async () => [{ address: '1.1.1.1', family: 4 }])
const r = await callLookup(lookup, 'example.com')
expect(r.err).toBeNull()
expect(r.address).toBe('1.1.1.1')
})
it('rejects if any resolved address is private (rebinding)', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '1.1.1.1', family: 4 },
{ address: '127.0.0.1', family: 4 }
])
const r = await callLookup(lookup, 'evil.com')
expect(r.err).toBeTruthy()
})
it('rejects when nothing resolves', async () => {
const lookup = createGuardedLookup(async () => [])
const r = await callLookup(lookup, 'nx.example')
expect(r.err).toBeTruthy()
})
// Node's autoSelectFamily (Happy Eyeballs, default in modern Node/Electron) calls the
// custom lookup with { all: true } for dual-stack hosts and expects an ARRAY back —
// returning a single address there throws ERR_INVALID_IP_ADDRESS ("Invalid IP: undefined").
it('returns the full validated array when called with { all: true }', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '2606:4700::6810:e784', family: 6 },
{ address: '104.16.231.132', family: 4 }
])
const r = await new Promise<{ err: unknown; addresses: unknown }>((resolve) => {
lookup('dual.example', { all: true }, (err, addresses) => resolve({ err, addresses }))
})
expect(r.err).toBeNull()
expect(r.addresses).toEqual([
{ address: '2606:4700::6810:e784', family: 6 },
{ address: '104.16.231.132', family: 4 }
])
})
it('still rejects a private address even under { all: true }', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '104.16.231.132', family: 4 },
{ address: '192.168.1.5', family: 4 }
])
const r = await new Promise<{ err: unknown; addresses: unknown }>((resolve) => {
lookup('rebind.example', { all: true }, (err, addresses) => resolve({ err, addresses }))
})
expect(r.err).toBeTruthy()
})
})

View File

@@ -0,0 +1,146 @@
import { lookup as dnsLookup, type LookupAddress } from 'dns'
import { isIP, type LookupFunction } from 'net'
function isPrivateIpv4(ip: string): boolean {
const parts = ip.split('.').map((s) => Number(s))
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true
const [a, b] = parts
if (a === 0) return true
if (a === 10) return true
if (a === 127) return true
if (a === 169 && b === 254) return true // link-local + cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 0) return true // IETF protocol assignments
if (a === 192 && b === 0 && parts[2] === 2) return true // TEST-NET-1
if (a === 192 && b === 168) return true
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking
if (a === 198 && b === 51 && parts[2] === 100) return true // TEST-NET-2
if (a === 203 && b === 0 && parts[2] === 113) return true // TEST-NET-3
if (a >= 224) return true // multicast + reserved
return false
}
// Expand an IPv6 string to 8 16-bit hextets; returns null if unparseable.
function expandIpv6(input: string): number[] | null {
let ip = input.toLowerCase()
const pct = ip.indexOf('%')
if (pct >= 0) ip = ip.slice(0, pct) // strip zone id
const halves = ip.split('::')
if (halves.length > 2) return null
const parseGroups = (s: string): string[] | null => {
if (s === '') return []
const groups = s.split(':')
const out: string[] = []
for (let i = 0; i < groups.length; i++) {
const g = groups[i]
if (g.includes('.')) {
// embedded IPv4 dotted form, only valid as the last group
if (i !== groups.length - 1) return null
const o = g.split('.').map((n) => Number(n))
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null
out.push((((o[0] << 8) | o[1]) >>> 0).toString(16))
out.push((((o[2] << 8) | o[3]) >>> 0).toString(16))
} else {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null
out.push(g)
}
}
return out
}
const head = parseGroups(halves[0])
if (head === null) return null
let groups: string[]
if (halves.length === 2) {
const tail = parseGroups(halves[1])
if (tail === null) return null
const missing = 8 - head.length - tail.length
if (missing < 1) return null // "::" must stand for at least one zero group
groups = [...head, ...Array(missing).fill('0'), ...tail]
} else {
groups = head
}
if (groups.length !== 8) return null
return groups.map((g) => parseInt(g, 16))
}
function isPrivateIpv6(ip: string): boolean {
const h = expandIpv6(ip)
if (!h) return true // fail closed
if (h.every((x) => x === 0)) return true // :: unspecified
if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true // ::1 loopback
if ((h[0] & 0xffc0) === 0xfe80) return true // link-local fe80::/10
if ((h[0] & 0xfe00) === 0xfc00) return true // ULA fc00::/7
if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 0) return true // discard 100::/64
if (h[0] === 0x2001 && h[1] === 0x0db8) return true // documentation 2001:db8::/32
if (h[0] === 0x2002) return true // 6to4, fail closed for embedded special-use IPv4
if ((h[0] & 0xff00) === 0xff00) return true // multicast ff00::/8
// IPv4-mapped ::ffff:a.b.c.d (decimal OR hex form both expand here)
if (h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0xffff) {
const v4 = `${(h[6] >> 8) & 0xff}.${h[6] & 0xff}.${(h[7] >> 8) & 0xff}.${h[7] & 0xff}`
return isPrivateIpv4(v4)
}
return false
}
export function isPrivateIp(ip: string): boolean {
const fam = isIP(ip)
if (fam === 4) return isPrivateIpv4(ip)
if (fam === 6) return isPrivateIpv6(ip)
return true // not a valid literal IP → unsafe
}
// 字面层面的“非公网/特殊主机名”判定(无需 DNS私网/环回/保留 IP literal或 localhost /
// *.localhost / 带尾点变体RFC 6761 保留,始终指向环回)。用于 descriptor(loginUrl/site)、
// discovery(gateway origin)、vault 复用,确保校验语义一致;代理模式下也能拦住字面内网/环回目标。
export function isForbiddenHost(host: string): boolean {
let h = host.trim().toLowerCase()
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1) // strip IPv6 brackets
if (h.endsWith('.')) h = h.slice(0, -1) // strip FQDN trailing dot
if (h === '') return true
if (h === 'localhost' || h.endsWith('.localhost')) return true
if (isIP(h) && isPrivateIp(h)) return true
return false
}
export type ResolveAll = (hostname: string) => Promise<LookupAddress[]>
const defaultResolveAll: ResolveAll = (hostname) =>
new Promise((resolve, reject) => {
dnsLookup(hostname, { all: true }, (err, addresses) => {
if (err) reject(err)
else resolve(addresses)
})
})
// 返回一个 Node 风格 lookup先解析全部地址全部为公网才放行并把连接钉到已校验地址挡 DNS rebinding。
// 必须尊重 options.allNode 的 autoSelectFamilyHappy Eyeballs现代 Node/Electron 默认开启)会用
// { all: true } 调用 lookup 并期望回调返回 LookupAddress[]此时若只回单个地址Node 抛
// ERR_INVALID_IP_ADDRESS"Invalid IP address: undefined"),双栈/多 A 记录的网关主机会直接连不上。
export function createGuardedLookup(resolveAll: ResolveAll = defaultResolveAll): LookupFunction {
return ((hostname, options, callback) => {
const opts = (typeof options === 'function' ? {} : options) as { all?: boolean }
const cb = (typeof options === 'function' ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: string | LookupAddress[],
family?: number
) => void
resolveAll(hostname)
.then((addrs) => {
if (!addrs || addrs.length === 0) {
cb(new Error('No addresses resolved'), '', 0)
return
}
const bad = addrs.find((a) => isPrivateIp(a.address))
if (bad) {
cb(new Error(`Refusing to connect to non-public address: ${bad.address}`), '', 0)
return
}
if (opts.all) cb(null, addrs)
else cb(null, addrs[0].address, addrs[0].family)
})
.catch((e: NodeJS.ErrnoException) => cb(e, '', 0))
}) as LookupFunction
}

View File

@@ -0,0 +1,95 @@
import http from 'http'
import { readFileSync } from 'fs'
import { join } from 'path'
import { createHash } from 'crypto'
import { describe, it, expect, vi } from 'vitest'
import { generatePkce, browserLogin } from './oauth'
vi.mock('electron', () => ({ shell: { openExternal: vi.fn() } }))
function get(url: string): Promise<number> {
return new Promise((resolve, reject) => {
http
.get(url, (res) => {
res.resume()
resolve(res.statusCode ?? 0)
})
.on('error', reject)
})
}
describe('oauth PKCE', () => {
it('challenge is S256(verifier) in base64url', () => {
const { verifier, challenge } = generatePkce()
expect(verifier).toMatch(/^[A-Za-z0-9\-._~]{43,128}$/)
expect(challenge).toBe(createHash('sha256').update(verifier).digest('base64url'))
})
})
describe('browserLogin', () => {
it('opens authorize with correct params, completes on matching state', async () => {
let authorizeUrl = ''
const p = browserLogin('https://panel.xx.com/oauth/authorize', {
open: (u) => {
authorizeUrl = u
}
})
// wait a tick for the server to bind + open() to fire
await new Promise((r) => setTimeout(r, 20))
const au = new URL(authorizeUrl)
expect(au.origin + au.pathname).toBe('https://panel.xx.com/oauth/authorize')
expect(au.searchParams.get('response_type')).toBe('code')
expect(au.searchParams.get('client_id')).toBe('mihomo-party')
expect(au.searchParams.get('code_challenge_method')).toBe('S256')
expect(au.searchParams.get('scope')).toBe('subscribe')
const redirect = au.searchParams.get('redirect_uri') as string
expect(redirect).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/)
const state = au.searchParams.get('state') as string
await get(`${redirect}?code=ABC&state=${state}`)
const result = await p
expect(result.code).toBe('ABC')
expect(result.redirectUri).toBe(redirect)
expect(result.verifier).toMatch(/^[A-Za-z0-9\-._~]{43,128}$/)
})
it('rejects on state mismatch', async () => {
let redirect = ''
const p = browserLogin('https://panel.xx.com/a', {
open: (u) => {
redirect = new URL(u).searchParams.get('redirect_uri') as string
}
})
await new Promise((r) => setTimeout(r, 20))
await get(`${redirect}?code=ABC&state=WRONG`)
await expect(p).rejects.toThrow()
})
it('times out', async () => {
const p = browserLogin('https://panel.xx.com/a', { open: () => {}, timeoutMs: 30 })
await expect(p).rejects.toThrow(/timed out/)
})
it('rejects promptly when opening the browser rejects (no wait for timeout)', async () => {
// timeoutMs high so a regression (silent wait) would hang past vitest's default test timeout
const p = browserLogin('https://panel.xx.com/a', {
open: () => Promise.reject(new Error('no browser')),
timeoutMs: 60000
})
await expect(p).rejects.toThrow()
})
it('rejects when open throws synchronously', async () => {
const p = browserLogin('https://panel.xx.com/a', {
open: () => {
throw new Error('boom')
},
timeoutMs: 60000
})
await expect(p).rejects.toThrow()
})
it('does not use an embedded webview', () => {
const src = readFileSync(join(__dirname, 'oauth.ts'), 'utf-8')
expect(src).not.toMatch(/BrowserWindow|webContents|webview|loadURL/)
})
})

View File

@@ -0,0 +1,96 @@
import http from 'http'
import { randomBytes, createHash } from 'crypto'
import { shell } from 'electron'
export const CLIENT_ID = 'mihomo-party'
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
export interface PkcePair {
verifier: string
challenge: string
}
export function generatePkce(): PkcePair {
const verifier = randomBytes(32).toString('base64url') // 43 chars, RFC 7636 unreserved
const challenge = createHash('sha256').update(verifier).digest('base64url')
return { verifier, challenge }
}
export interface OAuthResult {
code: string
verifier: string
redirectUri: string
}
export interface BrowserLoginOpts {
open?: (url: string) => void | Promise<unknown>
timeoutMs?: number
}
export function browserLogin(loginUrl: string, opts: BrowserLoginOpts = {}): Promise<OAuthResult> {
const { verifier, challenge } = generatePkce()
const state = randomBytes(16).toString('base64url')
const open = opts.open ?? ((u: string): Promise<void> => shell.openExternal(u))
const timeoutMs = opts.timeoutMs ?? CALLBACK_TIMEOUT_MS
const p = new Promise<OAuthResult>((resolve, reject) => {
const server = http.createServer()
let settled = false
const finish = (fn: () => void): void => {
if (settled) return
settled = true
clearTimeout(timer)
server.close()
fn()
}
const timer = setTimeout(() => finish(() => reject(new Error('Login timed out'))), timeoutMs)
server.on('request', (req, res) => {
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
if (reqUrl.pathname !== '/callback') {
res.writeHead(404)
res.end()
return
}
const code = reqUrl.searchParams.get('code')
const retState = reqUrl.searchParams.get('state')
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
if (!code || retState !== state) {
res.end('<html><body>Login failed. You may close this window.</body></html>')
finish(() => reject(new Error('Invalid OAuth callback (state mismatch or missing code)')))
return
}
res.end('<html><body>Login complete. You may close this window.</body></html>')
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
finish(() => resolve({ code, verifier, redirectUri: `http://127.0.0.1:${port}/callback` }))
})
server.on('error', (e) => finish(() => reject(e)))
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
const redirectUri = `http://127.0.0.1:${port}/callback`
const authorize = new URL(loginUrl)
authorize.searchParams.set('response_type', 'code')
authorize.searchParams.set('client_id', CLIENT_ID)
authorize.searchParams.set('redirect_uri', redirectUri)
authorize.searchParams.set('code_challenge', challenge)
authorize.searchParams.set('code_challenge_method', 'S256')
authorize.searchParams.set('state', state)
authorize.searchParams.set('scope', 'subscribe')
// 同步异常与异步拒绝都收敛:打不开浏览器时立刻 reject 并关闭回环监听,不空等 5 分钟超时。
void Promise.resolve()
.then(() => open(authorize.toString()))
.catch((err) =>
finish(() => reject(err instanceof Error ? err : new Error('Failed to open browser')))
)
})
})
// Pre-attach an internal no-op handler so Node never sees p as unhandled,
// even when rejection races ahead of the caller's await in the same event loop.
// External .then()/.catch()/await on p still fire normally.
p.catch((_e) => {})
return p
}

View File

@@ -0,0 +1,33 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { describe, it, expect } from 'vitest'
import { buildSignInput, signRequest, verifyRequest } from './device'
interface Vector {
op: number
deviceId: string
nonceId: string
privSeedB64: string
pubKeyB64: string
nonceB64: string
ts: number
inputHex: string
sigB64: string
}
const vectors: Vector[] = JSON.parse(
readFileSync(join(__dirname, '__fixtures__', 'sign-vectors.json'), 'utf-8')
)
describe('cross-language Ed25519 sign vectors', () => {
it('device.ts reproduces each recorded canonical input + signature', () => {
expect(vectors.length).toBeGreaterThan(0)
for (const v of vectors) {
const nonce = Buffer.from(v.nonceB64, 'base64')
const input = buildSignInput(v.op, v.deviceId, v.nonceId, nonce, v.ts)
expect(input.toString('hex')).toBe(v.inputHex)
expect(signRequest(v.privSeedB64, input)).toBe(v.sigB64)
expect(verifyRequest(v.pubKeyB64, input, v.sigB64)).toBe(true)
}
})
})

View File

@@ -0,0 +1,127 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { writeVault, readVault, removeVault, isVaultPersistent } from './vault'
let TMP = ''
let encryptionAvailable = true
vi.mock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => encryptionAvailable,
encryptString: (s: string) => Buffer.from('enc:' + s, 'utf-8'),
decryptString: (b: Buffer) => Buffer.from(b).toString('utf-8').replace(/^enc:/, '')
}
}))
vi.mock('../../utils/dirs', () => ({
pluginVaultDir: () => TMP,
pluginVaultPath: (id: string) => join(TMP, `${id}.bin`)
}))
function sampleVault(): IPluginVault {
return {
devicePrivKey: Buffer.alloc(32, 1).toString('base64'),
deviceId: '11111111-1111-4111-8111-111111111111',
gateway: {
gateway: 'https://gw.front.com',
endpoints: {
enroll: '/enroll',
challenge: '/challenge',
config: '/config',
revoke: '/revoke'
}
}
}
}
beforeEach(() => {
TMP = mkdtempSync(join(tmpdir(), 'cpxvault-'))
encryptionAvailable = true
})
afterEach(() => rmSync(TMP, { recursive: true, force: true }))
describe('vault with safeStorage available', () => {
it('round-trips through an encrypted file', async () => {
await writeVault('p1', sampleVault())
expect(existsSync(join(TMP, 'p1.bin'))).toBe(true)
const out = await readVault('p1')
expect(out?.deviceId).toBe('11111111-1111-4111-8111-111111111111')
expect(out?.gateway.gateway).toBe('https://gw.front.com')
})
it('removeVault deletes the file', async () => {
await writeVault('p1', sampleVault())
await removeVault('p1')
expect(existsSync(join(TMP, 'p1.bin'))).toBe(false)
expect(await readVault('p1')).toBeUndefined()
})
it('isVaultPersistent is true', () => {
expect(isVaultPersistent()).toBe(true)
})
it('decrypts from disk when the in-memory cache is cold', async () => {
await writeVault('p3', sampleVault())
// Simulate a fresh process/launch: empty in-memory cache, file still on disk.
vi.resetModules()
const fresh = await import('./vault')
const out = await fresh.readVault('p3')
expect(out?.deviceId).toBe('11111111-1111-4111-8111-111111111111')
expect(out?.gateway.gateway).toBe('https://gw.front.com')
})
it('treats a structurally-invalid decrypted vault as missing', async () => {
const { writeFileSync } = await import('fs')
// mock safeStorage 仅在明文前加 'enc:';落一个缺字段/坏私钥的结构到磁盘
const bad = Buffer.from('enc:' + JSON.stringify({ devicePrivKey: 'short', deviceId: 'x' }))
writeFileSync(join(TMP, 'pbad.bin'), bad)
vi.resetModules()
const fresh = await import('./vault')
expect(await fresh.readVault('pbad')).toBeUndefined()
})
it('rejects a vault with a forbidden gateway origin or malformed endpoint', async () => {
const { writeFileSync } = await import('fs')
const base = {
devicePrivKey: Buffer.alloc(32, 1).toString('base64'),
deviceId: '11111111-1111-4111-8111-111111111111'
}
const eps = { enroll: '/e', challenge: '/c', config: '/cfg', revoke: '/r' }
const cases: Array<[string, unknown]> = [
['vlocal', { ...base, gateway: { gateway: 'https://localhost', endpoints: eps } }],
[
'vproto',
{
...base,
gateway: { gateway: 'https://gw.front.com', endpoints: { ...eps, config: '//evil/cfg' } }
}
],
[
'vquery',
{
...base,
gateway: { gateway: 'https://gw.front.com', endpoints: { ...eps, config: '/cfg?x=1' } }
}
]
]
for (const [id, payload] of cases) {
writeFileSync(join(TMP, `${id}.bin`), Buffer.from('enc:' + JSON.stringify(payload)))
}
vi.resetModules()
const fresh = await import('./vault')
for (const [id] of cases) {
expect(await fresh.readVault(id)).toBeUndefined()
}
})
})
describe('vault without safeStorage (Linux fallback)', () => {
beforeEach(() => {
encryptionAvailable = false
})
it('does not write plaintext to disk but keeps in memory for the session', async () => {
await writeVault('p2', sampleVault())
expect(existsSync(join(TMP, 'p2.bin'))).toBe(false)
const out = await readVault('p2')
expect(out?.deviceId).toBe('11111111-1111-4111-8111-111111111111') // in-memory hit
expect(isVaultPersistent()).toBe(false)
})
})

View File

@@ -0,0 +1,71 @@
import { mkdir, writeFile, readFile, rm, rename } from 'fs/promises'
import { existsSync } from 'fs'
import { safeStorage } from 'electron'
import { pluginVaultDir, pluginVaultPath } from '../../utils/dirs'
import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url'
// safeStorage 不可用时的会话内内存兜底(重启即丢)
const memoryVaults = new Map<string, IPluginVault>()
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
// 校验从磁盘解密出来的 vault 结构(私钥 32 字节、deviceId 为 UUIDv4、网关为 https origin、
// 四个端点为相对 path。坏/被篡改的数据按“缺失”处理,由编排层走 needs-reauth
// 避免畸形私钥/网关进入签名或网络路径。
function isValidVault(v: unknown): v is IPluginVault {
if (typeof v !== 'object' || v === null) return false
const o = v as Record<string, unknown>
if (typeof o.devicePrivKey !== 'string' || Buffer.from(o.devicePrivKey, 'base64').length !== 32) {
return false
}
if (typeof o.deviceId !== 'string' || !UUID_V4.test(o.deviceId)) return false
const g = o.gateway as Record<string, unknown> | undefined
if (!g || parseGatewayOrigin(g.gateway) === null) return false
const e = g.endpoints as Record<string, unknown> | undefined
if (!e) return false
for (const k of ['enroll', 'challenge', 'config', 'revoke']) {
if (!isValidEndpointPath(e[k])) return false
}
return true
}
export function isVaultPersistent(): boolean {
return safeStorage.isEncryptionAvailable()
}
export async function writeVault(id: string, vault: IPluginVault): Promise<void> {
if (!isVaultPersistent()) {
memoryVaults.set(id, vault)
return
}
await mkdir(pluginVaultDir(), { recursive: true })
const enc = safeStorage.encryptString(JSON.stringify(vault))
const finalPath = pluginVaultPath(id)
const tmpPath = `${finalPath}.tmp`
await writeFile(tmpPath, enc, { mode: 0o600 })
await rename(tmpPath, finalPath) // 原子替换
memoryVaults.set(id, vault)
}
export async function readVault(id: string): Promise<IPluginVault | undefined> {
const cached = memoryVaults.get(id)
if (cached) return cached
if (!isVaultPersistent()) return undefined
const p = pluginVaultPath(id)
if (!existsSync(p)) return undefined
try {
const enc = await readFile(p)
const json = safeStorage.decryptString(enc)
const parsed = JSON.parse(json) as unknown
if (!isValidVault(parsed)) return undefined // 结构非法 → 视为缺失
memoryVaults.set(id, parsed)
return parsed
} catch {
return undefined // 损坏/不可解密 → 视为缺失,由编排层走 needs-reauth
}
}
export async function removeVault(id: string): Promise<void> {
memoryVaults.delete(id)
await rm(pluginVaultPath(id), { force: true })
}

View File

@@ -105,6 +105,18 @@ export function profilePath(id: string): string {
return path.join(profilesDir(), `${id}.yaml`)
}
export function pluginConfigPath(): string {
return path.join(dataDir(), 'plugin.yaml')
}
export function pluginVaultDir(): string {
return path.join(dataDir(), 'plugin-vault')
}
export function pluginVaultPath(id: string): string {
return path.join(pluginVaultDir(), `${id}.bin`)
}
export function overrideDir(): string {
return path.join(dataDir(), 'override')
}

View File

@@ -123,6 +123,14 @@ import { exportGistAgeSecretKey, generateGistAgeKeyPair, getGistUrl } from '../r
import { startMonitor } from '../resolve/trafficMonitor'
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
import {
previewPlugin,
installPlugin,
loginPlugin,
removePlugin,
updatePluginProfile
} from '../resolve/plugin'
import { getPluginConfig } from '../config/plugin'
import { getImageDataURL } from './image'
import { get as httpGet } from './chromeRequest'
import { getIconDataURL } from './icon'
@@ -339,6 +347,13 @@ const asyncHandlers: Record<string, AsyncFn> = {
showFloatingWindow,
closeFloatingWindow,
showContextMenu,
// Plugin
getPluginConfig,
previewPlugin,
installPlugin,
loginPlugin,
removePlugin,
updatePluginProfile,
// Misc
getGistUrl,
generateGistAgeKeyPair,

View File

@@ -145,6 +145,13 @@ const validInvokeChannels = [
'quitApp',
// Shortcut
'registerShortcut',
// Plugin
'getPluginConfig',
'previewPlugin',
'installPlugin',
'loginPlugin',
'removePlugin',
'updatePluginProfile',
// Misc
'getGistUrl',
'generateGistAgeKeyPair',
@@ -168,7 +175,8 @@ const validListenChannels = [
'profileConfigUpdated',
'groupsUpdated',
'rulesUpdated',
'updateDownloadProgress'
'updateDownloadProgress',
'pluginConfigUpdated'
] as const
// 允许的 send channels 白名单

View File

@@ -0,0 +1,121 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React, { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@renderer/components/base/toast'
import { previewPlugin, installPlugin } from '@renderer/utils/ipc'
interface Props {
onClose: () => void
}
function abToBase64(buf: ArrayBuffer): string {
let binary = ''
const bytes = new Uint8Array(buf)
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
return btoa(binary)
}
function hostOf(url: string): string {
try {
return new URL(url).host
} catch {
return url
}
}
const PluginInstallModal: React.FC<Props> = ({ onClose }) => {
const { t } = useTranslation()
const fileInput = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState('')
const [fileB64, setFileB64] = useState('')
const [preview, setPreview] = useState<IPluginDescriptorPreview | null>(null)
const [busy, setBusy] = useState(false)
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const f = e.target.files?.[0]
if (!f) return
setFileName(f.name)
setFileB64(abToBase64(await f.arrayBuffer()))
setPreview(null)
}
const doPreview = async (): Promise<void> => {
setBusy(true)
try {
setPreview(await previewPlugin(fileB64))
} catch (e) {
const msg = e instanceof Error ? e.message : ''
toast.error(msg.includes('v1') ? t('plugins.outdatedFile') : t('plugins.previewFailed'))
} finally {
setBusy(false)
}
}
const doInstall = async (): Promise<void> => {
setBusy(true)
try {
await installPlugin(fileB64)
toast.success(t('plugins.installed'))
onClose()
} catch {
toast.error(t('plugins.installFailed'))
} finally {
setBusy(false)
}
}
return (
<Modal isOpen onOpenChange={(open) => !open && onClose()} size="md">
<ModalContent>
<ModalHeader>{preview ? t('plugins.confirmTitle') : t('plugins.import')}</ModalHeader>
<ModalBody>
{!preview ? (
<div className="flex flex-col gap-3">
<input
ref={fileInput}
type="file"
accept=".cpx"
className="hidden"
onChange={onPickFile}
/>
<Button variant="flat" onPress={() => fileInput.current?.click()}>
{fileName || t('plugins.chooseFile')}
</Button>
</div>
) : (
<div className="flex flex-col gap-2 text-sm">
<div>
{t('plugins.provider')}: <b>{preview.name}</b>
</div>
{preview.site && (
<div>
{t('plugins.site')}: {preview.site}
</div>
)}
<div>
{t('plugins.loginUrl')}: <b>{hostOf(preview.loginUrl)}</b>
</div>
<div className="mt-2 text-warning">{t('plugins.installNotice')}</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
{t('plugins.cancel')}
</Button>
{!preview ? (
<Button color="primary" isLoading={busy} isDisabled={!fileB64} onPress={doPreview}>
{t('plugins.next')}
</Button>
) : (
<Button color="primary" isLoading={busy} onPress={doInstall}>
{t('plugins.install')}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default PluginInstallModal

View File

@@ -0,0 +1,92 @@
import { Card, CardBody, Chip, Button } from '@heroui/react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@renderer/components/base/toast'
import { removePlugin, loginPlugin } from '@renderer/utils/ipc'
import BaseConfirmModal from '@renderer/components/base/base-confirm-modal'
interface Props {
item: IPluginItem
onChanged: () => void
}
const statusColor: Record<IPluginStatus, 'success' | 'warning' | 'primary'> = {
active: 'success',
'needs-login': 'primary',
'needs-reauth': 'warning'
}
const PluginItem: React.FC<Props> = ({ item, onChanged }) => {
const { t } = useTranslation()
const [busy, setBusy] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const doLogin = async (): Promise<void> => {
setBusy(true)
toast.info(t('plugins.loginInProgress'))
try {
await loginPlugin(item.id)
toast.success(t('plugins.loginSuccess'))
} catch {
toast.error(t('plugins.loginFailed'))
} finally {
setBusy(false)
onChanged()
}
}
const needsLogin = item.status === 'needs-login'
const needsReauth = item.status === 'needs-reauth'
return (
<Card>
<CardBody className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-bold">{item.name}</span>
<Chip size="sm" color={statusColor[item.status]}>
{t(`plugins.status.${item.status}`)}
</Chip>
</div>
<span className="text-xs text-foreground-500">{item.loginUrl}</span>
{needsLogin && <div className="text-xs text-primary">{t('plugins.needsLoginTip')}</div>}
{needsReauth && <div className="text-xs text-warning">{t('plugins.reauthTip')}</div>}
<div className="flex gap-2 flex-wrap">
{needsLogin && (
<Button size="sm" color="primary" isLoading={busy} onPress={doLogin}>
{t('plugins.login')}
</Button>
)}
{needsReauth && (
<Button size="sm" color="warning" isLoading={busy} onPress={doLogin}>
{t('plugins.relogin')}
</Button>
)}
<Button size="sm" variant="flat" color="danger" onPress={() => setShowRemove(true)}>
{t('plugins.remove')}
</Button>
</div>
</CardBody>
{showRemove && (
<BaseConfirmModal
isOpen={showRemove}
title={t('plugins.remove')}
content={t('plugins.removeConfirm')}
onCancel={() => setShowRemove(false)}
onConfirm={async () => {
try {
await removePlugin(item.id)
} finally {
onChanged()
}
setShowRemove(false)
}}
/>
)}
</Card>
)
}
export default PluginItem

View File

@@ -17,7 +17,7 @@ import dayjs from '@renderer/utils/dayjs'
import React, { Key, useMemo, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { openFile } from '@renderer/utils/ipc'
import { openFile, updatePluginProfile } from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useTranslation } from 'react-i18next'
import BaseConfirmModal from '../base/base-confirm-modal'
@@ -296,18 +296,22 @@ const ProfileItem: React.FC<Props> = (props) => {
{info?.name}
</h3>
<div className="flex">
{info.type === 'remote' && (
{(info.type === 'remote' || info.type === 'plugin') && (
<Tooltip placement="left" content={dayjs(info.updated).fromNow()}>
<Button
isIconOnly
size="sm"
variant="light"
color="default"
disabled={updating}
disabled={updating || (info.type === 'plugin' && !info.pluginId)}
onPress={async () => {
setUpdating(true)
await addProfileItem(info)
setUpdating(false)
try {
setUpdating(true)
if (info.type === 'remote') await addProfileItem(info)
else if (info.pluginId) await updatePluginProfile(info.pluginId, true)
} finally {
setUpdating(false)
}
}}
>
<IoMdRefresh

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react'
import { getPluginConfig } from '@renderer/utils/ipc'
import { createConfigContext } from './create-config-context'
const { Provider, useConfig } = createConfigContext<IPluginConfig>({
swrKey: 'getPluginConfig',
fetcher: getPluginConfig,
ipcEvent: 'pluginConfigUpdated'
})
export const PluginConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => (
<Provider>{children}</Provider>
)
export const usePluginConfig = (): {
pluginConfig: IPluginConfig | undefined
mutatePluginConfig: () => void
} => {
const { config, mutate } = useConfig()
return { pluginConfig: config, mutatePluginConfig: mutate }
}

View File

@@ -804,5 +804,38 @@
"network.topology.resume": "Resume",
"settings.githubProxy": "GitHub Download Proxy",
"settings.githubProxy.auto": "Auto (proxy first)",
"settings.githubProxy.direct": "Direct"
"settings.githubProxy.direct": "Direct",
"plugins": {
"title": "Airport Plugins",
"import": "Import Plugin",
"chooseFile": "Choose File (.cpx)",
"next": "Next",
"install": "Install",
"cancel": "Cancel",
"confirmTitle": "Confirm Install",
"provider": "Provider",
"site": "Website",
"loginUrl": "Login domain",
"installNotice": "After install, your system browser opens this domain to log in. Your password is only entered on the provider's site — this app never touches it.",
"login": "Log In",
"relogin": "Log In Again",
"loginInProgress": "Opened in your system browser — please complete the login…",
"loginSuccess": "Logged in",
"loginFailed": "Login failed",
"needsLoginTip": "Installed — log in to fetch the subscription",
"reauthTip": "Session expired, please log in again",
"useProxy": "Via Proxy",
"useProxyWarning": "Plugin requests go through the local mixed-port proxy. https, host, redirect and size checks still apply, but the final resolved IP cannot be verified, so SSRF protection is reduced.",
"remove": "Remove",
"removeConfirm": "Remove this plugin and its subscription?",
"status": {
"needs-login": "Needs login",
"active": "Active",
"needs-reauth": "Needs re-login"
},
"installed": "Installed",
"installFailed": "Install failed",
"previewFailed": "Invalid plugin file",
"outdatedFile": "This plugin file format is outdated; please obtain the new one from your provider"
}
}

View File

@@ -768,5 +768,38 @@
"network.topology.resume": "ادامه",
"settings.githubProxy": "پروکسی دانلود GitHub",
"settings.githubProxy.auto": "خودکار (پروکسی اول)",
"settings.githubProxy.direct": "مستقیم"
"settings.githubProxy.direct": "مستقیم",
"plugins": {
"title": "افزونه‌های سرویس‌دهنده",
"import": "وارد کردن افزونه",
"chooseFile": "انتخاب فایل (.cpx)",
"next": "بعدی",
"install": "نصب",
"cancel": "لغو",
"confirmTitle": "تأیید نصب",
"provider": "سرویس‌دهنده",
"site": "وب‌سایت",
"loginUrl": "دامنه ورود",
"installNotice": "پس از نصب، مرورگر سیستم این دامنه را برای ورود باز می‌کند. رمز عبور فقط در سایت سرویس‌دهنده وارد می‌شود و این برنامه هرگز به آن دسترسی ندارد.",
"login": "ورود",
"relogin": "ورود دوباره",
"loginInProgress": "در مرورگر سیستم باز شد — لطفاً ورود را کامل کنید…",
"loginSuccess": "ورود انجام شد",
"loginFailed": "ورود ناموفق بود",
"needsLoginTip": "نصب شد — برای دریافت اشتراک وارد شوید",
"reauthTip": "نشست منقضی شده است، دوباره وارد شوید",
"useProxy": "از طریق پروکسی",
"useProxyWarning": "درخواست‌های افزونه از پروکسی mixed-port محلی عبور می‌کنند. بررسی https، میزبان، تغییر مسیر و اندازه همچنان اعمال می‌شود، اما IP نهایی قابل تأیید نیست و محافظت در برابر SSRF کاهش می‌یابد.",
"remove": "حذف",
"removeConfirm": "این افزونه و اشتراک آن حذف شود؟",
"status": {
"needs-login": "نیازمند ورود",
"active": "فعال",
"needs-reauth": "نیازمند ورود دوباره"
},
"installed": "نصب شد",
"installFailed": "نصب ناموفق بود",
"previewFailed": "فایل افزونه نامعتبر است",
"outdatedFile": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویس‌دهنده دریافت کنید"
}
}

View File

@@ -776,5 +776,38 @@
"settings.githubProxy.direct": "Прямое подключение",
"settings.triggerMainWindowBehavior": "Действие окна при нажатии на иконку в трее",
"settings.triggerMainWindowBehaviorShow": "Открытие",
"settings.triggerMainWindowBehaviorToggle": "Открытие/Закрытие"
"settings.triggerMainWindowBehaviorToggle": "Открытие/Закрытие",
"plugins": {
"title": "Плагины провайдеров",
"import": "Импорт плагина",
"chooseFile": "Выбрать файл (.cpx)",
"next": "Далее",
"install": "Установить",
"cancel": "Отмена",
"confirmTitle": "Подтвердите установку",
"provider": "Провайдер",
"site": "Сайт",
"loginUrl": "Домен входа",
"installNotice": "После установки системный браузер откроет этот домен для входа. Пароль вводится только на сайте провайдера — приложение его не получает.",
"login": "Войти",
"relogin": "Войти снова",
"loginInProgress": "Открыто в браузере — завершите вход…",
"loginSuccess": "Вход выполнен",
"loginFailed": "Ошибка входа",
"needsLoginTip": "Установлено — войдите, чтобы получить подписку",
"reauthTip": "Сессия истекла, войдите снова",
"useProxy": "Через прокси",
"useProxyWarning": "Запросы плагина идут через локальный mixed-port прокси. Проверки https, хоста, редиректа и размера сохраняются, но итоговый IP не проверяется — защита от SSRF снижена.",
"remove": "Удалить",
"removeConfirm": "Удалить этот плагин и его подписку?",
"status": {
"needs-login": "Нужен вход",
"active": "Активен",
"needs-reauth": "Нужен повторный вход"
},
"installed": "Установлено",
"installFailed": "Ошибка установки",
"previewFailed": "Недействительный файл плагина",
"outdatedFile": "Формат файла плагина устарел; получите новый у провайдера"
}
}

View File

@@ -804,5 +804,34 @@
"network.topology.resume": "恢复",
"settings.githubProxy": "GitHub 下载代理",
"settings.githubProxy.auto": "自动(优先代理)",
"settings.githubProxy.direct": "直连"
"settings.githubProxy.direct": "直连",
"plugins": {
"title": "机场插件",
"import": "导入插件",
"chooseFile": "选择文件 (.cpx)",
"next": "下一步",
"install": "安装",
"cancel": "取消",
"confirmTitle": "确认安装",
"provider": "机场",
"site": "官网",
"loginUrl": "登录域名",
"installNotice": "安装后将在系统浏览器打开该域名登录;密码只输入在机场官网,本应用不会接触你的密码。",
"login": "登录",
"relogin": "重新登录",
"loginInProgress": "已在系统浏览器打开,请完成登录…",
"loginSuccess": "登录成功",
"loginFailed": "登录失败",
"needsLoginTip": "已安装,请登录以获取订阅",
"reauthTip": "登录已失效,请重新登录",
"useProxy": "走代理",
"useProxyWarning": "插件请求经由本地混合端口代理发出。仍校验 https、主机、重定向和大小但无法验证代理最终解析到的 IPSSRF 防护降级。",
"remove": "删除",
"removeConfirm": "确认删除该插件及其订阅?",
"status": { "needs-login": "待登录", "active": "正常", "needs-reauth": "需重新登录" },
"installed": "已安装",
"installFailed": "安装失败",
"previewFailed": "插件文件无效",
"outdatedFile": "该插件文件格式已过期,请从机场重新获取"
}
}

View File

@@ -804,5 +804,34 @@
"network.topology.resume": "繼續",
"settings.githubProxy": "GitHub 下載代理",
"settings.githubProxy.auto": "自動(優先代理)",
"settings.githubProxy.direct": "直連"
"settings.githubProxy.direct": "直連",
"plugins": {
"title": "機場外掛",
"import": "匯入外掛",
"chooseFile": "選擇檔案 (.cpx)",
"next": "下一步",
"install": "安裝",
"cancel": "取消",
"confirmTitle": "確認安裝",
"provider": "機場",
"site": "官網",
"loginUrl": "登入網域",
"installNotice": "安裝後將在系統瀏覽器開啟該網域登入;密碼只輸入在機場官網,本應用不會接觸你的密碼。",
"login": "登入",
"relogin": "重新登入",
"loginInProgress": "已在系統瀏覽器開啟,請完成登入…",
"loginSuccess": "登入成功",
"loginFailed": "登入失敗",
"needsLoginTip": "已安裝,請登入以取得訂閱",
"reauthTip": "登入已失效,請重新登入",
"useProxy": "走代理",
"useProxyWarning": "外掛請求經由本機混合埠代理發出。仍校驗 https、主機、重新導向與大小但無法驗證代理最終解析到的 IPSSRF 防護降級。",
"remove": "刪除",
"removeConfirm": "確認刪除該外掛及其訂閱?",
"status": { "needs-login": "待登入", "active": "正常", "needs-reauth": "需重新登入" },
"installed": "已安裝",
"installFailed": "安裝失敗",
"previewFailed": "外掛檔案無效",
"outdatedFile": "該外掛檔案格式已過期,請從機場重新取得"
}
}

View File

@@ -13,6 +13,7 @@ import { AppConfigProvider } from './hooks/use-app-config'
import { ControledMihomoConfigProvider } from './hooks/use-controled-mihomo-config'
import { OverrideConfigProvider } from './hooks/use-override-config'
import { ProfileConfigProvider } from './hooks/use-profile-config'
import { PluginConfigProvider } from './hooks/use-plugin-config'
import { RulesProvider } from './hooks/use-rules'
import { GroupsProvider } from './hooks/use-groups'
import { ToastProvider } from './components/base/toast'
@@ -52,15 +53,17 @@ init().then(() => {
<AppConfigProvider>
<ControledMihomoConfigProvider>
<ProfileConfigProvider>
<OverrideConfigProvider>
<GroupsProvider>
<RulesProvider>
<ToastProvider>
<App />
</ToastProvider>
</RulesProvider>
</GroupsProvider>
</OverrideConfigProvider>
<PluginConfigProvider>
<OverrideConfigProvider>
<GroupsProvider>
<RulesProvider>
<ToastProvider>
<App />
</ToastProvider>
</RulesProvider>
</GroupsProvider>
</OverrideConfigProvider>
</PluginConfigProvider>
</ProfileConfigProvider>
</ControledMihomoConfigProvider>
</AppConfigProvider>

View File

@@ -13,10 +13,19 @@ import {
import BasePage from '@renderer/components/base/base-page'
import { toast } from '@renderer/components/base/toast'
import ProfileItem from '@renderer/components/profiles/profile-item'
import PluginItem from '@renderer/components/plugins/plugin-item'
import PluginInstallModal from '@renderer/components/plugins/plugin-install-modal'
import EditInfoModal from '@renderer/components/profiles/edit-info-modal'
import { useProfileConfig } from '@renderer/hooks/use-profile-config'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { getFilePath, readTextFile, subStoreCollections, subStoreSubs } from '@renderer/utils/ipc'
import { usePluginConfig } from '@renderer/hooks/use-plugin-config'
import {
getFilePath,
readTextFile,
subStoreCollections,
subStoreSubs,
updatePluginProfile
} from '@renderer/utils/ipc'
import type { KeyboardEvent } from 'react'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MdContentPaste, MdUnfoldMore, MdUnfoldLess } from 'react-icons/md'
@@ -48,11 +57,12 @@ const Profiles: React.FC = () => {
changeCurrentProfile,
mutateProfileConfig
} = useProfileConfig()
const { appConfig } = useAppConfig()
const { appConfig, patchAppConfig } = useAppConfig()
const {
useSubStore = DEFAULT_USE_SUB_STORE,
useCustomSubStore = false,
customSubStoreUrl = ''
customSubStoreUrl = '',
pluginUseProxy = false
} = appConfig || {}
const { current, items = [] } = profileConfig || {}
const navigate = useNavigate()
@@ -69,6 +79,8 @@ const Profiles: React.FC = () => {
const [fileOver, setFileOver] = useState(false)
const [url, setUrl] = useState('')
const [, setNow] = useState(new Date())
const { pluginConfig, mutatePluginConfig } = usePluginConfig()
const [showPluginImport, setShowPluginImport] = useState(false)
const isUrlEmpty = url.trim() === ''
const sensors = useSensors(useSensor(PointerSensor))
const { data: subs = [], mutate: mutateSubs } = useSWR(
@@ -256,12 +268,15 @@ const Profiles: React.FC = () => {
setUpdating(true)
for (const item of items) {
if (item.id === current) continue
if (item.type !== 'remote') continue
await addProfileItem(item)
if (item.type === 'remote') await addProfileItem(item)
else if (item.type === 'plugin' && item.pluginId)
await updatePluginProfile(item.pluginId, true)
}
const currentItem = items.find((item) => item.id === current)
if (currentItem && currentItem.type === 'remote') {
await addProfileItem(currentItem)
} else if (currentItem?.type === 'plugin' && currentItem.pluginId) {
await updatePluginProfile(currentItem.pluginId, true)
}
setUpdating(false)
}}
@@ -493,6 +508,40 @@ const Profiles: React.FC = () => {
</div>
<Divider />
</div>
<div className="px-2">
<div className="flex items-center justify-between mt-2 mb-2">
<span className="font-bold">{t('plugins.title')}</span>
<div className="flex items-center gap-3">
<Tooltip content={t('plugins.useProxyWarning')} placement="bottom">
<Checkbox
size="sm"
isSelected={pluginUseProxy}
onValueChange={(v) => patchAppConfig({ pluginUseProxy: v })}
>
{t('plugins.useProxy')}
</Checkbox>
</Tooltip>
<Button size="sm" color="primary" onPress={() => setShowPluginImport(true)}>
{t('plugins.import')}
</Button>
</div>
</div>
{(pluginConfig?.items?.length ?? 0) > 0 && (
<div className="grid grid-cols-1 gap-2 mb-3">
{pluginConfig?.items?.map((p) => (
<PluginItem key={p.id} item={p} onChanged={mutatePluginConfig} />
))}
</div>
)}
{showPluginImport && (
<PluginInstallModal
onClose={() => {
setShowPluginImport(false)
mutatePluginConfig()
}}
/>
)}
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<div
className={`${fileOver ? 'blur-sm' : ''} grid sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2 m-2`}

View File

@@ -153,6 +153,13 @@ interface IpcApi {
createHeapSnapshot: () => Promise<void>
// Shortcut
registerShortcut: (oldShortcut: string, newShortcut: string, action: string) => Promise<boolean>
// Plugin
getPluginConfig: (force?: boolean) => Promise<IPluginConfig>
previewPlugin: (fileBytesB64: string) => Promise<IPluginDescriptorPreview>
installPlugin: (fileBytesB64: string) => Promise<IPluginItem>
loginPlugin: (id: string) => Promise<void>
removePlugin: (id: string) => Promise<void>
updatePluginProfile: (id: string, force?: boolean) => Promise<void>
// Misc
getGistUrl: () => Promise<string>
generateGistAgeKeyPair: () => Promise<{ secretKey: string; recipient: string }>
@@ -310,6 +317,13 @@ export const {
createHeapSnapshot,
// Shortcut
registerShortcut,
// Plugin
getPluginConfig,
previewPlugin,
installPlugin,
loginPlugin,
removePlugin,
updatePluginProfile,
// Misc
getGistUrl,
generateGistAgeKeyPair,

77
src/shared/types.d.ts vendored
View File

@@ -327,6 +327,7 @@ interface IAppConfig {
autoQuitWithoutCoreMode?: 'core' | 'tray'
useCustomSubStore?: boolean
useProxyInSubStore?: boolean
pluginUseProxy?: boolean // 插件网关请求经由本地混合端口代理(安全保证降级,默认关闭)
mihomoCpuPriority?: Priority
customSubStoreUrl?: string
diffWorkDir?: boolean
@@ -557,7 +558,7 @@ interface ISubscriptionUserInfo {
interface IProfileItem {
id: string
type: 'remote' | 'local'
type: 'remote' | 'local' | 'plugin'
name: string
url?: string // remote
file?: string // local
@@ -574,6 +575,7 @@ interface IProfileItem {
userAgent?: string
ageSecretKey?: string
updateTimeout?: number
pluginId?: string
}
interface ISubStoreSub {
@@ -582,3 +584,76 @@ interface ISubStoreSub {
icon?: string
tag?: string[]
}
interface IPluginProvider {
name: string
icon?: string
site?: string
}
// .cpx v2 — public, unencrypted descriptor. Contains NO secrets.
interface IPluginDescriptor {
magic: 'CPXF'
v: 2
spec: 'cpx-plugin/2'
loginUrl: string // OAuth authorize endpoint, https, no query/fragment
provider: IPluginProvider
}
// Subset returned by previewPlugin for the install-confirm page (no records, no network)
interface IPluginDescriptorPreview {
name: string
icon?: string
site?: string
loginUrl: string // full url; UI shows the host
spec: string
}
interface IGatewayEndpoints {
enroll: string
challenge: string
config: string
revoke: string
}
// /.well-known/cpx-gateway discovery response
interface IGatewayWellKnown {
spec: 'cpx-plugin/2'
gateway: string // https origin, no path/query/fragment
endpoints: IGatewayEndpoints
}
type IPluginStatus = 'needs-login' | 'active' | 'needs-reauth'
interface IPluginItem {
id: string
name: string
icon?: string
site?: string
loginUrl: string // public metadata; required to re-open the browser after restart
spec: string
profileId?: string // absent while 'needs-login'; present once 'active'/'needs-reauth'
status: IPluginStatus
interval?: number
autoUpdate?: boolean
created: number
updated: number
lastUpdateErrorType?: 'auth' | 'transient'
lastUpdateErrorAt?: number
nextRetryAt?: number
failureCount?: number
}
interface IPluginConfig {
items: IPluginItem[]
}
// safeStorage-encrypted vault payload — the ONLY place secrets live.
interface IPluginVault {
devicePrivKey: string // Ed25519 raw 32-byte seed, base64 (standard, padded)
deviceId: string // UUIDv4
gateway: {
gateway: string // discovered https origin (cached for silent updates)
endpoints: IGatewayEndpoints
}
}

View File

@@ -1,6 +1,7 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
"exclude": ["**/*.test.ts", "node_modules"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"],

View File

@@ -8,6 +8,7 @@
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"exclude": ["**/*.test.ts", "node_modules"],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
environment: 'node'
}
})