mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: airport plugin
This commit is contained in:
0
.githooks/pre-commit
Normal file → Executable file
0
.githooks/pre-commit
Normal file → Executable file
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,4 +12,3 @@ party.md
|
||||
CLAUDE.md
|
||||
agent.md
|
||||
tsconfig.node.tsbuildinfo
|
||||
docs
|
||||
@@ -1,5 +1,6 @@
|
||||
out
|
||||
dist
|
||||
.claude
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
|
||||
50
changelog.md
50
changelog.md
@@ -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 额外字段处理逻辑并补充类型定义
|
||||
- 更新依赖
|
||||
|
||||
14
deploy/gateway/.dockerignore
Normal file
14
deploy/gateway/.dockerignore
Normal 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
|
||||
24
deploy/gateway/.env.example
Normal file
24
deploy/gateway/.env.example
Normal 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
8
deploy/gateway/Caddyfile
Normal 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
19
deploy/gateway/Dockerfile
Normal 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
285
deploy/gateway/README.md
Normal 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
19
deploy/gateway/admin.mjs
Executable 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)
|
||||
29
deploy/gateway/check-vectors.mjs
Normal file
29
deploy/gateway/check-vectors.mjs
Normal 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
48
deploy/gateway/deploy.sh
Executable 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
|
||||
32
deploy/gateway/docker-compose.yml
Normal file
32
deploy/gateway/docker-compose.yml
Normal 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:
|
||||
18
deploy/gateway/package.json
Normal file
18
deploy/gateway/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
129
deploy/gateway/src/admin.mjs
Normal file
129
deploy/gateway/src/admin.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
95
deploy/gateway/src/admin.test.mjs
Normal file
95
deploy/gateway/src/admin.test.mjs
Normal 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)
|
||||
})
|
||||
95
deploy/gateway/src/auth.mjs
Normal file
95
deploy/gateway/src/auth.mjs
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
113
deploy/gateway/src/auth.test.mjs
Normal file
113
deploy/gateway/src/auth.test.mjs
Normal 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)
|
||||
})
|
||||
31
deploy/gateway/src/codes.mjs
Normal file
31
deploy/gateway/src/codes.mjs
Normal 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 }
|
||||
}
|
||||
49
deploy/gateway/src/codes.test.mjs
Normal file
49
deploy/gateway/src/codes.test.mjs
Normal 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))
|
||||
})
|
||||
25
deploy/gateway/src/config.mjs
Normal file
25
deploy/gateway/src/config.mjs
Normal 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 || ''
|
||||
})
|
||||
}
|
||||
44
deploy/gateway/src/config.test.mjs
Normal file
44
deploy/gateway/src/config.test.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
90
deploy/gateway/src/crypto.mjs
Normal file
90
deploy/gateway/src/crypto.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
72
deploy/gateway/src/crypto.test.mjs
Normal file
72
deploy/gateway/src/crypto.test.mjs
Normal 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
108
deploy/gateway/src/db.mjs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
95
deploy/gateway/src/db.test.mjs
Normal file
95
deploy/gateway/src/db.test.mjs
Normal 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()
|
||||
})
|
||||
104
deploy/gateway/src/gateway.mjs
Normal file
104
deploy/gateway/src/gateway.mjs
Normal 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 })
|
||||
}
|
||||
391
deploy/gateway/src/gateway.test.mjs
Normal file
391
deploy/gateway/src/gateway.test.mjs
Normal 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))
|
||||
})
|
||||
50
deploy/gateway/src/http.mjs
Normal file
50
deploy/gateway/src/http.mjs
Normal 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()
|
||||
}
|
||||
54
deploy/gateway/src/http.test.mjs
Normal file
54
deploy/gateway/src/http.test.mjs
Normal 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
|
||||
}
|
||||
44
deploy/gateway/src/nonces.mjs
Normal file
44
deploy/gateway/src/nonces.mjs
Normal 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) }
|
||||
}
|
||||
59
deploy/gateway/src/nonces.test.mjs
Normal file
59
deploy/gateway/src/nonces.test.mjs
Normal 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'))
|
||||
})
|
||||
54
deploy/gateway/src/origin.mjs
Normal file
54
deploy/gateway/src/origin.mjs
Normal 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()
|
||||
})
|
||||
}
|
||||
97
deploy/gateway/src/origin.test.mjs
Normal file
97
deploy/gateway/src/origin.test.mjs
Normal 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/)
|
||||
})
|
||||
68
deploy/gateway/src/prompt.mjs
Normal file
68
deploy/gateway/src/prompt.mjs
Normal 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
|
||||
}
|
||||
23
deploy/gateway/src/ratelimit.mjs
Normal file
23
deploy/gateway/src/ratelimit.mjs
Normal 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 }
|
||||
}
|
||||
28
deploy/gateway/src/ratelimit.test.mjs
Normal file
28
deploy/gateway/src/ratelimit.test.mjs
Normal 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)
|
||||
})
|
||||
198
deploy/gateway/src/server.integration.test.mjs
Normal file
198
deploy/gateway/src/server.integration.test.mjs
Normal 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')
|
||||
})
|
||||
87
deploy/gateway/src/server.mjs
Normal file
87
deploy/gateway/src/server.mjs
Normal 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()
|
||||
540
docs/plugin/PROVIDER_INTEGRATION_v2.md
Normal file
540
docs/plugin/PROVIDER_INTEGRATION_v2.md
Normal 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.
|
||||
589
docs/plugin/机场服务端对接指南-v2.md
Normal file
589
docs/plugin/机场服务端对接指南-v2.md
Normal 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、userinfo;host 不能是私网、环回、`localhost`、`*.localhost` |
|
||||
| `provider` | 对象,只能包含 `name`、`icon`、`site` |
|
||||
| `provider.name` | 必填,非空字符串 |
|
||||
| `provider.icon` | 可选;仅支持 `data:image/png;base64,`、`data:image/jpeg;base64,`、`data:image/webp;base64,`;总长度不超过 65536 |
|
||||
| `provider.site` | 可选;HTTPS URL;host 约束同 `loginUrl`,可包含路径 |
|
||||
|
||||
`loginUrl` 必须是 authorize 端点,不是普通登录首页。客户端会在该 URL 后拼接 OAuth 参数。
|
||||
|
||||
生成脚本:
|
||||
|
||||
```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、userinfo;host 必须公网可达 |
|
||||
| `endpoints` | 必须包含 `enroll`、`challenge`、`config`、`revoke`;每个值是以 `/` 开头的相对路径,不能是绝对 URL,不能包含 `?`、`#`、反斜杠 |
|
||||
|
||||
客户端请求 `/.well-known/cpx-gateway` 时只走 HTTPS,不跟随重定向,响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。
|
||||
|
||||
### 网关轮换
|
||||
|
||||
替换网关时,更新 `/.well-known/cpx-gateway` 中的 `gateway` 即可。客户端在下列情况会回到登录域名重新发现,并重试一次:
|
||||
|
||||
- 当前网关返回 HTTP `410`;
|
||||
- 响应 JSON 为 `{"error":"gateway_retired"}`;
|
||||
- 当前网关发生网络层不可达,例如 DNS 失败、连接失败、TLS 握手失败。
|
||||
|
||||
普通 5xx、429、超时不会触发重新发现,客户端按瞬时故障退避重试。
|
||||
|
||||
重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。
|
||||
|
||||
---
|
||||
|
||||
## 6. 网关公共约定
|
||||
|
||||
四个端点均为 `POST`,请求体为 JSON。客户端使用加固 HTTPS 客户端访问:只走 HTTPS,不跟随重定向,响应体上限 10 MiB。
|
||||
|
||||
不使用 Authorization header,不下发 bearer token。设备身份由 `deviceId`、设备公钥和 Ed25519 签名确认。
|
||||
|
||||
错误信号按以下优先级处理:
|
||||
|
||||
| 客户端判定 | 条件 | 客户端行为 |
|
||||
| ----------- | ---------------------------------------------------------- | ------------------------ |
|
||||
| `retired` | HTTP `410`,或 JSON `{"error":"gateway_retired"}` | 重新发现网关并重试一次 |
|
||||
| `revoked` | JSON `{"error":"revoked"}` 或 `{"error":"device_revoked"}` | 标记为需要重新登录 |
|
||||
| `transient` | 其它非 2xx、超时、网络错误 | 退避重试,不改变登录状态 |
|
||||
| 成功 | 2xx 且没有错误标记 | 正常处理 |
|
||||
|
||||
账号到期、设备被踢、用户被禁用时,返回体必须包含 `revoked` 或 `device_revoked`。单独返回空的 `401`/`403` 会被客户端当作瞬时失败处理。
|
||||
|
||||
---
|
||||
|
||||
## 7. `POST {gateway}/enroll`
|
||||
|
||||
作用:用 authorize 阶段签发的 code 注册设备,相当于 OAuth token exchange,但不返回 bearer token。
|
||||
|
||||
请求体:
|
||||
|
||||
```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 200,body 为 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` | UUIDv4,36 字符,小写带连字符;服务端上限不超过 64 |
|
||||
| `nonceId` | 服务端生成的不透明可见 ASCII,长度不超过 64 |
|
||||
| `ts` | 整数,Unix 毫秒 |
|
||||
| `op` | `uint8`;config=1,revoke=2 |
|
||||
| `code` | authorize 签发的不透明字符串,长度不超过 2048 |
|
||||
| `code_challenge` / `code_verifier` | RFC 7636 base64url,无 padding;verifier 长度 43-128,字符集 `[A-Za-z0-9-._~]` |
|
||||
|
||||
除 PKCE 的 `code_challenge`、`code_verifier` 外,其余二进制字段全部使用标准 base64,带 `=` padding。不要混用 base64url。
|
||||
|
||||
---
|
||||
|
||||
## 13. PHP 参考代码
|
||||
|
||||
以下片段只展示关键校验。生产代码仍需补齐参数校验、nonce 状态、用户状态和错误处理。
|
||||
|
||||
PKCE:
|
||||
|
||||
```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`、端点名、错误码、请求耗时、网关版本等运维字段。
|
||||
Binary file not shown.
@@ -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
6
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
106
scripts/plugin/example-gateway.mjs
Normal file
106
scripts/plugin/example-gateway.mjs
Normal 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'))
|
||||
24
scripts/plugin/gen-cpx.mjs
Normal file
24
scripts/plugin/gen-cpx.mjs
Normal 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)
|
||||
85
scripts/plugin/gen-sign-vectors.mjs
Normal file
85
scripts/plugin/gen-sign-vectors.mjs
Normal 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')
|
||||
66
src/main/config/plugin.test.ts
Normal file
66
src/main/config/plugin.test.ts
Normal 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
64
src/main/config/plugin.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
51
src/main/core/profileUpdater.test.ts
Normal file
51
src/main/core/profileUpdater.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
24
src/main/resolve/plugin/__fixtures__/sign-vectors.json
Normal file
24
src/main/resolve/plugin/__fixtures__/sign-vectors.json
Normal 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=="
|
||||
}
|
||||
]
|
||||
18
src/main/resolve/plugin/backoff.test.ts
Normal file
18
src/main/resolve/plugin/backoff.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
17
src/main/resolve/plugin/backoff.ts
Normal file
17
src/main/resolve/plugin/backoff.ts
Normal 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 }
|
||||
}
|
||||
100
src/main/resolve/plugin/descriptor.test.ts
Normal file
100
src/main/resolve/plugin/descriptor.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
79
src/main/resolve/plugin/descriptor.ts
Normal file
79
src/main/resolve/plugin/descriptor.ts
Normal 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
|
||||
}
|
||||
55
src/main/resolve/plugin/device.test.ts
Normal file
55
src/main/resolve/plugin/device.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
68
src/main/resolve/plugin/device.ts
Normal file
68
src/main/resolve/plugin/device.ts
Normal 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'))
|
||||
}
|
||||
79
src/main/resolve/plugin/discovery.test.ts
Normal file
79
src/main/resolve/plugin/discovery.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
72
src/main/resolve/plugin/discovery.ts
Normal file
72
src/main/resolve/plugin/discovery.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/main/resolve/plugin/gateway-url.test.ts
Normal file
54
src/main/resolve/plugin/gateway-url.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/main/resolve/plugin/gateway-url.ts
Normal file
37
src/main/resolve/plugin/gateway-url.ts
Normal 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
|
||||
}
|
||||
15
src/main/resolve/plugin/gateway.netguard.test.ts
Normal file
15
src/main/resolve/plugin/gateway.netguard.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
197
src/main/resolve/plugin/gateway.test.ts
Normal file
197
src/main/resolve/plugin/gateway.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
220
src/main/resolve/plugin/gateway.ts
Normal file
220
src/main/resolve/plugin/gateway.ts
Normal 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 §7):nonceId 为不透明 ASCII ≤64;nonce 必须是 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
|
||||
}
|
||||
137
src/main/resolve/plugin/http-client.test.ts
Normal file
137
src/main/resolve/plugin/http-client.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
121
src/main/resolve/plugin/http-client.ts
Normal file
121
src/main/resolve/plugin/http-client.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
387
src/main/resolve/plugin/index.test.ts
Normal file
387
src/main/resolve/plugin/index.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
332
src/main/resolve/plugin/index.ts
Normal file
332
src/main/resolve/plugin/index.ts
Normal 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 泄露到 renderer(spec §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/unreachable(410、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 态必须有 profileId(spec §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()
|
||||
}
|
||||
133
src/main/resolve/plugin/net-guard.test.ts
Normal file
133
src/main/resolve/plugin/net-guard.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
146
src/main/resolve/plugin/net-guard.ts
Normal file
146
src/main/resolve/plugin/net-guard.ts
Normal 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.all:Node 的 autoSelectFamily(Happy 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
|
||||
}
|
||||
95
src/main/resolve/plugin/oauth.test.ts
Normal file
95
src/main/resolve/plugin/oauth.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
96
src/main/resolve/plugin/oauth.ts
Normal file
96
src/main/resolve/plugin/oauth.ts
Normal 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
|
||||
}
|
||||
33
src/main/resolve/plugin/sign-vectors.test.ts
Normal file
33
src/main/resolve/plugin/sign-vectors.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
127
src/main/resolve/plugin/vault.test.ts
Normal file
127
src/main/resolve/plugin/vault.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
71
src/main/resolve/plugin/vault.ts
Normal file
71
src/main/resolve/plugin/vault.ts
Normal 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 })
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 白名单
|
||||
|
||||
121
src/renderer/src/components/plugins/plugin-install-modal.tsx
Normal file
121
src/renderer/src/components/plugins/plugin-install-modal.tsx
Normal 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
|
||||
92
src/renderer/src/components/plugins/plugin-item.tsx
Normal file
92
src/renderer/src/components/plugins/plugin-item.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
21
src/renderer/src/hooks/use-plugin-config.tsx
Normal file
21
src/renderer/src/hooks/use-plugin-config.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "قالب فایل افزونه قدیمی است؛ لطفاً نسخه جدید را از سرویسدهنده دریافت کنید"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Формат файла плагина устарел; получите новый у провайдера"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、主机、重定向和大小,但无法验证代理最终解析到的 IP,SSRF 防护降级。",
|
||||
"remove": "删除",
|
||||
"removeConfirm": "确认删除该插件及其订阅?",
|
||||
"status": { "needs-login": "待登录", "active": "正常", "needs-reauth": "需重新登录" },
|
||||
"installed": "已安装",
|
||||
"installFailed": "安装失败",
|
||||
"previewFailed": "插件文件无效",
|
||||
"outdatedFile": "该插件文件格式已过期,请从机场重新获取"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、主機、重新導向與大小,但無法驗證代理最終解析到的 IP,SSRF 防護降級。",
|
||||
"remove": "刪除",
|
||||
"removeConfirm": "確認刪除該外掛及其訂閱?",
|
||||
"status": { "needs-login": "待登入", "active": "正常", "needs-reauth": "需重新登入" },
|
||||
"installed": "已安裝",
|
||||
"installFailed": "安裝失敗",
|
||||
"previewFailed": "外掛檔案無效",
|
||||
"outdatedFile": "該外掛檔案格式已過期,請從機場重新取得"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
77
src/shared/types.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user