diff --git a/.prettierignore b/.prettierignore index 39faa84b..b9f877d6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ pnpm-lock.yaml LICENSE.md tsconfig.json tsconfig.*.json +.codex-review diff --git a/deploy/gateway/.env.example b/deploy/gateway/.env.example index 44209458..e86b018d 100644 --- a/deploy/gateway/.env.example +++ b/deploy/gateway/.env.example @@ -2,6 +2,18 @@ # Caddy uses it to obtain a Let's Encrypt certificate. Required. DOMAIN=gw.example.com +# Optional: serve several domains from this ONE gateway process. Separate them with a comma +# AND a space ("a, b"): Caddy takes the value verbatim as site addresses and rejects a bare +# "a,b" (deploy.sh normalizes it, docker compose alone does not). Caddy obtains a certificate +# for each. Use together with GATEWAY_ORIGINS. +# DOMAINS=gw.example.com, gw2-cdn.example.net + +# Optional: 1..3 https origins advertised in /.well-known/cpx-gateway as `gateways` +# (`gateway` is always gateways[0]). Every origin MUST reach this same process — codes, +# nonces and rate limits live in memory, so multiple replicas are not supported. +# Defaults to PUBLIC_ORIGIN (https://$DOMAIN). +# GATEWAY_ORIGINS=https://gw.example.com,https://gw2-cdn.example.net + # Default per-user device limit for `cpx-admin add-user` (override per user with --limit). DEVICE_LIMIT_DEFAULT=3 @@ -22,3 +34,13 @@ DEVICE_LIMIT_DEFAULT=3 # RETIRED=false # Path (inside the container) to a private CA cert for the subscription origin # ORIGIN_CA_FILE= + +# Optional: JSON file (inside the container) with human-readable messages the client shows +# next to the matching error. Keys: device_revoked, device_limit, gateway_retired. +# MESSAGES_FILE=/data/messages.json + +# Optional: pre-signed discovery envelope (".") produced OFFLINE with +# `cpx-admin sign-discovery`. Served as well-known `signed` and as the X-CPX-Discovery header. +# The private key must never be on this host. Publish this BEFORE distributing a .cpx that +# contains providerPubKey. +# DISCOVERY_SIGNED_FILE=/data/discovery.signed diff --git a/deploy/gateway/Caddyfile b/deploy/gateway/Caddyfile index 529d3d93..aab9e50b 100644 --- a/deploy/gateway/Caddyfile +++ b/deploy/gateway/Caddyfile @@ -1,8 +1,13 @@ -# 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. +# Caddy obtains and renews Let's Encrypt certificates for every domain in {$DOMAINS} automatically +# ("a.example.com, b.example.net" — comma+space or space separated, never a bare "a,b": the value +# is substituted verbatim as site addresses; deploy.sh normalizes it from DOMAINS or DOMAIN in .env). +# It terminates TLS +# and reverse-proxies ALL of those domains to the SAME gateway container — the gateway keeps +# authorize codes, nonces and rate limits in process memory, so every gateway origin listed in +# GATEWAY_ORIGINS must land on this single process. Multiple replicas are not supported. # To receive cert-expiry notices, add a global block above: { email you@example.com } -{$DOMAIN} { +{$DOMAINS} { encode gzip reverse_proxy gateway:8080 } diff --git a/deploy/gateway/README.md b/deploy/gateway/README.md index 6eb5c77a..d620d793 100644 --- a/deploy/gateway/README.md +++ b/deploy/gateway/README.md @@ -92,6 +92,7 @@ curl https:///.well-known/cpx-gateway { "spec": "cpx-plugin/2", "gateway": "https://", + "gateways": ["https://"], "endpoints": { "enroll": "/enroll", "challenge": "/challenge", @@ -101,6 +102,32 @@ curl https:///.well-known/cpx-gateway } ``` +### 多网关域名(可选) + +同一个网关进程可以用多个域名对外提供服务,客户端会在一个域名超时或不可达时自动切到下一个(见对接指南 §5 / §6): + +1. 在 `.env` 里用 `DOMAINS=a.example.com, b-cdn.example.net` 列出全部域名——逗号后要有空格:Caddy 把该值原样当作站点地址,`a,b` 会被拒绝(`deploy.sh` 会自动补空格,直接用 docker compose 则不会)。Caddy 会为每个域名申请证书;都必须解析到这台 VPS。 +2. 用 `GATEWAY_ORIGINS=https://a.example.com,https://b-cdn.example.net`(1..3 个)声明 well-known 里的 `gateways`;`gateway` 始终等于第一个。 +3. `./deploy.sh`。 + +### 备用发现源(静态文件) + +`.cpx` 可以用 `discoveryUrls` 列出备用发现源(对接指南第 3 / 5 节)。备用源只需要在 `/.well-known/cpx-gateway` 路径上提供同一份 JSON 文件,任何 CDN / 对象存储都可以。备用源不能与 `loginUrl` 同 origin(同一主机不构成冗余,`gen-cpx.mjs` 与客户端都会拒绝);最简单的做法是列出 `DOMAINS` 里的第二个网关域名——它已经提供该路径: + +```bash +node scripts/plugin/gen-cpx.mjs https:///oauth/authorize "Your Airport" https:// your-airport.cpx \ + --discovery https:// --discovery https://cdn.example.net +``` + +若用静态托管,把网关返回的文档原样放到 `https://cdn.example.net/.well-known/cpx-gateway`,并保证 `gateway` 等于 `gateways[0]`: + +```bash +curl https:///.well-known/cpx-gateway > cpx-gateway.json +# 上传为 cdn.example.net/.well-known/cpx-gateway,Content-Type: application/json +``` + +**共享状态 / 单进程**:authorize code、nonce、登录限流都保存在网关进程内存里,所有 `gateways` 必须落到**同一个**网关进程(本部署由 Caddy 把所有域名反代到同一个容器)。**不支持多副本**:客户端可能在 `/challenge` 用一个域名、`/config` 用另一个域名,跨进程 nonce 会直接失败。 + 查看容器状态: ```bash @@ -214,6 +241,45 @@ docker run --rm -i -v gateway_gateway_data:/data busybox sh -c 'cat > /data/gate docker compose up -d ``` +签名发现文档(可选,对接指南第 5a 节): + +私钥**离线**保存与签发,网关进程只读取签好的信封文件。在一台离线机器上: + +```bash +# 1. 生成密钥(seed 写入 0600 文件,只打印公钥) +node deploy/gateway/admin.mjs keygen --out ./provider.seed +# 2. 写 payload.json(gateways / endpoints 必须与网关实际提供的一致;每次变更 seq 递增) +cat > payload.json <<'JSON' +{ "spec": "cpx-plugin/2", "seq": 1, + "gateways": ["https://"], + "endpoints": { "enroll": "/enroll", "challenge": "/challenge", "config": "/config", "revoke": "/revoke" } } +JSON +# 3. 签发(seed 只经文件 / stdin 传入,不放命令行) +node deploy/gateway/admin.mjs sign-discovery payload.json --seed-file ./provider.seed --out discovery.signed +``` + +把 `discovery.signed` 放进容器(例如 `gateway_data` 卷)并设置 `DISCOVERY_SIGNED_FILE`,重启后 well-known 会带 `signed`、`/config` 会带 `X-CPX-Discovery` 头;网关启动时会校验信封里的 `endpoints` 与自己提供的路径一致。**先发布签名文档,再分发带 `--pubkey` 的 `.cpx`**: + +```bash +node scripts/plugin/gen-cpx.mjs https:///oauth/authorize "Your Airport" https:// your-airport.cpx --pubkey +``` + +每个 `.cpx` 谱系使用独立密钥;本期不做密钥轮换,密钥泄露或丢失需要重新发放 `.cpx`。 + +给用户的错误说明(可选): + +在容器内放一个 JSON 文件并用 `MESSAGES_FILE` 指向它,网关会把对应文案附在错误响应的 `message` 字段里,客户端卡片原样显示(客户端会去除控制字符并截断到 200 个字符): + +```json +{ + "device_revoked": "订阅已到期,续费后请重新登录。", + "device_limit": "设备数已达上限,请在官网解绑旧设备。", + "gateway_retired": "服务地址已更换,客户端会自动重新发现。" +} +``` + +只支持 JSON(参考网关零依赖,没有 YAML 解析)。 + 网关退役: ```bash @@ -233,25 +299,31 @@ docker compose up -d 配置文件为 `.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 数据库路径 | +| 变量 | 默认值 | 说明 | +| ----------------------- | ------------------ | ---------------------------------------------------- | +| `DOMAIN` | 无 | 公网域名,必填 | +| `DOMAINS` | `$DOMAIN` | Caddy 服务的全部域名,逗号分隔(多网关域名时使用) | +| `PUBLIC_ORIGIN` | `https://$DOMAIN` | 写入 `/.well-known/cpx-gateway` 的 gateway origin | +| `GATEWAY_ORIGINS` | `$PUBLIC_ORIGIN` | well-known `gateways` 列表,逗号分隔 1..3 个 origin | +| `DEVICE_LIMIT_DEFAULT` | `3` | 新用户默认设备数上限,可被 `add-user --limit` 覆盖 | +| `CLOCK_SKEW_MS` | `300000` | `/config`、`/revoke` 签名时间戳允许偏差 | +| `CODE_TTL_MS` | `60000` | authorize code 有效期 | +| `NONCE_TTL_MS` | `60000` | challenge nonce 有效期 | +| `NONCE_POOL_MAX` | `8` | 单设备待用 nonce 数上限 | +| `LOGIN_MAX` | `10` | 单 IP 登录尝试次数上限 | +| `LOGIN_WINDOW_MS` | `60000` | 登录限流窗口 | +| `SUB_TIMEOUT_MS` | `30000` | 拉取隐藏订阅的超时 | +| `SUB_MAX_BYTES` | `10485760` | 隐藏订阅响应体上限 | +| `RETIRED` | `false` | 设置为 `true` 时返回网关退役信号 | +| `MESSAGES_FILE` | 空 | 可选 JSON 文件,错误响应附带给用户看的 `message` | +| `DISCOVERY_SIGNED_FILE` | 空 | 可选,离线签好的发现文档信封(对接指南第 5a 节) | +| `ORIGIN_CA_FILE` | 空 | 订阅 origin 使用私有 CA 时,在容器内指定 CA 文件路径 | +| `PORT` | `8080` | gateway 容器内监听端口 | +| `DB_PATH` | `/data/gateway.db` | SQLite 数据库路径 | -如果登录域名和网关域名需要拆开,保持 `.cpx` 里的 `loginUrl` 指向登录域名,同时把登录域名上的 `/.well-known/cpx-gateway` 的 `gateway` 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。 +如果登录域名和网关域名需要拆开,保持 `.cpx` 里的 `loginUrl` 指向登录域名,同时把登录域名上的 `/.well-known/cpx-gateway` 的 `gateway` / `gateways` 指向新的公网网关 origin。当前参考部署默认两者使用同一个域名。 + +参考网关只支持单进程部署:`GATEWAY_ORIGINS` 里的所有域名都必须反代到同一个 `gateway` 容器。 --- diff --git a/deploy/gateway/admin.mjs b/deploy/gateway/admin.mjs index f9ca970f..00d31aed 100755 --- a/deploy/gateway/admin.mjs +++ b/deploy/gateway/admin.mjs @@ -1,19 +1,30 @@ #!/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). +// the tested command logic (src/admin.mjs). The offline signing commands (keygen, +// sign-discovery) never touch the database, so it is only opened for the others. +import { readFileSync, writeFileSync } from 'node:fs' 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 OFFLINE_COMMANDS = new Set(['keygen', 'sign-discovery']) + const config = loadConfig() -const db = openDb(config.dbPath) -const code = await runAdmin(process.argv.slice(2), { +const args = process.argv.slice(2) +let db +if (!OFFLINE_COMMANDS.has(args[0])) { + const { openDb } = await import('./src/db.mjs') + db = openDb(config.dbPath) +} +const code = await runAdmin(args, { db, deviceLimitDefault: config.deviceLimitDefault, readPassword, + readFile: (p) => readFileSync(p, 'utf-8'), + writeFile: (p, data, opts) => writeFileSync(p, data, opts), + readStdin: async () => readFileSync(0, 'utf-8'), out: (s) => console.log(s), err: (s) => console.error(s) }) -db.close() +db?.close() process.exit(code) diff --git a/deploy/gateway/check-vectors.mjs b/deploy/gateway/check-vectors.mjs index a817ce40..de9738aa 100644 --- a/deploy/gateway/check-vectors.mjs +++ b/deploy/gateway/check-vectors.mjs @@ -7,7 +7,12 @@ 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' +import { + buildSignInput, + verifySignature, + signDiscovery, + verifyDiscoveryEnvelope +} from './src/crypto.mjs' const here = dirname(fileURLToPath(import.meta.url)) const fixture = join(here, '../../src/main/resolve/plugin/__fixtures__/sign-vectors.json') @@ -26,4 +31,21 @@ for (const v of vectors) { ) } -console.log(`check-vectors: OK — ${vectors.length} client vectors verified against gateway crypto`) +const discoveryFixture = join( + here, + '../../src/main/resolve/plugin/__fixtures__/discovery-vectors.json' +) +const discoveryVectors = JSON.parse(readFileSync(discoveryFixture, 'utf-8')) +for (const v of discoveryVectors) { + const seed = Buffer.from(v.seedB64, 'base64') + assert.equal( + signDiscovery(Buffer.from(v.payloadJson, 'utf-8'), seed), + v.signed, + `discovery envelope mismatch (${v.name})` + ) + assert.equal(verifyDiscoveryEnvelope(v.signed, v.pubKeyB64).toString('utf-8'), v.payloadJson) +} + +console.log( + `check-vectors: OK — ${vectors.length} sign vectors + ${discoveryVectors.length} discovery vectors verified against gateway crypto` +) diff --git a/deploy/gateway/deploy.sh b/deploy/gateway/deploy.sh index 74d0e1ec..9a5182bb 100755 --- a/deploy/gateway/deploy.sh +++ b/deploy/gateway/deploy.sh @@ -20,14 +20,24 @@ if [ ! -f .env ]; then echo "Wrote .env (DOMAIN=${DOMAIN})." fi -DOMAIN=$(grep -E '^DOMAIN=' .env | cut -d= -f2-) +# sed prints nothing (and exits 0) when a key is absent, so `set -e` does not abort on a .env +# that sets only one of DOMAIN / DOMAINS (docker compose accepts either). +DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tail -n 1 | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +DOMAINS=$(sed -n 's/^DOMAINS=//p' .env | tail -n 1) +DOMAINS=${DOMAINS:-$DOMAIN} +# Caddy takes {$DOMAINS} verbatim as site addresses and rejects "a,b" without a space: +# normalize any comma list to "a, b". +DOMAINS=$(printf '%s' "$DOMAINS" | sed -E 's/[[:space:]]*,[[:space:]]*/, /g; s/^[[:space:]]+|[[:space:]]+$//g') +DOMAIN=${DOMAIN:-${DOMAINS%%,*}} +[ -n "$DOMAIN" ] || { echo "DOMAIN or DOMAINS must be set in .env" >&2; exit 1; } +export DOMAINS echo "Building and starting containers..." docker compose up -d --build cat < ... add-user [--limit N] @@ -10,7 +11,10 @@ const USAGE = `Usage: cpx-admin ... del-user list-users [--show-sub] list-devices - revoke-device ` + revoke-device + keygen --out (offline; seed file is written with mode 0600) + sign-discovery [--seed-file ] [--out ] + (offline; seed from --seed-file or stdin)` function parse(rest) { const pos = [] @@ -18,6 +22,8 @@ function parse(rest) { for (let i = 0; i < rest.length; i++) { const t = rest[i] if (t === '--limit') flags.limit = rest[++i] + else if (t === '--out') flags.out = rest[++i] + else if (t === '--seed-file') flags.seedFile = rest[++i] else if (t === '--show-sub') flags.showSub = true else if (t.startsWith('--')) flags[t.slice(2)] = true else pos.push(t) @@ -122,6 +128,69 @@ export async function runAdmin(args, deps) { out(`revoked device ${deviceId}`) return 0 } + // ---- §5a offline discovery signing (no db access) ---- + case 'keygen': { + if (!need(flags.out, 'keygen requires --out (the seed is never printed)')) + return 1 + const seed = generateSeed() + try { + // exclusive create: never truncate/reuse an existing file (which would keep its old + // permissions) and never follow a pre-placed symlink + deps.writeFile(flags.out, seed.toString('base64') + '\n', { mode: 0o600, flag: 'wx' }) + } catch (e) { + err( + e?.code === 'EEXIST' + ? `refusing to overwrite existing file ${flags.out}` + : `cannot write ${flags.out}: ${e?.message ?? e}` + ) + return 1 + } + out(`wrote seed to ${flags.out} (mode 0600) — keep it offline`) + out(`providerPubKey: ${pubKeyFromSeed(seed)}`) + return 0 + } + case 'sign-discovery': { + const [payloadPath] = pos + if (!need(payloadPath, 'sign-discovery requires ')) return 1 + const seedText = String( + flags.seedFile ? deps.readFile(flags.seedFile) : await deps.readStdin() + ).trim() + const seed = Buffer.from(seedText, 'base64') + if ( + !need( + seed.length === 32 && seed.toString('base64') === seedText, + 'seed must be 32 raw bytes in standard base64' + ) + ) { + return 1 + } + let payload + try { + payload = JSON.parse(String(deps.readFile(payloadPath))) + } catch { + err(`cannot parse ${payloadPath}`) + return 1 + } + // the same field rules the client enforces — a document that fails here would be rejected + // by every keyed client after publication + try { + payload = validateDiscoveryPayload(payload) + } catch (e) { + err(e.message) + return 1 + } + let signed + try { + signed = signDiscovery(Buffer.from(JSON.stringify(payload), 'utf-8'), seed) + } catch (e) { + err(e.message) + return 1 + } + if (flags.out) deps.writeFile(flags.out, signed + '\n', { mode: 0o644 }) + out(signed) + err(`providerPubKey: ${pubKeyFromSeed(seed)}`) + return 0 + } default: err(USAGE) return 1 diff --git a/deploy/gateway/src/admin.test.mjs b/deploy/gateway/src/admin.test.mjs index 1234a7bf..d1aadb0d 100644 --- a/deploy/gateway/src/admin.test.mjs +++ b/deploy/gateway/src/admin.test.mjs @@ -93,3 +93,144 @@ test('an unknown command returns non-zero and prints usage', async () => { assert.notEqual(code, 0) assert.match(h.text(), /usage/i) }) + +// ---------- §5a offline signing ---------- +import { verifyDiscoveryEnvelope } from './crypto.mjs' + +function fsHarness() { + const files = new Map() + const h = harness() + h.deps.readFile = (p) => { + if (!files.has(p)) throw new Error(`ENOENT ${p}`) + const entry = files.get(p) + return typeof entry === 'string' ? entry : entry.data + } + h.deps.writeFile = (p, data, opts) => { + if (opts?.flag === 'wx' && files.has(p)) { + throw Object.assign(new Error(`EEXIST: file already exists, open '${p}'`), { code: 'EEXIST' }) + } + files.set(p, { data, mode: opts?.mode, flag: opts?.flag }) + } + h.deps.readStdin = async () => files.get('')?.data ?? '' + return { ...h, files } +} + +test('keygen writes a 0600 seed file and prints the public key; never prints the seed', async () => { + const h = fsHarness() + const code = await runAdmin(['keygen', '--out', '/tmp/seed'], h.deps) + assert.equal(code, 0) + const seedFile = h.files.get('/tmp/seed') + assert.equal(seedFile.mode, 0o600) + const seed = Buffer.from(seedFile.data.trim(), 'base64') + assert.equal(seed.length, 32) + assert.match(h.text(), /providerPubKey: [A-Za-z0-9+/]+=*/) + assert.ok(!h.text().includes(seedFile.data.trim())) +}) + +test('keygen refuses to run without --out', async () => { + const h = fsHarness() + assert.notEqual(await runAdmin(['keygen'], h.deps), 0) +}) + +test('sign-discovery reads the seed from a file or stdin and emits an envelope the client accepts', async () => { + const h = fsHarness() + await runAdmin(['keygen', '--out', '/k/seed'], h.deps) + const pub = h.text().match(/providerPubKey: (\S+)/)[1] + const payload = { + spec: 'cpx-plugin/2', + seq: 3, + gateways: ['https://gw.example.net'], + endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' } + } + h.files.set('/k/payload.json', JSON.stringify(payload)) + const lines1 = h.lines.length + const code = await runAdmin( + ['sign-discovery', '/k/payload.json', '--seed-file', '/k/seed', '--out', '/k/envelope'], + h.deps + ) + assert.equal(code, 0) + const envelope = h.lines[lines1] + assert.equal(h.files.get('/k/envelope').data.trim(), envelope) + const bytes = verifyDiscoveryEnvelope(envelope, pub) + assert.deepEqual(JSON.parse(bytes.toString('utf-8')), payload) + + // stdin path produces the same signature (Ed25519 is deterministic) + h.files.set('', { data: h.files.get('/k/seed').data }) + const lines2 = h.lines.length + assert.equal(await runAdmin(['sign-discovery', '/k/payload.json'], h.deps), 0) + assert.equal(h.lines[lines2], envelope) +}) + +test('sign-discovery rejects a bad seed and a malformed payload', async () => { + const h = fsHarness() + h.files.set('/k/seed', { data: 'not-a-seed' }) + h.files.set( + '/k/payload.json', + JSON.stringify({ spec: 'cpx-plugin/2', seq: 1, gateways: ['https://a'], endpoints: {} }) + ) + assert.notEqual( + await runAdmin(['sign-discovery', '/k/payload.json', '--seed-file', '/k/seed'], h.deps), + 0 + ) + h.files.set('/k/seed', { data: Buffer.alloc(32, 1).toString('base64') }) + h.files.set('/k/bad.json', JSON.stringify({ spec: 'cpx-plugin/2', seq: 0, gateways: [] })) + assert.notEqual( + await runAdmin(['sign-discovery', '/k/bad.json', '--seed-file', '/k/seed'], h.deps), + 0 + ) +}) + +test('ISS-016: keygen creates the seed file exclusively and refuses an existing path', async () => { + const h = fsHarness() + assert.equal(await runAdmin(['keygen', '--out', '/k/seed'], h.deps), 0) + assert.equal(h.files.get('/k/seed').flag, 'wx') + assert.equal(h.files.get('/k/seed').mode, 0o600) + const before = h.files.get('/k/seed').data + assert.notEqual(await runAdmin(['keygen', '--out', '/k/seed'], h.deps), 0) + assert.equal(h.files.get('/k/seed').data, before) + assert.match(h.text(), /refusing to overwrite/) +}) + +test('ISS-018: sign-discovery refuses a payload the client would reject', async () => { + const h = fsHarness() + await runAdmin(['keygen', '--out', '/k/seed'], h.deps) + h.files.set( + '/k/bad1.json', + JSON.stringify({ + spec: 'cpx-plugin/2', + seq: 1, + gateways: ['https://10.0.0.1'], + endpoints: { + enroll: '/enroll', + challenge: '/challenge', + config: '/config', + revoke: '/revoke' + } + }) + ) + assert.notEqual( + await runAdmin(['sign-discovery', '/k/bad1.json', '--seed-file', '/k/seed'], h.deps), + 0 + ) + assert.match(h.text(), /public https origin/) + h.files.set( + '/k/bad2.json', + JSON.stringify({ + spec: 'cpx-plugin/2', + seq: 1, + gateways: ['https://gw.example.net'], + endpoints: { + enroll: '/enroll', + challenge: '/challenge', + config: '/config', + revoke: '/revoke' + }, + extra: true + }) + ) + assert.notEqual( + await runAdmin(['sign-discovery', '/k/bad2.json', '--seed-file', '/k/seed'], h.deps), + 0 + ) + assert.match(h.text(), /unknown key/) +}) diff --git a/deploy/gateway/src/config.mjs b/deploy/gateway/src/config.mjs index c1ac275f..a379297f 100644 --- a/deploy/gateway/src/config.mjs +++ b/deploy/gateway/src/config.mjs @@ -1,3 +1,46 @@ +import { parseOrigin } from './discovery.mjs' + +export const MAX_GATEWAY_ORIGINS = 3 + +function normalizeOrigin(raw) { + let u + try { + u = new URL(raw.trim()) + } catch { + throw new Error(`GATEWAY_ORIGINS: "${raw}" is not a valid URL`) + } + if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash) { + throw new Error(`GATEWAY_ORIGINS: "${raw}" must be a plain https origin`) + } + if (u.pathname && u.pathname !== '/') { + throw new Error(`GATEWAY_ORIGINS: "${raw}" must not contain a path`) + } + return u.origin +} + +// Comma-separated list, 1..3 entries, normalized and deduplicated. Falls back to [publicOrigin]. +export function parseGatewayOrigins(value, publicOrigin) { + if (!value || !value.trim()) return [normalizeOrigin(publicOrigin)] + const out = [] + for (const part of value.split(',')) { + const raw = part.trim() + if (!raw) continue + // Explicit entries are published to every client, which rejects the WHOLE list when any + // origin is private / loopback (client gateway-url.ts parseGatewayList) — same rule here. + const origin = parseOrigin(raw) + if (!origin) { + throw new Error( + `GATEWAY_ORIGINS: "${raw}" must be a public https origin (no path/query/userinfo)` + ) + } + if (!out.includes(origin)) out.push(origin) + } + if (out.length < 1 || out.length > MAX_GATEWAY_ORIGINS) { + throw new Error(`GATEWAY_ORIGINS must list 1..${MAX_GATEWAY_ORIGINS} origins`) + } + return out +} + // 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) { @@ -5,11 +48,15 @@ export function loadConfig(env = process.env) { const n = Number(v) return Number.isFinite(n) ? n : d } - const domain = env.DOMAIN || 'localhost' + const domain = (env.DOMAINS || env.DOMAIN || 'localhost').split(',')[0].trim() + const publicOrigin = env.PUBLIC_ORIGIN || `https://${domain}` return Object.freeze({ port: num(env.PORT, 8080), dbPath: env.DB_PATH || '/data/gateway.db', - publicOrigin: env.PUBLIC_ORIGIN || `https://${domain}`, + publicOrigin, + // §2.2: 1..3 https origins served by THIS process. All of them must share the same + // in-memory code / nonce / rate-limit state, so multiple replicas are not supported. + gatewayOrigins: parseGatewayOrigins(env.GATEWAY_ORIGINS, publicOrigin), deviceLimitDefault: num(env.DEVICE_LIMIT_DEFAULT, 3), codeTtlMs: num(env.CODE_TTL_MS, 60000), nonceTtlMs: num(env.NONCE_TTL_MS, 60000), @@ -20,6 +67,12 @@ export function loadConfig(env = process.env) { 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 || '' + originCaFile: env.ORIGIN_CA_FILE || '', + // §4: optional JSON file { device_revoked, device_limit, gateway_retired } of human-readable + // messages attached to the matching error responses. Read by server.mjs. + messagesFile: env.MESSAGES_FILE || '', + // §5a: pre-signed discovery envelope file ("."). The private key never + // enters this process; sign offline with `cpx-admin sign-discovery`. Read by server.mjs. + discoverySignedFile: env.DISCOVERY_SIGNED_FILE || '' }) } diff --git a/deploy/gateway/src/config.test.mjs b/deploy/gateway/src/config.test.mjs index 52531324..5a6c3ae8 100644 --- a/deploy/gateway/src/config.test.mjs +++ b/deploy/gateway/src/config.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { loadConfig } from './config.mjs' +import { loadConfig, parseGatewayOrigins } from './config.mjs' +import { parseMessages, parseDiscoverySigned } from './server.mjs' test('applies sane defaults for an empty environment', () => { const c = loadConfig({}) @@ -42,3 +43,95 @@ test('the returned config object is frozen', () => { c.port = 1 }) }) + +test('gatewayOrigins falls back to [PUBLIC_ORIGIN] when GATEWAY_ORIGINS is unset', () => { + assert.deepEqual(loadConfig({ DOMAIN: 'gw.example.com' }).gatewayOrigins, [ + 'https://gw.example.com' + ]) + assert.deepEqual(loadConfig({ PUBLIC_ORIGIN: 'https://other.example' }).gatewayOrigins, [ + 'https://other.example' + ]) +}) + +test('GATEWAY_ORIGINS parses a comma list of 1..3 origins, normalized and deduplicated', () => { + const c = loadConfig({ + DOMAIN: 'a.example', + GATEWAY_ORIGINS: ' https://a.example/, https://b-cdn.example ,https://a.example' + }) + assert.deepEqual(c.gatewayOrigins, ['https://a.example', 'https://b-cdn.example']) +}) + +test('DOMAINS (comma list) derives publicOrigin from its first entry', () => { + const c = loadConfig({ DOMAINS: 'a.example,b.example' }) + assert.equal(c.publicOrigin, 'https://a.example') + assert.deepEqual(c.gatewayOrigins, ['https://a.example']) +}) + +test('GATEWAY_ORIGINS rejects private / loopback origins the client would refuse (R2-ISS-016)', () => { + assert.throws(() => parseGatewayOrigins('https://127.0.0.1', 'https://x'), /public/) + assert.throws( + () => parseGatewayOrigins('https://a.example,https://gw.localhost', 'https://x'), + /public/ + ) + assert.throws(() => parseGatewayOrigins('https://[::1]', 'https://x'), /public/) + // the PUBLIC_ORIGIN fallback keeps accepting a dev default such as https://localhost + assert.deepEqual(parseGatewayOrigins('', 'https://localhost'), ['https://localhost']) +}) + +test('GATEWAY_ORIGINS rejects non-https, paths, and more than 3 entries', () => { + assert.throws(() => parseGatewayOrigins('http://a.example', 'https://x'), /https/) + assert.throws(() => parseGatewayOrigins('https://a.example/base', 'https://x'), /path/) + assert.throws( + () => parseGatewayOrigins('https://a.x,https://b.x,https://c.x,https://d.x', 'https://x'), + /1\.\.3/ + ) +}) + +test('MESSAGES_FILE path is exposed; parseMessages keeps only the three known string keys', () => { + assert.equal( + loadConfig({ MESSAGES_FILE: '/etc/cpx/messages.json' }).messagesFile, + '/etc/cpx/messages.json' + ) + assert.deepEqual( + parseMessages( + JSON.stringify({ + device_revoked: ' expired ', + device_limit: 7, + extra: 'x', + gateway_retired: '' + }) + ), + { device_revoked: 'expired' } + ) + assert.throws(() => parseMessages('nope'), /valid JSON/) + assert.throws(() => parseMessages('[]'), /object/) +}) + +test('DISCOVERY_SIGNED_FILE is exposed; parseDiscoverySigned decodes the payload and checks endpoints', () => { + assert.equal( + loadConfig({ DISCOVERY_SIGNED_FILE: '/data/discovery.signed' }).discoverySignedFile, + '/data/discovery.signed' + ) + const payload = { + spec: 'cpx-plugin/2', + seq: 5, + gateways: ['https://a.example', 'https://b.example'], + endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' } + } + const sig = Buffer.alloc(64, 1).toString('base64') + const signed = Buffer.from(JSON.stringify(payload)).toString('base64') + '.' + sig + const parsed = parseDiscoverySigned(signed + '\n') + assert.equal(parsed.signed, signed) + assert.deepEqual(parsed.payload.gateways, payload.gateways) + assert.throws(() => parseDiscoverySigned('nodot'), /one "\."/) + const wrongPath = { ...payload, endpoints: { ...payload.endpoints, config: '/cfg' } } + assert.throws( + () => + parseDiscoverySigned(Buffer.from(JSON.stringify(wrongPath)).toString('base64') + '.' + sig), + /endpoints\.config/ + ) + // ISS-019: a file with CR/LF or non-canonical base64 is refused at startup instead of breaking + // every /config response's writeHead + assert.throws(() => parseDiscoverySigned(signed.replace('.', '\r\n.')), /canonical/) + assert.throws(() => parseDiscoverySigned(signed.slice(0, -1) + '.' + sig), /canonical|one/) +}) diff --git a/deploy/gateway/src/crypto.mjs b/deploy/gateway/src/crypto.mjs index c26f5c15..1419d031 100644 --- a/deploy/gateway/src/crypto.mjs +++ b/deploy/gateway/src/crypto.mjs @@ -3,9 +3,11 @@ // signatures produced by the app verify here (see check-vectors.mjs for the proof). import { createHash, + createPrivateKey, createPublicKey, randomBytes, scryptSync, + sign, timingSafeEqual, verify } from 'node:crypto' @@ -72,6 +74,63 @@ export function buildSignInput(op, deviceId, nonceId, nonce, ts) { ]) } +// ---- Signed discovery document (client design §5a) ---- +// Domain-separation prefix: "CPX2-DISCOVERY" followed by a NUL byte, then the exact payload bytes. +export const DISCOVERY_SIGN_PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8') +export const DISCOVERY_MAX_PAYLOAD_BYTES = 4096 +const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') + +export function generateSeed() { + return randomBytes(32) +} + +function keyFromSeed(seed) { + return createPrivateKey({ + key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), + format: 'der', + type: 'pkcs8' + }) +} + +export function pubKeyFromSeed(seed) { + const jwk = createPublicKey(keyFromSeed(seed)).export({ format: 'jwk' }) + return Buffer.from(jwk.x, 'base64url').toString('base64') +} + +export function isCanonicalB64(s) { + if (typeof s !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(s)) return false + return Buffer.from(s, 'base64').toString('base64') === s +} + +// Offline: sign the exact payload bytes and produce the envelope ".". +export function signDiscovery(payloadBytes, seed) { + if (payloadBytes.length < 1 || payloadBytes.length > DISCOVERY_MAX_PAYLOAD_BYTES) { + throw new Error(`discovery payload must be 1..${DISCOVERY_MAX_PAYLOAD_BYTES} bytes`) + } + const sig = sign(null, Buffer.concat([DISCOVERY_SIGN_PREFIX, payloadBytes]), keyFromSeed(seed)) + return `${payloadBytes.toString('base64')}.${sig.toString('base64')}` +} + +// Format checks + signature verification of an envelope; returns the payload bytes. +export function verifyDiscoveryEnvelope(signed, pubKeyB64) { + if (typeof signed !== 'string') throw new Error('signed must be a string') + const parts = signed.split('.') + if (parts.length !== 2) throw new Error('signed must contain exactly one "."') + const [payloadB64, sigB64] = parts + if (!isCanonicalB64(payloadB64) || !isCanonicalB64(sigB64)) { + throw new Error('signed: non-canonical base64') + } + const payloadBytes = Buffer.from(payloadB64, 'base64') + if (payloadBytes.length < 1 || payloadBytes.length > DISCOVERY_MAX_PAYLOAD_BYTES) { + throw new Error('signed: payload size out of range') + } + if (Buffer.from(sigB64, 'base64').length !== 64) throw new Error('signed: bad signature length') + if (!verifySignature(pubKeyB64, Buffer.concat([DISCOVERY_SIGN_PREFIX, payloadBytes]), sigB64)) { + throw new Error('signed: signature verification failed') + } + return payloadBytes +} + // 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 { diff --git a/deploy/gateway/src/crypto.test.mjs b/deploy/gateway/src/crypto.test.mjs index 0a449036..935a3087 100644 --- a/deploy/gateway/src/crypto.test.mjs +++ b/deploy/gateway/src/crypto.test.mjs @@ -70,3 +70,59 @@ test('verifySignature returns false on a garbage signature instead of throwing', assert.equal(verifySignature(VEC.pubKeyB64, input, 'not-base64-!!!'), false) assert.equal(verifySignature('bad-pubkey', input, VEC.sigB64), false) }) + +// ---------- §5a signed discovery: interop with the client vectors ---------- +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { + signDiscovery, + verifyDiscoveryEnvelope, + pubKeyFromSeed, + DISCOVERY_SIGN_PREFIX +} from './crypto.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const discoveryVectors = JSON.parse( + readFileSync( + join(here, '../../../src/main/resolve/plugin/__fixtures__/discovery-vectors.json'), + 'utf-8' + ) +) + +test('gateway crypto reproduces the client discovery vectors byte-for-byte', () => { + assert.ok(discoveryVectors.length > 0) + assert.equal( + DISCOVERY_SIGN_PREFIX.toString('hex'), + Buffer.from('CPX2-DISCOVERY\u0000').toString('hex') + ) + for (const v of discoveryVectors) { + const seed = Buffer.from(v.seedB64, 'base64') + assert.equal(pubKeyFromSeed(seed), v.pubKeyB64) + const payloadBytes = Buffer.from(v.payloadJson, 'utf-8') + assert.equal(signDiscovery(payloadBytes, seed), v.signed) + const back = verifyDiscoveryEnvelope(v.signed, v.pubKeyB64) + assert.equal(back.toString('utf-8'), v.payloadJson) + assert.equal(createHash('sha256').update(back).digest('hex'), v.digestHex) + } +}) + +test('verifyDiscoveryEnvelope rejects tampering, wrong key, bad format', () => { + const v = discoveryVectors[0] + const [p, sig] = v.signed.split('.') + assert.throws(() => verifyDiscoveryEnvelope(`${p}.${sig}.x`, v.pubKeyB64), /"\."/) + assert.throws( + () => verifyDiscoveryEnvelope(`${p.replace(/=+$/, '')}.${sig}`, v.pubKeyB64), + /canonical/ + ) + const bad = Buffer.from(sig, 'base64') + bad[3] ^= 1 + assert.throws( + () => verifyDiscoveryEnvelope(`${p}.${bad.toString('base64')}`, v.pubKeyB64), + /verification/ + ) + assert.throws( + () => verifyDiscoveryEnvelope(v.signed, discoveryVectors[1].pubKeyB64), + /verification/ + ) +}) diff --git a/deploy/gateway/src/discovery.mjs b/deploy/gateway/src/discovery.mjs new file mode 100644 index 00000000..26346492 --- /dev/null +++ b/deploy/gateway/src/discovery.mjs @@ -0,0 +1,236 @@ +// Signed discovery document helpers shared by the offline signer (admin.mjs) and the server: +// the same field rules the client enforces (src/main/resolve/plugin/discovery-sig.ts), applied +// BEFORE a document is signed or served, so a provider cannot publish a document every keyed +// client will reject. Zero dependencies. +import { DISCOVERY_MAX_PAYLOAD_BYTES, isCanonicalB64 } from './crypto.mjs' + +const PAYLOAD_KEYS = new Set(['spec', 'seq', 'gateways', 'endpoints', 'loginUrl', 'discoveryUrls']) +const REQUIRED_ENDPOINTS = ['enroll', 'challenge', 'config', 'revoke'] +const OPTIONAL_ENDPOINTS = new Set(['bootstrap']) +const MAX_GATEWAYS = 3 +const MAX_DISCOVERY_URLS = 8 +const MAX_SEQ = Number.MAX_SAFE_INTEGER + +function isPrivateIpv4(ip) { + const p = ip.split('.').map(Number) + if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true + const [a, b, c] = p + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) || + a >= 224 + ) +} + +// Expand an IPv6 string to 8 16-bit hextets; null if unparseable. Mirrors the client's +// net-guard.ts so that hex-form IPv4-mapped addresses (what WHATWG URL normalizes +// "::ffff:127.0.0.1" into) are judged exactly like the dotted form. +function expandIpv6(input) { + let ip = input.toLowerCase() + const pct = ip.indexOf('%') + if (pct >= 0) ip = ip.slice(0, pct) + const halves = ip.split('::') + if (halves.length > 2) return null + const parseGroups = (str) => { + if (str === '') return [] + const groups = str.split(':') + const out = [] + for (let i = 0; i < groups.length; i++) { + const g = groups[i] + if (g.includes('.')) { + if (i !== groups.length - 1) return null + const o = g.split('.').map(Number) + 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 + 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 + groups = [...head, ...Array(missing).fill('0'), ...tail] + } else { + groups = head + } + if (groups.length !== 8) return null + return groups.map((g) => parseInt(g, 16)) +} + +// Same special ranges as the client: unspecified, loopback, link-local, ULA, discard 100::/64, +// documentation 2001:db8::/32, 6to4 2002::/16, multicast, and IPv4-mapped with a private IPv4. +function isPrivateIpv6(ip) { + const h = expandIpv6(ip) + if (!h) return true + if (h.every((x) => x === 0)) return true + if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true + if ((h[0] & 0xffc0) === 0xfe80) return true + if ((h[0] & 0xfe00) === 0xfc00) return true + if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 0) return true + if (h[0] === 0x2001 && h[1] === 0x0db8) return true + if (h[0] === 0x2002) return true + if ((h[0] & 0xff00) === 0xff00) return true + 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 isForbiddenHost(host) { + let h = String(host).trim().toLowerCase() + if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1) + if (h.endsWith('.')) h = h.slice(0, -1) + if (h === '' || h === 'localhost' || h.endsWith('.localhost')) return true + if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) return isPrivateIpv4(h) + if (h.includes(':')) return isPrivateIpv6(h) + return false +} + +// https origin only: scheme + host (+ port); no path, query, fragment, userinfo; public host. +export function parseOrigin(value) { + if (typeof value !== 'string') return null + let u + try { + u = new URL(value) + } catch { + return null + } + if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash) return null + if (u.pathname && u.pathname !== '/') return null + if (isForbiddenHost(u.hostname)) return null + return u.origin +} + +export function isValidEndpointPath(v) { + if (typeof v !== 'string' || !v.startsWith('/')) return false + if (v.startsWith('//') || v.includes('\\') || v.includes('?') || v.includes('#')) return false + if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return false + return true +} + +function parseLoginUrl(v) { + if (typeof v !== 'string') throw new Error('loginUrl must be a string') + let u + try { + u = new URL(v) + } catch { + throw new Error('loginUrl must be a valid URL') + } + if (u.protocol !== 'https:') throw new Error('loginUrl must be https') + if (u.username || u.password) throw new Error('loginUrl must not contain userinfo') + if (u.search || u.hash) throw new Error('loginUrl must not contain query or fragment') + if (isForbiddenHost(u.hostname)) throw new Error('loginUrl must be a public host') + return u.toString() +} + +// Validate and normalize a discovery payload object. Throws an Error naming the offending field. +export function validateDiscoveryPayload(raw) { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('payload must be a JSON object') + } + for (const k of Object.keys(raw)) { + if (!PAYLOAD_KEYS.has(k)) throw new Error(`payload: unknown key "${k}"`) + } + if (raw.spec !== 'cpx-plugin/2') throw new Error('payload.spec must be "cpx-plugin/2"') + if (!Number.isSafeInteger(raw.seq) || raw.seq < 1 || raw.seq > MAX_SEQ) { + throw new Error('payload.seq must be an integer in 1..2^53-1') + } + if ( + !Array.isArray(raw.gateways) || + raw.gateways.length < 1 || + raw.gateways.length > MAX_GATEWAYS + ) { + throw new Error(`payload.gateways must list 1..${MAX_GATEWAYS} https origins`) + } + const gateways = [] + for (const g of raw.gateways) { + const origin = parseOrigin(g) + if (!origin) throw new Error(`payload.gateways: "${g}" is not a public https origin`) + if (!gateways.includes(origin)) gateways.push(origin) + } + if (typeof raw.endpoints !== 'object' || raw.endpoints === null || Array.isArray(raw.endpoints)) { + throw new Error('payload.endpoints must be an object') + } + const endpoints = {} + for (const k of Object.keys(raw.endpoints)) { + if (!REQUIRED_ENDPOINTS.includes(k) && !OPTIONAL_ENDPOINTS.has(k)) { + throw new Error(`payload.endpoints: unknown endpoint "${k}"`) + } + if (!isValidEndpointPath(raw.endpoints[k])) { + throw new Error(`payload.endpoints.${k} must be a relative path starting with "/"`) + } + endpoints[k] = raw.endpoints[k] + } + for (const k of REQUIRED_ENDPOINTS) { + if (!(k in endpoints)) throw new Error(`payload.endpoints.${k} is required`) + } + const out = { spec: 'cpx-plugin/2', seq: raw.seq, gateways, endpoints } + if (raw.loginUrl !== undefined) out.loginUrl = parseLoginUrl(raw.loginUrl) + if (raw.discoveryUrls !== undefined) { + if (!Array.isArray(raw.discoveryUrls) || raw.discoveryUrls.length > MAX_DISCOVERY_URLS) { + throw new Error( + `payload.discoveryUrls must be an array of at most ${MAX_DISCOVERY_URLS} origins` + ) + } + const loginOrigin = out.loginUrl ? new URL(out.loginUrl).origin : undefined + const urls = [] + for (const d of raw.discoveryUrls) { + const origin = parseOrigin(d) + if (!origin) throw new Error(`payload.discoveryUrls: "${d}" is not a public https origin`) + if (loginOrigin && origin === loginOrigin) { + throw new Error('payload.discoveryUrls must not repeat the loginUrl origin') + } + if (urls.includes(origin)) + throw new Error('payload.discoveryUrls must not contain duplicates') + urls.push(origin) + } + out.discoveryUrls = urls + } + return out +} + +// Format checks of an envelope "." without verifying the signature (the +// server has no key material). Canonical base64 on both halves also rules out CR/LF and any +// other byte that would break the HTTP header the envelope is sent in. +export function parseDiscoveryEnvelope(text) { + const signed = String(text).trim() + const parts = signed.split('.') + if (parts.length !== 2) throw new Error('envelope must contain exactly one "."') + const [payloadB64, sigB64] = parts + if (!isCanonicalB64(payloadB64) || !isCanonicalB64(sigB64)) { + throw new Error('envelope: both halves must be canonical standard base64') + } + const payloadBytes = Buffer.from(payloadB64, 'base64') + if (payloadBytes.length < 1 || payloadBytes.length > DISCOVERY_MAX_PAYLOAD_BYTES) { + throw new Error(`envelope: payload must be 1..${DISCOVERY_MAX_PAYLOAD_BYTES} bytes`) + } + if (Buffer.from(sigB64, 'base64').length !== 64) { + throw new Error('envelope: signature must be 64 bytes') + } + let payload + try { + payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payloadBytes)) + } catch { + throw new Error('envelope: payload is not valid UTF-8 JSON') + } + return { signed, payload: validateDiscoveryPayload(payload) } +} diff --git a/deploy/gateway/src/discovery.test.mjs b/deploy/gateway/src/discovery.test.mjs new file mode 100644 index 00000000..fa797468 --- /dev/null +++ b/deploy/gateway/src/discovery.test.mjs @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + isForbiddenHost, + parseOrigin, + validateDiscoveryPayload, + parseDiscoveryEnvelope +} from './discovery.mjs' +import { signDiscovery } from './crypto.mjs' + +const GOOD = { + spec: 'cpx-plugin/2', + seq: 12, + gateways: ['https://gw1.example.net/', 'https://gw2-cdn.example.com'], + endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' }, + loginUrl: 'https://panel-new.example.com/oauth/authorize', + discoveryUrls: ['https://gw2-cdn.example.com'] +} + +test('validateDiscoveryPayload accepts and normalizes a well-formed document', () => { + const out = validateDiscoveryPayload(GOOD) + assert.deepEqual(out.gateways, ['https://gw1.example.net', 'https://gw2-cdn.example.com']) + assert.equal(out.seq, 12) + assert.deepEqual(out.discoveryUrls, ['https://gw2-cdn.example.com']) +}) + +test('validateDiscoveryPayload rejects what the client rejects', () => { + const cases = [ + [{ ...GOOD, extra: 1 }, /unknown key/], + [{ ...GOOD, seq: 0 }, /seq/], + [{ ...GOOD, seq: 2 ** 53 }, /seq/], + [{ ...GOOD, gateways: [] }, /gateways/], + [{ ...GOOD, gateways: ['https://a', 'https://b', 'https://c', 'https://d'] }, /gateways/], + [{ ...GOOD, gateways: ['https://10.0.0.1'] }, /public https origin/], + [{ ...GOOD, gateways: ['https://localhost'] }, /public https origin/], + [{ ...GOOD, gateways: ['https://[::1]'] }, /public https origin/], + [{ ...GOOD, gateways: ['http://gw.example.net'] }, /public https origin/], + [{ ...GOOD, gateways: ['https://gw.example.net/base'] }, /public https origin/], + [{ ...GOOD, endpoints: { ...GOOD.endpoints, x: '/x' } }, /unknown endpoint/], + [{ ...GOOD, endpoints: { ...GOOD.endpoints, config: 'config' } }, /endpoints.config/], + [ + { ...GOOD, endpoints: { enroll: '/e', challenge: '/c', config: '/cfg' } }, + /revoke is required/ + ], + [{ ...GOOD, loginUrl: 'http://panel.example.com/a' }, /loginUrl/], + [{ ...GOOD, discoveryUrls: ['https://panel-new.example.com'] }, /loginUrl origin/], + [{ ...GOOD, discoveryUrls: ['https://x.example', 'https://x.example/'] }, /duplicates/] + ] + for (const [payload, re] of cases) assert.throws(() => validateDiscoveryPayload(payload), re) +}) + +test('isForbiddenHost / parseOrigin mirror the client literal host rules', () => { + for (const h of [ + 'localhost', + 'foo.localhost', + 'localhost.', + '127.0.0.1', + '10.1.2.3', + '169.254.169.254', + '198.18.0.1', + '[::1]', + 'fe80::1', + 'fd00::1', + '::ffff:127.0.0.1', + '' + ]) { + assert.equal(isForbiddenHost(h), true, h) + } + for (const h of ['gw.example.net', 'localhost.com', '1.1.1.1', '2606:4700::1']) { + assert.equal(isForbiddenHost(h), false, h) + } + // R1: WHATWG URL normalizes mapped/compressed IPv6 into hex form; every special range the client + // rejects must be rejected here as well, through parseOrigin (the path admin/server use) + for (const bad of [ + 'https://[::ffff:127.0.0.1]', + 'https://[::ffff:7f00:1]', + 'https://[::ffff:10.0.0.1]', + 'https://[100::1]', + 'https://[2002:7f00:1::1]', + 'https://[2001:db8::1]', + 'https://[ff02::1]', + 'https://[::]' + ]) { + assert.equal(parseOrigin(bad), null, bad) + } + assert.equal(parseOrigin('https://[2606:4700::1]'), 'https://[2606:4700::1]') + assert.equal(parseOrigin('https://gw.example.net:8443/'), 'https://gw.example.net:8443') + assert.equal(parseOrigin('https://u:p@gw.example.net'), null) +}) + +test('parseDiscoveryEnvelope enforces canonical base64, sizes and the payload rules', () => { + const seed = Buffer.alloc(32, 7) + const signed = signDiscovery(Buffer.from(JSON.stringify(GOOD)), seed) + const parsed = parseDiscoveryEnvelope(signed + '\n') + assert.equal(parsed.signed, signed) + assert.deepEqual(parsed.payload.gateways, [ + 'https://gw1.example.net', + 'https://gw2-cdn.example.com' + ]) + const [p, sig] = signed.split('.') + assert.throws(() => parseDiscoveryEnvelope(`${p}\r\n.${sig}`), /canonical/) + assert.throws(() => parseDiscoveryEnvelope(`${p.replace(/=+$/, '')}.${sig}`), /canonical/) + assert.throws(() => parseDiscoveryEnvelope(`${p}.${sig}.x`), /one "."/) + assert.throws( + () => parseDiscoveryEnvelope(`${p}.${Buffer.alloc(63).toString('base64')}`), + /64 bytes/ + ) + const badPayload = signDiscovery(Buffer.from(JSON.stringify({ ...GOOD, extra: 1 })), seed) + assert.throws(() => parseDiscoveryEnvelope(badPayload), /unknown key/) +}) diff --git a/deploy/gateway/src/gateway.mjs b/deploy/gateway/src/gateway.mjs index b9db075e..dbbb48c3 100644 --- a/deploy/gateway/src/gateway.mjs +++ b/deploy/gateway/src/gateway.mjs @@ -6,6 +6,13 @@ 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}$/ +// §4: error JSON may carry a provider-written `message` (client sanitizes and caps it at 200 +// code points). Attached only when MESSAGES_FILE defines one for that error. +function errorBody(deps, error) { + const message = deps.messages?.[error] + return message ? { error, message } : { error } +} + 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 @@ -32,7 +39,7 @@ export function enroll(body, res, deps) { 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' }) + return sendJson(res, 403, errorBody(deps, 'device_limit')) } deps.db.upsertDevice({ deviceId: body.deviceId, @@ -43,9 +50,9 @@ export function enroll(body, res, deps) { } export function challenge(body, res, deps) { - if (deps.config.retired) return sendJson(res, 410, { error: 'gateway_retired' }) + if (deps.config.retired) return sendJson(res, 410, errorBody(deps, 'gateway_retired')) const device = deps.db.getDevice(body?.deviceId) - if (!device) return sendJson(res, 403, { error: 'device_revoked' }) + if (!device) return sendJson(res, 403, errorBody(deps, 'device_revoked')) const issued = deps.nonces.issue(device.deviceId) if (!issued) return sendJson(res, 429, { error: 'too_many_nonces' }) sendJson(res, 200, issued) @@ -75,9 +82,9 @@ function verifySignedRequest(body, op, device, deps) { } export async function config(body, res, deps) { - if (deps.config.retired) return sendJson(res, 410, { error: 'gateway_retired' }) + if (deps.config.retired) return sendJson(res, 410, errorBody(deps, 'gateway_retired')) const device = deps.db.getDevice(body?.deviceId) - if (!device) return sendJson(res, 403, { error: 'device_revoked' }) + if (!device) return sendJson(res, 403, errorBody(deps, 'device_revoked')) const bad = verifySignedRequest(body, OP_CONFIG, device, deps) if (bad) return sendJson(res, bad.status, { error: bad.error }) @@ -88,7 +95,9 @@ export async function config(body, res, deps) { maxBytes: deps.config.subMaxBytes, ca: deps.config.originCa }) - sendText(res, 200, yaml, 'text/yaml; charset=utf-8') + // §5a: push the pre-signed discovery document in-band; keyed clients verify it themselves. + const extra = deps.discovery ? { 'x-cpx-discovery': deps.discovery.signed } : {} + sendText(res, 200, yaml, 'text/yaml; charset=utf-8', extra) } catch { sendJson(res, 502, { error: 'upstream' }) } diff --git a/deploy/gateway/src/gateway.test.mjs b/deploy/gateway/src/gateway.test.mjs index 7cc97e96..c576fd0d 100644 --- a/deploy/gateway/src/gateway.test.mjs +++ b/deploy/gateway/src/gateway.test.mjs @@ -389,3 +389,66 @@ test('revoke: bad signature on an existing device → 403 and device stays bound assert.equal(jsonOf(res).error, 'bad_signature') assert.ok(deps.db.getDevice(dev.deviceId)) }) + +// ---------- §4 provider messages ---------- +test('challenge: device_revoked carries the configured message, and none when unset', () => { + const withMsg = { ...setup(), messages: { device_revoked: '订阅已到期,续费后请重新登录。' } } + const res = mockRes() + challenge({ deviceId: randomUUID() }, res, withMsg) + assert.equal(res.status, 403) + assert.deepEqual(jsonOf(res), { + error: 'device_revoked', + message: '订阅已到期,续费后请重新登录。' + }) + + const res2 = mockRes() + challenge({ deviceId: randomUUID() }, res2, setup()) + assert.deepEqual(jsonOf(res2), { error: 'device_revoked' }) +}) + +test('gateway_retired and device_limit carry their messages when configured', () => { + const deps = { + ...setup({ retired: true }), + messages: { gateway_retired: 'moved', device_limit: 'too many devices' } + } + const res = mockRes() + challenge({ deviceId: randomUUID() }, res, deps) + assert.deepEqual(jsonOf(res), { error: 'gateway_retired', message: 'moved' }) + + const live = { ...setup(), messages: { device_limit: 'too many devices' } } + bind(live) + bind(live) // alice's limit is 2 + const { code, verifier } = mintCode(live) + const res2 = mockRes() + enroll( + { + code, + code_verifier: verifier, + redirect_uri: REDIRECT, + client_id: CLIENT, + deviceId: newDevice().deviceId, + devicePubKey: newDevice().pubKey + }, + res2, + live + ) + assert.equal(res2.status, 403) + assert.deepEqual(jsonOf(res2), { error: 'device_limit', message: 'too many devices' }) +}) + +// ---------- §5a X-CPX-Discovery on /config ---------- +test('config: sets X-CPX-Discovery only when a signed discovery document is configured', async () => { + const withDoc = { ...setup(), discovery: { signed: 'AAAA.BBBB', payload: {} } } + const dev = bind(withDoc) + const res = mockRes() + await config(signedBody(withDoc, dev, OP_CONFIG), res, withDoc) + assert.equal(res.status, 200) + assert.equal(res.headers['x-cpx-discovery'], 'AAAA.BBBB') + + const plain = setup() + const dev2 = bind(plain) + const res2 = mockRes() + await config(signedBody(plain, dev2, OP_CONFIG), res2, plain) + assert.equal(res2.status, 200) + assert.equal(res2.headers['x-cpx-discovery'], undefined) +}) diff --git a/deploy/gateway/src/http.mjs b/deploy/gateway/src/http.mjs index 343181a6..fb9ada9a 100644 --- a/deploy/gateway/src/http.mjs +++ b/deploy/gateway/src/http.mjs @@ -37,8 +37,8 @@ export function sendJson(res, status, 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 sendText(res, status, body, contentType = 'text/plain; charset=utf-8', extra = {}) { + res.writeHead(status, { 'content-type': contentType, ...extra }).end(body) } export function sendHtml(res, status, html) { diff --git a/deploy/gateway/src/server.integration.test.mjs b/deploy/gateway/src/server.integration.test.mjs index 65336d54..f604f623 100644 --- a/deploy/gateway/src/server.integration.test.mjs +++ b/deploy/gateway/src/server.integration.test.mjs @@ -35,8 +35,14 @@ before(async () => { nonces: createNonceStore(), rateLimiter: createRateLimiter({ max: 100, windowMs: 1000 }), fetchSubscription: async () => CLASH, + // §5a: a pre-signed envelope; the integration test only checks plumbing, the crypto tests verify it + discovery: { + signed: 'cGF5bG9hZA==.c2ln', + payload: { spec: 'cpx-plugin/2', seq: 1, gateways: ['https://gw.test', 'https://gw2.test'] } + }, config: { publicOrigin: 'https://gw.test', + gatewayOrigins: ['https://gw.test', 'https://gw2.test'], clockSkewMs: 300000, retired: false, subTimeoutMs: 5000, @@ -100,7 +106,10 @@ test('full flow: well-known → authorize → enroll → challenge → config assert.equal(wk.status, 200) const wkBody = JSON.parse(wk.body) assert.equal(wkBody.gateway, 'https://gw.test') + assert.deepEqual(wkBody.gateways, ['https://gw.test', 'https://gw2.test']) + assert.equal(wkBody.gateway, wkBody.gateways[0]) assert.equal(wkBody.endpoints.config, '/config') + assert.equal(wkBody.signed, 'cGF5bG9hZA==.c2ln') // 2. PKCE login params const verifier = randomBytes(32).toString('base64url') diff --git a/deploy/gateway/src/server.mjs b/deploy/gateway/src/server.mjs index 410711f8..29653466 100644 --- a/deploy/gateway/src/server.mjs +++ b/deploy/gateway/src/server.mjs @@ -11,6 +11,7 @@ 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' +import { parseDiscoveryEnvelope } from './discovery.mjs' const BODY_MAX = 64 * 1024 const ENDPOINTS = { @@ -28,10 +29,18 @@ export function createHandler(deps) { const method = req.method if (method === 'GET' && path === '/.well-known/cpx-gateway') { + // `gateway` stays a string for old clients and MUST equal gateways[0] (client rejects + // the whole document otherwise); both are generated from the same list. + // With a signed document, every public field is generated from its payload (§5a). + const gateways = deps.discovery + ? deps.discovery.payload.gateways + : (deps.config.gatewayOrigins ?? [deps.config.publicOrigin]) return sendJson(res, 200, { spec: 'cpx-plugin/2', - gateway: deps.config.publicOrigin, - endpoints: ENDPOINTS + gateway: gateways[0], + gateways, + endpoints: ENDPOINTS, + ...(deps.discovery ? { signed: deps.discovery.signed } : {}) }) } @@ -60,9 +69,59 @@ export function createHandler(deps) { } } +const MESSAGE_KEYS = ['device_revoked', 'device_limit', 'gateway_retired'] + +// Only the three known keys, only strings, trimmed, non-empty. Anything else is ignored. +export function parseMessages(text) { + let raw + try { + raw = JSON.parse(text) + } catch { + throw new Error('MESSAGES_FILE is not valid JSON') + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('MESSAGES_FILE must be a JSON object') + } + const out = {} + for (const k of MESSAGE_KEYS) { + if (typeof raw[k] === 'string' && raw[k].trim()) out[k] = raw[k].trim() + } + return out +} + +// Parse the envelope file served as well-known `signed` and the X-CPX-Discovery header. The +// signature is not verified here (the process has no key material); the client verifies. The +// payload is decoded so that `gateway` / `gateways` / `endpoints` can be generated from it, which +// keeps the top-level fields consistent with the signed document — a keyed client rejects the +// whole source when they disagree. +export function parseDiscoverySigned(text) { + let parsed + try { + parsed = parseDiscoveryEnvelope(text) + } catch (e) { + throw new Error(`DISCOVERY_SIGNED_FILE: ${e.message}`) + } + for (const k of Object.keys(ENDPOINTS)) { + if (parsed.payload.endpoints[k] !== ENDPOINTS[k]) { + throw new Error( + `DISCOVERY_SIGNED_FILE: endpoints.${k} must be "${ENDPOINTS[k]}" (the path this gateway serves)` + ) + } + } + return parsed +} + export function buildDeps(config) { const originCa = config.originCaFile ? readFileSync(config.originCaFile) : undefined + const messages = config.messagesFile + ? parseMessages(readFileSync(config.messagesFile, 'utf-8')) + : {} + const discovery = config.discoverySignedFile + ? parseDiscoverySigned(readFileSync(config.discoverySignedFile, 'utf-8')) + : undefined return { + messages, + discovery, db: openDb(config.dbPath), codes: createCodeStore({ ttlMs: config.codeTtlMs }), nonces: createNonceStore({ ttlMs: config.nonceTtlMs, poolMax: config.noncePoolMax }), @@ -80,7 +139,9 @@ 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})`) + console.log( + `cpx-gateway listening on :${config.port} (gateways ${config.gatewayOrigins.join(', ')})` + ) }) } diff --git a/docs/plugin/PROVIDER_INTEGRATION_v2.md b/docs/plugin/PROVIDER_INTEGRATION_v2.md index 4629a4b7..c35ce666 100644 --- a/docs/plugin/PROVIDER_INTEGRATION_v2.md +++ b/docs/plugin/PROVIDER_INTEGRATION_v2.md @@ -117,26 +117,31 @@ For an existing panel, the usual additions are: 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 | +| Field | Rule | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `magic` | String `"CPXF"` | +| `v` | Number `2` | +| `spec` | String `"cpx-plugin/2"` | +| Top-level keys | Only `magic`, `v`, `spec`, `loginUrl`, `provider`, `discoveryUrls`, `providerPubKey` | +| `loginUrl` | HTTPS URL; no query, fragment, or userinfo; host must not be private, loopback, `localhost`, or `*.localhost` | +| `discoveryUrls` | Optional. 1..8 public HTTPS origins (no path/query/fragment/userinfo), deduplicated, none equal to the `loginUrl` origin. Backup discovery sources, see §5. **Only clients from the release that added it accept this key; older clients reject the file** | +| `providerPubKey` | Optional. Ed25519 raw 32-byte public key, standard base64 with padding. Once present the client **requires** a signed discovery document (§5a) and rejects unsigned ones. Use one key per `.cpx` lineage. **Older clients reject the file** | +| `provider` | Object; only `name`, `icon`, `site`, `description` | +| `provider.name` | Required non-empty string | +| `provider.icon` | Optional data URI; only PNG/JPEG/WEBP; total string length <= 65536 | +| `provider.site` | Optional HTTPS URL; same host restrictions as `loginUrl`; path is allowed | +| `provider.description` | Optional string shown to the user; sanitized like error `message` (§6) and capped at 500 code points. **Older clients reject the file** | `loginUrl` is the OAuth authorize endpoint, not a generic login page. The client appends OAuth parameters to it. Generator: ```bash -node scripts/plugin/gen-cpx.mjs [site] [output] +node scripts/plugin/gen-cpx.mjs [site] [output] [--discovery ]... ``` +`--discovery` may be repeated; each value becomes one entry of `discoveryUrls`. + ### Distribution Options - **File download**: Distribute the `.cpx` file directly. Clients with the file association registered can launch the app by double-clicking the file and will see the plugin preview and confirmation page. @@ -192,6 +197,7 @@ Response: { "spec": "cpx-plugin/2", "gateway": "https://gw.front.example.net", + "gateways": ["https://gw.front.example.net", "https://gw2-cdn.example.com"], "endpoints": { "enroll": "/enroll", "challenge": "/challenge", @@ -203,27 +209,106 @@ Response: 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 | +| Field | Rule | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spec` | String `"cpx-plugin/2"` | +| `gateway` | HTTPS origin only: scheme, host, optional port; no path, query, fragment, or userinfo; public host. **Must equal the normalized `gateways[0]`** when `gateways` is present | +| `gateways` | Optional. 1..3 HTTPS origins with the same rules as `gateway`, deduplicated. Absent → the client uses `[gateway]`. Any invalid entry, an empty list, or more than 3 entries invalidates the whole document | +| `endpoints` | Must contain `enroll`, `challenge`, `config`, `revoke`; each value is a relative path starting with `/`; no absolute URL, `?`, `#`, or backslash. The same endpoints apply to every gateway | The discovery request uses HTTPS only, does not follow redirects, and caps the response body at 64 KiB. Non-2xx, invalid JSON, or invalid fields are discovery failures. -### Gateway Rotation +Old clients only read `gateway`; new clients read `gateways` and fall back to `[gateway]`. Generate both from the same list so they never disagree. -To rotate the gateway, update `/.well-known/cpx-gateway`. +**Multiple gateways must share state.** All `gateways` must point at the same backend state (authorize codes, devices, nonces): the client may take a nonce from `/challenge` on one gateway and post `/config` on another after a timeout. The reference deployment terminates TLS for every gateway domain in Caddy and forwards them all to one gateway process; it does not support multiple replicas. -The client rediscovers and retries once when the cached gateway returns: +### Gateway Rotation and Switching -- HTTP `410`; -- JSON `{"error":"gateway_retired"}`; -- network-level failure, such as DNS failure, connection failure, or TLS handshake failure. +To rotate gateways, update `/.well-known/cpx-gateway`. -Plain 5xx, 429, and timeouts are treated as transient failures. They do not trigger rediscovery. +Within one client operation (a complete business action such as challenge + config), the client tries the cached gateways in order — the last gateway that worked first, then the rest — and moves to the next candidate when the current one returns: -Rediscovery requires the login host to be reachable. +- HTTP `410` or JSON `{"error":"gateway_retired"}`; +- a network-level failure: DNS failure, connection failure, TLS handshake failure; +- a timeout (no HTTP response at all). + +Any HTTP response other than the retired marker stops the operation: plain 5xx, 429, or `revoked` are never reasons to try another gateway. Only when **every** cached gateway failed in the switch-worthy way does the client rediscover **once** and try the new list, skipping targets it already tried in the same operation (same origin **and** same endpoints). If that still fails the operation ends as transient and backs off. + +Each gateway origin is also routed independently: the client may reach one gateway directly and another through its local proxy (see §6). + +Rediscovery requires a discovery source to be reachable (the login host, and any `discoveryUrls` from the descriptor). + +### Multiple Discovery Sources + +The login host is a single point of failure for rediscovery: if it is blocked, an enrolled device can no longer learn about a new gateway. The descriptor may therefore list backup sources in `discoveryUrls` (§3). They carry the same trust as `loginUrl` — both are static roots the user accepted at import time. + +Discovery order is `[origin of loginUrl, ...discoveryUrls]`; every source is asked for `https:///.well-known/cpx-gateway`. **Any** failure at one source — network error, non-2xx, invalid JSON, invalid fields, or a client-side guard refusal — moves on to the next source; a backup may be a plain static file on a CDN, so `404` simply means "not provided here". When every source fails, the last error wins. Each source is its own origin, so route selection (§6) is independent per source. + +A backup source only needs to serve the JSON document at that path over public HTTPS. Any CDN or object storage works, and the gateway itself already serves it, so listing a gateway origin in `discoveryUrls` is the simplest option. Backup sources help already-enrolled devices recover; a **new** login still needs the login host, because the OAuth page opens in the system browser. + +### 5a. Signed Discovery Document + +With `providerPubKey` in the descriptor the trust root moves from a host name to a key: a discovery document is accepted from anywhere — the login host, any gateway, a static CDN file, or the `/config` response — as long as it verifies under that key and its sequence number is not older than what the client already accepted. This makes the login host replaceable and lets `gateways`, `endpoints`, `loginUrl` and `discoveryUrls` rotate without touching the `.cpx` file. + +**Envelope.** One string `"."`: + +- `payloadB64`: the UTF-8 bytes of the payload JSON, standard base64 with padding. +- `sigB64`: the Ed25519 signature (64 bytes) over `"CPX2-DISCOVERY\0" || payloadBytes` — the ASCII prefix followed by a NUL byte, then the exact payload bytes — standard base64 with padding. The prefix separates discovery documents from any other message signed with the same key. +- Exactly one `.`. Both halves must be **canonical** base64 (decoding and re-encoding reproduces the input). The payload is at most **4 KiB**; after base64 and signature the envelope is about 5.6 KiB, inside the usual 8 KiB per-header limit of CDNs and reverse proxies. + +The signer serializes the payload itself and signs those bytes; the client verifies the received bytes before parsing them. No canonicalization (JCS or similar) is involved on either side. + +**Payload.** + +```json +{ + "spec": "cpx-plugin/2", + "seq": 12, + "gateways": ["https://gw1.example.net", "https://gw2-cdn.example.com"], + "endpoints": { + "enroll": "/enroll", + "challenge": "/challenge", + "config": "/config", + "revoke": "/revoke" + }, + "loginUrl": "https://panel-new.example.com/oauth/authorize", + "discoveryUrls": ["https://gw2-cdn.example.com"] +} +``` + +| Field | Rule | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spec` | `"cpx-plugin/2"` | +| `seq` | Integer, `1 ≤ seq ≤ 2^53−1`. Strictly increase it on every change | +| `gateways` | Same rules as §5 (1..3 public HTTPS origins) | +| `endpoints` | Same rules as §5; an optional `bootstrap` path is reserved for a later phase | +| `loginUrl` | Optional; same rules as the descriptor field. When accepted it replaces the stored login URL; the next login opens the new one | +| `discoveryUrls` | Optional; absent = unchanged, `[]` = clear, otherwise the descriptor rules (1..8). When the payload also carries `loginUrl`, the list must not contain that URL's origin — the whole document is invalid otherwise, so remove it before signing. When the payload omits `loginUrl`, the client drops entries equal to its currently stored login origin at apply time | + +No other keys are allowed. + +**Where it is served** — two places, same format, same verification: + +1. The well-known document gains an optional top-level `signed`. Keep `gateway` / `gateways` / `endpoints` for unkeyed and older clients; a keyed client compares them with the payload and rejects the whole source if they disagree, so generate both from the same payload. +2. A successful `/config` response may carry the header `X-CPX-Discovery: .`. Send it as a single header value; the client ignores repeated headers. + +**What the client does.** + +| `providerPubKey` in `.cpx` | `signed` in well-known | Behaviour | +| -------------------------- | ---------------------- | ---------------------------------------------------------------------- | +| no | any | unsigned path (§5); `signed` and `X-CPX-Discovery` are ignored | +| yes | no | **this source fails** (downgrade protection); the next source is tried | +| yes | yes | verify → parse → sequence check → apply | + +The client stores the last accepted `seq` together with the SHA-256 of the payload bytes. For an incoming document: no stored value → accept; `seq` higher → accept; same `seq` and same digest → accept as an idempotent re-application; same `seq` but a different digest → reject (two different documents with one number, e.g. inconsistent CDN copies); lower `seq` → reject. A rejected well-known source is skipped like any other discovery failure. A rejected or malformed `X-CPX-Discovery` header is only logged: the authenticated `/config` response is never discarded because of it. + +The sequence number protects against the **network** replaying an older document; it is not a defense against someone with write access to the client's own files. + +**Applying an accepted document.** Gateways and endpoints go into the client's encrypted cache first; then `loginUrl`, `discoveryUrls`, `seq` and the digest are written to the plugin record in one step. The sequence number is the commit marker: if anything fails in between, the marker does not advance and the next arrival of the same document repairs the record idempotently. On a fresh login the discovery document is applied **before** the browser opens, so a rotated `loginUrl` takes effect immediately and a cancelled login cannot be talked back into an older document afterwards. + +**Key management.** Sign offline. The gateway process holds no private key; it only serves a pre-signed envelope file (`DISCOVERY_SIGNED_FILE` in the reference gateway, used both for `signed` and for the header). `cpx-admin keygen` and `cpx-admin sign-discovery ` (or `scripts/plugin/sign-discovery.mjs`) read the seed from a file or stdin, never from the command line. Use a separate key for every `.cpx` lineage: documents signed with the same key are interchangeable between the plugins that share it. Key rotation is not part of this version — a lost or leaked key means issuing a new `.cpx`. + +**Publish order.** Serve the signed well-known first, then distribute the `.cpx` containing `providerPubKey`. In the other order every keyed client fails discovery until `signed` appears. --- @@ -235,15 +320,32 @@ No Authorization header is used. No bearer token is issued. Device identity is b 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 | +| Class | Condition | Client action | +| ------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `retired` | HTTP `410`, or JSON `{"error":"gateway_retired"}` | Try the next cached gateway; rediscover once when all are exhausted | +| `revoked` | JSON `{"error":"revoked"}` or `{"error":"device_revoked"}` | Mark as needs re-authentication | +| `unreachable` | DNS failure, connection failure, TLS handshake failure | Try the next route, then the next gateway; rediscover once when exhausted | +| `transient` | Other non-2xx (5xx, 429, bare 401/403), or a timeout | Timeout: next route / next gateway. Any HTTP response: back off and retry | +| `blocked` | The gateway host resolved to a private/loopback address (client-side guard, nothing is sent) | Back off; the user must fix the target or explicitly choose proxy mode | +| success | 2xx without an error marker | Continue | For expired accounts, disabled users, or revoked devices, include `revoked` or `device_revoked` in the JSON body. A bare `401` or `403` is treated as transient. +**Optional `message`.** Any error JSON may add a human-readable `message` that the client shows on the plugin card next to its own fixed status text: + +```json +{ + "error": "revoked", + "message": "Your subscription expired on 2026-09-01. Renew and log in again." +} +``` + +The client only reads `message` when it is a string; it trims it, strips control characters except newline (U+000A), truncates to 200 code points, and treats an empty result as absent. It is rendered as plain text (no Markdown, no links). The message is stored with the plugin until the next successful operation clears it. The same sanitizing rules apply to the static `provider.description` in `.cpx` (cap 500 code points), which is shown on the install page and on the card. + +**Routes.** Each request leaves the client either directly or through the user's local proxy. In the default _auto_ mode the client remembers the route that last worked for a plugin, tries it first, and switches to the other one only on a network-level failure or a timeout — never on an HTTP response. Stickiness is per origin: once a gateway origin has answered on a route, the rest of the operation keeps using that route for it. Before any request goes through the proxy, the client resolves the gateway host locally and refuses hosts that resolve to private addresses (`blocked`). Two residual risks remain, both shared with the explicit proxy mode: DNS rebinding between that check and the proxy's own lookup, and hosts that fail to resolve locally are still allowed through the proxy. + +**`/enroll` is never replayed.** The authorize `code` is consumed by the first request that reaches the gateway. The client therefore only tries another route or another gateway for `/enroll` when the request provably never left the client (connection refused / DNS / TLS failure before the request was written). A timeout, a connection reset after sending, or any HTTP response ends the login; the user simply logs in again for a fresh code. + --- ## 7. `POST {gateway}/enroll` @@ -278,7 +380,7 @@ 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. +- Re-login creates a new key pair and a new device binding. The client then revokes the device it replaced (see §10), so a user who logs in repeatedly on one machine does not accumulate bindings. --- @@ -354,13 +456,20 @@ Server steps: Successful `/config` response is not JSON. The client parses it as Clash YAML and requires an object containing at least `proxies` or `proxy-providers`. +A successful response may additionally carry the header `X-CPX-Discovery: .` (§5a). The client reads it only for plugins with `providerPubKey`, only as a single header value, and a failed verification is logged and ignored — the YAML body is still applied. + Do not return the subscription URL, origin API host, or origin token. --- ## 10. `POST {gateway}/revoke` -Purpose: unbind a device. The client calls this best-effort when the user deletes the plugin. +Purpose: unbind a device. The client calls it best-effort in two situations: + +- the user deletes the plugin — for the current device and any device still waiting in the list below; +- a re-login replaced the device — for the **previous** device, signed with the previous key, right after the login completes. If that call fails, the client keeps the old credentials and retries after later successful `/config` fetches, one device per attempt. + +A `/challenge` answer of `revoked` / `device_revoked` for the old device counts as "already unbound" and ends the retries. Nothing new is required of the gateway beyond §7 and the idempotency rule below; the only visible change is that `/revoke` now also arrives after re-logins, for a device that is no longer the account's newest one — unbind that `deviceId` only. Request body is the same as `/config`. @@ -413,6 +522,8 @@ Implementation notes: | `op` | `uint8`; config=1, revoke=2 | | `code` | Opaque authorize code, length <= 2048 | | `code_challenge` / `code_verifier` | RFC 7636 base64url, no padding; verifier length 43-128, charset `[A-Za-z0-9-._~]` | +| `providerPubKey` | Ed25519 public key, 32 raw bytes, standard base64 with padding | +| `signed` / `X-CPX-Discovery` | `.`; both halves canonical standard base64 with padding | Only PKCE fields use base64url without padding. Binary protocol fields use standard base64 with padding. @@ -470,6 +581,17 @@ echo json_encode(['error' => 'revoked']); --- +Signed discovery document (§5a), done offline: + +```php +$payload = json_encode($doc, JSON_UNESCAPED_SLASHES); // sign exactly these bytes +$sig = sodium_crypto_sign_detached("CPX2-DISCOVERY\0" . $payload, $secretKey); +$signed = base64_encode($payload) . '.' . base64_encode($sig); +// providerPubKey for the .cpx: base64_encode(sodium_crypto_sign_publickey($keypair)) +``` + +--- + ## 14. Test Vectors File: @@ -501,6 +623,14 @@ Regenerate vectors: node scripts/plugin/gen-sign-vectors.mjs ``` +Signed discovery vectors (§5a): + +```text +src/main/resolve/plugin/__fixtures__/discovery-vectors.json +``` + +Each entry contains `seedB64`, `pubKeyB64`, `payloadJson`, `payloadB64`, `signInputHex` (prefix + payload), `sigB64`, `signed` and `digestHex`. Verify that signing `signInputHex` with the seed reproduces `sigB64`, that `signed` verifies under `pubKeyB64`, and that SHA-256 of the payload bytes equals `digestHex`. Regenerate with `node scripts/plugin/gen-discovery-vectors.mjs`. + --- ## 15. Launch Checklist @@ -522,6 +652,9 @@ Login host: Gateway: - [ ] `gateway` is a public HTTPS origin. +- [ ] if `gateways` is present it lists 1..3 public HTTPS origins and `gateway` equals `gateways[0]`. +- [ ] every listed gateway reaches the same backend state (codes, devices, nonces); no independent replicas. +- [ ] if the `.cpx` carries `providerPubKey`: the well-known already serves `signed`, the top-level fields match the payload, and the signed document went live **before** the `.cpx` was distributed. - [ ] endpoint paths are relative and contain no backslash, query, or fragment. - [ ] `/enroll` verifies PKCE, code, redirect URI, and client ID. - [ ] `/challenge` issues 32-byte standard-base64 nonce values with short TTL and pool limits. diff --git a/docs/plugin/机场服务端对接指南-v2.md b/docs/plugin/机场服务端对接指南-v2.md index c94ef624..2c249f0b 100644 --- a/docs/plugin/机场服务端对接指南-v2.md +++ b/docs/plugin/机场服务端对接指南-v2.md @@ -104,26 +104,31 @@ v2 不再把真实订阅 URL 或 API 域名写入客户端文件。用户导入 字段要求: -| 字段 | 要求 | -| --------------- | --------------------------------------------------------------------------------------------------------------- | -| `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`,可包含路径 | +| 字段 | 要求 | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `magic` | 字符串 `"CPXF"` | +| `v` | 数字 `2` | +| `spec` | 字符串 `"cpx-plugin/2"` | +| 顶层字段 | 只能包含 `magic`、`v`、`spec`、`loginUrl`、`provider`、`discoveryUrls`、`providerPubKey` | +| `loginUrl` | HTTPS URL;不能包含 query、fragment、userinfo;host 不能是私网、环回、`localhost`、`*.localhost` | +| `discoveryUrls` | 可选。1..8 个公网 HTTPS origin(无 path/query/fragment/userinfo),去重,不得与 `loginUrl` 同 origin。备用发现源,见第 5 节。**只有加入该字段的版本之后的客户端才接受此键,旧客户端会拒绝导入** | +| `providerPubKey` | 可选。Ed25519 原始 32 字节公钥,标准 base64 带 padding。一旦存在,客户端**强制要求**签名发现文档(第 5a 节),拒绝未签名文档。每个 `.cpx` 谱系使用独立密钥。**旧客户端会拒绝导入** | +| `provider` | 对象,只能包含 `name`、`icon`、`site`、`description` | +| `provider.name` | 必填,非空字符串 | +| `provider.icon` | 可选;仅支持 `data:image/png;base64,`、`data:image/jpeg;base64,`、`data:image/webp;base64,`;总长度不超过 65536 | +| `provider.site` | 可选;HTTPS URL;host 约束同 `loginUrl`,可包含路径 | +| `provider.description` | 可选;给用户看的说明文字,清洗规则同错误 `message`(第 6 节),上限 500 码点。**旧客户端会拒绝导入** | `loginUrl` 必须是 authorize 端点,不是普通登录首页。客户端会在该 URL 后拼接 OAuth 参数。 生成脚本: ```bash -node scripts/plugin/gen-cpx.mjs [site] [output] +node scripts/plugin/gen-cpx.mjs [site] [output] [--discovery ]... ``` +`--discovery` 可重复,每个值对应 `discoveryUrls` 的一项。 + ### 分发方式 - **文件分发**:直接提供 `.cpx` 下载。已注册文件关联的客户端支持双击 `.cpx` 启动应用,并打开插件预览确认页。 @@ -178,6 +183,7 @@ GET https:///.well-known/cpx-gateway { "spec": "cpx-plugin/2", "gateway": "https://gw.front.example.net", + "gateways": ["https://gw.front.example.net", "https://gw2-cdn.example.com"], "endpoints": { "enroll": "/enroll", "challenge": "/challenge", @@ -189,25 +195,106 @@ GET https:///.well-known/cpx-gateway 字段要求: -| 字段 | 要求 | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `spec` | 字符串 `"cpx-plugin/2"` | -| `gateway` | HTTPS origin,仅包含 scheme、host、可选 port;不能有 path、query、fragment、userinfo;host 必须公网可达 | -| `endpoints` | 必须包含 `enroll`、`challenge`、`config`、`revoke`;每个值是以 `/` 开头的相对路径,不能是绝对 URL,不能包含 `?`、`#`、反斜杠 | +| 字段 | 要求 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spec` | 字符串 `"cpx-plugin/2"` | +| `gateway` | HTTPS origin,仅包含 scheme、host、可选 port;不能有 path、query、fragment、userinfo;host 必须公网可达。**有 `gateways` 时必须等于归一化后的 `gateways[0]`** | +| `gateways` | 可选。1..3 个 HTTPS origin,规则同 `gateway`,按归一化结果去重。缺失时客户端按 `[gateway]` 处理。任一项非法、空列表、超过 3 个都会使整份文档无效 | +| `endpoints` | 必须包含 `enroll`、`challenge`、`config`、`revoke`;每个值是以 `/` 开头的相对路径,不能是绝对 URL,不能包含 `?`、`#`、反斜杠。所有网关共用同一组 endpoints | 客户端请求 `/.well-known/cpx-gateway` 时只走 HTTPS,不跟随重定向,响应体上限 64 KiB。非 2xx、JSON 非法、字段校验失败均视为发现失败。 -### 网关轮换 +旧客户端只读 `gateway`;新客户端读 `gateways`,缺失时退回 `[gateway]`。两者请从同一个列表生成,保证一致。 -替换网关时,更新 `/.well-known/cpx-gateway` 中的 `gateway` 即可。客户端在下列情况会回到登录域名重新发现,并重试一次: +**多网关必须共享状态。** 所有 `gateways` 必须指向同一份后端状态(authorize code / 设备 / nonce):客户端可能在一个网关上 `/challenge` 拿到 nonce,超时后到另一个网关上 `/config`。参考部署由 Caddy 为所有网关域名终止 TLS 并转发到**同一个**网关进程,**不支持多副本**。 -- 当前网关返回 HTTP `410`; -- 响应 JSON 为 `{"error":"gateway_retired"}`; -- 当前网关发生网络层不可达,例如 DNS 失败、连接失败、TLS 握手失败。 +### 网关轮换与切换 -普通 5xx、429、超时不会触发重新发现,客户端按瞬时故障退避重试。 +替换网关时,更新 `/.well-known/cpx-gateway` 即可。 -重新发现依赖登录域名可用。登录域名故障时,已缓存网关仍可继续使用,但无法切换到新网关。 +在一次客户端操作(一个完整业务动作,如 challenge + config)内,客户端按缓存列表逐个尝试——上次成功的网关优先,其余按原序——在当前网关出现下列情况时换到下一个: + +- HTTP `410` 或 JSON `{"error":"gateway_retired"}`; +- 网络层不可达:DNS 失败、连接失败、TLS 握手失败; +- 超时(完全没有 HTTP 响应)。 + +除退役标记外的任何 HTTP 响应都会结束本次操作:普通 5xx、429、`revoked` 都不会换网关。只有当**全部**缓存网关都以上述可切换方式失败时,客户端才重新发现**一次**并尝试新列表,且跳过本次操作已试过的目标(同 origin **且**同 endpoints)。仍失败则按瞬时故障退避。 + +每个网关 origin 的出口路由也是独立选择的:客户端可能直连一个网关、经本地代理访问另一个网关(见第 6 节)。 + +重新发现依赖至少一个发现源可用(登录域名,以及 `.cpx` 里的 `discoveryUrls`)。 + +### 多发现源 + +登录域名是重新发现的单点:它被封后,已登录设备就再也拿不到新网关。因此 `.cpx` 可以在 `discoveryUrls`(第 3 节)里列出备用发现源,信任级别与 `loginUrl` 相同——都是用户导入时接受的静态信任根。 + +发现顺序为 `[loginUrl 的 origin, …discoveryUrls]`,每个源请求 `https:///.well-known/cpx-gateway`。某个源的**任何**失败——网络错误、非 2xx、JSON 非法、字段非法、客户端本地 guard 拒绝——都会换到下一个源;备用源可能只是 CDN 上的一个静态文件,`404` 只表示“此处不提供”。全部失败时以最后一个错误为准。每个源是独立 origin,出口路由(第 6 节)也各自选择。 + +备用源只需在该路径上通过公网 HTTPS 提供同一份 JSON 文件,任何 CDN / 对象存储都可以;网关本身已经提供该路径,因此把网关 origin 列进 `discoveryUrls` 是最省事的做法。备用源只帮助已登录设备自愈;**新登录**仍然依赖登录域名,因为 OAuth 页面是在系统浏览器里打开的。 + +### 5a. 签名发现文档 + +`.cpx` 带 `providerPubKey` 后,信任根从"主机名"换成"密钥":发现文档从哪拿到都无所谓——登录域名、任一网关、静态 CDN 文件、`/config` 响应——只要能用该密钥验签、且序号不早于客户端已接受的版本即可。登录域名不再是单点,`gateways`、`endpoints`、`loginUrl`、`discoveryUrls` 都可以在不动 `.cpx` 的情况下轮换。 + +**信封。** 一个字符串 `"."`: + +- `payloadB64`:payload JSON 的 UTF-8 字节,标准 base64 带 padding。 +- `sigB64`:Ed25519 对 `"CPX2-DISCOVERY\0" || payloadBytes` 的 64 字节签名——ASCII 前缀、一个 NUL 字节、然后是 payload 原始字节——标准 base64 带 padding。前缀提供域分离,防止同一密钥签出的其他类型消息被冒用。 +- 恰好一个 `.`。两段都必须是**规范** base64(解码后重新编码必须与输入完全一致)。payload 最多 **4 KiB**;经 base64 与签名后约 5.6 KiB,在常见 CDN / 反向代理 8 KiB 单头限制内。 + +签发方自己序列化 payload 并对那串字节签名;客户端验的是收到的那串字节,验过之后才解析。两边都不需要规范化(不用 JCS)。 + +**payload。** + +```json +{ + "spec": "cpx-plugin/2", + "seq": 12, + "gateways": ["https://gw1.example.net", "https://gw2-cdn.example.com"], + "endpoints": { + "enroll": "/enroll", + "challenge": "/challenge", + "config": "/config", + "revoke": "/revoke" + }, + "loginUrl": "https://panel-new.example.com/oauth/authorize", + "discoveryUrls": ["https://gw2-cdn.example.com"] +} +``` + +| 字段 | 要求 | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spec` | `"cpx-plugin/2"` | +| `seq` | 整数,`1 ≤ seq ≤ 2^53−1`。每次变更必须严格递增 | +| `gateways` | 规则同第 5 节(1..3 个公网 HTTPS origin) | +| `endpoints` | 规则同第 5 节;可选的 `bootstrap` 路径留给后续阶段 | +| `loginUrl` | 可选;规则同 `.cpx` 字段。接受后替换已保存的登录地址,下次登录打开新地址 | +| `discoveryUrls` | 可选;缺失 = 不改,`[]` = 清空,非空时规则同 `.cpx`(1..8)。payload 同时给出 `loginUrl` 时,列表不得包含其 origin,否则整份文档无效,签发前须移除;未给出 `loginUrl` 时,客户端在应用阶段按已保存的登录地址去除同源项 | + +不允许其他键。 + +**投放位置**——两处,同一格式、同一校验: + +1. well-known 文档新增可选顶层 `signed`。旧字段 `gateway` / `gateways` / `endpoints` 照旧保留给无密钥与旧客户端;有密钥客户端会把它们与 payload 比较,不一致则整个源无效,因此两者请从同一份 payload 生成。 +2. `/config` 成功响应可带响应头 `X-CPX-Discovery: .`。只发一个头值;重复头会被客户端忽略。 + +**客户端行为。** + +| `.cpx` 有 `providerPubKey` | well-known 有 `signed` | 行为 | +| -------------------------- | ---------------------- | ----------------------------------------------------------- | +| 否 | 任意 | 走无签名路径(第 5 节);忽略 `signed` 与 `X-CPX-Discovery` | +| 是 | 否 | **该源发现失败**(降级攻击防护),试下一个源 | +| 是 | 是 | 验签 → 解析 → 序号校验 → 应用 | + +客户端保存最后接受的 `seq` 以及 payload 字节的 SHA-256。对新到达的文档:未存过 → 接受;`seq` 更大 → 接受;`seq` 相同且摘要相同 → 作为幂等重放接受;`seq` 相同但摘要不同 → 拒绝(同一序号下的两份不同文档,例如多 CDN 副本不一致);`seq` 更小 → 拒绝。被拒绝的 well-known 源和其他发现失败一样跳过。被拒绝或畸形的 `X-CPX-Discovery` 头只记日志:已认证成功的 `/config` 响应绝不因此作废。 + +序号防的是**网络侧**重放更旧的文档;它不防有本机文件系统权限的攻击者。 + +**接受后的应用顺序。** 网关列表与端点先写入客户端的加密缓存;然后 `loginUrl`、`discoveryUrls`、`seq` 与摘要一步写入插件记录。序号是提交标记:中间任何一步失败都不推进标记,下一次同一文档到达时幂等修复。新登录时发现文档在打开浏览器**之前**就应用,因此轮换后的 `loginUrl` 立即生效,取消登录之后也不会被拉回更旧的文档。 + +**密钥管理。** 离线签发。网关进程不持有私钥,只读取预先签好的信封文件(参考网关的 `DISCOVERY_SIGNED_FILE`,同时用于 `signed` 与响应头)。`cpx-admin keygen` 与 `cpx-admin sign-discovery `(或 `scripts/plugin/sign-discovery.mjs`)从文件或 stdin 读取 seed,绝不放在命令行参数里。每个 `.cpx` 谱系使用独立密钥:同一密钥签出的文档在共用该密钥的插件之间可以互相冒用。本期不做密钥轮换——密钥泄露或丢失都需要重新发放 `.cpx`。 + +**发布顺序。** 先上线带 `signed` 的 well-known,再分发含 `providerPubKey` 的 `.cpx`。顺序反了,所有有密钥的客户端在 `signed` 出现之前都会发现失败。 --- @@ -219,15 +306,29 @@ GET https:///.well-known/cpx-gateway 错误信号按以下优先级处理: -| 客户端判定 | 条件 | 客户端行为 | -| ----------- | ---------------------------------------------------------- | ------------------------ | -| `retired` | HTTP `410`,或 JSON `{"error":"gateway_retired"}` | 重新发现网关并重试一次 | -| `revoked` | JSON `{"error":"revoked"}` 或 `{"error":"device_revoked"}` | 标记为需要重新登录 | -| `transient` | 其它非 2xx、超时、网络错误 | 退避重试,不改变登录状态 | -| 成功 | 2xx 且没有错误标记 | 正常处理 | +| 客户端判定 | 条件 | 客户端行为 | +| ------------- | ------------------------------------------------------------- | ---------------------------------------------------- | +| `retired` | HTTP `410`,或 JSON `{"error":"gateway_retired"}` | 换下一个缓存网关;全部用尽后重新发现一次 | +| `revoked` | JSON `{"error":"revoked"}` 或 `{"error":"device_revoked"}` | 标记为需要重新登录 | +| `unreachable` | DNS 失败、连接失败、TLS 握手失败 | 换下一条路由,再换下一个网关;全部用尽后重新发现一次 | +| `transient` | 其它非 2xx(5xx、429、空 401/403),或超时 | 超时:换路由 / 换网关。任何 HTTP 响应:退避重试 | +| `blocked` | 网关 host 在客户端本地解析到私网/环回地址(不会发出任何请求) | 退避;用户需修正目标或显式选择代理模式 | +| 成功 | 2xx 且没有错误标记 | 正常处理 | 账号到期、设备被踢、用户被禁用时,返回体必须包含 `revoked` 或 `device_revoked`。单独返回空的 `401`/`403` 会被客户端当作瞬时失败处理。 +**可选的 `message`。** 任何错误 JSON 都可以附带给用户看的 `message`,客户端会显示在插件卡片上、固定状态文案之下: + +```json +{ "error": "revoked", "message": "订阅已于 2026-09-01 到期,续费后请重新登录。" } +``` + +客户端只在 `message` 是字符串时读取;trim、去除除换行(U+000A)外的控制字符、按码点截断到 200、清洗后为空视为无。渲染为纯文本(不做 Markdown、不识别链接)。该消息随插件保存,直到下一次操作成功时清空。`.cpx` 里的静态 `provider.description` 使用同一套清洗规则(上限 500 码点),显示在安装确认页与卡片上。 + +**路由。** 每个请求要么直连、要么经用户本地代理发出。默认的 _自动_ 模式下,客户端记住该插件上次成功的路由并优先使用,只在网络层失败或超时时换另一条——任何 HTTP 响应都不会换路由。粘性按 origin 计:某个网关 origin 一旦在某条路由上有响应,本次操作对它的后续请求都走这条路由。经代理发出任何请求之前,客户端先在本地解析网关 host,解析到私网地址即拒绝(`blocked`)。剩余两条风险与显式代理模式相同:预检与代理侧解析之间的 DNS rebinding;本地解析失败的 host 仍会放行经代理。 + +**`/enroll` 不会重放。** authorize `code` 会被第一个到达网关的请求消费。因此客户端只在请求确定没有离开本机时(写入请求前的连接拒绝 / DNS / TLS 失败)才为 `/enroll` 换路由或换网关;超时、发送后的连接重置、任何 HTTP 响应都会直接结束本次登录,用户重新登录取新 code 即可。 + --- ## 7. `POST {gateway}/enroll` @@ -262,7 +363,7 @@ GET https:///.well-known/cpx-gateway - `deviceId` 由客户端生成,服务端不要替换。 - 一个用户可绑定多台设备,建议设置设备数上限和清理策略。 - enroll 成功后,即使首次 config 失败,该设备绑定仍然有效。不要因为订阅拉取失败删除绑定。 -- 用户重新登录会生成新设备密钥,也会产生新的设备绑定。 +- 用户重新登录会生成新设备密钥,也会产生新的设备绑定;随后客户端会注销被替换的旧设备(见第 10 节),同一台电脑反复登录不会堆积绑定。 --- @@ -333,6 +434,8 @@ GET https:///.well-known/cpx-gateway 8. 由 `deviceId` 关联到 `user_id`,内部调用现有订阅生成逻辑或隐藏 origin。 9. 返回 HTTP 200,body 为 Clash YAML 文本。 +成功响应还可以附带响应头 `X-CPX-Discovery: .`(第 5a 节)。客户端只对带 `providerPubKey` 的插件读取它、只接受单一头值;校验失败只记日志并忽略,YAML 正文照常应用。 + 成功响应不是 JSON。客户端会把 body 当作 Clash 配置解析,要求 YAML 可解析为对象,并且至少包含 `proxies` 或 `proxy-providers` 之一。 订阅 URL、origin API、内部鉴权 token 不应下发给客户端。 @@ -341,7 +444,12 @@ GET https:///.well-known/cpx-gateway ## 10. `POST {gateway}/revoke` -作用:解绑设备。客户端删除插件时会尽力调用该端点。 +作用:解绑设备。客户端在两种情况下尽力调用该端点: + +- 用户删除插件——注销当前设备,以及下文"待回收"名单里的设备; +- 重新登录换了新设备——登录完成后立即用**旧**设备的密钥注销旧设备。若这次调用失败,客户端保留旧密钥,在之后每次 `/config` 拉取成功后重试,每次只处理一台。 + +旧设备的 `/challenge` 返回 `revoked` / `device_revoked` 即视为"已解绑",客户端停止重试。除第 7 节的错误码约定与下文的幂等要求外,网关不需要新增任何东西;唯一可见的变化是 `/revoke` 现在也会在重新登录之后到来,针对的是账号里已不是最新的那台设备——只解绑该 `deviceId` 即可。 请求体同 `/config`: @@ -404,6 +512,8 @@ sig = Ed25519_sign(devicePrivKey, SignInput) | `op` | `uint8`;config=1,revoke=2 | | `code` | authorize 签发的不透明字符串,长度不超过 2048 | | `code_challenge` / `code_verifier` | RFC 7636 base64url,无 padding;verifier 长度 43-128,字符集 `[A-Za-z0-9-._~]` | +| `providerPubKey` | Ed25519 公钥,32 原始字节,标准 base64,带 padding | +| `signed` / `X-CPX-Discovery` | `.`;两段都是规范的标准 base64,带 padding | 除 PKCE 的 `code_challenge`、`code_verifier` 外,其余二进制字段全部使用标准 base64,带 `=` padding。不要混用 base64url。 @@ -463,6 +573,17 @@ echo json_encode(['error' => 'revoked']); --- +签名发现文档(第 5a 节),离线执行: + +```php +$payload = json_encode($doc, JSON_UNESCAPED_SLASHES); // 签的就是这串字节 +$sig = sodium_crypto_sign_detached("CPX2-DISCOVERY\0" . $payload, $secretKey); +$signed = base64_encode($payload) . '.' . base64_encode($sig); +// .cpx 的 providerPubKey:base64_encode(sodium_crypto_sign_publickey($keypair)) +``` + +--- + ## 14. 签名自测 测试向量文件: @@ -496,6 +617,14 @@ src/main/resolve/plugin/__fixtures__/sign-vectors.json node scripts/plugin/gen-sign-vectors.mjs ``` +签名发现文档向量(第 5a 节): + +```text +src/main/resolve/plugin/__fixtures__/discovery-vectors.json +``` + +每条包含 `seedB64`、`pubKeyB64`、`payloadJson`、`payloadB64`、`signInputHex`(前缀 + payload)、`sigB64`、`signed`、`digestHex`。验证:用 seed 对 `signInputHex` 签名得到 `sigB64`;`signed` 能用 `pubKeyB64` 验过;payload 字节的 SHA-256 等于 `digestHex`。重新生成:`node scripts/plugin/gen-discovery-vectors.mjs`。 + 仓库中另有 [`scripts/plugin/example-gateway.mjs`](../../scripts/plugin/example-gateway.mjs),仅用于查看协议形状。它使用明文 HTTP 和 loopback origin,会被真实客户端的 HTTPS 校验拒绝;真实联调用 [`deploy/gateway/`](../../deploy/gateway/)。 --- @@ -519,6 +648,9 @@ node scripts/plugin/gen-sign-vectors.mjs 网关: - [ ] `gateway` 是公网 HTTPS origin,无 path、query、fragment、userinfo。 +- [ ] 如提供 `gateways`:1..3 个公网 HTTPS origin,且 `gateway` 等于 `gateways[0]`。 +- [ ] 列出的每个网关都落到同一份后端状态(code / 设备 / nonce),没有独立副本。 +- [ ] `.cpx` 带 `providerPubKey` 时:well-known 已含 `signed`、顶层字段与 payload 一致,且签名文档**先于** `.cpx` 发布。 - [ ] 四个 endpoint 都是相对路径,不含反斜杠、query、fragment。 - [ ] `/enroll` 校验 PKCE、code、redirect_uri、client_id,写入设备绑定。 - [ ] `/challenge` 发放 32 字节随机 nonce,标准 base64,短 TTL,待用池有上限。 diff --git a/package.json b/package.json index 2c7dde95..179e4ec8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "file-icon-info": "^1.1.1", "flag-icons": "^7.5.0", "http-proxy-agent": "^9.1.0", - "https-proxy-agent": "^9.1.0", "i18next": "^26.3.6", "iconv-lite": "^0.7.3", "js-yaml": "^5.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15d19f1b..1faedbf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: http-proxy-agent: specifier: ^9.1.0 version: 9.1.0 - https-proxy-agent: - specifier: ^9.1.0 - version: 9.1.0 i18next: specifier: ^26.3.6 version: 26.3.6(typescript@5.9.3) @@ -4121,10 +4118,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - https-proxy-agent@9.1.0: - resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} - engines: {node: '>= 20'} - i18next@26.3.6: resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} peerDependencies: @@ -11056,15 +11049,6 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: - dependencies: - agent-base: 9.0.0 - debug: 4.4.3 - proxy-agent-negotiate: 1.1.0 - transitivePeerDependencies: - - kerberos - - supports-color - i18next@26.3.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/scripts/plugin/gen-cpx.mjs b/scripts/plugin/gen-cpx.mjs index 52976d08..4a48e854 100644 --- a/scripts/plugin/gen-cpx.mjs +++ b/scripts/plugin/gen-cpx.mjs @@ -1,24 +1,117 @@ -// Usage: node scripts/plugin/gen-cpx.mjs [site] [out.cpx] +// Usage: node scripts/plugin/gen-cpx.mjs [site] [out.cpx] [--discovery ]... [--pubkey ] +// --discovery may be repeated (1..8 public https origins, not the loginUrl origin): backup +// discovery sources the client tries after the login host for /.well-known/cpx-gateway. +// --pubkey: Ed25519 raw 32-byte public key (standard base64) printed by sign-discovery.mjs / +// cpx-admin keygen. Once present, clients REQUIRE a signed discovery document — publish the +// signed well-known first (integration guide §5a). import { writeFileSync } from 'fs' +// Same public-host rules the client enforces on import (descriptor.ts / gateway-url.ts), so the +// generator cannot emit a file the client rejects. Zero-dependency helpers from the reference gateway. +import { isForbiddenHost, parseOrigin } from '../../deploy/gateway/src/discovery.mjs' -const [loginUrl, name, site, out = 'plugin.cpx'] = process.argv.slice(2) +const positional = [] +const discoveryUrls = [] +let pubkey +const argv = process.argv.slice(2) +for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--pubkey') { + pubkey = argv[++i] + if (!pubkey) { + console.error('--pubkey requires a base64 value') + process.exit(1) + } + } else if (a.startsWith('--pubkey=')) { + pubkey = a.slice('--pubkey='.length) + } else if (a === '--discovery') { + const v = argv[++i] + if (!v) { + console.error('--discovery requires an https origin') + process.exit(1) + } + discoveryUrls.push(v) + } else if (a.startsWith('--discovery=')) { + discoveryUrls.push(a.slice('--discovery='.length)) + } else { + positional.push(a) + } +} + +const [loginUrl, name, site, out = 'plugin.cpx'] = positional if (!loginUrl || !name) { console.error( - 'Usage: node gen-cpx.mjs [site] [out.cpx]' + 'Usage: node gen-cpx.mjs [site] [out.cpx] [--discovery ]...' ) process.exit(1) } -const u = new URL(loginUrl) +let u +try { + u = new URL(loginUrl) +} catch { + console.error('loginUrl: not a valid URL') + process.exit(1) +} if (u.protocol !== 'https:' || u.search || u.hash) { console.error('loginUrl must be https with no query/fragment') process.exit(1) } +if (u.username || u.password || isForbiddenHost(u.hostname)) { + console.error( + 'loginUrl must use a public host without userinfo (the client rejects private/loopback hosts)' + ) + process.exit(1) +} +// 旧位置参数用法允许用空字符串占位 site(`… "" out.cpx`),此时不输出 site 字段,也不校验 +if (site) { + let s + try { + s = new URL(site) + } catch { + console.error('site: not a valid URL') + process.exit(1) + } + if (s.protocol !== 'https:' || s.username || s.password || isForbiddenHost(s.hostname)) { + console.error('site must be a public https URL without userinfo') + process.exit(1) + } +} + +const origins = [] +for (const raw of discoveryUrls) { + const origin = parseOrigin(raw) + if (!origin) { + console.error( + `--discovery ${raw}: must be a public https origin (no path/query/fragment/userinfo)` + ) + process.exit(1) + } + if (origin === u.origin) { + console.error(`--discovery ${raw}: must differ from the loginUrl origin`) + process.exit(1) + } + if (!origins.includes(origin)) origins.push(origin) +} +if (origins.length > 8) { + console.error('at most 8 --discovery origins') + process.exit(1) +} + +if (pubkey !== undefined) { + const raw = Buffer.from(pubkey, 'base64') + if (raw.length !== 32 || raw.toString('base64') !== pubkey) { + console.error('--pubkey must be a 32-byte Ed25519 public key in standard base64 with padding') + process.exit(1) + } +} + const descriptor = { magic: 'CPXF', v: 2, spec: 'cpx-plugin/2', loginUrl, - provider: { name, ...(site ? { site } : {}) } + provider: { name, ...(site ? { site } : {}) }, + ...(origins.length ? { discoveryUrls: origins } : {}), + ...(pubkey ? { providerPubKey: pubkey } : {}) } writeFileSync(out, JSON.stringify(descriptor, null, 2) + '\n') console.log('wrote', out) diff --git a/scripts/plugin/gen-discovery-vectors.mjs b/scripts/plugin/gen-discovery-vectors.mjs new file mode 100644 index 00000000..300459a3 --- /dev/null +++ b/scripts/plugin/gen-discovery-vectors.mjs @@ -0,0 +1,78 @@ +// Regenerates src/main/resolve/plugin/__fixtures__/discovery-vectors.json — cross-implementation +// vectors for the signed discovery document (integration guide §5a / §14). Deterministic seeds. +import { writeFileSync, mkdirSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { createHash, createPrivateKey, createPublicKey, sign } from 'crypto' + +const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') +const PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8') + +function keyFromSeed(seed) { + return createPrivateKey({ + key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), + format: 'der', + type: 'pkcs8' + }) +} + +const cases = [ + { + name: 'full payload (seq 12, two gateways, loginUrl + discoveryUrls)', + seedByte: 7, + payload: { + spec: 'cpx-plugin/2', + seq: 12, + gateways: ['https://gw1.example.net', 'https://gw2-cdn.example.com'], + endpoints: { + enroll: '/enroll', + challenge: '/challenge', + config: '/config', + revoke: '/revoke' + }, + loginUrl: 'https://panel-new.example.com/oauth/authorize', + discoveryUrls: ['https://gw2-cdn.example.com'] + } + }, + { + name: 'minimal payload (seq 1, single gateway)', + seedByte: 8, + payload: { + spec: 'cpx-plugin/2', + seq: 1, + gateways: ['https://gw.example.net'], + endpoints: { + enroll: '/enroll', + challenge: '/challenge', + config: '/config', + revoke: '/revoke' + } + } + } +] + +const out = cases.map((c) => { + const seed = Buffer.alloc(32, c.seedByte) + const priv = keyFromSeed(seed) + const pub = Buffer.from(createPublicKey(priv).export({ format: 'jwk' }).x, 'base64url') + const payloadBytes = Buffer.from(JSON.stringify(c.payload), 'utf-8') + const input = Buffer.concat([PREFIX, payloadBytes]) + const sig = sign(null, input, priv) + return { + name: c.name, + seedB64: seed.toString('base64'), + pubKeyB64: pub.toString('base64'), + payloadJson: payloadBytes.toString('utf-8'), + payloadB64: payloadBytes.toString('base64'), + signInputHex: input.toString('hex'), + sigB64: sig.toString('base64'), + signed: `${payloadBytes.toString('base64')}.${sig.toString('base64')}`, + digestHex: createHash('sha256').update(payloadBytes).digest('hex') + } +}) + +const here = dirname(fileURLToPath(import.meta.url)) +const dest = join(here, '../../src/main/resolve/plugin/__fixtures__/discovery-vectors.json') +mkdirSync(dirname(dest), { recursive: true }) +writeFileSync(dest, JSON.stringify(out, null, 2) + '\n') +console.log('wrote', dest) diff --git a/scripts/plugin/sign-discovery.mjs b/scripts/plugin/sign-discovery.mjs new file mode 100644 index 00000000..5f862d71 --- /dev/null +++ b/scripts/plugin/sign-discovery.mjs @@ -0,0 +1,89 @@ +// Offline signer for the CPX v2 signed discovery document (integration guide §5a). +// +// Usage: +// node scripts/plugin/sign-discovery.mjs [--seed-file ] [--out ] +// +// The Ed25519 seed (32 raw bytes, standard base64) is read from --seed-file or, when omitted, from +// stdin — never from the command line. Keep the seed offline; the gateway process only needs the +// resulting envelope file (DISCOVERY_SIGNED_FILE). Prints "." and the public key. +// +// The payload file is parsed and re-serialized compactly; the signature covers exactly those bytes +// (prefix "CPX2-DISCOVERY\0" || payloadBytes). No canonicalization is needed on either side. +import { readFileSync, writeFileSync } from 'fs' +import { createPrivateKey, createPublicKey, sign } from 'crypto' +import { validateDiscoveryPayload } from '../../deploy/gateway/src/discovery.mjs' + +const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') +const PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8') +const MAX_PAYLOAD_BYTES = 4096 + +function die(msg) { + console.error(msg) + process.exit(1) +} + +function keyFromSeed(seed) { + return createPrivateKey({ + key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]), + format: 'der', + type: 'pkcs8' + }) +} + +function signDiscovery(payloadObject, seed) { + const payloadBytes = Buffer.from(JSON.stringify(payloadObject), 'utf-8') + if (payloadBytes.length > MAX_PAYLOAD_BYTES) { + throw new Error(`payload is ${payloadBytes.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`) + } + const priv = keyFromSeed(seed) + const sig = sign(null, Buffer.concat([PREFIX, payloadBytes]), priv) + const pub = Buffer.from(createPublicKey(priv).export({ format: 'jwk' }).x, 'base64url') + return { + signed: `${payloadBytes.toString('base64')}.${sig.toString('base64')}`, + pubKeyB64: pub.toString('base64') + } +} + +const args = process.argv.slice(2) +let payloadPath +let seedFile +let outFile +for (let i = 0; i < args.length; i++) { + const a = args[i] + if (a === '--seed-file') seedFile = args[++i] + else if (a === '--out') outFile = args[++i] + else if (!payloadPath) payloadPath = a + else die(`unexpected argument: ${a}`) +} +if (!payloadPath) { + die('Usage: node sign-discovery.mjs [--seed-file ] [--out ]') +} + +const seedText = (seedFile ? readFileSync(seedFile, 'utf-8') : readFileSync(0, 'utf-8')).trim() +const seed = Buffer.from(seedText, 'base64') +if (seed.length !== 32 || seed.toString('base64') !== seedText) { + die('seed must be 32 raw bytes in standard base64 (with padding)') +} + +let payload +try { + payload = JSON.parse(readFileSync(payloadPath, 'utf-8')) +} catch (e) { + die(`cannot parse ${payloadPath}: ${e.message}`) +} +// Same field rules as the client (discovery-sig.ts): fail here rather than after publication. +try { + payload = validateDiscoveryPayload(payload) +} catch (e) { + die(e.message) +} + +let result +try { + result = signDiscovery(payload, seed) +} catch (e) { + die(e.message) +} +if (outFile) writeFileSync(outFile, result.signed + '\n') +console.log(result.signed) +console.error(`public key (providerPubKey): ${result.pubKeyB64}`) diff --git a/src/main/config/override.ts b/src/main/config/override.ts index 1f23cd0a..e4359309 100644 --- a/src/main/config/override.ts +++ b/src/main/config/override.ts @@ -3,17 +3,24 @@ import { existsSync } from 'fs' import { overrideConfigPath, overridePath } from '../utils/dirs' import * as chromeRequest from '../utils/chromeRequest' import { parse, stringify } from '../utils/yaml' -import { atomicWriteFile, WriteQueue } from '../utils/safeFile' +import { atomicWriteFile } from '../utils/safeFile' import { DEFAULT_MIHOMO_PORTS } from '../../shared/appConfig' import { getControledMihomoConfig } from './controledMihomo' +import { runtimeConfigWriteQueue } from './runtimeConfigQueue' let overrideConfig: IOverrideConfig // override.yaml -const overrideConfigWriteQueue = new WriteQueue() +// 每次经写队列提交的写入 +1:一次迟到的冷加载 / 强制读取不得用旧内容覆盖比它新的缓存(与 profile.ts 同型) +let overrideConfigVersion = 0 +// 与 profile.yaml 共用(见 runtimeConfigQueue.ts) +const overrideConfigWriteQueue = runtimeConfigWriteQueue export async function getOverrideConfig(force = false): Promise { if (force || !overrideConfig) { + const seen = overrideConfigVersion const data = await readFile(overrideConfigPath(), 'utf-8') - overrideConfig = parse(data) || { items: [] } + const loaded = (parse(data) || { items: [] }) as IOverrideConfig + // 读取期间有写入提交:磁盘与缓存都已比这次读取新,保留缓存 + if (overrideConfigVersion === seen || !overrideConfig) overrideConfig = loaded } if (typeof overrideConfig !== 'object') overrideConfig = { items: [] } if (!Array.isArray(overrideConfig.items)) overrideConfig.items = [] @@ -25,6 +32,7 @@ export async function setOverrideConfig(config: IOverrideConfig): Promise const nextConfig = JSON.parse(JSON.stringify(config)) as IOverrideConfig await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) overrideConfig = nextConfig + overrideConfigVersion++ }) } @@ -42,6 +50,7 @@ export async function updateOverrideConfig( const nextConfig = updater(JSON.parse(JSON.stringify(currentConfig)) as IOverrideConfig) await atomicWriteFile(overrideConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) overrideConfig = nextConfig + overrideConfigVersion++ }) } diff --git a/src/main/config/plugin.test.ts b/src/main/config/plugin.test.ts index 5d53ddc6..6d4e6028 100644 --- a/src/main/config/plugin.test.ts +++ b/src/main/config/plugin.test.ts @@ -15,7 +15,8 @@ import { getPluginItem, updatePluginItem, patchPluginItem, - removePluginItem + removePluginItem, + normalizeDiscoveryMarker } from './plugin' function item(id: string): IPluginItem { @@ -74,3 +75,41 @@ describe('plugin config CRUD', () => { expect((await getPluginItem('dup'))?.name).toBe('renamed') }) }) + +const DIGEST = 'a'.repeat(64) + +describe('normalizeDiscoveryMarker (§5.3, ISS-011)', () => { + it('keeps a well-formed pair', () => { + const it1 = { ...item('m1'), discoverySeq: 12, discoveryDigest: DIGEST } + normalizeDiscoveryMarker(it1) + expect(it1.discoverySeq).toBe(12) + expect(it1.discoveryDigest).toBe(DIGEST) + }) + it('drops both when only one half is present or the digest is malformed', () => { + const a = { ...item('m2'), discoverySeq: 12 } + normalizeDiscoveryMarker(a) + expect(a.discoverySeq).toBeUndefined() + const b = { ...item('m3'), discoveryDigest: DIGEST } + normalizeDiscoveryMarker(b) + expect(b.discoveryDigest).toBeUndefined() + const c = { ...item('m4'), discoverySeq: 1, discoveryDigest: 'nope' } + normalizeDiscoveryMarker(c) + expect(c.discoverySeq).toBeUndefined() + }) + it('drops a seq that is not a safe integer ≥ 1', () => { + for (const seq of [0, -1, 1.5, 2 ** 53, Number.MAX_VALUE]) { + const it2 = { ...item('m5'), discoverySeq: seq, discoveryDigest: DIGEST } + normalizeDiscoveryMarker(it2) + expect(it2.discoverySeq).toBeUndefined() + expect(it2.discoveryDigest).toBeUndefined() + } + }) + it('is applied when the config is read from disk', async () => { + writeFileSync( + join(TMP, 'plugin.yaml'), + 'items:\n - id: d1\n name: X\n loginUrl: https://panel.x.com/oauth/authorize\n spec: cpx-plugin/2\n status: active\n created: 1\n updated: 1\n discoverySeq: 5\n' + ) + const cfg = await getPluginConfig(true) + expect(cfg.items[0].discoverySeq).toBeUndefined() + }) +}) diff --git a/src/main/config/plugin.ts b/src/main/config/plugin.ts index 0d78b1e9..8e819c18 100644 --- a/src/main/config/plugin.ts +++ b/src/main/config/plugin.ts @@ -1,5 +1,4 @@ -import { readFile } from 'fs/promises' -import { existsSync } from 'fs' +import { existsSync, readFileSync } from 'fs' import { pluginConfigPath } from '../utils/dirs' import { atomicWriteFile, WriteQueue } from '../utils/safeFile' import { parse, stringify } from '../utils/yaml' @@ -7,23 +6,58 @@ import { parse, stringify } from '../utils/yaml' let pluginConfig: IPluginConfig | undefined const writeQueue = new WriteQueue() +// 插件记录的调度默认值:安装时写入 interval;autoUpdate 缺省视为开启 +export const DEFAULT_PLUGIN_INTERVAL_MIN = 1440 // 24h + +// profile 侧的调度字段以插件记录为准;这里是唯一的解析点(缺省值只在此处出现) +export function pluginSchedule(item?: IPluginItem): { interval: number; autoUpdate: boolean } { + return { + interval: item?.interval ?? DEFAULT_PLUGIN_INTERVAL_MIN, + autoUpdate: item?.autoUpdate ?? true + } +} + +// §5.3:discoverySeq / discoveryDigest 是成对的提交标记,同时存在或同时缺失;半截状态视为未设置。 +export function normalizeDiscoveryMarker(item: IPluginItem): void { + const seqOk = Number.isSafeInteger(item.discoverySeq) && (item.discoverySeq as number) >= 1 + const digestOk = + typeof item.discoveryDigest === 'string' && /^[0-9a-f]{64}$/.test(item.discoveryDigest) + if (seqOk && digestOk) return + delete item.discoverySeq + delete item.discoveryDigest +} + +// 从磁盘读取并规范化,赋给全局缓存。只能在 writeQueue 内调用(loadLocked / update 内)。 +function loadUnlocked(): void { + if (existsSync(pluginConfigPath())) { + pluginConfig = parseSync() + } else { + pluginConfig = { items: [] } + } + if (typeof pluginConfig !== 'object' || pluginConfig === null) pluginConfig = { items: [] } + if (!Array.isArray(pluginConfig.items)) pluginConfig.items = [] + for (const item of pluginConfig.items) normalizeDiscoveryMarker(item) +} + +function parseSync(): IPluginConfig { + return parse(readFileSync(pluginConfigPath(), 'utf-8')) +} + export async function getPluginConfig(force = false): Promise { + // 磁盘加载必须与写入串行:否则一次迟到的冷启动读取会用旧内容覆盖已提交的更新缓存, + // 让签名发现的 seq 回退检查(index.ts 从此缓存读 discoverySeq)失效。命中缓存的读取无需入队。 if (force || !pluginConfig) { - if (existsSync(pluginConfigPath())) { - const data = await readFile(pluginConfigPath(), 'utf-8') - pluginConfig = parse(data) - } else { - pluginConfig = { items: [] } - } - if (typeof pluginConfig !== 'object' || pluginConfig === null) pluginConfig = { items: [] } - if (!Array.isArray(pluginConfig.items)) pluginConfig.items = [] + await writeQueue.run(async () => { + if (force || !pluginConfig) loadUnlocked() + }) } return JSON.parse(JSON.stringify(pluginConfig)) as IPluginConfig } async function update(updater: (c: IPluginConfig) => IPluginConfig): Promise { await writeQueue.run(async () => { - const current = await getPluginConfig(true) + if (!pluginConfig) loadUnlocked() + const current = JSON.parse(JSON.stringify(pluginConfig)) as IPluginConfig const next = updater(current) await atomicWriteFile(pluginConfigPath(), stringify(next), { encoding: 'utf8' }) pluginConfig = next diff --git a/src/main/config/profile.test.ts b/src/main/config/profile.test.ts index c4a6d737..9abd6af2 100644 --- a/src/main/config/profile.test.ts +++ b/src/main/config/profile.test.ts @@ -1,20 +1,69 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createProfile } from './profile' +import { getOverrideConfig, updateOverrideConfig } from './override' +import { + createProfile, + getProfileConfig, + getProfileItem, + removeProfileItem, + updateProfileConfig, + updateProfileItem, + upsertPluginProfile, + syncPluginProfileSchedule +} from './profile' +import { addProfileUpdater } from '../core/profileUpdater' let testDir = '' const mocks = vi.hoisted(() => ({ axiosGet: vi.fn(), checkProfileConfig: vi.fn(), + getPluginItem: vi.fn(), + // fires on every profile.yaml path resolution (i.e. at the start of each config read/write) + onProfileConfigPath: vi.fn(), + // awaited right after every fs/promises readFile completes (lets a test commit a write "during" a read) + afterRead: vi.fn(), + // awaited right before every atomicWriteFile (lets a test race something against a write in flight) + beforeWrite: vi.fn(), generateProfile: vi.fn(), hotReload: vi.fn(), restartCore: vi.fn() })) vi.mock('electron', () => ({ app: { getVersion: () => '2.0.0' } })) +vi.mock('fs/promises', async (importOriginal) => { + const orig = await importOriginal() + const readFile = orig.readFile as (p: string, o?: unknown) => Promise + return { + ...orig, + readFile: async (p: string, o?: unknown) => { + const r = await readFile(p, o) + await mocks.afterRead(p) + return r + } + } +}) +vi.mock('../utils/safeFile', async (importOriginal) => { + const orig = await importOriginal() + return { + ...orig, + atomicWriteFile: async (...args: Parameters) => { + await mocks.beforeWrite(String(args[0])) + return orig.atomicWriteFile(...args) + } + } +}) +// the real override module backs the global override set (override.yaml lives in the test dir) +vi.mock('../core/factory', async () => { + const { getOverrideConfig } = await import('./override') + return { + generateProfile: mocks.generateProfile, + globalOverrideIdsNow: async () => + (await getOverrideConfig()).items.filter((o) => o.global).map((o) => o.id) + } +}) vi.mock('i18next', () => ({ default: { t: (key: string) => key } })) vi.mock('axios', () => ({ default: { get: mocks.axiosGet } })) vi.mock('../utils/age', () => ({ @@ -24,8 +73,13 @@ vi.mock('../utils/dirs', () => ({ mihomoCorePath: () => join(testDir, 'mihomo'), mihomoProfileWorkDir: (id: string) => join(testDir, 'work', id), mihomoWorkDir: () => join(testDir, 'work'), - profileConfigPath: () => join(testDir, 'profile.yaml'), - profilePath: (id: string) => join(testDir, 'profiles', `${id}.yaml`) + profileConfigPath: () => { + mocks.onProfileConfigPath() + return join(testDir, 'profile.yaml') + }, + profilePath: (id: string) => join(testDir, 'profiles', `${id}.yaml`), + overrideConfigPath: () => join(testDir, 'override.yaml'), + overridePath: (id: string, ext: string) => join(testDir, 'overrides', `${id}.${ext}`) })) vi.mock('../utils/logger', () => ({ createLogger: () => ({ @@ -43,7 +97,6 @@ vi.mock('../core/manager', () => ({ checkProfileConfig: mocks.checkProfileConfig, restartCore: mocks.restartCore })) -vi.mock('../core/factory', () => ({ generateProfile: mocks.generateProfile })) vi.mock('../core/profileUpdater', () => ({ addProfileUpdater: vi.fn(), removeProfileUpdater: vi.fn() @@ -59,6 +112,13 @@ vi.mock('./app', () => ({ vi.mock('./controledMihomo', () => ({ getControledMihomoConfig: () => Promise.resolve({ 'mixed-port': 7890 }) })) +vi.mock('./plugin', () => ({ + getPluginItem: mocks.getPluginItem, + pluginSchedule: (item?: { interval?: number; autoUpdate?: boolean }) => ({ + interval: item?.interval ?? 1440, + autoUpdate: item?.autoUpdate ?? true + }) +})) const oldProfile = `proxies: - name: old @@ -74,7 +134,7 @@ const newProfile = `proxies: port: 8081 ` -beforeEach(() => { +beforeEach(async () => { testDir = mkdtempSync(join(tmpdir(), 'mihomo-party-profile-test-')) mkdirSync(join(testDir, 'profiles'), { recursive: true }) writeFileSync( @@ -82,8 +142,12 @@ beforeEach(() => { 'current: remote\nitems:\n - id: remote\n type: remote\n name: Remote\n' ) writeFileSync(join(testDir, 'profiles', 'remote.yaml'), oldProfile) + writeFileSync(join(testDir, 'override.yaml'), 'items: []\n') vi.clearAllMocks() + mocks.onProfileConfigPath.mockReset() + mocks.afterRead.mockReset() + mocks.beforeWrite.mockReset() mocks.axiosGet.mockResolvedValue({ status: 200, data: newProfile, @@ -92,6 +156,20 @@ beforeEach(() => { mocks.generateProfile.mockResolvedValue('remote') mocks.checkProfileConfig.mockResolvedValue(undefined) mocks.hotReload.mockResolvedValue(undefined) + // the plugin record the schedule is read from at write time + mocks.getPluginItem.mockResolvedValue({ id: 'pg', interval: 60, autoUpdate: true }) + // the module caches must match the freshly written files (all real writes go through the queue and keep them in sync) + await getProfileConfig(true) + await getOverrideConfig(true) +}) + +const globalOverride = (id: string): IOverrideItem => ({ + id, + type: 'local', + ext: 'yaml', + name: id, + updated: 1, + global: true }) afterEach(() => { @@ -127,3 +205,412 @@ describe('remote profile candidate validation', () => { expect(mocks.hotReload).toHaveBeenCalledOnce() }) }) + +describe('profile deletion (R2-ISS-034 / R2-ISS-067)', () => { + it('a failed core restart deletes nothing: the record stays for a retry, and the retry completes the deletion', async () => { + const workDir = join(testDir, 'work', 'remote') + mkdirSync(workDir, { recursive: true }) + writeFileSync(join(workDir, 'config.yaml'), 'proxies: []\n') + mocks.restartCore.mockRejectedValueOnce(new Error('restart failed')) + + await expect(removeProfileItem('remote')).rejects.toThrow('restart failed') + expect(existsSync(workDir)).toBe(true) + expect(existsSync(join(testDir, 'profiles', 'remote.yaml'))).toBe(true) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).toContain('id: remote') + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledOnce() // timer re-armed + + await removeProfileItem('remote') // current already moved away → no restart needed + expect(existsSync(workDir)).toBe(false) + expect(existsSync(join(testDir, 'profiles', 'remote.yaml'))).toBe(false) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).not.toContain('id: remote') + }) + + it('R2-ISS-071: concurrent deletions never leave current pointing at a deleted profile', async () => { + writeFileSync( + join(testDir, 'profile.yaml'), + 'current: A\nitems:\n - id: A\n type: remote\n name: A\n - id: B\n type: remote\n name: B\n' + ) + await getProfileConfig(true) + const results = await Promise.allSettled([removeProfileItem('A'), removeProfileItem('B')]) + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']) + expect(await getProfileConfig()).toEqual({ current: undefined, items: [] }) + }) + + it.each([1, 2])( + 'R2-ISS-072 (V2): a failed profile.yaml write (#%s) during deletion keeps the record and re-arms its updater', + async (failing) => { + let writes = 0 + mocks.beforeWrite.mockImplementation(async (p: string) => { + if (!p.endsWith('profile.yaml')) return + writes++ + if (writes === failing) throw new Error('disk full') + }) + await expect(removeProfileItem('remote')).rejects.toThrow('disk full') + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).toContain('id: remote') + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledWith( + expect.objectContaining({ id: 'remote' }) + ) + } + ) + + it('R2-ISS-067: a failed subscription-file removal keeps the record (and the work dir) so the user can retry', async () => { + const workDir = join(testDir, 'work', 'remote') + mkdirSync(workDir, { recursive: true }) + writeFileSync(join(workDir, 'config.yaml'), 'proxies: []\n') + // a directory where the file should be: rm() without recursive fails (EISDIR / EPERM-like) + rmSync(join(testDir, 'profiles', 'remote.yaml')) + mkdirSync(join(testDir, 'profiles', 'remote.yaml')) + + await expect(removeProfileItem('remote')).rejects.toThrow() + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).toContain('id: remote') + expect(existsSync(workDir)).toBe(true) + // the record stays → its updater is re-armed (R2-ISS-072) + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledWith( + expect.objectContaining({ id: 'remote' }) + ) + + rmSync(join(testDir, 'profiles', 'remote.yaml'), { recursive: true }) + await removeProfileItem('remote') + expect(existsSync(workDir)).toBe(false) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).not.toContain('id: remote') + }) +}) + +describe('plugin subscription validation (BL-002)', () => { + const meta = { profileId: 'plugin1', pluginId: 'pg', name: 'P' } + + it('rejects a subscription that fails core validation and keeps the old file', async () => { + writeFileSync(join(testDir, 'profiles', 'plugin1.yaml'), oldProfile) + mocks.checkProfileConfig.mockRejectedValueOnce(new Error("proxy 'missing' not found")) + await expect(upsertPluginProfile(meta, newProfile)).rejects.toMatchObject({ + code: 'PLUGIN_PROFILE_INVALID' + }) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + expect(mocks.generateProfile).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ profileId: 'plugin1', updateRuntimeConfig: false }) + ) + expect(mocks.checkProfileConfig).toHaveBeenCalledOnce() + // the item was never inserted, so no updater was armed + expect(vi.mocked(addProfileUpdater)).not.toHaveBeenCalled() + }) + + it('writes a subscription that passes validation and arms the updater on first insert', async () => { + await upsertPluginProfile(meta, newProfile) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(newProfile) + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledOnce() + expect(vi.mocked(addProfileUpdater).mock.calls[0][0]).toMatchObject({ + id: 'plugin1', + type: 'plugin', + autoUpdate: true, + interval: 60 + }) + }) +}) + +describe('plugin schedule sync (BL-003)', () => { + it('updates the profile item and always re-arms (idempotent); unknown profile is a no-op', async () => { + await upsertPluginProfile({ profileId: 'plugin1', pluginId: 'pg', name: 'P' }, newProfile) + vi.mocked(addProfileUpdater).mockClear() + await syncPluginProfileSchedule('plugin1', { autoUpdate: false }) + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledOnce() + expect(vi.mocked(addProfileUpdater).mock.calls[0][0]).toMatchObject({ + id: 'plugin1', + autoUpdate: false, + interval: 60 + }) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).toContain('autoUpdate: false') + // an unchanged sync still re-arms: the decision is never based on a possibly stale cache + await syncPluginProfileSchedule('plugin1', { autoUpdate: false }) + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledTimes(2) + await syncPluginProfileSchedule('nope', { autoUpdate: false }) // unknown profile → no-op + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledTimes(2) + }) +}) + +describe('plugin subscription validation follow-ups (V6 regressions)', () => { + const meta = { profileId: 'plugin1', pluginId: 'pg', name: 'P' } + + it('forwards the caller signal and a hard timeout to the core validator', async () => { + const ac = new AbortController() + await upsertPluginProfile(meta, newProfile, ac.signal) + expect(mocks.checkProfileConfig).toHaveBeenLastCalledWith( + expect.any(String), + 'mihomo', + undefined, + expect.objectContaining({ signal: ac.signal, timeoutMs: expect.any(Number) }) + ) + }) + + it('does not revert a user override changed while the core was validating', async () => { + await upsertPluginProfile(meta, newProfile) + await updateProfileItem({ ...(await getProfileItem('plugin1'))!, override: ['A'] }) + // the validator "takes a while"; meanwhile the user switches the override to B + mocks.checkProfileConfig.mockImplementationOnce(async () => { + await updateProfileItem({ ...(await getProfileItem('plugin1'))!, override: ['B'] }) + }) + await upsertPluginProfile(meta, oldProfile) + expect((await getProfileItem('plugin1'))?.override).toEqual(['B']) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + }) + + it('concurrent off→on schedule toggles end with the updater armed (last writer wins)', async () => { + await upsertPluginProfile(meta, newProfile) + vi.mocked(addProfileUpdater).mockClear() + await Promise.all([ + syncPluginProfileSchedule('plugin1', { autoUpdate: false }), + syncPluginProfileSchedule('plugin1', { autoUpdate: true }) + ]) + const calls = vi.mocked(addProfileUpdater).mock.calls + expect(calls).toHaveLength(2) + expect(calls[calls.length - 1][0]).toMatchObject({ id: 'plugin1', autoUpdate: true }) + expect((await getProfileItem('plugin1'))?.autoUpdate).toBe(true) + }) +}) + +describe('plugin subscription validation follow-ups (V7 regressions)', () => { + const meta = { profileId: 'plugin1', pluginId: 'pg', name: 'P' } + + it('R2-ISS-051: refuses to write when the budget ran out after validation succeeded', async () => { + writeFileSync(join(testDir, 'profiles', 'plugin1.yaml'), oldProfile) + const ac = new AbortController() + // the core check itself passed; the budget expires while the candidate dir is being cleaned up + mocks.checkProfileConfig.mockImplementationOnce(async () => ac.abort()) + await expect(upsertPluginProfile(meta, newProfile, ac.signal)).rejects.toMatchObject({ + code: 'PLUGIN_PROFILE_INVALID' + }) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).not.toContain('plugin1') + expect(vi.mocked(addProfileUpdater)).not.toHaveBeenCalled() + }) + + it('R2-ISS-051: refuses to write when the budget runs out during the config read that precedes the write', async () => { + writeFileSync(join(testDir, 'profiles', 'plugin1.yaml'), oldProfile) + const ac = new AbortController() + let validated = false + mocks.checkProfileConfig.mockImplementationOnce(async () => { + validated = true + }) + // the first config read after validation is setProfileStr's; the budget expires while it is in flight + mocks.onProfileConfigPath.mockImplementation(() => { + if (validated && !ac.signal.aborted) ac.abort() + }) + await expect(upsertPluginProfile(meta, newProfile, ac.signal)).rejects.toMatchObject({ + code: 'PLUGIN_PROFILE_INVALID' + }) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).not.toContain('plugin1') + expect(vi.mocked(addProfileUpdater)).not.toHaveBeenCalled() + }) + + it('R2-ISS-053: an override enabled during validation triggers a re-validation against the new set before the write', async () => { + await upsertPluginProfile(meta, newProfile) + await updateProfileItem({ ...(await getProfileItem('plugin1'))!, override: ['A'] }) + mocks.generateProfile.mockClear() + mocks.checkProfileConfig.mockImplementationOnce(async () => { + await updateProfileItem({ ...(await getProfileItem('plugin1'))!, override: ['A', 'B'] }) + }) + await upsertPluginProfile(meta, oldProfile) + // validated once against [A], then again against [A, B]; the write happened after the second pass + expect(mocks.generateProfile).toHaveBeenCalledTimes(2) + expect(mocks.generateProfile.mock.calls[0][1]).toMatchObject({ profileOverrideIds: ['A'] }) + expect(mocks.generateProfile.mock.calls[1][1]).toMatchObject({ profileOverrideIds: ['A', 'B'] }) + expect((await getProfileItem('plugin1'))?.override).toEqual(['A', 'B']) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + }) + + it('R2-ISS-053: a global override toggled during validation also forces a re-validation', async () => { + await upsertPluginProfile(meta, newProfile) + mocks.generateProfile.mockClear() + mocks.checkProfileConfig.mockImplementationOnce(async () => { + await updateOverrideConfig(() => ({ items: [globalOverride('G')] })) + }) + await upsertPluginProfile(meta, oldProfile) + expect(mocks.generateProfile).toHaveBeenCalledTimes(2) + // the global set the core validated against is the one the caller recorded, passed in explicitly + expect(mocks.generateProfile.mock.calls[0][1]).toMatchObject({ globalOverrideIds: [] }) + expect(mocks.generateProfile.mock.calls[1][1]).toMatchObject({ globalOverrideIds: ['G'] }) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + }) + + it('R2-ISS-053 (V1-R5): a global override flipped off and back on around the generation step is still caught', async () => { + await updateOverrideConfig(() => ({ items: [globalOverride('G')] })) + await upsertPluginProfile(meta, newProfile) + mocks.generateProfile.mockClear() + // the recorded set is [G]; G is off while the config is generated and back on before the commit check + mocks.generateProfile.mockImplementationOnce(async (_c, o) => { + expect(o?.globalOverrideIds).toEqual(['G']) // generation uses the recorded set, not a fresh read + await updateOverrideConfig(() => ({ items: [] })) + return 'remote' + }) + mocks.checkProfileConfig.mockImplementationOnce(async () => { + await updateOverrideConfig(() => ({ items: [globalOverride('G')] })) + }) + await upsertPluginProfile(meta, oldProfile) + expect(mocks.generateProfile).toHaveBeenCalledTimes(1) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + }) + + it('R2-ISS-053 (V2-R5): a global override toggle cannot land between the final check and the file write', async () => { + await upsertPluginProfile(meta, newProfile) + mocks.generateProfile.mockClear() + let toggle: Promise | undefined + let landedDuringWrite: boolean | undefined + mocks.beforeWrite.mockImplementation(async (p: string) => { + if (toggle || !p.endsWith(join('profiles', 'plugin1.yaml'))) return + // the commit passed its final check and is about to replace the file: flip a global override now + toggle = updateOverrideConfig(() => ({ items: [globalOverride('G')] })) + await new Promise((r) => setTimeout(r, 30)) + landedDuringWrite = (await getOverrideConfig()).items.length > 0 + }) + await upsertPluginProfile(meta, oldProfile) + // the toggle was queued behind the commit's critical section (shared runtime-config write queue) + expect(landedDuringWrite).toBe(false) + await toggle + expect((await getOverrideConfig()).items.map((i) => i.id)).toEqual(['G']) + // what landed was validated against the set that was current at the write: no globals + expect(mocks.generateProfile).toHaveBeenCalledTimes(1) + expect(mocks.generateProfile.mock.calls[0][1]).toMatchObject({ globalOverrideIds: [] }) + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(oldProfile) + }) + + it('R2-ISS-063: a forced override read that completes after a queued write does not roll the cache back', async () => { + let injected = false + mocks.afterRead.mockImplementation(async (p: string) => { + if (injected || !p.endsWith('override.yaml')) return + injected = true + await updateOverrideConfig(() => ({ items: [globalOverride('NEW')] })) + }) + const forced = await getOverrideConfig(true) + expect(forced.items.map((i) => i.id)).toEqual(['NEW']) + expect((await getOverrideConfig()).items.map((i) => i.id)).toEqual(['NEW']) + }) + + it('R2-ISS-065: a budget that expires while the commit waits for the write queue aborts the commit without writing', async () => { + await upsertPluginProfile(meta, newProfile) + let release!: () => void + const held = updateProfileConfig(async (c) => { + await new Promise((r) => { + release = r + }) + return c + }) + await new Promise((r) => setTimeout(r, 5)) // the holder is inside the queue + const ac = new AbortController() + const commit = upsertPluginProfile(meta, oldProfile, ac.signal) + await new Promise((r) => setTimeout(r, 20)) // validated; now queued behind the holder + ac.abort(new Error('budget exhausted')) + await expect(commit).rejects.toMatchObject({ code: 'PLUGIN_PROFILE_INVALID' }) + release() + await held + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(newProfile) + }) + + it('R2-ISS-073: a commit error after the subscription file was written is reported as-is, even if the budget expired meanwhile', async () => { + await upsertPluginProfile(meta, newProfile) + const ac = new AbortController() + let subscriptionWritten = false + mocks.beforeWrite.mockImplementation(async (p: string) => { + if (p.endsWith(join('profiles', 'plugin1.yaml'))) subscriptionWritten = true + else if (subscriptionWritten && p.endsWith('profile.yaml')) { + ac.abort(new Error('budget exhausted')) + throw new Error('disk full') + } + }) + await expect(upsertPluginProfile(meta, oldProfile, ac.signal)).rejects.toThrow('disk full') + }) + + it('R2-ISS-060: a failed first activation does not lose the auto-update timer on the retry', async () => { + writeFileSync(join(testDir, 'profile.yaml'), 'items: []\n') + mocks.restartCore.mockRejectedValueOnce(new Error('core refused to start')) + await expect(upsertPluginProfile(meta, newProfile)).rejects.toThrow('core refused to start') + // the item and its schedule were committed; the updater was armed before the activation attempt + expect(await getProfileItem('plugin1')).toMatchObject({ interval: 60, autoUpdate: true }) + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledOnce() + expect((await getProfileConfig()).current).toBeUndefined() + await upsertPluginProfile(meta, newProfile) // next login: activation succeeds + expect((await getProfileConfig()).current).toBe('plugin1') + expect(vi.mocked(addProfileUpdater)).toHaveBeenCalledOnce() // nothing changed → not re-armed, but armed + }) + + it('R2-ISS-053: gives up after bounded re-validations when the override set keeps changing; old file kept', async () => { + await upsertPluginProfile(meta, newProfile) + mocks.generateProfile.mockClear() + let n = 0 + mocks.checkProfileConfig.mockImplementation(async () => { + n++ + await updateProfileItem({ ...(await getProfileItem('plugin1'))!, override: [`O${n}`] }) + }) + await expect(upsertPluginProfile(meta, oldProfile)).rejects.toMatchObject({ + code: 'PLUGIN_PROFILE_INVALID' + }) + expect(mocks.generateProfile).toHaveBeenCalledTimes(3) // 1 + MAX_PLUGIN_PROFILE_REVALIDATIONS + expect(readFileSync(join(testDir, 'profiles', 'plugin1.yaml'), 'utf8')).toBe(newProfile) + }) + + it('R2-ISS-057: the first subscription (no current profile yet) is activated through the real switch flow', async () => { + writeFileSync(join(testDir, 'profile.yaml'), 'items: []\n') + await upsertPluginProfile(meta, newProfile) + expect((await getProfileConfig()).current).toBe('plugin1') + // useHotReloadProfile is off in this harness → the switch flow restarts the core + expect(mocks.restartCore).toHaveBeenCalledOnce() + }) + + it('R2-ISS-057: a re-fetch of the current plugin profile hot-reloads; a non-current one leaves the core alone', async () => { + await upsertPluginProfile(meta, newProfile) // current stays "remote" + expect(mocks.hotReload).not.toHaveBeenCalled() + expect(mocks.restartCore).not.toHaveBeenCalled() + await updateProfileConfig((c) => { + c.current = 'plugin1' + return c + }) + await upsertPluginProfile(meta, oldProfile) + expect(mocks.hotReload).toHaveBeenCalledOnce() + }) + + it('R2-ISS-056: a forced config read that completes after a queued write does not roll the cache back', async () => { + await getProfileConfig(true) + let injected = false + mocks.afterRead.mockImplementation(async (p: string) => { + if (injected || !p.endsWith('profile.yaml')) return + injected = true + // commits (and caches) a newer config while the forced read's file content is already in hand + await updateProfileConfig((c) => { + c.current = 'newer' + return c + }) + }) + const forced = await getProfileConfig(true) + expect(forced.current).toBe('newer') + expect((await getProfileConfig()).current).toBe('newer') + expect(readFileSync(join(testDir, 'profile.yaml'), 'utf8')).toContain('current: newer') + }) + + it('R2-ISS-052: an in-flight fetch does not revert a schedule the user changed during validation', async () => { + mocks.getPluginItem.mockResolvedValue({ id: 'pg', interval: 60, autoUpdate: false }) + await upsertPluginProfile(meta, newProfile) + expect((await getProfileItem('plugin1'))?.autoUpdate).toBe(false) + vi.mocked(addProfileUpdater).mockClear() + // the next fetch is validating; meanwhile the user turns auto-update on (plugin record + schedule sync) + mocks.checkProfileConfig.mockImplementationOnce(async () => { + mocks.getPluginItem.mockResolvedValue({ id: 'pg', interval: 60, autoUpdate: true }) + await syncPluginProfileSchedule('plugin1', { autoUpdate: true }) + }) + await upsertPluginProfile(meta, oldProfile) + expect((await getProfileItem('plugin1'))?.autoUpdate).toBe(true) + // the sync armed it; the fetch saw the same schedule at write time and did not re-arm it to off + const calls = vi.mocked(addProfileUpdater).mock.calls + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls[calls.length - 1][0]).toMatchObject({ id: 'plugin1', autoUpdate: true }) + }) + + it('R2-ISS-052: a first insert takes the schedule from the plugin record at write time', async () => { + mocks.getPluginItem.mockResolvedValue({ id: 'pg', interval: 30, autoUpdate: false }) + await upsertPluginProfile(meta, newProfile) + expect(await getProfileItem('plugin1')).toMatchObject({ interval: 30, autoUpdate: false }) + expect(vi.mocked(addProfileUpdater).mock.calls[0][0]).toMatchObject({ + interval: 30, + autoUpdate: false + }) + }) +}) diff --git a/src/main/config/profile.ts b/src/main/config/profile.ts index ec027d0e..d3f9db5f 100644 --- a/src/main/config/profile.ts +++ b/src/main/config/profile.ts @@ -14,8 +14,8 @@ import { decryptAgeContent } from '../utils/age' import { DEFAULT_MIHOMO_PORTS } from '../../shared/appConfig' import { subStorePort } from '../resolve/server' import { mihomoCloseAllConnections, mihomoHotReloadConfig } from '../core/mihomoApi' -import { checkProfileConfig, restartCore } from '../core/manager' -import { generateProfile } from '../core/factory' +import { checkProfileConfig, restartCore, type CheckProfileOptions } from '../core/manager' +import { generateProfile, globalOverrideIdsNow } from '../core/factory' import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater' import { mihomoCorePath, @@ -25,15 +25,18 @@ import { profilePath } from '../utils/dirs' import { createLogger } from '../utils/logger' -import { atomicWriteFile, WriteQueue } from '../utils/safeFile' +import { atomicWriteFile } from '../utils/safeFile' import { getAppConfig } from './app' import { getControledMihomoConfig } from './controledMihomo' +import { getPluginItem, pluginSchedule } from './plugin' +import { runtimeConfigWriteQueue } from './runtimeConfigQueue' const profileLogger = createLogger('Profile') const execFilePromise = promisify(execFile) let profileConfig: IProfileConfig -const profileConfigWriteQueue = new WriteQueue() +// 与 override.yaml 共用(见 runtimeConfigQueue.ts) +const profileConfigWriteQueue = runtimeConfigWriteQueue let changeProfileQueue: Promise = Promise.resolve() // 并发去重 const inflightRemoteFetches = new Map>() @@ -88,10 +91,16 @@ async function removeProfileWorkDir(id: string): Promise { } } +// 每次经写队列提交的写入 +1:一次迟到的冷加载 / 强制读取不得用旧内容覆盖比它新的缓存(R2-ISS-045 同型) +let profileConfigVersion = 0 + export async function getProfileConfig(force = false): Promise { if (force || !profileConfig) { + const seen = profileConfigVersion const data = await readFile(profileConfigPath(), 'utf-8') - profileConfig = parse(data) || { items: [] } + const loaded = (parse(data) || { items: [] }) as IProfileConfig + // 读取期间有写入提交:磁盘与缓存都已比这次读取新,保留缓存 + if (profileConfigVersion === seen || !profileConfig) profileConfig = loaded } if (typeof profileConfig !== 'object') profileConfig = { items: [] } if (!Array.isArray(profileConfig.items)) profileConfig.items = [] @@ -103,11 +112,15 @@ export async function setProfileConfig(config: IProfileConfig): Promise { const nextConfig = JSON.parse(JSON.stringify(config)) as IProfileConfig await atomicWriteFile(profileConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) profileConfig = nextConfig + profileConfigVersion++ }) } +// signal 只约束排队等待:它在轮到本次写入之前触发则写入不执行(调用方得到 signal 的 reason); +// 已经开始的写入一律完成 export async function updateProfileConfig( - updater: (config: IProfileConfig) => IProfileConfig | Promise + updater: (config: IProfileConfig) => IProfileConfig | Promise, + signal?: AbortSignal ): Promise { return await profileConfigWriteQueue.run(async () => { const data = await readFile(profileConfigPath(), 'utf-8') @@ -119,8 +132,9 @@ export async function updateProfileConfig( const nextConfig = await updater(JSON.parse(JSON.stringify(currentConfig))) await atomicWriteFile(profileConfigPath(), stringify(nextConfig), { encoding: 'utf8' }) profileConfig = nextConfig + profileConfigVersion++ return JSON.parse(JSON.stringify(nextConfig)) as IProfileConfig - }) + }, signal) } export async function getProfileItem(id: string | undefined): Promise { @@ -222,39 +236,74 @@ export async function addProfileItem(item: Partial): Promise } export async function removeProfileItem(id: string): Promise { + const item = await getProfileItem(id) + if (item?.type === 'plugin' && item.pluginId) { + // 级联删除插件:tombstone → plugin lock → revoke → profile → item → vault 在同一个临界区内完成。 + // profile 记录由插件侧在锁内删除,避免在途更新的 upsertPluginProfile 在取锁前把它重建出来。 + const { removePluginForProfile } = await import('../resolve/plugin') + const { mainWindow } = await import('../window') + await removePluginForProfile(item.pluginId, id) + mainWindow?.webContents.send('pluginConfigUpdated') + return + } + await removeProfileItemCore(id) +} + +// 正在删除中的 profile:并发删除时不能把彼此选作新的 current +const deletingProfiles = new Set() + +// 删除 profile 记录、文件与工作目录,不做插件级联。返回被删除的记录(若存在)。 +// 顺序:先让核心不再使用它(切走 current + 重启),再删订阅文件与工作目录,最后删记录。任何一步失败都抛出、 +// 定时器装回,而记录仍在列表里可以重试——不会出现"记录没了、含节点凭据的文件还在"的无主残留(R2-ISS-067)。 +async function removeProfileItemCore(id: string): Promise { + deletingProfiles.add(id) + try { + return await removeProfileItemSteps(id) + } finally { + deletingProfiles.delete(id) + } +} + +function nextCurrentAfter(config: IProfileConfig, removing: string): string | undefined { + return config.items.find((i) => i.id !== removing && !deletingProfiles.has(i.id))?.id +} + +async function removeProfileItemSteps(id: string): Promise { + const item = (await getProfileConfig()).items.find((i) => i.id === id) 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 - config.current = config.items.length > 0 ? config.items[0].id : undefined - } - return config - }) - - if (existsSync(profilePath(id))) { - await rm(profilePath(id)) - } - if (shouldRestart) { - 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') + let repicked = false + try { + let switched = false + await updateProfileConfig((config) => { + if (config.current === id) { + switched = true + config.current = nextCurrentAfter(config, id) + } + return config + }) + if (switched) await restartCore() + if (existsSync(profilePath(id))) await rm(profilePath(id)) + await removeProfileWorkDir(id) + await updateProfileConfig((config) => { + removedItem = config.items?.find((i) => i.id === id) + config.items = config.items?.filter((i) => i.id !== id) + // 并发删除可能在我们切走之后又把 current 指回来(对方选 next 时我们还在列表里):最终一步再核对一次 + if (config.current !== undefined && !config.items.some((i) => i.id === config.current)) { + config.current = nextCurrentAfter(config, id) + repicked = true + } + return config + }) + } catch (e) { + // 记录还在(任何一步失败——配置写入、核心重启、文件 / 工作目录删除):把定时器装回去,留给用户重试 + if (item) await addProfileUpdater(item) + throw e } + // 记录已删除:重启失败只需上抛,没有定时器要恢复 + if (repicked) await restartCore() + return removedItem } export async function getCurrentProfileItem(): Promise { @@ -528,7 +577,7 @@ export async function createProfile(item: Partial): Promise): Promise { +// 候选校验:把新内容放进临时目录,生成带 override 的完整运行配置,再交核心 `-t` 校验。远程订阅与插件订阅 +// 共用;失败抛错,调用方保留旧文件。 +export async function validateProfileCandidate( + item: IProfileItem, + content: string, + opts: CheckProfileOptions & { globalOverrideIds?: string[] } = {} +): Promise { const candidateDir = await mkdtemp(join(tmpdir(), 'mihomo-party-profile-')) const candidatePath = join(candidateDir, 'config.yaml') @@ -558,10 +613,11 @@ async function validateRemoteProfileCandidate(item: IProfileItem, content: strin baseProfile, ageSecretKey: item.ageSecretKey, profileOverrideIds: item.override ?? [], + globalOverrideIds: opts.globalOverrideIds, outputPath: candidatePath, updateRuntimeConfig: false }) - await checkProfileConfig(candidatePath, core, item.ageSecretKey) + await checkProfileConfig(candidatePath, core, item.ageSecretKey, opts) } finally { await rm(candidateDir, { recursive: true, force: true }).catch(() => {}) } @@ -579,20 +635,23 @@ export async function setProfileStr(id: string, content: string): Promise // 读取最新的配置 const { current } = await getProfileConfig(true) await atomicWriteFile(profilePath(id), content, { encoding: 'utf8' }) - if (current === id) { + if (current === id) await reloadCurrentProfile() +} + +// 当前订阅的内容已替换:热加载,失败则回退到重启核心 +async function reloadCurrentProfile(): Promise { + try { + await mihomoHotReloadConfig() + profileLogger.info('Config reloaded successfully') + } catch (error) { + profileLogger.error('Failed to reload config', error) try { - await mihomoHotReloadConfig() - profileLogger.info('Config reloaded successfully') - } catch (error) { - profileLogger.error('Failed to reload config', error) - try { - profileLogger.info('Falling back to restart core') - await restartCore() - profileLogger.info('Core restarted successfully') - } catch (restartError) { - profileLogger.error('Failed to restart core', restartError) - throw restartError - } + profileLogger.info('Falling back to restart core') + await restartCore() + profileLogger.info('Core restarted successfully') + } catch (restartError) { + profileLogger.error('Failed to restart core', restartError) + throw restartError } } } @@ -730,45 +789,167 @@ export async function convertMrsRuleset(filePath: string, behavior: string): Pro } } -// 插件 profile:内容已由 plugin 网关取得,这里只写内容 + 维护 profile item,不走远程 URL 下载 +// 插件订阅内容未通过核心校验:与磁盘写入失败区分开,调用方按"服务端给了坏配置"的瞬时失败处理, +// 保留旧订阅。用 code 而不是 class 身份识别,模块被 mock 时依然可判。 +export const PLUGIN_PROFILE_INVALID = 'PLUGIN_PROFILE_INVALID' +export class PluginProfileInvalidError extends Error { + code = PLUGIN_PROFILE_INVALID + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)) + this.name = 'PluginProfileInvalidError' + } +} +export function isPluginProfileInvalidError(e: unknown): boolean { + return ( + typeof e === 'object' && e !== null && (e as { code?: unknown }).code === PLUGIN_PROFILE_INVALID + ) +} + +// 插件 profile:内容已由 plugin 网关取得,这里只写内容 + 维护 profile item,不走远程 URL 下载。 +// 写入前与远程订阅一样先跑候选校验(含用户 override 与核心 -t):结构合法但语义非法的内容不得覆盖仍可用的旧订阅。 +// 核心校验子进程的硬上限:即便调用方没有预算 signal,一次 `-t` 也不能无限期占住插件锁 +const PLUGIN_PROFILE_CHECK_TIMEOUT_MS = 60_000 + +// 一次校验只覆盖当时参与生成运行配置的 override 集合(profile 自己的 override 列表 + 全局 override)。 +// 全局集合由这里读一次并原样传给 generateProfile,键与校验所用集合按构造一致;落盘前在 profile 写队列内 +// 重新推导同一集合核对,变了就按新集合重新校验(有界),保证写下的"内容 + override"组合经过校验。 +// override 文件内容本身的编辑不在此列(与远程订阅一致)。 +function overrideSetKey(item: IProfileItem | undefined, globalIds: string[]): string { + return JSON.stringify({ own: item?.override ?? [], global: globalIds }) +} + +const MAX_PLUGIN_PROFILE_REVALIDATIONS = 2 + +class OverrideChangedError extends Error {} + export async function upsertPluginProfile( - meta: { - profileId: string - pluginId: string - name: string - interval?: number - autoUpdate?: boolean - }, - content: string + meta: { profileId: string; pluginId: string; name: string }, + content: string, + signal?: AbortSignal ): Promise { - 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() + // 插件拥有的字段;用户在 profile 上设置的其它字段(override 等)不属于这里,写回时不得带入旧快照。 + // 调度字段(interval / autoUpdate)不在这里:它们在写入时从插件记录现读,见 commitPluginProfile + const owned = { + id: meta.profileId, + type: 'plugin' as const, + name: meta.name, + pluginId: meta.pluginId, + updated: Date.now() + } + for (let attempt = 0; ; attempt++) { + // 校验用当前快照里的 override(与运行时一致) + const snapshot = await getProfileItem(meta.profileId) + const candidate: IProfileItem = { ...snapshot, ...owned } + const globalIds = await globalOverrideIdsNow() + const validatedKey = overrideSetKey(candidate, globalIds) + try { + await validateProfileCandidate(candidate, content, { + signal, + timeoutMs: PLUGIN_PROFILE_CHECK_TIMEOUT_MS, + globalOverrideIds: globalIds + }) + } catch (e) { + throw new PluginProfileInvalidError(e) } - if (idx === -1) { - isNew = true - config.items.push(item) - } else { - config.items[idx] = { ...config.items[idx], ...item } + try { + await commitPluginProfile(meta, owned, content, validatedKey, signal) + return + } catch (e) { + if (!(e instanceof OverrideChangedError)) throw e + // 校验期间 override 集合变了:按新集合再校验一次;一直在变就放弃本次,旧订阅原样保留 + if (attempt >= MAX_PLUGIN_PROFILE_REVALIDATIONS) throw new PluginProfileInvalidError(e) } - 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 { - await removeProfileItem(profileId) +// 落盘(profile 写队列内,同一临界区):核对 override 集合 → 查预算 → 写订阅文件 → 更新 item。 +// 订阅文件一旦写下,item 写入必须完成(§0.4 唯一的持久化提交;半截提交比迟到的提交更糟)。 +async function commitPluginProfile( + meta: { profileId: string; pluginId: string; name: string }, + owned: IProfileItem, + content: string, + validatedKey: string, + signal?: AbortSignal +): Promise { + let isNew = false + let scheduleChanged = false + let wasCurrent = false + let hadCurrent = true + // 是否已进入临界区:只有排队等待期间的中止才按预算失败处理;进入之后的任何错误都是真实的提交错误 + let entered = false + const commit = updateProfileConfig(async (config) => { + entered = true + const idx = config.items.findIndex((i) => i.id === meta.profileId) + const current = idx === -1 ? undefined : config.items[idx] + if (overrideSetKey(current, await globalOverrideIdsNow()) !== validatedKey) { + throw new OverrideChangedError('profile override changed during validation') + } + // 写入边界:校验通过后到这里(清理临时目录、读取配置)预算也可能耗尽;下面是第一处落盘,之前最后一次查 signal + if (signal?.aborted) throw new PluginProfileInvalidError(signal.reason) + await atomicWriteFile(profilePath(meta.profileId), content, { encoding: 'utf8' }) + // 调度字段在写队列内从插件记录现读(不是 op 开始时的旧快照):用户在校验期间切换的自动更新要么已被读到, + // 要么其 syncPluginProfileSchedule 排在本次写入之后覆盖,不会被拉取回退 + const schedule = pluginSchedule(await getPluginItem(meta.pluginId)) + if (!current) { + isNew = true + config.items.push({ ...owned, ...schedule }) + } else { + scheduleChanged = + current.interval !== schedule.interval || current.autoUpdate !== schedule.autoUpdate + config.items[idx] = { ...current, ...owned, ...schedule } + } + wasCurrent = config.current === meta.profileId + hadCurrent = !!config.current + return config + }, signal) + try { + await commit + } catch (e) { + // 排队等待期间预算耗尽(还没进临界区、什么都没写):与校验阶段中止同一条路径(R2-ISS-065)。 + // 已进入临界区后的错误(如 profile.yaml 写入失败)原样抛出,即使此时预算恰好也耗尽了 + if (!entered && signal?.aborted) throw new PluginProfileInvalidError(signal.reason) + throw e + } + // 新建,或调度字段相对写入时的现值有变化(插件记录里改了 interval / autoUpdate 之后的下一次拉取)→ 重建定时器。 + // 先于下面的核心加载:item 与调度已经落盘,加载失败也不能把定时器漏掉——重试时 item 已存在、调度未变,不会再走到这里 + if (isNew || scheduleChanged) { + const saved = await getProfileItem(meta.profileId) + if (saved) await addProfileUpdater(saved) + } + if (wasCurrent) { + // 当前订阅的内容变了:热加载(失败则重启核心) + await reloadCurrentProfile() + } else if (!hadCurrent) { + // 还没有当前订阅(例如删光后安装插件):走正式切换流程让核心真正加载它——只写 current 不会加载, + // 而且 changeCurrentProfile 遇到已相同的 current 会直接返回 + await changeCurrentProfile(meta.profileId) + } +} + +// 插件设置里改了自动更新 / 间隔:同步到关联的 profile item 并重建定时器(调度以 profile item 为准)。 +// autoUpdate 为 false 时 addProfileUpdater 只拆不装,定时器随之停止。 +// 写入只合并调度字段(不带旧快照),并且总是重建定时器:多次并发切换按 profile 写队列的顺序落盘, +// 最后一次写入者的值最终生效并被装上——不能凭可能过期的缓存判断"无变化"而跳过。 +export async function syncPluginProfileSchedule( + profileId: string, + schedule: { interval?: number; autoUpdate?: boolean } +): Promise { + let saved: IProfileItem | undefined + await updateProfileConfig((config) => { + const idx = config.items.findIndex((i) => i.id === profileId) + if (idx === -1 || config.items[idx].type !== 'plugin') return config + config.items[idx] = { + ...config.items[idx], + ...(schedule.interval !== undefined ? { interval: schedule.interval } : {}), + ...(schedule.autoUpdate !== undefined ? { autoUpdate: schedule.autoUpdate } : {}) + } + saved = config.items[idx] + return config + }) + if (saved) await addProfileUpdater(saved) +} + +// 由插件删除临界区调用:只删 profile,不再级联回插件(调用方已持有 plugin lock)。 +export async function removePluginProfileContent(profileId: string): Promise { + await removeProfileItemCore(profileId) } diff --git a/src/main/config/runtimeConfigQueue.ts b/src/main/config/runtimeConfigQueue.ts new file mode 100644 index 00000000..c2efcddd --- /dev/null +++ b/src/main/config/runtimeConfigQueue.ts @@ -0,0 +1,6 @@ +import { WriteQueue } from '../utils/safeFile' + +// profile.yaml 与 override.yaml 共用一个写队列:两份配置共同决定生成的运行配置。插件订阅提交的临界区 +//(核对参与校验的 override 集合 → 落盘)与全局 override 的开关必须互相串行,否则核对与落盘之间的一次切换 +// 会让落盘的"内容 + override"组合未经校验。队列不可重入:队列内的回调只能读这两份配置,不能再写。 +export const runtimeConfigWriteQueue = new WriteQueue() diff --git a/src/main/core/factory.ts b/src/main/core/factory.ts index a08e54eb..e8d6d48c 100644 --- a/src/main/core/factory.ts +++ b/src/main/core/factory.ts @@ -38,10 +38,18 @@ interface GenerateProfileOptions { baseProfile?: IMihomoConfig ageSecretKey?: string profileOverrideIds?: string[] + // 调用方已读取的全局 override id 集合:给出时不再自行读取,生成所用的集合与调用方记录的完全一致 + //(插件订阅校验用它把"参与校验的集合"绑定到校验本身) + globalOverrideIds?: string[] outputPath?: string updateRuntimeConfig?: boolean } +export async function globalOverrideIdsNow(): Promise { + const { items = [] } = (await getOverrideConfig()) || {} + return items.filter((item) => item.global).map((item) => item.id) +} + // 辅助函数:处理带偏移量的规则 function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) { const normalRules: string[] = [] @@ -129,7 +137,7 @@ export async function generateProfile( await Promise.all([ getProfileItem(profileId), options.baseProfile ?? getProfile(profileId), - getOrderedOverrideIds(profileId, options.profileOverrideIds), + getOrderedOverrideIds(profileId, options.profileOverrideIds, options.globalOverrideIds), getControledMihomoConfig() ]) const ageSecretKey = options.ageSecretKey ?? currentProfileItem?.ageSecretKey ?? '' @@ -315,13 +323,13 @@ async function prepareProfileWorkDir(current: string | undefined): Promise async function getOrderedOverrideIds( current: string | undefined, - profileOverrideIds?: string[] + profileOverrideIds?: string[], + globalOverrideIds?: string[] ): Promise<{ normal: string[] smart: string[] }> { - const { items = [] } = (await getOverrideConfig()) || {} - const globalOverride = items.filter((item) => item.global).map((item) => item.id) + const globalOverride = globalOverrideIds ?? (await globalOverrideIdsNow()) const override = profileOverrideIds ?? (await getProfileItem(current))?.override ?? [] const orderedOverrideIds = [...new Set(globalOverride.concat(override))] diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index 2be78f6d..579427a7 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -962,17 +962,27 @@ async function checkProfile( ) } +export interface CheckProfileOptions { + // 调用方的预算 signal:中止后校验子进程被终止,校验按失败处理(调用方不得再写入) + signal?: AbortSignal + // 校验子进程的硬上限(毫秒):防止一次 `-t` 无限期占住调用方持有的锁 + timeoutMs?: number +} + export async function checkProfileConfig( configPath: string, core: string = 'mihomo', - ageSecretKey?: string + ageSecretKey?: string, + opts: CheckProfileOptions = {} ): Promise { const corePath = mihomoCorePath(core) await syncSmartModelToTestDir() try { await execFilePromise(corePath, ['-t', '-f', configPath, '-d', mihomoTestDir()], { - env: buildCoreEnv(undefined, ageSecretKey) + env: buildCoreEnv(undefined, ageSecretKey), + signal: opts.signal, + timeout: opts.timeoutMs }) } catch (error) { managerLogger.error('Profile check failed', error) diff --git a/src/main/resolve/plugin/__fixtures__/discovery-vectors.json b/src/main/resolve/plugin/__fixtures__/discovery-vectors.json new file mode 100644 index 00000000..56afddfe --- /dev/null +++ b/src/main/resolve/plugin/__fixtures__/discovery-vectors.json @@ -0,0 +1,24 @@ +[ + { + "name": "full payload (seq 12, two gateways, loginUrl + discoveryUrls)", + "seedB64": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=", + "pubKeyB64": "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw=", + "payloadJson": "{\"spec\":\"cpx-plugin/2\",\"seq\":12,\"gateways\":[\"https://gw1.example.net\",\"https://gw2-cdn.example.com\"],\"endpoints\":{\"enroll\":\"/enroll\",\"challenge\":\"/challenge\",\"config\":\"/config\",\"revoke\":\"/revoke\"},\"loginUrl\":\"https://panel-new.example.com/oauth/authorize\",\"discoveryUrls\":[\"https://gw2-cdn.example.com\"]}", + "payloadB64": "eyJzcGVjIjoiY3B4LXBsdWdpbi8yIiwic2VxIjoxMiwiZ2F0ZXdheXMiOlsiaHR0cHM6Ly9ndzEuZXhhbXBsZS5uZXQiLCJodHRwczovL2d3Mi1jZG4uZXhhbXBsZS5jb20iXSwiZW5kcG9pbnRzIjp7ImVucm9sbCI6Ii9lbnJvbGwiLCJjaGFsbGVuZ2UiOiIvY2hhbGxlbmdlIiwiY29uZmlnIjoiL2NvbmZpZyIsInJldm9rZSI6Ii9yZXZva2UifSwibG9naW5VcmwiOiJodHRwczovL3BhbmVsLW5ldy5leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemUiLCJkaXNjb3ZlcnlVcmxzIjpbImh0dHBzOi8vZ3cyLWNkbi5leGFtcGxlLmNvbSJdfQ==", + "signInputHex": "435058322d444953434f56455259007b2273706563223a226370782d706c7567696e2f32222c22736571223a31322c226761746577617973223a5b2268747470733a2f2f6777312e6578616d706c652e6e6574222c2268747470733a2f2f6777322d63646e2e6578616d706c652e636f6d225d2c22656e64706f696e7473223a7b22656e726f6c6c223a222f656e726f6c6c222c226368616c6c656e6765223a222f6368616c6c656e6765222c22636f6e666967223a222f636f6e666967222c227265766f6b65223a222f7265766f6b65227d2c226c6f67696e55726c223a2268747470733a2f2f70616e656c2d6e65772e6578616d706c652e636f6d2f6f617574682f617574686f72697a65222c22646973636f7665727955726c73223a5b2268747470733a2f2f6777322d63646e2e6578616d706c652e636f6d225d7d", + "sigB64": "3NPFClefXW3UFuREs7TNeYZgow0xolF18w7JNICCZ0CrCPfDsezhDfOkpwfzkPD32NaUUdRW7nW0AQV46MeVBg==", + "signed": "eyJzcGVjIjoiY3B4LXBsdWdpbi8yIiwic2VxIjoxMiwiZ2F0ZXdheXMiOlsiaHR0cHM6Ly9ndzEuZXhhbXBsZS5uZXQiLCJodHRwczovL2d3Mi1jZG4uZXhhbXBsZS5jb20iXSwiZW5kcG9pbnRzIjp7ImVucm9sbCI6Ii9lbnJvbGwiLCJjaGFsbGVuZ2UiOiIvY2hhbGxlbmdlIiwiY29uZmlnIjoiL2NvbmZpZyIsInJldm9rZSI6Ii9yZXZva2UifSwibG9naW5VcmwiOiJodHRwczovL3BhbmVsLW5ldy5leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemUiLCJkaXNjb3ZlcnlVcmxzIjpbImh0dHBzOi8vZ3cyLWNkbi5leGFtcGxlLmNvbSJdfQ==.3NPFClefXW3UFuREs7TNeYZgow0xolF18w7JNICCZ0CrCPfDsezhDfOkpwfzkPD32NaUUdRW7nW0AQV46MeVBg==", + "digestHex": "30a3eef1f9d427b126aad827bdb2982a818e0370f4e1aab8ce8598f82dbaf511" + }, + { + "name": "minimal payload (seq 1, single gateway)", + "seedB64": "CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg=", + "pubKeyB64": "E5j2LG0aRXxRumpLXz29L2n8qTIWIY3ImX5Ba9F9k8o=", + "payloadJson": "{\"spec\":\"cpx-plugin/2\",\"seq\":1,\"gateways\":[\"https://gw.example.net\"],\"endpoints\":{\"enroll\":\"/enroll\",\"challenge\":\"/challenge\",\"config\":\"/config\",\"revoke\":\"/revoke\"}}", + "payloadB64": "eyJzcGVjIjoiY3B4LXBsdWdpbi8yIiwic2VxIjoxLCJnYXRld2F5cyI6WyJodHRwczovL2d3LmV4YW1wbGUubmV0Il0sImVuZHBvaW50cyI6eyJlbnJvbGwiOiIvZW5yb2xsIiwiY2hhbGxlbmdlIjoiL2NoYWxsZW5nZSIsImNvbmZpZyI6Ii9jb25maWciLCJyZXZva2UiOiIvcmV2b2tlIn19", + "signInputHex": "435058322d444953434f56455259007b2273706563223a226370782d706c7567696e2f32222c22736571223a312c226761746577617973223a5b2268747470733a2f2f67772e6578616d706c652e6e6574225d2c22656e64706f696e7473223a7b22656e726f6c6c223a222f656e726f6c6c222c226368616c6c656e6765223a222f6368616c6c656e6765222c22636f6e666967223a222f636f6e666967222c227265766f6b65223a222f7265766f6b65227d7d", + "sigB64": "c3QQcgGazY1b7F1ncVjNb7KUHYITL7VJ+RjSildSpR23h5QBt1GlpujMXGAbxtHHBwBKYVvq4X2qOuRO/0MhCw==", + "signed": "eyJzcGVjIjoiY3B4LXBsdWdpbi8yIiwic2VxIjoxLCJnYXRld2F5cyI6WyJodHRwczovL2d3LmV4YW1wbGUubmV0Il0sImVuZHBvaW50cyI6eyJlbnJvbGwiOiIvZW5yb2xsIiwiY2hhbGxlbmdlIjoiL2NoYWxsZW5nZSIsImNvbmZpZyI6Ii9jb25maWciLCJyZXZva2UiOiIvcmV2b2tlIn19.c3QQcgGazY1b7F1ncVjNb7KUHYITL7VJ+RjSildSpR23h5QBt1GlpujMXGAbxtHHBwBKYVvq4X2qOuRO/0MhCw==", + "digestHex": "a5fcfc90393206ac80ad425da5092d9db46a32e0e12ece330e4440541b749c72" + } +] diff --git a/src/main/resolve/plugin/abortable.ts b/src/main/resolve/plugin/abortable.ts new file mode 100644 index 00000000..d21321e2 --- /dev/null +++ b/src/main/resolve/plugin/abortable.ts @@ -0,0 +1,37 @@ +// op 内不受底层取消机制约束的等待(DNS 预检、代理配置读取、vault 解密)统一经此接收同一个 signal,并可按 +// 网络余量限时(§0.4 规则 1:一个 op 一个 AbortSignal;网络阶段靠限时在 deadline 前 reserve 结束)。 +// 被包装的 promise 本身不会被取消:中止后它继续跑到结束,结果被丢弃;调用方按 CPX_TIMEOUT 结束。 +import { CPX_TIMEOUT, codedError } from './errors' + +export function abortable(p: Promise, signal?: AbortSignal, timeoutMs?: number): Promise { + if (!signal && timeoutMs === undefined) return p + const timeout = (): Error => codedError('Request timed out', CPX_TIMEOUT, 'pre-send') + if (signal?.aborted) { + // 已中止:不再等 p,但 p 的拒绝也要有人接住——同一个 signal 往往让底层等待(如锁排队)同时拒绝 + p.catch(() => {}) + return Promise.reject(timeout()) + } + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined + const cleanup = (): void => { + signal?.removeEventListener('abort', onAbort) + if (timer) clearTimeout(timer) + } + const onAbort = (): void => { + cleanup() + reject(timeout()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined) timer = setTimeout(onAbort, Math.max(0, timeoutMs)) + p.then( + (v) => { + cleanup() + resolve(v) + }, + (e) => { + cleanup() + reject(e) + } + ) + }) +} diff --git a/src/main/resolve/plugin/descriptor.test.ts b/src/main/resolve/plugin/descriptor.test.ts index 093097bf..71fe5ed7 100644 --- a/src/main/resolve/plugin/descriptor.test.ts +++ b/src/main/resolve/plugin/descriptor.test.ts @@ -97,4 +97,72 @@ describe('parseDescriptor', () => { it('rejects missing/empty provider.name', () => { expect(() => parseDescriptor(file({ provider: { name: '' } }))).toThrow() }) + + // §3 discoveryUrls + it('accepts 1..8 backup discovery origins and normalizes them', () => { + const d = parseDescriptor( + file({ discoveryUrls: ['https://cdn.xx.com/', 'https://gw.xx.com:8443'] }) + ) + expect(d.discoveryUrls).toEqual(['https://cdn.xx.com', 'https://gw.xx.com:8443']) + }) + it('rejects 9 discovery origins', () => { + const nine = Array.from({ length: 9 }, (_, i) => `https://d${i}.xx.com`) + expect(() => parseDescriptor(file({ discoveryUrls: nine }))).toThrow(/discoveryUrls/) + }) + it('rejects an empty discoveryUrls list', () => { + expect(() => parseDescriptor(file({ discoveryUrls: [] }))).toThrow(/discoveryUrls/) + }) + it('rejects a discovery origin with a path', () => { + expect(() => parseDescriptor(file({ discoveryUrls: ['https://cdn.xx.com/wk'] }))).toThrow( + /discoveryUrls/ + ) + }) + it('rejects a discovery origin equal to the loginUrl origin', () => { + expect(() => parseDescriptor(file({ discoveryUrls: ['https://panel.xx.com'] }))).toThrow( + /loginUrl origin/ + ) + }) + it('rejects a private discovery origin', () => { + expect(() => parseDescriptor(file({ discoveryUrls: ['https://10.0.0.1'] }))).toThrow( + /discoveryUrls/ + ) + }) + it('rejects duplicate discovery origins (after normalization)', () => { + expect(() => + parseDescriptor(file({ discoveryUrls: ['https://cdn.xx.com', 'https://cdn.xx.com/'] })) + ).toThrow(/duplicates/) + }) + + // §4 provider.description + it('accepts provider.description and sanitizes it', () => { + const d = parseDescriptor( + file({ provider: { name: 'X', description: ' line1\u0001\nline2 ' } }) + ) + expect(d.provider.description).toBe('line1\nline2') + }) + it('truncates provider.description to 500 code points', () => { + const d = parseDescriptor(file({ provider: { name: 'X', description: '字'.repeat(501) } })) + expect(Array.from(d.provider.description ?? '')).toHaveLength(500) + }) + it('drops an empty provider.description and rejects a non-string one', () => { + expect( + parseDescriptor(file({ provider: { name: 'X', description: ' ' } })).provider.description + ).toBeUndefined() + expect(() => parseDescriptor(file({ provider: { name: 'X', description: 1 } }))).toThrow() + }) + + // §5 providerPubKey + it('accepts a 32-byte standard-base64 providerPubKey', () => { + const key = Buffer.alloc(32, 9).toString('base64') + expect(parseDescriptor(file({ providerPubKey: key })).providerPubKey).toBe(key) + }) + it('rejects a providerPubKey that is not exactly 32 canonical base64 bytes', () => { + expect(() => + parseDescriptor(file({ providerPubKey: Buffer.alloc(31, 9).toString('base64') })) + ).toThrow(/providerPubKey/) + expect(() => + parseDescriptor(file({ providerPubKey: Buffer.alloc(32, 9).toString('base64url') })) + ).toThrow(/providerPubKey/) + expect(() => parseDescriptor(file({ providerPubKey: 42 }))).toThrow(/providerPubKey/) + }) }) diff --git a/src/main/resolve/plugin/descriptor.ts b/src/main/resolve/plugin/descriptor.ts index e3ecc612..cb3b3f9b 100644 --- a/src/main/resolve/plugin/descriptor.ts +++ b/src/main/resolve/plugin/descriptor.ts @@ -1,6 +1,10 @@ import { isForbiddenHost } from './net-guard' +import { parseGatewayOrigin } from './gateway-url' +import { MAX_PROVIDER_DESCRIPTION, sanitizeProviderText } from './text' +import { isB64Bytes } from './encoding' const ICON_MAX_LEN = 64 * 1024 +const MAX_DISCOVERY_URLS = 8 const ICON_PREFIXES = [ 'data:image/png;base64,', 'data:image/jpeg;base64,', @@ -46,6 +50,28 @@ function assertHttpsUrl(v: unknown, where: string): URL { return u } +// §3:备用发现源。每项为公网 https origin(parseGatewayOrigin 规则),1..8 个,去重,不得与 loginUrl 同 origin。 +// 信任级别与 loginUrl 相同——都是用户导入时接受的静态信任根。 +function validateDiscoveryUrls(v: unknown, loginOrigin: string): string[] | undefined { + if (v === undefined) return undefined + if (!Array.isArray(v) || v.length < 1 || v.length > MAX_DISCOVERY_URLS) { + fail(`discoveryUrls must list 1..${MAX_DISCOVERY_URLS} public https origins`) + } + const out: string[] = [] + for (const item of v) { + const origin = parseGatewayOrigin(item) + if (!origin) { + fail( + 'discoveryUrls entries must be public https origins with no path/query/fragment/userinfo' + ) + } + if (origin === loginOrigin) fail('discoveryUrls must not repeat the loginUrl origin') + if (out.includes(origin)) fail('discoveryUrls must not contain duplicates') + out.push(origin) + } + return out +} + export function parseDescriptor(jsonText: string): IPluginDescriptor { let raw: unknown try { @@ -62,18 +88,36 @@ export function parseDescriptor(jsonText: string): IPluginDescriptor { } 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') + assertOnlyKeys( + raw, + ['magic', 'v', 'spec', 'loginUrl', 'provider', 'discoveryUrls', 'providerPubKey'], + 'descriptor' + ) + // §5:签名一旦在 .cpx 中声明即强制校验;公钥必须是规范 base64 的 32 字节 + if (raw.providerPubKey !== undefined && !isB64Bytes(raw.providerPubKey, 32)) { + fail('providerPubKey must be a 32-byte Ed25519 public key in standard base64') + } const loginUrl = assertHttpsUrl(raw.loginUrl, 'loginUrl') if (loginUrl.search || loginUrl.hash) fail('loginUrl must not contain query or fragment') + const discoveryUrls = validateDiscoveryUrls(raw.discoveryUrls, loginUrl.origin) if (!isObject(raw.provider)) fail('provider must be an object') - assertOnlyKeys(raw.provider, ['name', 'icon', 'site'], 'provider') + assertOnlyKeys(raw.provider, ['name', 'icon', 'site', 'description'], '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') + if (raw.provider.description !== undefined && typeof raw.provider.description !== 'string') { + fail('provider.description must be a string') + } + // §4.2:机场静态说明,清洗规则同 message,截断 500 码点;清洗后为空则视为未提供 + const description = sanitizeProviderText(raw.provider.description, MAX_PROVIDER_DESCRIPTION) - return raw as unknown as IPluginDescriptor + const descriptor = raw as unknown as IPluginDescriptor + if (discoveryUrls) descriptor.discoveryUrls = discoveryUrls + if (description) descriptor.provider.description = description + else delete descriptor.provider.description + return descriptor } diff --git a/src/main/resolve/plugin/discovery-sig.test.ts b/src/main/resolve/plugin/discovery-sig.test.ts new file mode 100644 index 00000000..64fa69a4 --- /dev/null +++ b/src/main/resolve/plugin/discovery-sig.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { createPrivateKey, sign } from 'crypto' +import { describe, it, expect } from 'vitest' +import { buildDiscoverySignInput, checkSeq, parseSigned } from './discovery-sig' + +interface Vector { + name: string + seedB64: string + pubKeyB64: string + payloadJson: string + payloadB64: string + signInputHex: string + sigB64: string + signed: string + digestHex: string +} +const vectors: Vector[] = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') +) +const V = vectors[0] +const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') +function signWith(seedB64: string, payload: string | Buffer, prefix = true): string { + const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + const input = prefix ? buildDiscoverySignInput(bytes) : bytes + return `${bytes.toString('base64')}.${sign(null, input, key).toString('base64')}` +} +const base = (): Record => JSON.parse(V.payloadJson) +const envelope = (payload: Record): string => + signWith(V.seedB64, JSON.stringify(payload)) + +describe('parseSigned', () => { + it('accepts the recorded vectors and reproduces their digests', () => { + for (const v of vectors) { + const { payload, digest } = parseSigned(v.signed, v.pubKeyB64) + expect(payload).toEqual(JSON.parse(v.payloadJson)) + expect(digest).toBe(v.digestHex) + } + }) + it('rejects a tampered payload byte', () => { + const [p, sig] = V.signed.split('.') + const bytes = Buffer.from(p, 'base64') + bytes[bytes.length - 2] ^= 0x01 + expect(() => parseSigned(`${bytes.toString('base64')}.${sig}`, V.pubKeyB64)).toThrow( + /signature/ + ) + }) + it('rejects a signature made under a different key', () => { + expect(() => parseSigned(signWith(vectors[1].seedB64, V.payloadJson), V.pubKeyB64)).toThrow( + /signature/ + ) + }) + it('rejects missing or extra dots', () => { + expect(() => parseSigned(V.payloadB64, V.pubKeyB64)).toThrow(/"\."/) + expect(() => parseSigned(`${V.signed}.x`, V.pubKeyB64)).toThrow(/"\."/) + }) + it('rejects non-canonical base64 (missing padding, extra chars)', () => { + const [p, sig] = V.signed.split('.') + expect(() => parseSigned(`${p.replace(/=+$/, '')}.${sig}`, V.pubKeyB64)).toThrow(/canonical/) + expect(() => parseSigned(`${p}.${sig}!`, V.pubKeyB64)).toThrow(/canonical/) + }) + it('rejects a 63-byte signature', () => { + const [p] = V.signed.split('.') + expect(() => + parseSigned(`${p}.${Buffer.alloc(63, 1).toString('base64')}`, V.pubKeyB64) + ).toThrow(/64 bytes/) + }) + it('rejects a payload larger than 4 KiB', () => { + const big = { ...base(), loginUrl: 'https://panel.example.com/' + 'a'.repeat(4100) } + expect(() => parseSigned(envelope(big), V.pubKeyB64)).toThrow(/bytes/) + }) + it('rejects unknown keys', () => { + expect(() => parseSigned(envelope({ ...base(), extra: 1 }), V.pubKeyB64)).toThrow(/unknown/) + expect(() => + parseSigned( + envelope({ ...base(), endpoints: { ...(base().endpoints as object), x: '/x' } }), + V.pubKeyB64 + ) + ).toThrow(/unknown endpoint/) + }) + it('rejects seq that is not an integer in 1..2^53-1', () => { + for (const seq of [0, 1.5, -1, 2 ** 53, '12']) { + expect(() => parseSigned(envelope({ ...base(), seq }), V.pubKeyB64)).toThrow(/seq/) + } + expect(parseSigned(envelope({ ...base(), seq: 2 ** 53 - 1 }), V.pubKeyB64).payload.seq).toBe( + 2 ** 53 - 1 + ) + }) + it('ISS-014: rejects a payload that is not valid UTF-8 even when the signature verifies', () => { + const good = Buffer.from(JSON.stringify(base()), 'utf-8') + const idx = good.indexOf(Buffer.from('/enroll')) + const bad = Buffer.concat([ + good.subarray(0, idx + 1), + Buffer.from([0xc0]), + good.subarray(idx + 1) + ]) + expect(() => parseSigned(signWith(V.seedB64, bad), V.pubKeyB64)).toThrow(/UTF-8/) + }) + it('rejects a signature without the domain-separation prefix', () => { + expect(() => parseSigned(signWith(V.seedB64, V.payloadJson, false), V.pubKeyB64)).toThrow( + /signature/ + ) + }) + it('accepts an optional bootstrap endpoint and validates loginUrl / discoveryUrls format', () => { + const ok = envelope({ + ...base(), + endpoints: { ...(base().endpoints as object), bootstrap: '/bootstrap' } + }) + expect(parseSigned(ok, V.pubKeyB64).payload.endpoints).not.toHaveProperty('bootstrap') + expect(() => + parseSigned(envelope({ ...base(), loginUrl: 'http://panel.example.com/a' }), V.pubKeyB64) + ).toThrow(/loginUrl/) + expect(() => + parseSigned(envelope({ ...base(), discoveryUrls: ['https://x/', 'https://x'] }), V.pubKeyB64) + ).toThrow(/duplicates/) + expect( + parseSigned(envelope({ ...base(), discoveryUrls: [] }), V.pubKeyB64).payload.discoveryUrls + ).toEqual([]) + const { discoveryUrls: _d, ...noUrls } = base() + expect(parseSigned(envelope(noUrls), V.pubKeyB64).payload.discoveryUrls).toBeUndefined() + }) +}) + +describe('checkSeq (§5.3)', () => { + const signer = (minSeq?: number, currentDigest?: string): DiscoverySigner => ({ + pubKeyB64: V.pubKeyB64, + minSeq, + currentDigest + }) + it('accepts anything when nothing is stored', () => { + expect(checkSeq(1, 'x', signer())).toBe('accept') + }) + it('accepts higher, aligns equal+same digest, rejects equal+different digest and lower', () => { + expect(checkSeq(13, 'x', signer(12, 'd'))).toBe('accept') + expect(checkSeq(12, 'd', signer(12, 'd'))).toBe('align') + expect(checkSeq(12, 'e', signer(12, 'd'))).toBe('equivocation') + expect(checkSeq(11, 'x', signer(12, 'd'))).toBe('rollback') + }) +}) diff --git a/src/main/resolve/plugin/discovery-sig.ts b/src/main/resolve/plugin/discovery-sig.ts new file mode 100644 index 00000000..b90e2768 --- /dev/null +++ b/src/main/resolve/plugin/discovery-sig.ts @@ -0,0 +1,155 @@ +// 签名发现文档(§5):信封 ".",Ed25519 对 "CPX2-DISCOVERY\0" || payloadBytes 签名。 +// 校验无需规范化:验的是收到的那串字节,验过之后才 JSON.parse。 +import { verifyRequest } from './device' +import { isCanonicalB64, sha256Hex } from './encoding' +import { + parseGatewayList, + parseGatewayOrigin, + isValidEndpointPath, + normalizeEndpointPath +} from './gateway-url' +import { isForbiddenHost } from './net-guard' + +// 域分离前缀(含结尾 NUL 字节):防止同一密钥签出的其他类型消息被冒用 +export const DISCOVERY_SIGN_PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8') +// payload 字节上限(两处投放位置共用):经 base64 与签名后约 5.6 KiB,在常见 8 KiB 单头限制内 +export const MAX_DISCOVERY_PAYLOAD_BYTES = 4096 +export const MAX_DISCOVERY_URLS = 8 +const MAX_SEQ = Number.MAX_SAFE_INTEGER // 2^53 − 1 +const PAYLOAD_KEYS = ['spec', 'seq', 'gateways', 'endpoints', 'loginUrl', 'discoveryUrls'] +const REQUIRED_ENDPOINTS = ['enroll', 'challenge', 'config', 'revoke'] as const +const OPTIONAL_ENDPOINTS = ['bootstrap'] + +function fail(msg: string): never { + throw new Error(`Invalid signed discovery: ${msg}`) +} + +export function buildDiscoverySignInput(payloadBytes: Uint8Array): Buffer { + return Buffer.concat([DISCOVERY_SIGN_PREFIX, Buffer.from(payloadBytes)]) +} + +function parseLoginUrl(v: unknown): string { + if (typeof v !== 'string') fail('loginUrl must be a string') + let u: URL + try { + u = new URL(v) + } catch { + fail('loginUrl must be a valid URL') + } + if (u.protocol !== 'https:') fail('loginUrl must be https') + if (u.username || u.password) fail('loginUrl must not contain userinfo') + if (u.search || u.hash) fail('loginUrl must not contain query or fragment') + if (isForbiddenHost(u.hostname)) fail('loginUrl must be a public host') + return u.toString() +} + +// 缺失 = 不改(返回 undefined);[] = 清空;非空时规则同 .cpx(1..8、去重、不与 loginUrl 同 origin) +function parseDiscoveryUrls(v: unknown, loginOrigin: string | undefined): string[] | undefined { + if (v === undefined) return undefined + if (!Array.isArray(v) || v.length > MAX_DISCOVERY_URLS) { + fail(`discoveryUrls must be an array of at most ${MAX_DISCOVERY_URLS} origins`) + } + const out: string[] = [] + for (const item of v) { + const origin = parseGatewayOrigin(item) + if (!origin) fail('discoveryUrls entries must be public https origins') + if (loginOrigin && origin === loginOrigin) { + fail('discoveryUrls must not repeat the loginUrl origin') + } + if (out.includes(origin)) fail('discoveryUrls must not contain duplicates') + out.push(origin) + } + return out +} + +function parsePayload(bytes: Buffer): IDiscoveryPayload { + // 严格 UTF-8:非法字节序列直接拒绝,而不是被替换成 U+FFFD 后混进端点路径 + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + fail('payload is not valid UTF-8') + } + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + fail('payload is not valid JSON') + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + fail('payload must be an object') + } + const obj = raw as Record + for (const k of Object.keys(obj)) { + if (!PAYLOAD_KEYS.includes(k)) fail(`unknown key "${k}"`) + } + if (obj.spec !== 'cpx-plugin/2') fail('spec must be "cpx-plugin/2"') + const seq = obj.seq + if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 1 || seq > MAX_SEQ) { + fail('seq must be an integer in 1..2^53-1') + } + const gateways = parseGatewayList(obj.gateways) + if (!gateways) fail('gateways must be 1..3 public https origins') + if (typeof obj.endpoints !== 'object' || obj.endpoints === null || Array.isArray(obj.endpoints)) { + fail('endpoints required') + } + const e = obj.endpoints as Record + for (const k of Object.keys(e)) { + if (!REQUIRED_ENDPOINTS.includes(k as never) && !OPTIONAL_ENDPOINTS.includes(k)) { + fail(`unknown endpoint "${k}"`) + } + if (!isValidEndpointPath(e[k])) fail(`endpoints.${k} must be a relative path`) + } + const endpoints = {} as IGatewayEndpoints + for (const k of REQUIRED_ENDPOINTS) { + const v = e[k] + if (!isValidEndpointPath(v)) fail(`endpoints.${k} required`) + endpoints[k] = normalizeEndpointPath(v) + } + const loginUrl = obj.loginUrl === undefined ? undefined : parseLoginUrl(obj.loginUrl) + const discoveryUrls = parseDiscoveryUrls( + obj.discoveryUrls, + loginUrl ? new URL(loginUrl).origin : undefined + ) + const payload: IDiscoveryPayload = { spec: 'cpx-plugin/2', seq, gateways, endpoints } + if (loginUrl !== undefined) payload.loginUrl = loginUrl + if (discoveryUrls !== undefined) payload.discoveryUrls = discoveryUrls + return payload +} + +// 格式错 / 验签失败抛 Error。digest = SHA-256(payloadBytes) 的 hex。 +export function parseSigned( + signed: unknown, + pubKeyB64: string +): { payload: IDiscoveryPayload; digest: string } { + if (typeof signed !== 'string') fail('signed must be a string') + const parts = signed.split('.') + if (parts.length !== 2) fail('signed must contain exactly one "."') + const [payloadB64, sigB64] = parts + if (!isCanonicalB64(payloadB64) || !isCanonicalB64(sigB64)) fail('non-canonical base64') + const sig = Buffer.from(sigB64, 'base64') + if (sig.length !== 64) fail('signature must be exactly 64 bytes') + const payloadBytes = Buffer.from(payloadB64, 'base64') + if (payloadBytes.length === 0 || payloadBytes.length > MAX_DISCOVERY_PAYLOAD_BYTES) { + fail(`payload must be 1..${MAX_DISCOVERY_PAYLOAD_BYTES} bytes`) + } + let ok = false + try { + ok = verifyRequest(pubKeyB64, buildDiscoverySignInput(payloadBytes), sigB64) + } catch { + ok = false + } + if (!ok) fail('signature verification failed') + return { payload: parsePayload(payloadBytes), digest: sha256Hex(payloadBytes) } +} + +export type SeqVerdict = 'accept' | 'align' | 'rollback' | 'equivocation' + +// §5.3 seq / digest 规则:未存 → 任意接受;seq > stored → 接受;seq === stored 且 digest 相同 → 对齐; +// seq === stored 且 digest 不同 → 拒绝(签发方 equivocation / 多 CDN 不一致);seq < stored → 拒绝。 +export function checkSeq(seq: number, digest: string, signer: DiscoverySigner): SeqVerdict { + if (signer.minSeq === undefined) return 'accept' + if (seq > signer.minSeq) return 'accept' + if (seq < signer.minSeq) return 'rollback' + return digest === signer.currentDigest ? 'align' : 'equivocation' +} diff --git a/src/main/resolve/plugin/discovery-vectors.test.ts b/src/main/resolve/plugin/discovery-vectors.test.ts new file mode 100644 index 00000000..c28681ea --- /dev/null +++ b/src/main/resolve/plugin/discovery-vectors.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { createPrivateKey, sign } from 'crypto' +import { describe, it, expect } from 'vitest' +import { verifyRequest } from './device' +import { buildDiscoverySignInput, parseSigned } from './discovery-sig' +import { sha256Hex } from './encoding' + +interface Vector { + name: string + seedB64: string + pubKeyB64: string + payloadJson: string + payloadB64: string + signInputHex: string + sigB64: string + signed: string + digestHex: string +} + +const vectors: Vector[] = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') +) +const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + +describe('cross-language signed discovery vectors', () => { + it('discovery-sig.ts reproduces each recorded sign input, signature, digest and envelope', () => { + expect(vectors.length).toBeGreaterThan(0) + for (const v of vectors) { + const payloadBytes = Buffer.from(v.payloadJson, 'utf-8') + expect(payloadBytes.toString('base64')).toBe(v.payloadB64) + const input = buildDiscoverySignInput(payloadBytes) + expect(input.toString('hex')).toBe(v.signInputHex) + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(v.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + expect(sign(null, input, key).toString('base64')).toBe(v.sigB64) + expect(verifyRequest(v.pubKeyB64, input, v.sigB64)).toBe(true) + expect(sha256Hex(payloadBytes)).toBe(v.digestHex) + expect(`${v.payloadB64}.${v.sigB64}`).toBe(v.signed) + const parsed = parseSigned(v.signed, v.pubKeyB64) + expect(parsed.digest).toBe(v.digestHex) + expect(parsed.payload).toEqual(JSON.parse(v.payloadJson)) + } + }) +}) diff --git a/src/main/resolve/plugin/discovery.test.ts b/src/main/resolve/plugin/discovery.test.ts index da1a2d95..c64f3408 100644 --- a/src/main/resolve/plugin/discovery.test.ts +++ b/src/main/resolve/plugin/discovery.test.ts @@ -2,8 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const requestOnce = vi.fn() vi.mock('./http-client', () => ({ requestOnce: (...a: unknown[]) => requestOnce(...a) })) +vi.mock('../../config/plugin', () => ({ getPluginItem: vi.fn() })) +vi.mock('./vault', () => ({ readVault: vi.fn() })) +import { readFileSync } from 'fs' +import { join } from 'path' +import { createPrivateKey, sign } from 'crypto' import { discoverGateway } from './discovery' +import { buildDiscoverySignInput } from './discovery-sig' +import { CPX_GUARD_REFUSED, CPX_TIMEOUT, codedError } from './errors' +import { runOperation, type OperationContext } from './operation' +import { autoRouteProvider } from './route' const OK = { spec: 'cpx-plugin/2', @@ -13,67 +22,296 @@ const OK = { function reply(body: unknown, status = 200): void { requestOnce.mockResolvedValueOnce({ status, headers: {}, body: JSON.stringify(body) }) } -const NET = { timeout: 5000 } +// RoutedRequester 适配:把 op 层的路由执行器直接接到被 mock 的 requestOnce 上 +const NET = { request: (url: string, opts: unknown) => requestOnce(url, opts) } +const CTX = { requester: NET } as unknown as OperationContext +const SRC = ['https://panel.xx.com'] +const disc = (sources: string[] = SRC): Promise => + discoverGateway({ sources }, CTX) beforeEach(() => requestOnce.mockReset()) describe('discoverGateway', () => { - it('fetches the exact loginUrl host well-known and returns parsed gateway', async () => { + it('fetches the well-known document from the source origin and returns parsed gateways', async () => { reply(OK) - const wk = await discoverGateway('https://panel.xx.com/oauth/authorize', NET) + const wk = await disc() 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.gateways).toEqual(['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() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).rejects.toThrow() }) it('rejects a localhost gateway', async () => { reply({ ...OK, gateway: 'https://localhost' }) - await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).rejects.toThrow() }) it('rejects wrong spec', async () => { reply({ ...OK, spec: 'cpx-plugin/1' }) - await expect(discoverGateway('https://panel.xx.com/a', NET)).rejects.toThrow() + await expect(disc()).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() + await expect(disc()).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() + await expect(disc()).rejects.toThrow() + }) + + // §2.2 多网关 + it('gateways missing → [gateway]', async () => { + reply(OK) + expect((await disc()).gateways).toEqual(['https://gw.front.com']) + }) + it('accepts up to 3 gateways, deduplicated, with gateway === gateways[0]', async () => { + reply({ + ...OK, + gateways: ['https://gw.front.com', 'https://gw2.front.com/', 'https://gw2.front.com'] + }) + expect((await disc()).gateways).toEqual(['https://gw.front.com', 'https://gw2.front.com']) + }) + it('rejects 4 gateways', async () => { + reply({ ...OK, gateways: ['https://gw.front.com', 'https://b', 'https://c', 'https://d'] }) + await expect(disc()).rejects.toThrow(/gateways/) + }) + it('rejects an empty gateways list', async () => { + reply({ ...OK, gateways: [] }) + await expect(disc()).rejects.toThrow(/gateways/) + }) + it('rejects the whole document when any gateway is private', async () => { + reply({ ...OK, gateways: ['https://gw.front.com', 'https://10.0.0.1'] }) + await expect(disc()).rejects.toThrow(/gateways/) + }) + it('rejects when gateway !== gateways[0]', async () => { + reply({ ...OK, gateways: ['https://gw2.front.com', 'https://gw.front.com'] }) + await expect(disc()).rejects.toThrow(/gateways\[0\]/) + }) +}) + +// §3 多发现源 +describe('discoverGateway with multiple sources', () => { + const TWO = ['https://panel.xx.com', 'https://cdn.xx.com'] + + it('source 1 returns 404, source 2 is valid → success from source 2', async () => { + requestOnce.mockResolvedValueOnce({ status: 404, headers: {}, body: 'not here' }) + reply(OK) + const wk = await disc(TWO) + expect(wk.gateways).toEqual(['https://gw.front.com']) + expect(requestOnce.mock.calls.map((c) => c[0])).toEqual([ + 'https://panel.xx.com/.well-known/cpx-gateway', + 'https://cdn.xx.com/.well-known/cpx-gateway' + ]) + }) + it('source 1 refused by the guard → skipped, source 2 continues', async () => { + requestOnce.mockRejectedValueOnce(codedError('refused', CPX_GUARD_REFUSED, 'pre-send')) + reply(OK) + await expect(disc(TWO)).resolves.toMatchObject({ gateways: ['https://gw.front.com'] }) + }) + it('source 1 invalid document → next source', async () => { + reply({ ...OK, spec: 'cpx-plugin/1' }) + reply(OK) + await expect(disc(TWO)).resolves.toMatchObject({ gateways: ['https://gw.front.com'] }) + }) + it('R2-ISS-004: endpoint paths in the candidate are normalized', async () => { + reply({ ...OK, endpoints: { ...OK.endpoints, config: '/v1/../config' } }) + expect((await disc()).endpoints.config).toBe('/config') + }) + it('all sources fail → throws the last error', async () => { + requestOnce.mockResolvedValueOnce({ status: 404, headers: {}, body: '' }) + requestOnce.mockResolvedValueOnce({ status: 500, headers: {}, body: '' }) + await expect(disc(TWO)).rejects.toMatchObject({ status: 500 }) + }) + it('route stickiness is per source: source 1 direct 404, source 2 direct timeout → source 2 via proxy', async () => { + requestOnce.mockResolvedValueOnce({ status: 404, headers: {}, body: '' }) + requestOnce.mockRejectedValueOnce(codedError('timeout', CPX_TIMEOUT, 'pre-send')) + reply(OK) + const item = { + id: 'p', + name: 'X', + loginUrl: 'https://panel.xx.com/oauth/authorize', + spec: 'cpx-plugin/2', + status: 'needs-login', + routeMode: 'auto', + created: 0, + updated: 0 + } as IPluginItem + const r = await runOperation( + { + item, + app: { subscriptionTimeout: 5000 }, + retryPolicy: 'safe', + routeProvider: autoRouteProvider('direct', async () => ({ host: '127.0.0.1', port: 7890 })), + resolveAll: async () => [{ address: '1.1.1.1', family: 4 }] + }, + (ctx) => discoverGateway({ sources: TWO }, ctx) + ) + expect(r.ok).toBe(true) + const calls = requestOnce.mock.calls.map((c) => [c[0], (c[1] as { proxy?: unknown }).proxy]) + expect(calls).toEqual([ + ['https://panel.xx.com/.well-known/cpx-gateway', undefined], + ['https://cdn.xx.com/.well-known/cpx-gateway', undefined], + ['https://cdn.xx.com/.well-known/cpx-gateway', { host: '127.0.0.1', port: 7890 }] + ]) + }) +}) + +// §5a 签名发现文档 +describe('discoverGateway with a signer (§5.3)', () => { + interface Vector { + seedB64: string + pubKeyB64: string + payloadJson: string + signed: string + digestHex: string + } + const vectors: Vector[] = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') + ) + const V = vectors[0] + const PAYLOAD = JSON.parse(V.payloadJson) as { + seq: number + gateways: string[] + endpoints: IGatewayEndpoints + loginUrl: string + discoveryUrls: string[] + } + const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + function envelope(payload: Record): string { + const bytes = Buffer.from(JSON.stringify(payload), 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(V.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + return `${bytes.toString('base64')}.${sign(null, buildDiscoverySignInput(bytes), key).toString('base64')}` + } + // 顶层字段从同一 payload 生成,天然一致 + function doc( + payload = PAYLOAD as unknown as Record, + signed = envelope(payload) + ): unknown { + return { + spec: 'cpx-plugin/2', + gateway: (payload.gateways as string[])[0], + gateways: payload.gateways, + endpoints: payload.endpoints, + signed + } + } + const signer = (minSeq?: number, currentDigest?: string): DiscoverySigner => ({ + pubKeyB64: V.pubKeyB64, + minSeq, + currentDigest + }) + const TWO = ['https://panel.xx.com', 'https://cdn.xx.com'] + const discS = (s: DiscoverySigner | undefined, sources = SRC): Promise => + discoverGateway({ sources, signer: s }, CTX) + + it('keyed plugin, no signed field → that source fails (downgrade protection), next source is tried', async () => { + reply(OK) + reply(doc()) + const c = await discS(signer(), TWO) + expect(c.seq).toBe(PAYLOAD.seq) + expect(requestOnce).toHaveBeenCalledTimes(2) + }) + it('keyed plugin, valid signed and consistent top level → candidate comes from the payload', async () => { + reply(doc()) + const c = await discS(signer()) + expect(c).toEqual({ + gateways: PAYLOAD.gateways, + endpoints: PAYLOAD.endpoints, + seq: PAYLOAD.seq, + digest: V.digestHex, + loginUrl: PAYLOAD.loginUrl, + discoveryUrls: PAYLOAD.discoveryUrls + }) + }) + it('top-level gateway disagrees with the payload → source invalid, next source used', async () => { + const d = doc() as Record + reply({ ...d, gateway: 'https://other.example', gateways: ['https://other.example'] }) + reply(doc()) + const c = await discS(signer(), TWO) + expect(c.gateways).toEqual(PAYLOAD.gateways) + expect(requestOnce).toHaveBeenCalledTimes(2) + }) + it('R2-ISS-004: equivalent endpoint spellings agree after normalization', async () => { + const p = { + ...PAYLOAD, + endpoints: { ...PAYLOAD.endpoints, config: '/v1/../config' } + } as unknown as Record + const d = doc(p) as Record + // top-level spells it "/config", the signed payload "/v1/../config": same request path + reply({ ...d, endpoints: { ...PAYLOAD.endpoints, config: '/config' } }) + const c = await discS(signer()) + expect(c.endpoints.config).toBe('/config') + expect(c.seq).toBe(PAYLOAD.seq) + }) + it('unkeyed plugin ignores signed entirely', async () => { + reply(doc({ ...PAYLOAD, seq: 99 } as unknown as Record, 'garbage.garbage')) + const c = await discS(undefined) + expect(c.seq).toBeUndefined() + expect(c.gateways).toEqual(PAYLOAD.gateways) + }) + it('source 1 rolls seq back, source 2 is current → source 2 wins', async () => { + // stored seq 11 with a different digest: source 1 (seq 11) is an equivocation, source 2 (seq 12) is newer + reply(doc({ ...PAYLOAD, seq: 11 } as unknown as Record)) + reply(doc()) + const c = await discS(signer(11, 'other-digest'), TWO) + expect(c.seq).toBe(12) + expect(requestOnce).toHaveBeenCalledTimes(2) + }) + it('same seq with a different digest is rejected (equivocation), same digest aligns', async () => { + reply(doc()) + await expect(discS(signer(12, 'not-the-digest'))).rejects.toThrow(/equivocation/) + reply(doc()) + await expect(discS(signer(12, V.digestHex))).resolves.toMatchObject({ seq: 12 }) + }) + it('discoveryUrls missing → undefined (unchanged); [] → cleared', async () => { + const { discoveryUrls: _d, ...noUrls } = PAYLOAD as unknown as Record + reply(doc(noUrls)) + expect((await discS(signer())).discoveryUrls).toBeUndefined() + reply(doc({ ...PAYLOAD, discoveryUrls: [] } as unknown as Record)) + expect((await discS(signer())).discoveryUrls).toEqual([]) + }) + it('a bad signature makes the source invalid', async () => { + const d = doc() as { signed: string } + const [p, sig] = d.signed.split('.') + const bad = Buffer.from(sig, 'base64') + bad[0] ^= 1 + reply({ ...d, signed: `${p}.${bad.toString('base64')}` }) + await expect(discS(signer())).rejects.toThrow(/signature/) }) }) diff --git a/src/main/resolve/plugin/discovery.ts b/src/main/resolve/plugin/discovery.ts index 74f1f630..68b24328 100644 --- a/src/main/resolve/plugin/discovery.ts +++ b/src/main/resolve/plugin/discovery.ts @@ -1,14 +1,24 @@ -import type { LookupFunction } from 'net' -import { createGuardedLookup } from './net-guard' -import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url' -import { requestOnce } from './http-client' +import { + parseGatewayOrigin, + parseGatewayList, + isValidEndpointPath, + normalizeEndpointPath +} from './gateway-url' +import { checkSeq, parseSigned } from './discovery-sig' +import { warnLog } from './log' +import type { OperationContext, RoutedRequester } from './operation' const MAX_BYTES = 64 * 1024 -export interface DiscoverOpts { - timeout: number - lookup?: LookupFunction - proxy?: { host: string; port: number } +export interface DiscoverInput { + // 发现源顺序:[loginUrl 的 origin, …discoveryUrls];每个源请求 https:///.well-known/cpx-gateway + sources: string[] + // §5:插件带 providerPubKey 时必须提供;此时 well-known 必须含 signed,且顶层字段与 payload 一致 + signer?: DiscoverySigner +} + +export function originOf(url: string): string { + return new URL(url).origin } function fail(msg: string): never { @@ -25,31 +35,18 @@ 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 + return normalizeEndpointPath(v) } -export async function discoverGateway( - loginUrl: string, - opts: DiscoverOpts -): Promise { - 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 - } +interface WellKnownDocument { + wk: IGatewayWellKnown + signed: unknown +} + +function parseWellKnownDocument(body: string): WellKnownDocument { let raw: unknown try { - raw = JSON.parse(res.body) + raw = JSON.parse(body) } catch { fail('not valid JSON') } @@ -57,16 +54,102 @@ export async function discoverGateway( const obj = raw as Record if (obj.spec !== 'cpx-plugin/2') fail('spec must be "cpx-plugin/2"') const gateway = assertHttpsOrigin(obj.gateway, 'gateway') + // §2.2:gateways 可选(1..3、去重);存在时 gateway 必须等于归一化后的 gateways[0],否则整份文档无效。 + let gateways: string[] + if (obj.gateways === undefined) { + gateways = [gateway] + } else { + const list = parseGatewayList(obj.gateways) + if (!list) fail('gateways must be 1..3 public https origins') + if (list[0] !== gateway) fail('gateway must equal gateways[0]') + gateways = list + } if (typeof obj.endpoints !== 'object' || obj.endpoints === null) fail('endpoints required') const e = obj.endpoints as Record 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') - } + wk: { + spec: 'cpx-plugin/2', + gateways, + 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') + } + }, + signed: obj.signed } } + +export function parseWellKnown(body: string): IGatewayWellKnown { + return parseWellKnownDocument(body).wk +} + +function sameList(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]) +} + +function sameEndpoints(a: IGatewayEndpoints, b: IGatewayEndpoints): boolean { + return ( + a.enroll === b.enroll && + a.challenge === b.challenge && + a.config === b.config && + a.revoke === b.revoke + ) +} + +async function fetchWellKnown( + source: string, + requester: RoutedRequester, + signer: DiscoverySigner | undefined +): Promise { + const url = `${source}/.well-known/cpx-gateway` + const res = await requester.request(url, { method: 'GET', maxBytes: MAX_BYTES }) + 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 + } + const { wk, signed } = parseWellKnownDocument(res.body) + // 无密钥:走无签名路径,忽略 signed + if (!signer) return { gateways: wk.gateways, endpoints: wk.endpoints } + // 有密钥而文档无 signed:该源发现失败(降级攻击防护) + if (signed === undefined) fail('signed is required for a plugin with providerPubKey') + const { payload, digest } = parseSigned(signed, signer.pubKeyB64) + // 顶层字段与 payload 归一化比较,不一致 → 整个源无效,不应用任何字段 + if (!sameList(wk.gateways, payload.gateways) || !sameEndpoints(wk.endpoints, payload.endpoints)) { + fail('top-level gateway fields disagree with the signed payload') + } + const verdict = checkSeq(payload.seq, digest, signer) + if (verdict === 'rollback' || verdict === 'equivocation') { + void warnLog(`discovery source ${source} rejected: ${verdict} (seq ${payload.seq})`) + fail(`signed payload rejected: ${verdict}`) + } + const candidate: IDiscoveryCandidate = { + gateways: payload.gateways, + endpoints: payload.endpoints, + seq: payload.seq, + digest + } + if (payload.loginUrl !== undefined) candidate.loginUrl = payload.loginUrl + if (payload.discoveryUrls !== undefined) candidate.discoveryUrls = payload.discoveryUrls + return candidate +} + +// §3:逐个发现源尝试。发现阶段任何失败(网络、非 2xx、JSON / 字段无效、guard 拒绝)都试下一个源—— +// 备用源可能只是静态 CDN 文件,404 是正常的“此处不提供”。全部失败 → 抛最后一个错误。 +// 每个源是独立 origin,路由粘性自动分作用域;整体受 op 预算约束。 +export async function discoverGateway( + input: DiscoverInput, + ctx: OperationContext +): Promise { + let lastError: unknown + for (const source of input.sources) { + try { + return await fetchWellKnown(source, ctx.requester, input.signer) + } catch (e) { + lastError = e + } + } + throw lastError ?? new Error('Invalid gateway discovery: no discovery sources') +} diff --git a/src/main/resolve/plugin/encoding.ts b/src/main/resolve/plugin/encoding.ts new file mode 100644 index 00000000..a2dd43c3 --- /dev/null +++ b/src/main/resolve/plugin/encoding.ts @@ -0,0 +1,19 @@ +// 二进制字段的编码约定(对接指南 §12):标准 base64 带 padding,且必须规范——解码后重新编码与输入完全一致。 +import { createHash } from 'crypto' + +const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/ + +// 规范 base64:字符集合法、解码后再编码与输入完全一致(拒绝缺 padding、多余字符、非零尾比特) +export function isCanonicalB64(s: unknown): s is string { + if (typeof s !== 'string' || !B64_RE.test(s)) return false + return Buffer.from(s, 'base64').toString('base64') === s +} + +// 规范 base64 且解码后恰为 n 字节 +export function isB64Bytes(s: unknown, n: number): s is string { + return isCanonicalB64(s) && Buffer.from(s, 'base64').length === n +} + +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} diff --git a/src/main/resolve/plugin/errors.ts b/src/main/resolve/plugin/errors.ts new file mode 100644 index 00000000..6be0438e --- /dev/null +++ b/src/main/resolve/plugin/errors.ts @@ -0,0 +1,87 @@ +// 插件网络层共享的错误分类:稳定的错误 code、发送阶段(phase)、网关错误类型。 +// http-client 产生带 code/phase 的底层错误;gateway 把它们映射为 GatewayError; +// route 用同一套 code 判断某个失败是否值得换一条路由(§1.3)。 + +export const CPX_TIMEOUT = 'CPX_TIMEOUT' +export const CPX_REDIRECT_REFUSED = 'CPX_REDIRECT_REFUSED' +export const CPX_RESPONSE_TOO_LARGE = 'CPX_RESPONSE_TOO_LARGE' +// guarded lookup 或 §1.4 预检拒绝了私网地址:终态,不再尝试任何路由。 +export const CPX_GUARD_REFUSED = 'CPX_GUARD_REFUSED' +// 经代理的 https 隧道建立失败(代理对 CONNECT 返回非 2xx):目标根本没有收到请求,按连接失败处理—— +// 可回退到直连(§1.3),网关层视为不可达(§2.4);不能当成目标的 HTTP 响应而固定路由。 +export const CPX_PROXY_CONNECT_FAILED = 'CPX_PROXY_CONNECT_FAILED' + +// pre-send:socket 尚未完成 connect / secureConnect,请求肯定没有到达服务器; +// possibly-sent:之后的一切错误,服务器可能已经收到并处理了请求。 +export type ErrorPhase = 'pre-send' | 'possibly-sent' + +export interface CodedError extends Error { + code?: string + phase?: ErrorPhase + // 已收到 HTTP 响应头之后才失败(body 阶段出错 / 拒绝重定向 / 响应过大):服务器已到达, + // 路由不再回退(§1.3),网关按“有 status 的 transient”停止切换(§2.4)。 + status?: number +} + +export function codeOf(e: unknown): string { + if (typeof e !== 'object' || e === null) return '' + const code = (e as CodedError).code + return typeof code === 'string' ? code : '' +} + +export function statusOf(e: unknown): number | undefined { + if (typeof e !== 'object' || e === null) return undefined + const status = (e as CodedError).status + return typeof status === 'number' ? status : undefined +} + +export function phaseOf(e: unknown): ErrorPhase | undefined { + if (typeof e !== 'object' || e === null) return undefined + const phase = (e as CodedError).phase + return phase === 'pre-send' || phase === 'possibly-sent' ? phase : undefined +} + +export function codedError(message: string, code: string, phase?: ErrorPhase): CodedError { + const err = new Error(message) as CodedError + err.code = code + if (phase) err.phase = phase + return err +} + +// DNS 解析失败 / 连接拒绝 / TLS 失败 → 网关“不可达/已退役”信号(spec §5),交由编排层重新发现。 +export const UNREACHABLE_CODES = new Set([ + CPX_PROXY_CONNECT_FAILED, + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EHOSTDOWN', + 'ENETDOWN', + 'EPIPE', + 'EPROTO' +]) + +export function isUnreachableCode(code: string): boolean { + 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) +} + +export type GatewayErrorKind = 'revoked' | 'retired' | 'unreachable' | 'transient' | 'blocked' + +export class GatewayError extends Error { + kind: GatewayErrorKind + status?: number + phase?: ErrorPhase + // §4.2:机场在错误 JSON 里主动写的 message,经白名单提取与清洗;卡片原样显示 + providerMessage?: string + constructor(kind: GatewayErrorKind, message: string, status?: number, phase?: ErrorPhase) { + super(message) + this.name = 'GatewayError' + this.kind = kind + this.status = status + if (phase) this.phase = phase + } +} diff --git a/src/main/resolve/plugin/gateway-url.test.ts b/src/main/resolve/plugin/gateway-url.test.ts index 85d73ebe..f0ec21f2 100644 --- a/src/main/resolve/plugin/gateway-url.test.ts +++ b/src/main/resolve/plugin/gateway-url.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url' +import { + parseGatewayOrigin, + parseGatewayList, + isValidEndpointPath, + normalizeEndpointPath +} from './gateway-url' describe('parseGatewayOrigin', () => { it('accepts a plain https origin and returns it normalized', () => { @@ -32,6 +37,21 @@ describe('parseGatewayOrigin', () => { }) }) +describe('normalizeEndpointPath (R2-ISS-004/031)', () => { + it('resolves . and .. to the path the request will use, idempotently', () => { + expect(normalizeEndpointPath('/v1/../config')).toBe('/config') + expect(normalizeEndpointPath('/./revoke')).toBe('/revoke') + expect(normalizeEndpointPath('/config')).toBe('/config') + }) + it('keeps a path that collapses to "//x" on the same origin and stays idempotent', () => { + const once = normalizeEndpointPath('/a/..//enroll') + expect(once).toBe('/.//enroll') + expect(normalizeEndpointPath(once)).toBe(once) + expect(isValidEndpointPath(once)).toBe(true) + expect(new URL(once, 'https://gw.example').host).toBe('gw.example') + }) +}) + describe('isValidEndpointPath', () => { it('accepts a relative path starting with /', () => { expect(isValidEndpointPath('/config')).toBe(true) @@ -52,3 +72,19 @@ describe('isValidEndpointPath', () => { expect(isValidEndpointPath('/foo\\bar')).toBe(false) }) }) + +describe('parseGatewayList', () => { + it('normalizes and deduplicates 1..3 origins', () => { + expect(parseGatewayList(['https://a.com/', 'https://b.com', 'https://a.com'])).toEqual([ + 'https://a.com', + 'https://b.com' + ]) + }) + it('rejects empty, oversized, non-array, and lists with any invalid origin', () => { + expect(parseGatewayList([])).toBeNull() + expect(parseGatewayList(['https://a', 'https://b', 'https://c', 'https://d'])).toBeNull() + expect(parseGatewayList('https://a.com')).toBeNull() + expect(parseGatewayList(['https://a.com', 'http://b.com'])).toBeNull() + expect(parseGatewayList(['https://a.com', 'https://localhost'])).toBeNull() + }) +}) diff --git a/src/main/resolve/plugin/gateway-url.ts b/src/main/resolve/plugin/gateway-url.ts index 53b3481f..3693f36c 100644 --- a/src/main/resolve/plugin/gateway-url.ts +++ b/src/main/resolve/plugin/gateway-url.ts @@ -20,8 +20,32 @@ export function parseGatewayOrigin(v: unknown): string | null { return u.origin } +export const MAX_GATEWAYS = 3 + +// 网关列表(§2.2):1..3 个,逐个通过 parseGatewayOrigin,按归一化后的 origin 去重。任一项非法 → null。 +export function parseGatewayList(v: unknown): string[] | null { + if (!Array.isArray(v) || v.length < 1 || v.length > MAX_GATEWAYS) return null + const out: string[] = [] + for (const item of v) { + const origin = parseGatewayOrigin(item) + if (!origin) return null + if (!out.includes(origin)) out.push(origin) + } + return out +} + // 端点必须是以 '/' 开头的相对 path:不得为协议相对(//host)、不得含 scheme/host/query/fragment, // 也不得含反斜杠 —— WHATWG URL 在 http(s) 下把 '\' 当作 '/',故 '/\evil/x' 会逃逸到另一个 host。 +// 端点路径归一化(§2.4 去重键、§5.2 顶层 / payload 比较):先经 isValidEndpointPath,再按 WHATWG 解析出 +// 实际请求会使用的 pathname("." / ".." 被解析,百分号编码取解析器结果)。gateway.ts 的 urlOf 用同一解析规则, +// 因此归一化后的路径就是真正发出的路径;签名与 digest 仍按原始 payload 字节计算,不受影响。 +export function normalizeEndpointPath(v: string): string { + const path = new URL(v, 'https://cpx.invalid').pathname + // "/a/..//x" 解析后的 pathname 是 "//x":作为相对引用再次解析会变成协议相对地址(另一个 host)。 + // 前置 "/." 保持它仍是原网关下的路径 "//x"(与基线实际请求一致),且再次归一化结果不变。 + return path.startsWith('//') ? '/.' + path : path +} + export function isValidEndpointPath(v: unknown): v is string { if (typeof v !== 'string' || !v.startsWith('/')) return false if ( diff --git a/src/main/resolve/plugin/gateway.netguard.test.ts b/src/main/resolve/plugin/gateway.netguard.test.ts index 478e8d3e..3dacfb7f 100644 --- a/src/main/resolve/plugin/gateway.netguard.test.ts +++ b/src/main/resolve/plugin/gateway.netguard.test.ts @@ -1,5 +1,21 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../../config/plugin', () => ({ getPluginItem: vi.fn() })) +vi.mock('./vault', () => ({ readVault: vi.fn() })) + import { challenge } from './gateway' +import { runOperation } from './operation' +import { singleRouteProvider } from './route' + +const ITEM = { + id: 'p', + name: 'X', + loginUrl: 'https://panel.xx.com/oauth/authorize', + spec: 'cpx-plugin/2', + status: 'active', + created: 0, + updated: 0 +} as IPluginItem // No http-client mock here: the real hardened client + guarded lookup must refuse a private gateway. describe('gateway network hardening (real client)', () => { @@ -8,8 +24,16 @@ describe('gateway network hardening (real client)', () => { gateway: 'https://localhost', endpoints: { enroll: '/e', challenge: '/c', config: '/cfg', revoke: '/r' } } - await expect(challenge(target, 'DID', { timeout: 2000 })).rejects.toMatchObject({ - kind: 'transient' - }) + const r = await runOperation( + { + item: ITEM, + app: { subscriptionTimeout: 2000 }, + retryPolicy: 'safe', + routeProvider: singleRouteProvider('direct') + }, + (ctx) => challenge(target, 'DID', ctx.requester) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatchObject({ kind: 'blocked' }) }) }) diff --git a/src/main/resolve/plugin/gateway.test.ts b/src/main/resolve/plugin/gateway.test.ts index 2e0e45e0..3820d552 100644 --- a/src/main/resolve/plugin/gateway.test.ts +++ b/src/main/resolve/plugin/gateway.test.ts @@ -10,7 +10,8 @@ const TARGET = { gateway: 'https://gw.front.com', endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' } } -const NET = { timeout: 5000 } +// RoutedRequester 适配:把 op 层的路由执行器直接接到被 mock 的 requestOnce 上 +const NET = { request: (url: string, opts: unknown) => requestOnce(url, opts) } const CLASH = 'proxies:\n - {name: a, type: ss, server: 1.1.1.1, port: 8388, cipher: aes-128-gcm, password: x}\n' @@ -78,12 +79,13 @@ describe('gateway.fetchConfig', () => { const nonceBuf = Buffer.alloc(32, 9) jsonReply({ nonceId: 'N1', nonce: nonceBuf.toString('base64'), exp: 60 }) rawReply(CLASH) - const yaml = await fetchConfig( + const { yaml, discovery } = await fetchConfig( TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET ) expect(yaml).toBe(CLASH) + expect(discovery).toBeUndefined() const body = lastBody() expect(body).toMatchObject({ deviceId: dev.deviceId, nonceId: 'N1' }) const input = buildSignInput(OP_CONFIG, dev.deviceId, 'N1', nonceBuf, body.ts) @@ -147,6 +149,31 @@ describe('gateway.fetchConfig', () => { fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET) ).rejects.toMatchObject({ kind: 'unreachable' }) }) + it('maps a guard refusal (CPX_GUARD_REFUSED) to blocked', async () => { + const dev = generateDevice() + requestOnce.mockRejectedValueOnce( + Object.assign(new Error('Refusing to connect to non-public address: 10.0.0.1'), { + code: 'CPX_GUARD_REFUSED', + phase: 'pre-send' + }) + ) + await expect( + fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET) + ).rejects.toMatchObject({ kind: 'blocked', phase: 'pre-send' }) + }) + it('maps CPX_TIMEOUT to transient without a status', async () => { + const dev = generateDevice() + requestOnce.mockRejectedValueOnce( + Object.assign(new Error('Request timed out'), { code: 'CPX_TIMEOUT', phase: 'pre-send' }) + ) + const err = (await fetchConfig( + TARGET, + { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, + NET + ).catch((e) => e)) as GatewayError + expect(err.kind).toBe('transient') + expect(err.status).toBeUndefined() + }) it('maps a timeout/generic error (no network code) to transient', async () => { const dev = generateDevice() requestOnce.mockRejectedValueOnce(new Error('Request timed out')) @@ -195,3 +222,136 @@ describe('gateway urlOf host-escape defense', () => { expect(requestOnce).not.toHaveBeenCalled() }) }) + +// §4 机场消息 +describe('gateway provider message (§4.2)', () => { + const dev = generateDevice() + const cred = (): { deviceId: string; privKeyB64: string } => ({ + deviceId: dev.deviceId, + privKeyB64: dev.privKeyB64 + }) + it('extracts message from a revoked response', async () => { + jsonReply({ error: 'revoked', message: '订阅已于 2026-09-01 到期,续费后请重新登录。' }, 403) + await expect(fetchConfig(TARGET, cred(), NET)).rejects.toMatchObject({ + kind: 'revoked', + providerMessage: '订阅已于 2026-09-01 到期,续费后请重新登录。' + }) + }) + it('keeps newlines but strips other control characters', async () => { + jsonReply({ error: 'gateway_retired', message: 'a\u0001b\nc' }, 410) + await expect(fetchConfig(TARGET, cred(), NET)).rejects.toMatchObject({ + kind: 'retired', + providerMessage: 'ab\nc' + }) + }) + it('truncates to 200 code points', async () => { + jsonReply({ error: 'x', message: '字'.repeat(201) }, 503) + const err = (await fetchConfig(TARGET, cred(), NET).catch((e) => e)) as GatewayError + expect(err.kind).toBe('transient') + expect(Array.from(err.providerMessage ?? '')).toHaveLength(200) + }) + it('ignores a non-string or empty message', async () => { + jsonReply({ error: 'revoked', message: { text: 'no' } }, 403) + const a = (await fetchConfig(TARGET, cred(), NET).catch((e) => e)) as GatewayError + expect(a.providerMessage).toBeUndefined() + jsonReply({ error: 'revoked', message: ' ' }, 403) + const b = (await fetchConfig(TARGET, cred(), NET).catch((e) => e)) as GatewayError + expect(b.providerMessage).toBeUndefined() + }) +}) + +// §5 X-CPX-Discovery 头 +describe('gateway fetchConfig discovery header (§5.2)', () => { + const dev = generateDevice() + const cred = (): { deviceId: string; privKeyB64: string } => ({ + deviceId: dev.deviceId, + privKeyB64: dev.privKeyB64 + }) + it('returns a single string header as discovery', async () => { + jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 }) + requestOnce.mockResolvedValueOnce({ + status: 200, + headers: { 'x-cpx-discovery': 'AAAA.BBBB' }, + body: CLASH + }) + const r = await fetchConfig(TARGET, cred(), NET) + expect(r).toEqual({ yaml: CLASH, discovery: 'AAAA.BBBB' }) + }) + it('ignores an array-valued header', async () => { + jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 }) + requestOnce.mockResolvedValueOnce({ + status: 200, + headers: { 'x-cpx-discovery': ['a.b', 'c.d'] }, + body: CLASH + }) + const r = await fetchConfig(TARGET, cred(), NET) + expect(r.discovery).toBeUndefined() + expect(r.yaml).toBe(CLASH) + }) +}) + +describe('R2-ISS-009: transport errors after the response headers', () => { + it('maps a status-bearing transport error to transient(status); 410 to retired', async () => { + requestOnce.mockRejectedValueOnce( + Object.assign(new Error('aborted'), { + code: 'ECONNRESET', + phase: 'possibly-sent', + status: 503 + }) + ) + await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ + kind: 'transient', + status: 503, + phase: 'possibly-sent' + }) + requestOnce.mockRejectedValueOnce( + Object.assign(new Error('aborted'), { + code: 'ECONNRESET', + phase: 'possibly-sent', + status: 410 + }) + ) + await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ + kind: 'retired', + status: 410 + }) + }) +}) + +describe('R2-ISS-039/040: proxy tunnel failure mapping and config structure', () => { + it('maps CPX_PROXY_CONNECT_FAILED to unreachable so gateway recovery treats it as a path failure', async () => { + requestOnce.mockRejectedValueOnce( + Object.assign(new Error('tunnel'), { code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' }) + ) + await expect(challenge(TARGET, 'DID', NET)).rejects.toMatchObject({ + kind: 'unreachable', + phase: 'pre-send' + }) + }) + it('rejects a 200 body whose proxies / proxy-providers have the wrong shape', async () => { + const dev = generateDevice() + for (const body of [ + 'proxies: definitely-not-a-list\n', + 'proxy-providers: true\n', + 'proxies:\n a: 1\n', + // R2-ISS-040b: a valid field must not mask a present-but-wrong-typed one + 'proxies: definitely-not-a-list\nproxy-providers: {}\n', + 'proxies: []\nproxy-providers: true\n', + // R2-ISS-046: structurally unloadable elements are rejected + 'proxies:\n - null\n', + 'proxies:\n - just-a-string\n', + 'proxy-providers:\n p: true\n' + ]) { + jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 }) + rawReply(body) + await expect( + fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET) + ).rejects.toMatchObject({ kind: 'transient' }) + } + jsonReply({ nonceId: 'N1', nonce: Buffer.alloc(32, 1).toString('base64'), exp: 60 }) + rawReply('proxy-providers:\n p:\n type: http\n url: https://x.example/sub\n') + await expect( + fetchConfig(TARGET, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, NET) + ).resolves.toMatchObject({ yaml: expect.stringContaining('proxy-providers') }) + }) +}) diff --git a/src/main/resolve/plugin/gateway.ts b/src/main/resolve/plugin/gateway.ts index 5551ea62..66e7bede 100644 --- a/src/main/resolve/plugin/gateway.ts +++ b/src/main/resolve/plugin/gateway.ts @@ -1,39 +1,34 @@ -import type { LookupFunction } from 'net' +import type { IncomingHttpHeaders } from 'http' import { parse } from '../../utils/yaml' -import { createGuardedLookup } from './net-guard' -import { requestOnce } from './http-client' import { buildSignInput, signRequest, OP_CONFIG, OP_REVOKE } from './device' +import { + CPX_GUARD_REFUSED, + GatewayError, + codeOf, + isUnreachableCode, + phaseOf, + statusOf, + type GatewayErrorKind +} from './errors' +import type { RoutedRequester } from './operation' +import { MAX_PROVIDER_MESSAGE, sanitizeProviderText } from './text' +import { isB64Bytes } from './encoding' +import { warnLog } from './log' + +export { GatewayError, type GatewayErrorKind } from './errors' 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 | undefined text: string + headers: IncomingHttpHeaders } function urlOf(t: GatewayTarget, ep: keyof IGatewayEndpoints): string { @@ -45,47 +40,34 @@ function urlOf(t: GatewayTarget, ep: keyof IGatewayEndpoints): string { 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 { - let res: { status: number; body: string } +// 错误映射(§1.6):CPX_GUARD_REFUSED → blocked(终态);已收到响应头后才失败 → 有 status 的 transient +// (410 → retired),服务器已到达,不换网关(§2.4);UNREACHABLE_CODES / TLS → unreachable(缓存网关 +// “不可达/已退役”信号,交由编排层重新发现,spec §5);CPX_TIMEOUT 与其余 → transient(无 status)。 +async function postJson( + url: string, + body: unknown, + requester: RoutedRequester +): Promise { + let res: { status: number; body: string; headers: IncomingHttpHeaders } try { - res = await requestOnce(url, { + res = await requester.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - timeout: net.timeout, - maxBytes: MAX_BYTES, - lookup: lookupFor(net), - proxy: net.proxy + maxBytes: MAX_BYTES }) } catch (e) { + if (e instanceof GatewayError) throw e const err = e as NodeJS.ErrnoException - throw new GatewayError(isUnreachable(err) ? 'unreachable' : 'transient', err.message) + const code = codeOf(err) + const status = statusOf(err) + let kind: GatewayErrorKind + if (code === CPX_GUARD_REFUSED) kind = 'blocked' + else if (status === 410) kind = 'retired' + else if (status !== undefined) kind = 'transient' + else if (isUnreachableCode(code)) kind = 'unreachable' + else kind = 'transient' + throw new GatewayError(kind, err.message, status, phaseOf(err)) } let json: Record | undefined try { @@ -97,18 +79,28 @@ async function postJson(url: string, body: unknown, net: GatewayNet): Promise= 300) { - return new GatewayError('transient', `gateway status ${r.status}`, r.status) + return withProviderMessage( + new GatewayError('transient', `gateway status ${r.status}`, r.status), + r + ) } return null } @@ -118,13 +110,6 @@ 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 { @@ -134,7 +119,23 @@ function isClashConfig(yamlText: string): boolean { } if (typeof parsed !== 'object' || parsed === null) return false const obj = parsed as Record - return Boolean(obj['proxies'] || obj['proxy-providers']) + // 只做基本结构校验:proxies 必须是数组、proxy-providers 必须是映射。真值检查会让 + // `proxies: some-string` / `proxy-providers: true` 这类无法加载的内容覆盖仍可用的旧订阅。 + // 先拒绝任何"给出了但类型错误"的字段,再要求至少存在一个合法字段——否则一个合法字段会放行另一个错误字段。 + const proxies = obj['proxies'] + const providers = obj['proxy-providers'] + const isPlainObject = (v: unknown): boolean => + typeof v === 'object' && v !== null && !Array.isArray(v) + // proxies 必须是对象数组,proxy-providers 必须是对象到对象的映射:拒绝 [null] / ["x"] / {p: true} + // 这类结构上无法加载的内容,避免覆盖仍可用的旧订阅。(完整语义校验见 backlog:接入核心 checkProfileConfig。) + if (proxies !== undefined) { + if (!Array.isArray(proxies) || !proxies.every(isPlainObject)) return false + } + if (providers !== undefined) { + if (!isPlainObject(providers)) return false + if (!Object.values(providers as Record).every(isPlainObject)) return false + } + return proxies !== undefined || providers !== undefined } export interface EnrollBody { @@ -146,8 +147,12 @@ export interface EnrollBody { deviceId: string } -export async function enroll(t: GatewayTarget, body: EnrollBody, net: GatewayNet): Promise { - const r = await postJson(urlOf(t, 'enroll'), body, net) +export async function enroll( + t: GatewayTarget, + body: EnrollBody, + requester: RoutedRequester +): Promise { + const r = await postJson(urlOf(t, 'enroll'), body, requester) const err = classify(r) if (err) throw err } @@ -155,9 +160,9 @@ export async function enroll(t: GatewayTarget, body: EnrollBody, net: GatewayNet export async function challenge( t: GatewayTarget, deviceId: string, - net: GatewayNet + requester: RoutedRequester ): Promise<{ nonceId: string; nonce: string; exp: number }> { - const r = await postJson(urlOf(t, 'challenge'), { deviceId }, net) + const r = await postJson(urlOf(t, 'challenge'), { deviceId }, requester) const err = classify(r) if (err) throw err const j = r.json @@ -185,9 +190,9 @@ async function signedPost( ep: 'config' | 'revoke', op: number, dev: DeviceCred, - net: GatewayNet + requester: RoutedRequester ): Promise { - const ch = await challenge(t, dev.deviceId, net) + const ch = await challenge(t, dev.deviceId, requester) const nonceBuf = Buffer.from(ch.nonce, 'base64') const ts = Date.now() const input = buildSignInput(op, dev.deviceId, ch.nonceId, nonceBuf, ts) @@ -195,26 +200,46 @@ async function signedPost( return postJson( urlOf(t, ep), { deviceId: dev.deviceId, nonceId: ch.nonceId, nonce: ch.nonce, ts, sig }, - net + requester ) } +export interface ConfigResult { + yaml: string + // §5:/config 成功响应可选的 X-CPX-Discovery 头("."),只取单一字符串头 + discovery?: string +} + +const DISCOVERY_HEADER = 'x-cpx-discovery' + +function singleHeader(headers: IncomingHttpHeaders, name: string): string | undefined { + const v = headers[name] + if (typeof v === 'string') return v + if (Array.isArray(v)) void warnLog(`${name}: multiple header values ignored`) + return undefined +} + export async function fetchConfig( t: GatewayTarget, dev: DeviceCred, - net: GatewayNet -): Promise { - const r = await signedPost(t, 'config', OP_CONFIG, dev, net) + requester: RoutedRequester +): Promise { + const r = await signedPost(t, 'config', OP_CONFIG, dev, requester) 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 + const discovery = singleHeader(r.headers, DISCOVERY_HEADER) + return discovery === undefined ? { yaml: r.text } : { yaml: r.text, discovery } } -export async function revoke(t: GatewayTarget, dev: DeviceCred, net: GatewayNet): Promise { - const r = await signedPost(t, 'revoke', OP_REVOKE, dev, net) +export async function revoke( + t: GatewayTarget, + dev: DeviceCred, + requester: RoutedRequester +): Promise { + const r = await signedPost(t, 'revoke', OP_REVOKE, dev, requester) const err = classify(r) if (err) throw err } diff --git a/src/main/resolve/plugin/http-client.test.ts b/src/main/resolve/plugin/http-client.test.ts index 7174bc19..6fa30353 100644 --- a/src/main/resolve/plugin/http-client.test.ts +++ b/src/main/resolve/plugin/http-client.test.ts @@ -1,10 +1,58 @@ import http from 'http' +import net from 'net' +import tls from 'tls' +import { execFileSync } from 'child_process' +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { describe, it, expect, afterEach } from 'vitest' -import { requestOnce } from './http-client' +import { requestOnce, buildProxyUrl } from './http-client' let server: http.Server | undefined afterEach(() => server?.close()) +// A throwaway self-signed cert for the TLS-over-tunnel tests (skipped when openssl is unavailable) +function selfSignedCert(): { key: string; cert: string } | undefined { + const dir = mkdtempSync(join(tmpdir(), 'cpx-tls-')) + try { + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:prime256v1', + '-nodes', + '-keyout', + join(dir, 'key.pem'), + '-out', + join(dir, 'cert.pem'), + '-days', + '1', + '-subj', + '/CN=target.invalid' + ], + { stdio: 'ignore' } + ) + return { + key: readFileSync(join(dir, 'key.pem'), 'utf8'), + cert: readFileSync(join(dir, 'cert.pem'), 'utf8') + } + } catch { + return undefined + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} +const TLS_FIXTURE = selfSignedCert() + +async function waitFor(cond: () => boolean, ms = 1000): Promise { + const until = Date.now() + ms + while (!cond() && Date.now() < until) await new Promise((r) => setTimeout(r, 10)) +} + function start(handler: http.RequestListener): Promise { return new Promise((resolve) => { server = http.createServer(handler) @@ -53,7 +101,11 @@ describe('requestOnce', () => { }) await expect( requestOnce(url + '/r', { method: 'GET', timeout: 5000, maxBytes: 1024 }) - ).rejects.toThrow(/redirect/i) + ).rejects.toMatchObject({ + message: expect.stringMatching(/redirect/i), + code: 'CPX_REDIRECT_REFUSED', + phase: 'possibly-sent' + }) }) it('rejects oversized responses', async () => { @@ -63,7 +115,11 @@ describe('requestOnce', () => { }) await expect( requestOnce(url + '/big', { method: 'GET', timeout: 5000, maxBytes: 1000 }) - ).rejects.toThrow(/too large/i) + ).rejects.toMatchObject({ + message: expect.stringMatching(/too large/i), + code: 'CPX_RESPONSE_TOO_LARGE', + phase: 'possibly-sent' + }) }) it('rejects forbidden headers', async () => { @@ -108,7 +164,23 @@ describe('requestOnce', () => { }) await expect( requestOnce(url + '/slow', { method: 'GET', timeout: 50, maxBytes: 1024 }) - ).rejects.toThrow(/timed out/i) + ).rejects.toMatchObject({ message: expect.stringMatching(/timed out/i), code: 'CPX_TIMEOUT' }) + }) + + it('aborts on the op signal and maps the abort to CPX_TIMEOUT', async () => { + const url = await start((_req, res) => { + setTimeout(() => res.end('late'), 500) + }) + const ac = new AbortController() + setTimeout(() => ac.abort(new Error('budget exhausted')), 20) + await expect( + requestOnce(url + '/hang', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + signal: ac.signal + }) + ).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) }) it('routes the request through the configured proxy when proxy is set', async () => { @@ -134,4 +206,406 @@ describe('requestOnce', () => { proxy.close() } }) + + it('marks a connection refusal as pre-send with its errno code', async () => { + const probe = http.createServer() + await new Promise((r) => probe.listen(0, '127.0.0.1', () => r())) + const port = (probe.address() as { port: number }).port + await new Promise((r) => probe.close(() => r())) + await expect( + requestOnce(`http://127.0.0.1:${port}/x`, { method: 'GET', timeout: 5000, maxBytes: 1024 }) + ).rejects.toMatchObject({ code: 'ECONNREFUSED', phase: 'pre-send' }) + }) + + it('marks a reset after the request was sent as possibly-sent', async () => { + const url = await start((req) => { + req.socket.destroy() + }) + await expect( + requestOnce(url + '/reset', { method: 'GET', timeout: 5000, maxBytes: 1024 }) + ).rejects.toMatchObject({ code: 'ECONNRESET', phase: 'possibly-sent' }) + }) + + it('aborts a lookup that never returns via the signal and maps it to CPX_TIMEOUT', async () => { + const ac = new AbortController() + setTimeout(() => ac.abort(new Error('budget exhausted')), 30) + await expect( + requestOnce('http://never.example/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + signal: ac.signal, + lookup: () => { + /* never calls back */ + } + }) + ).rejects.toMatchObject({ code: 'CPX_TIMEOUT', phase: 'pre-send' }) + }) + + it('R2-ISS-007: a proxy that accepts the connection but never answers CONNECT is cut off by the signal', async () => { + const sockets: net.Socket[] = [] + let closed = 0 + const proxy = net.createServer((socket) => { + sockets.push(socket) // accept, never answer the CONNECT + socket.resume() // a paused server socket never reads, so it would never observe the client's FIN + socket.on('close', () => closed++) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + const ac = new AbortController() + setTimeout(() => ac.abort(new Error('budget exhausted')), 50) + const started = Date.now() + try { + await expect( + requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + signal: ac.signal, + proxy: { host: '127.0.0.1', port } + }) + ).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) + expect(Date.now() - started).toBeLessThan(2000) + // BL-004 (ISS-007 residual): the hung CONNECT connection is torn down by the abort, not left to the proxy + expect(sockets).toHaveLength(1) + await waitFor(() => closed === sockets.length) + expect(closed).toBe(1) + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-054: an invalid proxy port (mixed-port disabled → 0) is refused before any connection', async () => { + for (const port of [0, -1, 70000, 1.5, NaN]) { + const started = Date.now() + await expect( + requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port, auth: { user: 'u', pass: 'p' } } + }) + ).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' }) + expect(Date.now() - started).toBeLessThan(500) + } + }) + + it('BL-004: the CONNECT request carries the target authority and the proxy credentials', async () => { + const sockets: net.Socket[] = [] + let head = '' + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', (d) => { + head = d.toString('latin1') + socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n') + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + try { + await expect( + requestOnce('https://target.invalid:8443/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port, auth: { user: 'us@er', pass: 'p:a/ss' } } + }) + ).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' }) + expect(head.startsWith('CONNECT target.invalid:8443 HTTP/1.1\r\n')).toBe(true) + expect(head).toContain('\r\nHost: target.invalid:8443\r\n') + expect(head).toContain( + `\r\nProxy-Authorization: Basic ${Buffer.from('us@er:p:a/ss').toString('base64')}\r\n` + ) + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it.skipIf(!TLS_FIXTURE)( + 'R2-ISS-059: a request over the tunnel carries the right Host (no :80) and completes end to end', + async () => { + const sockets: net.Socket[] = [] + const hosts: string[] = [] + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + // from here on the proxy plays the target: a TLS server on the same connection + const secure = new tls.TLSSocket(socket, { isServer: true, ...TLS_FIXTURE }) + secure.on('error', () => undefined) + secure.once('data', (d) => { + const m = /\r\nHost: ([^\r]+)\r\n/i.exec(d.toString('latin1')) + hosts.push(m?.[1] ?? '') + secure.end('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') + }) + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + const saved = process.env.NODE_TLS_REJECT_UNAUTHORIZED + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' // self-signed target; verification is not under test here + try { + const res = await requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }) + expect(res.status).toBe(200) + expect(res.body).toBe('ok') + const res2 = await requestOnce('https://target.invalid:8443/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }) + expect(res2.body).toBe('ok') + expect(hosts).toEqual(['target.invalid', 'target.invalid:8443']) + } finally { + if (saved === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + else process.env.NODE_TLS_REJECT_UNAUTHORIZED = saved + proxy.close() + sockets.forEach((s) => s.destroy()) + } + } + ) + + it('BL-004: after CONNECT 200 the client starts TLS on the tunnel with the target name as SNI', async () => { + const sockets: net.Socket[] = [] + let hello: Buffer | undefined + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + socket.once('data', (d) => { + hello = d + socket.destroy() + }) + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + try { + const err = await requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }).then( + () => undefined, + (e: unknown) => e as { phase?: string } + ) + expect(err?.phase).toBe('pre-send') + // TLS handshake record (0x16) carrying a ClientHello whose SNI is the target hostname + expect(hello?.[0]).toBe(0x16) + expect(hello?.includes('target.invalid')).toBe(true) + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-022: a slow-drip response is cut off by the wall-clock timeout, not the idle timeout', async () => { + const sockets: net.Socket[] = [] + const drip = net.createServer((socket) => { + sockets.push(socket) + socket.write('HTTP/1.1 200 OK\r\n') + const t = setInterval(() => socket.write('X-Drip: 1\r\n'), 40) // never finishes the headers + socket.on('close', () => clearInterval(t)) + }) + await new Promise((r) => drip.listen(0, '127.0.0.1', () => r())) + const port = (drip.address() as { port: number }).port + const started = Date.now() + try { + await expect( + requestOnce(`http://127.0.0.1:${port}/x`, { method: 'GET', timeout: 300, maxBytes: 1024 }) + ).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) + expect(Date.now() - started).toBeLessThan(1500) + } finally { + drip.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-039: a proxy that rejects CONNECT is a pre-send connection failure, not a target response', async () => { + const sockets: net.Socket[] = [] + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', () => { + socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n') + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + try { + const err = await requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }).then( + () => undefined, + (e: unknown) => e as { code?: string; phase?: string; status?: number } + ) + expect(err).toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' }) + expect(err?.status).toBeUndefined() + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-043: a proxy that closes before answering CONNECT is a pre-send tunnel failure', async () => { + const sockets: net.Socket[] = [] + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', () => socket.end()) // FIN without any CONNECT response + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + try { + await expect( + requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }) + ).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' }) + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-042: a TLS handshake failure over a proxy tunnel is pre-send (phase not flipped before secureConnect)', async () => { + const sockets: net.Socket[] = [] + const proxy = net.createServer((socket) => { + sockets.push(socket) + socket.once('data', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + // answer the ClientHello with non-TLS bytes → handshake fails before secureConnect + setTimeout(() => socket.write('not a tls server\n'), 15) + }) + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const port = (proxy.address() as { port: number }).port + try { + const err = await requestOnce('https://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 1024, + proxy: { host: '127.0.0.1', port } + }).then( + () => undefined, + (e: unknown) => e as { phase?: string; code?: string } + ) + // the handshake never completed → the request was never sent → pre-send (enroll may retry) + expect(err?.phase).toBe('pre-send') + } finally { + proxy.close() + sockets.forEach((s) => s.destroy()) + } + }) + + it('R2-ISS-035: every direct request runs the guarded lookup — no connection reuse across requests', async () => { + const url = new URL( + await start((_req, res) => { + res.writeHead(200) + res.end('ok') + }) + ) + let lookups = 0 + const lookup = ((hostname: string, options: unknown, callback?: unknown): void => { + lookups++ + const cb = (typeof options === 'function' ? options : callback) as ( + err: null, + address: string | { address: string; family: number }[], + family?: number + ) => void + const all = + typeof options === 'object' && options !== null && (options as { all?: boolean }).all + if (all) cb(null, [{ address: '127.0.0.1', family: 4 }]) + else cb(null, '127.0.0.1', 4) + }) as unknown as import('net').LookupFunction + const target = `http://guarded.invalid:${url.port}/x` + await requestOnce(target, { method: 'GET', timeout: 5000, maxBytes: 1024, lookup }) + await requestOnce(target, { method: 'GET', timeout: 5000, maxBytes: 1024, lookup }) + expect(lookups).toBe(2) + }) + + it('R2-ISS-030: a synchronous request-construction failure rejects cleanly and never fires the timer', async () => { + // a negative timeout makes http.request throw before any socket exists + await expect( + requestOnce('http://127.0.0.1:1/x', { method: 'GET', timeout: -1000, maxBytes: 1024 }) + ).rejects.toMatchObject({ code: 'ERR_OUT_OF_RANGE', phase: 'pre-send' }) + // the wall-clock timer (scheduled for "-1000ms" → immediately) must have been cleared: an + // uncaught ReferenceError here would fail the test run + await new Promise((r) => setTimeout(r, 20)) + }) + + it('R2-ISS-009: a failure after the response headers carries the status (503 then reset)', async () => { + const url = await start((_req, res) => { + // declare more body than is sent so the client has parsed the headers and is waiting on the + // body when the socket is reset + res.writeHead(503, { 'content-length': '100' }) + res.write('partial') + setTimeout(() => res.socket?.destroy(), 50) + }) + await expect( + requestOnce(url + '/x', { method: 'GET', timeout: 5000, maxBytes: 1024 }) + ).rejects.toMatchObject({ status: 503, phase: 'possibly-sent' }) + }) + + it('R2-ISS-009: redirect refusal and oversize responses carry the status too', async () => { + const url = await start((req, res) => { + if (req.url === '/r') { + res.writeHead(302, { location: '/x' }) + res.end() + } else { + res.writeHead(200) + res.end('x'.repeat(100)) + } + }) + await expect( + requestOnce(url + '/r', { method: 'GET', timeout: 5000, maxBytes: 1024 }) + ).rejects.toMatchObject({ code: 'CPX_REDIRECT_REFUSED', status: 302 }) + await expect( + requestOnce(url + '/big', { method: 'GET', timeout: 5000, maxBytes: 10 }) + ).rejects.toMatchObject({ code: 'CPX_RESPONSE_TOO_LARGE', status: 200 }) + }) + + it('sends URL-encoded proxy credentials (containing @, : and /) as Proxy-Authorization', async () => { + const seen: string[] = [] + const proxy = http.createServer((req, res) => { + seen.push(req.headers['proxy-authorization'] ?? '') + res.writeHead(200) + res.end('ok') + }) + await new Promise((r) => proxy.listen(0, '127.0.0.1', () => r())) + const proxyPort = (proxy.address() as { port: number }).port + try { + const res = await requestOnce('http://target.invalid/x', { + method: 'GET', + timeout: 5000, + maxBytes: 4096, + proxy: { host: '127.0.0.1', port: proxyPort, auth: { user: 'us@er', pass: 'p:a/ss' } } + }) + expect(res.body).toBe('ok') + expect(seen).toEqual([`Basic ${Buffer.from('us@er:p:a/ss').toString('base64')}`]) + } finally { + proxy.close() + } + }) + + it('buildProxyUrl encodes credentials through the URL object instead of string concatenation', () => { + const u = new URL( + buildProxyUrl({ host: '127.0.0.1', port: 1, auth: { user: 'us@er', pass: 'p:a/ss' } }) + ) + expect(u.username).toBe('us%40er') + expect(u.password).toBe('p%3Aa%2Fss') + expect(buildProxyUrl({ host: '::1', port: 2 })).toBe('http://[::1]:2/') + }) }) diff --git a/src/main/resolve/plugin/http-client.ts b/src/main/resolve/plugin/http-client.ts index 0385b9d6..151d27eb 100644 --- a/src/main/resolve/plugin/http-client.ts +++ b/src/main/resolve/plugin/http-client.ts @@ -1,8 +1,24 @@ import http from 'http' import https from 'https' -import type { LookupFunction } from 'net' +import { isIP, type LookupFunction, type Socket } from 'net' +import tls, { type TLSSocket } from 'tls' import { HttpProxyAgent } from 'http-proxy-agent' -import { HttpsProxyAgent } from 'https-proxy-agent' +import { + CPX_PROXY_CONNECT_FAILED, + CPX_REDIRECT_REFUSED, + CPX_RESPONSE_TOO_LARGE, + CPX_TIMEOUT, + codedError, + codeOf, + type CodedError, + type ErrorPhase +} from './errors' + +export interface PluginProxy { + host: string + port: number + auth?: { user: string; pass: string } +} export interface PluginRequestOptions { method: 'GET' | 'POST' @@ -12,7 +28,9 @@ export interface PluginRequestOptions { maxBytes: number lookup?: LookupFunction // 走代理时由代理负责解析/连接目标,本地 SSRF guarded lookup 不再适用(安全保证降级) - proxy?: { host: string; port: number } + proxy?: PluginProxy + // op 预算(§0.4):中止后与平台 ETIMEDOUT 一起映射为 CPX_TIMEOUT + signal?: AbortSignal } export interface PluginResponse { @@ -49,73 +67,306 @@ function validateHeaders(input: Record): Record return headers } +// 代理 URL 用 URL 对象设置 username/password(自动百分号编码),不拼字符串;两个 agent 都会解码后 +// 生成 Proxy-Authorization。 +export function buildProxyUrl(proxy: PluginProxy): string { + const host = proxy.host.includes(':') ? `[${proxy.host}]` : proxy.host + const u = new URL(`http://${host}:${proxy.port}`) + if (proxy.auth) { + u.username = proxy.auth.user + u.password = proxy.auth.pass + } + return u.toString() +} + +function isValidPort(port: unknown): port is number { + return Number.isInteger(port) && (port as number) >= 1 && (port as number) <= 65535 +} + +function stripBrackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname +} + +interface ProxyTunnel { + // CONNECT 请求本身:预算中止 / 墙钟超时时 destroy 它,连到代理的 socket 随之关闭 + req: http.ClientRequest + // 隧道建立后包好 TLS 的 socket(握手尚未开始,交给真正的请求后再进行) + socket: Promise +} + +// 经代理的 https 不用 https-proxy-agent 而自己建隧道(ISS-007 残余):agent-base 在 CONNECT 完成前不把连接交给 +// 请求,预算中止时 req.destroy() 够不到它,挂起的 CONNECT 连接会残留到代理自己关闭为止。这里 CONNECT 本身是 +// 一个 agent=false 的 http.ClientRequest——连到代理的 socket 在创建时同步分配,destroy 立即关闭它;隧道建立后 +// 用 tls.connect 包一层,经 createConnection 交给真正的请求。 +function openProxyTunnel( + proxy: URL, + target: URL, + opts: { signal?: AbortSignal; timeout: number } +): ProxyTunnel { + const targetHost = stripBrackets(target.hostname) + const targetPort = Number(target.port) || 443 + const headers: Record = { Host: `${target.hostname}:${targetPort}` } + if (proxy.username || proxy.password) { + const cred = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}` + headers['Proxy-Authorization'] = `Basic ${Buffer.from(cred).toString('base64')}` + } + const tunnelFailed = (detail: string): CodedError => + codedError(`Proxy tunnel failed (${detail})`, CPX_PROXY_CONNECT_FAILED, 'pre-send') + const req = http.request({ + host: stripBrackets(proxy.hostname), + // 只有 URL 省略端口时才取默认的 80;显式的 0 等非法端口原样交给 connect 失败,不得改写成别的端口 + port: proxy.port === '' ? 80 : Number(proxy.port), + method: 'CONNECT', + path: `${target.hostname}:${targetPort}`, + headers, + agent: false, + timeout: opts.timeout, + signal: opts.signal + }) + const socket = new Promise((resolve, reject) => { + req.once('connect', (res, raw: Socket, head: Buffer) => { + // 代理对 CONNECT 的非 2xx 响应(Node 对 CONNECT 一律以 'connect' 事件交付):目标根本没有收到请求 + if (res.statusCode !== 200) { + raw.destroy() + reject(tunnelFailed(`status ${res.statusCode ?? 0}`)) + return + } + if (head.length > 0) raw.unshift(head) + resolve( + tls.connect({ + socket: raw, + host: targetHost, + servername: isIP(targetHost) ? undefined : targetHost + }) + ) + }) + req.once('response', (res) => { + res.destroy() + reject(tunnelFailed(`status ${res.statusCode ?? 0}`)) + }) + req.once('timeout', () => req.destroy(codedError('Request timed out', CPX_TIMEOUT))) + // 隧道阶段的任何连接错误(拒绝 / 复位 / 未应答即 FIN)都是隧道失败(pre-send,可回退直连); + // 超时与预算中止保留 CPX_TIMEOUT 语义 + req.once('error', (e) => reject(isTimeoutLike(e) ? e : tunnelFailed(e.message))) + }) + req.end() + return { req, socket } +} + +function isTimeoutLike(e: unknown): boolean { + const code = codeOf(e) + if (code === CPX_TIMEOUT || code === 'ETIMEDOUT' || code === 'ABORT_ERR') return true + return e instanceof Error && e.name === 'AbortError' +} + +// 超时、预算中止(AbortError)与平台 ETIMEDOUT 统一映射为 CPX_TIMEOUT;其余错误原样透传。 +// 所有错误都带 phase(§0.3)。 +function normalizeError(e: unknown, phase: ErrorPhase): unknown { + let err: unknown = e + if (codeOf(e) !== CPX_TIMEOUT && isTimeoutLike(e)) { + err = codedError('Request timed out', CPX_TIMEOUT) + } + if (typeof err === 'object' && err !== null && !(err as CodedError).phase) { + ;(err as CodedError).phase = phase + } + return err +} + +// TLS socket 以 secureConnect 为准,明文 socket 以 connect 为准;复用的已连接 socket 立即视为已发送。 +function watchPhase(socket: Socket, flip: () => void): void { + const tlsSocket = socket as TLSSocket + if (tlsSocket.encrypted) { + // TLS:握手完成前不得翻 possibly-sent(否则 enroll 的 pre-send-only 会拒绝本可回退的握手期失败)。 + // getProtocol() 在握手完成前就可能返回真实版本,不可靠;authorized / authorizationError 只有在握手 + // 结束后才被写入,据此判断“握手已完成”。否则等 secureConnect(现在直连与代理都每请求新建连接, + // 不存在复用的已握手 socket,secureConnect 必然在此后触发)。 + const handshakeDone = + !socket.connecting && (tlsSocket.authorized === true || tlsSocket.authorizationError != null) + if (handshakeDone) flip() + else socket.once('secureConnect', flip) + return + } + if (!socket.connecting) flip() + else socket.once('connect', flip) +} + export function requestOnce(urlStr: string, opts: PluginRequestOptions): Promise { return new Promise((resolve, reject) => { let url: URL try { url = new URL(urlStr) } catch { - reject(new Error('Invalid URL')) + reject(codedError('Invalid URL', 'CPX_INVALID_URL', 'pre-send')) return } const mod = url.protocol === 'https:' ? https : url.protocol === 'http:' ? http : null if (!mod) { - reject(new Error(`Unsupported protocol: ${url.protocol}`)) + reject(codedError(`Unsupported protocol: ${url.protocol}`, 'CPX_INVALID_URL', 'pre-send')) return } + const client: typeof http | typeof https = mod let headers: Record try { headers = validateHeaders(opts.headers ?? {}) } catch (e) { - reject(e) + reject(normalizeError(e, 'pre-send')) return } if (opts.body !== undefined) headers['Content-Length'] = String(Buffer.byteLength(opts.body)) + // 预算已耗尽:不创建请求 + if (opts.signal?.aborted) { + reject(codedError('Request timed out', CPX_TIMEOUT, 'pre-send')) + return + } + // 非法的代理端口(如核心关闭了混合端口时的 0):按隧道不可用拒绝,绝不改写成别的端口去连—— + // 那会把请求和代理凭据送给无关的本地服务。所有调用方(含 .cpx 下载)在这里统一把关 + if (opts.proxy && !isValidPort(opts.proxy.port)) { + reject( + codedError( + `Invalid proxy port: ${String(opts.proxy.port)}`, + CPX_PROXY_CONNECT_FAILED, + 'pre-send' + ) + ) + return + } - // 代理模式:连接打到本地代理,目标由代理解析;不再注入 guarded lookup。 - const proxyUrl = opts.proxy ? `http://${opts.proxy.host}:${opts.proxy.port}` : undefined - const agent = proxyUrl - ? url.protocol === 'https:' - ? new HttpsProxyAgent(proxyUrl) + // 代理模式:连接打到本地代理,目标由代理解析;不再注入 guarded lookup。经代理的 http 用 HttpProxyAgent + // (连接在创建时同步分配,可被 destroy);经代理的 https 自建 CONNECT 隧道(openProxyTunnel),请求不带 agent, + // 用 createConnection 接收隧道 socket。 + // 直连模式:agent=false,每个请求新建连接、不复用进程级 globalAgent 的 keep-alive 池(Node ≥ 19 默认开启)—— + // 池里可能有主进程其它调用方建立的、指向私网地址的同 host:port socket,复用会绕过 guarded lookup, + // 而 lookup 只在建立新连接时执行。 + const proxyUrl = opts.proxy ? buildProxyUrl(opts.proxy) : undefined + const tunneled = proxyUrl !== undefined && url.protocol === 'https:' + const agent: http.Agent | false | undefined = proxyUrl + ? tunneled + ? undefined : new HttpProxyAgent(proxyUrl) - : undefined + : false - const req = mod.request( - url, - { - method: opts.method, - headers, - agent, - lookup: proxyUrl ? undefined : opts.lookup, + let phase: ErrorPhase = 'pre-send' + // 已收到响应头后记录的状态码:之后的任何失败都带上它(§1.3 / §2.4 的“服务器已到达”) + let responseStatus: number | undefined + let settled = false + let req: http.ClientRequest | undefined + let connectReq: http.ClientRequest | undefined + // 唯一的结束入口:清理墙钟定时器与 abort 监听;之后到达的任何事件都被忽略。 + // (wallClock 在下方创建;这里只在异步回调里读取,首次调用时已存在。) + const finish = (fn: () => void): void => { + if (settled) return + settled = true + clearTimeout(wallClock) + opts.signal?.removeEventListener('abort', onAbort) + fn() + } + const fail = (e: unknown): void => + finish(() => { + let err = normalizeError(e, phase) as CodedError + // 经代理、请求尚未发出(phase 仍为 pre-send)且尚无响应时的无 code 错误来自隧道阶段(http 代理 agent + // 的连接阶段、https 隧道内的 TLS 阶段):目标没有收到请求,按隧道建立失败处理,让路由层回退直连。 + // 已翻到 possibly-sent 之后的错误不能再降级为 pre-send——否则 pre-send-only 策略会重试可能已送达的请求 + if (proxyUrl && phase === 'pre-send' && responseStatus === undefined && !err.code) { + err = codedError(err.message, CPX_PROXY_CONNECT_FAILED, 'pre-send') + } + if (responseStatus !== undefined && err.status === undefined) err.status = responseStatus + reject(err) + }) + // 预算中止 / 墙钟超时:结束 Promise,并销毁已经存在的连接——真正的请求,或仍在等代理应答的 CONNECT 请求 + // (它的 socket 在创建时就已分配,destroy 立即关闭它)。墙钟定时器用的是同一个 opts.timeout:Node 自身的 + // timeout 是 socket 空闲超时,对持续滴流的对端不生效。 + const onTimeout = (): void => { + const err = codedError('Request timed out', CPX_TIMEOUT) + connectReq?.destroy(err) + req?.destroy(err) + fail(err) + } + const onAbort = (): void => onTimeout() + opts.signal?.addEventListener('abort', onAbort, { once: true }) + const wallClock = setTimeout(onTimeout, opts.timeout) + + if (tunneled) { + const tunnel = openProxyTunnel(new URL(proxyUrl), url, { + signal: opts.signal, 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})`)) + }) + connectReq = tunnel.req + tunnel.socket.then((socket) => { + // 隧道建成时请求已经结束(预算中止 / 超时):不再发请求,关掉刚建好的连接 + if (settled) { + socket.destroy() 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) + start(() => socket) + }, fail) + } else { + start() + } + + // 请求构造可能同步抛错(例如非法的 timeout 选项):必须经同一个结束入口清理定时器与监听, + // 否则定时器随后会访问从未初始化的 req。 + function start(createConnection?: () => Socket): void { + try { + req = createRequest(createConnection) + } catch (e) { + fail(e) + return + } + req.on('socket', (socket: Socket) => { + watchPhase(socket, () => { + phase = 'possibly-sent' }) - res.on('end', () => + }) + req.on('timeout', () => req?.destroy(codedError('Request timed out', CPX_TIMEOUT))) + req.on('error', fail) + if (opts.body !== undefined) req.write(opts.body) + req.end() + } + + function createRequest(createConnection?: () => Socket): http.ClientRequest { + return client.request( + url, + { + method: opts.method, + headers, + agent, + createConnection, + // 隧道内的请求没有 agent,Node 拿不到协议默认端口,会把 Host 算成 host:80——显式给出 + defaultPort: url.protocol === 'https:' ? 443 : 80, + lookup: proxyUrl ? undefined : opts.lookup, + timeout: opts.timeout, + signal: opts.signal + }, + onResponse + ) + } + + function onResponse(res: http.IncomingMessage): void { + phase = 'possibly-sent' + const status = res.statusCode ?? 0 + responseStatus = status + if (status >= 300 && status < 400) { + res.destroy() + fail(codedError(`Refusing to follow redirect (status ${status})`, CPX_REDIRECT_REFUSED)) + return + } + const chunks: Buffer[] = [] + let size = 0 + res.on('data', (c: Buffer) => { + size += c.length + if (size > opts.maxBytes) { + res.destroy() + fail(codedError('Response too large', CPX_RESPONSE_TOO_LARGE)) + return + } + chunks.push(c) + }) + res.on('end', () => + finish(() => 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() + ) + res.on('error', fail) + } }) } diff --git a/src/main/resolve/plugin/index.test.ts b/src/main/resolve/plugin/index.test.ts index f6f12e7d..c47b6b4c 100644 --- a/src/main/resolve/plugin/index.test.ts +++ b/src/main/resolve/plugin/index.test.ts @@ -22,9 +22,19 @@ vi.mock('./vault', () => ({ ensureVaultWritable: vi.fn(async () => { if (vaultPreflightUnavailable) throw new Error('Plugin vault is temporarily unavailable') }), + updateVault: vi.fn(async (id: string, mutator: (v: IPluginVault) => IPluginVault) => { + if (!vaults[id]) return false + vaults[id] = mutator(vaults[id]) + return true + }), removeVault: vi.fn(async (id: string) => { delete vaults[id] }), + removeVaultIfDevice: vi.fn(async (id: string, deviceId: string) => { + if (!vaults[id] || vaults[id].deviceId !== deviceId) return false + delete vaults[id] + return true + }), isVaultPersistent: async () => true, VaultUnavailableError: class VaultUnavailableError extends Error {} })) @@ -36,10 +46,19 @@ vi.mock('../../config/plugin', () => ({ updatePluginItem: vi.fn(async (i: IPluginItem) => { pluginItems[i.id] = i }), + patchPluginItem: vi.fn(async (id: string, patch: Partial) => { + if (!pluginItems[id]) throw new Error('Plugin not found') + pluginItems[id] = { ...pluginItems[id], ...patch } + }), removePluginItem: vi.fn(async (id: string) => { delete pluginItems[id] }), - getPluginConfig: vi.fn(async () => ({ items: Object.values(pluginItems) })) + getPluginConfig: vi.fn(async () => ({ items: Object.values(pluginItems) })), + DEFAULT_PLUGIN_INTERVAL_MIN: 1440, + pluginSchedule: (item?: IPluginItem) => ({ + interval: item?.interval ?? 1440, + autoUpdate: item?.autoUpdate ?? true + }) })) vi.mock('../../config/profile', () => ({ upsertPluginProfile: vi.fn(async (meta: { profileId: string }, content: string) => { @@ -47,15 +66,36 @@ vi.mock('../../config/profile', () => ({ }), removePluginProfileContent: vi.fn(async (pid: string) => { delete profiles[pid] - }) + }), + isPluginProfileInvalidError: (e: unknown) => + typeof e === 'object' && + e !== null && + (e as { code?: unknown }).code === 'PLUGIN_PROFILE_INVALID', + syncPluginProfileSchedule: vi.fn(async () => {}) })) vi.mock('../../config/app', () => ({ getAppConfig: vi.fn(async () => ({ subscriptionTimeout: 5000 })) })) vi.mock('../../window', () => ({ mainWindow: null })) +vi.mock('../../config/controledMihomo', () => ({ + getControledMihomoConfig: vi.fn(async () => ({ 'mixed-port': 7890 })) +})) +const requestOnce = vi.fn() +vi.mock('./http-client', () => ({ requestOnce: (...a: unknown[]) => requestOnce(...a) })) +// 路由预检不得依赖真实 DNS:所有目标都视为公网 +vi.mock('./net-guard', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + resolveAllPublicOrThrow: async () => [{ address: '1.1.1.1', family: 4 }] + } +}) const discoverGateway = vi.fn() -vi.mock('./discovery', () => ({ discoverGateway: (...a: unknown[]) => discoverGateway(...a) })) +vi.mock('./discovery', () => ({ + discoverGateway: (...a: unknown[]) => discoverGateway(...a), + originOf: (u: string) => new URL(u).origin +})) const browserLogin = vi.fn() vi.mock('./oauth', () => ({ browserLogin: (...a: unknown[]) => browserLogin(...a), @@ -74,7 +114,12 @@ vi.mock('./gateway', async (importOriginal) => { } }) +import { readFileSync } from 'fs' +import { join } from 'path' +import { createPrivateKey, sign } from 'crypto' import { GatewayError } from './gateway' +import { buildDiscoverySignInput } from './discovery-sig' +import { sha256Hex } from './encoding' import { readVault as readVaultMock } from './vault' import { previewPlugin, @@ -82,24 +127,34 @@ import { loginPlugin, updatePluginProfile, auditPluginVault, - removePlugin + removePlugin, + removePluginForProfile, + patchPluginItem } from './index' +import { getAppConfig } from '../../config/app' 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', + gateways: ['https://gw.front.com'], endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' } } -function file(): string { +const GW1 = 'https://gw.front.com' +const GW2 = 'https://gw2.front.com' +const GW3 = 'https://gw3.front.com' +function state(gateways: string[], lastGood?: string): IPluginGatewayState { + return { gateway: lastGood ?? gateways[0], gateways, endpoints: WK.endpoints, lastGood } +} +function file(extra: Record = {}): 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' } + provider: { name: 'XX', site: 'https://xx.com' }, + ...extra }), 'utf-8' ).toString('base64') @@ -120,8 +175,9 @@ beforeEach(() => { redirectUri: 'http://127.0.0.1:1/callback' }) enroll.mockReset().mockResolvedValue(undefined) - fetchConfig.mockReset().mockResolvedValue(CLASH) + fetchConfig.mockReset().mockResolvedValue({ yaml: CLASH }) revoke.mockReset().mockResolvedValue(undefined) + requestOnce.mockReset() }) describe('previewPlugin', () => { @@ -159,6 +215,8 @@ describe('loginPlugin', () => { const vault = vaults[item.id] expect(Buffer.from(vault.devicePrivKey, 'base64')).toHaveLength(32) expect(vault.gateway.gateway).toBe('https://gw.front.com') + expect(vault.gateway.gateways).toEqual(['https://gw.front.com']) + expect(vault.gateway.lastGood).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' }), @@ -183,7 +241,7 @@ describe('loginPlugin', () => { await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') expect(enroll).toHaveBeenCalledOnce() expect(revoke).toHaveBeenCalledWith( - expect.objectContaining({ gateway: WK.gateway }), + expect.objectContaining({ gateway: WK.gateways[0] }), expect.objectContaining({ deviceId: expect.any(String), privKeyB64: expect.any(String) }), expect.any(Object) ) @@ -207,7 +265,7 @@ describe('loginPlugin', () => { devicePrivKey: Buffer.alloc(32, 1).toString('base64'), deviceId: '11111111-1111-4111-8111-111111111111' } - vaults[item.id] = { ...dev, gateway: { gateway: WK.gateway, endpoints: WK.endpoints } } + vaults[item.id] = { ...dev, gateway: state(WK.gateways) } browserLogin.mockClear() enroll.mockClear() discoverGateway.mockClear() @@ -226,12 +284,12 @@ describe('loginPlugin', () => { devicePrivKey: Buffer.alloc(32, 2).toString('base64'), deviceId: '22222222-2222-4222-8222-222222222222' } - vaults[item.id] = { ...dev, gateway: { gateway: WK.gateway, endpoints: WK.endpoints } } + vaults[item.id] = { ...dev, gateway: state(WK.gateways) } browserLogin.mockClear() enroll.mockClear() fetchConfig .mockRejectedValueOnce(new GatewayError('revoked', 'revoked')) // reuse attempt - .mockResolvedValueOnce(CLASH) // full-flow first fetch + .mockResolvedValueOnce({ yaml: CLASH }) // full-flow first fetch await loginPlugin(item.id) expect(browserLogin).toHaveBeenCalled() expect(enroll).toHaveBeenCalled() @@ -270,9 +328,9 @@ 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' - ) + fetchConfig.mockResolvedValueOnce({ + yaml: '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) @@ -370,11 +428,8 @@ describe('updatePluginProfile', () => { discoverGateway.mockClear() fetchConfig .mockRejectedValueOnce(new GatewayError('retired', 'gone')) - .mockResolvedValueOnce(CLASH) - discoverGateway.mockResolvedValueOnce({ - ...WK, - gateway: 'https://gw2.front.com' - }) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: ['https://gw2.front.com'] }) await updatePluginProfile(item.id) expect(discoverGateway).toHaveBeenCalledTimes(1) expect(vaults[item.id].gateway.gateway).toBe('https://gw2.front.com') @@ -387,8 +442,8 @@ describe('updatePluginProfile', () => { discoverGateway.mockClear() fetchConfig .mockRejectedValueOnce(new GatewayError('unreachable', 'ENOTFOUND')) - .mockResolvedValueOnce(CLASH) - discoverGateway.mockResolvedValueOnce({ ...WK, gateway: 'https://gw3.front.com' }) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: ['https://gw3.front.com'] }) await updatePluginProfile(item.id) expect(discoverGateway).toHaveBeenCalledTimes(1) expect(vaults[item.id].gateway.gateway).toBe('https://gw3.front.com') @@ -455,7 +510,7 @@ describe('removePlugin', () => { revoke .mockRejectedValueOnce(new GatewayError('retired', 'gone')) .mockResolvedValueOnce(undefined) - discoverGateway.mockResolvedValueOnce({ ...WK, gateway: 'https://gw4.front.com' }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: ['https://gw4.front.com'] }) await removePlugin(item.id) expect(discoverGateway).toHaveBeenCalledTimes(1) expect(revoke).toHaveBeenCalledTimes(2) @@ -463,3 +518,1685 @@ describe('removePlugin', () => { expect(vaults[item.id]).toBeUndefined() }) }) + +// §0.5 插件级串行与删除临界区 +describe('plugin lock', () => { + function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } + } + const NEW_CLASH = + 'proxies: [{name: new, type: ss, server: 3.3.3.3, port: 1, cipher: aes-128-gcm, password: z}]\n' + + it('serializes a slow older update and a fast newer one; the newer profile wins', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const slow = deferred<{ yaml: string }>() + fetchConfig.mockReturnValueOnce(slow.promise).mockResolvedValueOnce({ yaml: NEW_CLASH }) + const first = updatePluginProfile(item.id, true) + const second = updatePluginProfile(item.id, true) + await new Promise((r) => setTimeout(r, 10)) + expect(fetchConfig).toHaveBeenCalledTimes(2) // 1 from login + only the first update so far + slow.resolve({ yaml: CLASH }) + await Promise.all([first, second]) + expect(fetchConfig).toHaveBeenCalledTimes(3) + expect(profiles[pluginItems[item.id].profileId!]).toBe(NEW_CLASH) + }) + + it('removePlugin during an update: the update abandons its commit, nothing is resurrected', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const pid = pluginItems[item.id].profileId! + const slow = deferred<{ yaml: string }>() + fetchConfig.mockReturnValueOnce(slow.promise) + const update = updatePluginProfile(item.id, true) + await new Promise((r) => setTimeout(r, 5)) + const removal = removePlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + expect(pluginItems[item.id]).toBeDefined() // removal is queued behind the running update + slow.resolve({ yaml: NEW_CLASH }) + await Promise.all([update, removal]) + expect(pluginItems[item.id]).toBeUndefined() + expect(vaults[item.id]).toBeUndefined() + expect(profiles[pid]).not.toBe(NEW_CLASH) + // a later update sees the tombstone and does nothing + fetchConfig.mockClear() + await updatePluginProfile(item.id, true) + expect(fetchConfig).not.toHaveBeenCalled() + expect(vaults[item.id]).toBeUndefined() + }) + + it('removeProfileItem cascade during an update behaves the same and does not self-lock', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const slow = deferred<{ yaml: string }>() + fetchConfig.mockReturnValueOnce(slow.promise) + const update = updatePluginProfile(item.id, true) + await new Promise((r) => setTimeout(r, 5)) + const removal = removePluginForProfile(item.id, pluginItems[item.id].profileId!) + slow.resolve({ yaml: NEW_CLASH }) + await Promise.all([update, removal]) + expect(revoke).toHaveBeenCalled() + expect(pluginItems[item.id]).toBeUndefined() + expect(vaults[item.id]).toBeUndefined() + }) + + it('removePlugin while a login waits on the browser: enroll result is discarded and revoked', async () => { + const item = await installPlugin(file()) + const browser = deferred<{ code: string; verifier: string; redirectUri: string }>() + browserLogin.mockReturnValueOnce(browser.promise) + const login = loginPlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + await removePlugin(item.id) + browser.resolve({ code: 'C', verifier: 'V', redirectUri: 'http://127.0.0.1:1/callback' }) + await expect(login).rejects.toThrow('PLUGIN_LOGIN_FAILED') + expect(vaults[item.id]).toBeUndefined() + expect(pluginItems[item.id]).toBeUndefined() + }) + + it('rejects a second concurrent login for the same plugin', async () => { + const item = await installPlugin(file()) + const browser = deferred<{ code: string; verifier: string; redirectUri: string }>() + browserLogin.mockReturnValueOnce(browser.promise) + const first = loginPlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + browser.resolve({ code: 'C', verifier: 'V', redirectUri: 'http://127.0.0.1:1/callback' }) + await first + expect(pluginItems[item.id].status).toBe('active') + }) +}) + +// §1 路由模式、lastGoodRoute 与失败原因 +describe('route mode persistence (§1)', () => { + it('installPlugin writes routeMode from the global default and mirrors useProxy', async () => { + vi.mocked(getAppConfig).mockResolvedValueOnce({ + subscriptionTimeout: 5000, + pluginUseProxy: true + }) + const viaProxy = await installPlugin(file()) + expect(viaProxy.routeMode).toBe('proxy') + expect(viaProxy.useProxy).toBe(true) + const auto = await installPlugin(file()) + expect(auto.routeMode).toBe('auto') + expect(auto.useProxy).toBe(false) + }) + + it('patchPluginItem mirrors useProxy whenever routeMode changes', async () => { + const item = await installPlugin(file()) + await patchPluginItem(item.id, { routeMode: 'proxy' }) + expect(pluginItems[item.id].useProxy).toBe(true) + await patchPluginItem(item.id, { routeMode: 'direct' }) + expect(pluginItems[item.id].useProxy).toBe(false) + await patchPluginItem(item.id, { interval: 5 }) + expect(pluginItems[item.id].useProxy).toBe(false) + }) + + it('commit records lastGoodRoute=proxy after a direct timeout fell back to the proxy (auto mode)', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + // 让 fetchConfig 真正经过路由执行器:direct 超时 → proxy 200 + fetchConfig.mockImplementationOnce(async (t: { gateway: string }, _c: unknown, requester) => { + await (requester as { request: (url: string, opts: unknown) => Promise }).request( + t.gateway + '/config', + { + method: 'POST', + maxBytes: 1 + } + ) + return { yaml: CLASH } + }) + requestOnce + .mockRejectedValueOnce( + Object.assign(new Error('timeout'), { code: 'CPX_TIMEOUT', phase: 'pre-send' }) + ) + .mockResolvedValueOnce({ status: 200, headers: {}, body: '' }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].status).toBe('active') + expect(pluginItems[item.id].lastGoodRoute).toBe('proxy') + expect(requestOnce).toHaveBeenCalledTimes(2) + expect((requestOnce.mock.calls[1][1] as { proxy?: unknown }).proxy).toBeDefined() + }) + + it('does not record lastGoodRoute in an explicit mode', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + pluginItems[item.id] = { ...pluginItems[item.id], routeMode: 'direct' } + fetchConfig.mockImplementationOnce(async (t: { gateway: string }, _c: unknown, requester) => { + await (requester as { request: (url: string, opts: unknown) => Promise }).request( + t.gateway + '/config', + { + method: 'POST', + maxBytes: 1 + } + ) + return { yaml: CLASH } + }) + requestOnce.mockResolvedValueOnce({ status: 200, headers: {}, body: '' }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastGoodRoute).toBeUndefined() + }) + + it('a field patched externally during the op is not overwritten by the commit', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + fetchConfig.mockImplementationOnce(async () => { + pluginItems[item.id] = { ...pluginItems[item.id], interval: 99 } + return { yaml: CLASH } + }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].interval).toBe(99) + expect(pluginItems[item.id].status).toBe('active') + }) + + it.each([ + ['blocked', new GatewayError('blocked', 'refused'), 'blocked'], + ['unreachable', new GatewayError('unreachable', 'ENOTFOUND'), 'network'], + ['transient without status', new GatewayError('transient', 'timeout'), 'network'], + ['transient with status', new GatewayError('transient', '503', 503), 'server'] + ] as const)('%s failure → backoff with lastUpdateErrorReason=%s', async (_n, err, reason) => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + // unreachable 会触发一次重发现并重试,因此两次尝试都失败 + fetchConfig.mockRejectedValueOnce(err).mockRejectedValueOnce(err) + await updatePluginProfile(item.id, true) + fetchConfig.mockReset().mockResolvedValue({ yaml: CLASH }) + const rec = pluginItems[item.id] + expect(rec.status).toBe('active') + expect(rec.lastUpdateErrorType).toBe('transient') + expect(rec.lastUpdateErrorReason).toBe(reason) + expect(rec.failureCount).toBe(1) + fetchConfig.mockResolvedValueOnce({ yaml: CLASH }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastUpdateErrorReason).toBeUndefined() + }) + + it('blocked passes straight through gateway recovery (no rediscovery)', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + discoverGateway.mockClear() + fetchConfig.mockRejectedValueOnce(new GatewayError('blocked', 'refused')) + await updatePluginProfile(item.id, true) + expect(discoverGateway).not.toHaveBeenCalled() + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('blocked') + }) +}) + +// §2 多网关切换 + 一次重发现 + 登录流程重组 +describe('gateway recovery (§2.4)', () => { + async function activeWith(gateways: string[], lastGood?: string): Promise { + const item = await installPlugin(file()) + await loginPlugin(item.id) + vaults[item.id] = { ...vaults[item.id], gateway: state(gateways, lastGood) } + discoverGateway.mockClear() + fetchConfig.mockReset() + return item.id + } + const targetsTried = (): string[] => + fetchConfig.mock.calls.map((c) => (c[0] as { gateway: string }).gateway) + const NEW_CLASH = + 'proxies: [{name: gw2, type: ss, server: 4.4.4.4, port: 1, cipher: aes-128-gcm, password: q}]\n' + + it('gw1 timeout, gw2 ok → content from gw2 and lastGood=gw2 (no rediscovery)', async () => { + const id = await activeWith([GW1, GW2]) + fetchConfig + .mockRejectedValueOnce(new GatewayError('transient', 'timeout')) + .mockResolvedValueOnce({ yaml: NEW_CLASH }) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW1, GW2]) + expect(discoverGateway).not.toHaveBeenCalled() + expect(vaults[id].gateway.lastGood).toBe(GW2) + expect(vaults[id].gateway.gateway).toBe(GW2) + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2]) + expect(profiles[pluginItems[id].profileId!]).toBe(NEW_CLASH) + }) + + it('starts from lastGood, then the rest in order', async () => { + const id = await activeWith([GW1, GW2, GW3], GW2) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH }) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW2, GW1, GW3]) + expect(vaults[id].gateway.lastGood).toBe(GW3) + }) + + it('gw1 503 → does not try gw2', async () => { + const id = await activeWith([GW1, GW2]) + fetchConfig.mockRejectedValueOnce(new GatewayError('transient', '503', 503)) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW1]) + expect(pluginItems[id].lastUpdateErrorReason).toBe('server') + }) + + it('gw1 revoked → does not try gw2; needs-reauth', async () => { + const id = await activeWith([GW1, GW2]) + fetchConfig.mockRejectedValueOnce(new GatewayError('revoked', 'revoked')) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW1]) + expect(pluginItems[id].status).toBe('needs-reauth') + }) + + it('all three time out → rediscover once; same list → nothing new → ok:false with the rediscovered list committed', async () => { + const id = await activeWith([GW1, GW2, GW3]) + fetchConfig.mockRejectedValue(new GatewayError('transient', 'timeout')) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW1, GW2, GW3] }) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW1, GW2, GW3]) + expect(discoverGateway).toHaveBeenCalledTimes(1) + expect(pluginItems[id].lastUpdateErrorType).toBe('transient') + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2, GW3]) + expect(vaults[id].gateway.lastGood).toBeUndefined() + }) + + it('rediscovered list with a new origin → only the new origin is tried', async () => { + const id = await activeWith([GW1, GW2]) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW1, GW3] }) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW1, GW2, GW3]) + expect(vaults[id].gateway.gateways).toEqual([GW1, GW3]) + expect(vaults[id].gateway.lastGood).toBe(GW3) + expect(pluginItems[id].status).toBe('active') + }) + + it('same origin with a changed config path counts as a new target', async () => { + const id = await activeWith([GW1]) + fetchConfig + .mockRejectedValueOnce(new GatewayError('retired', 'gone')) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ + ...WK, + gateways: [GW1], + endpoints: { ...WK.endpoints, config: '/v2/config' } + }) + await updatePluginProfile(id, true) + expect(fetchConfig).toHaveBeenCalledTimes(2) + expect( + (fetchConfig.mock.calls[1][0] as { endpoints: { config: string } }).endpoints.config + ).toBe('/v2/config') + expect(vaults[id].gateway.endpoints.config).toBe('/v2/config') + }) + + it('R2-ISS-004: an equivalent config path spelling is the same target, not a second attempt', async () => { + const id = await activeWith([GW1]) + fetchConfig.mockRejectedValueOnce(new GatewayError('retired', 'gone')) + discoverGateway.mockResolvedValueOnce({ + ...WK, + gateways: [GW1], + endpoints: { ...WK.endpoints, config: '/v1/../config' } + }) + await updatePluginProfile(id, true) + expect(fetchConfig).toHaveBeenCalledTimes(1) + expect(pluginItems[id].lastUpdateErrorType).toBe('transient') + }) + + it('rediscovery ok but the new gateway returns 503 → ok:false, new list still committed and used next time', async () => { + const id = await activeWith([GW1]) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockRejectedValueOnce(new GatewayError('transient', '503', 503)) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW2] }) + await updatePluginProfile(id, true) + expect(pluginItems[id].lastUpdateErrorReason).toBe('server') + expect(vaults[id].gateway.gateways).toEqual([GW2]) + fetchConfig.mockReset().mockResolvedValueOnce({ yaml: CLASH }) + await updatePluginProfile(id, true) + expect(targetsTried()).toEqual([GW2]) + expect(pluginItems[id].status).toBe('active') + }) + + describe('enroll (pre-send-only) gateway switching', () => { + async function freshWith(gateways: string[]): Promise { + const item = await installPlugin(file()) + discoverGateway.mockResolvedValue({ ...WK, gateways }) + enroll.mockReset() + return item.id + } + const enrollTargets = (): string[] => + enroll.mock.calls.map((c) => (c[0] as { gateway: string }).gateway) + + it('timeout → no gateway switch, login fails', async () => { + const id = await freshWith([GW1, GW2]) + enroll.mockRejectedValueOnce(new GatewayError('transient', 'timeout')) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(enrollTargets()).toEqual([GW1]) + }) + it('pre-send ECONNREFUSED → switches to gw2', async () => { + const id = await freshWith([GW1, GW2]) + enroll + .mockRejectedValueOnce( + new GatewayError('unreachable', 'ECONNREFUSED', undefined, 'pre-send') + ) + .mockResolvedValueOnce(undefined) + await loginPlugin(id) + expect(enrollTargets()).toEqual([GW1, GW2]) + expect(vaults[id].gateway.lastGood).toBe(GW2) + expect(pluginItems[id].status).toBe('active') + }) + it('possibly-sent ECONNRESET → no switch', async () => { + const id = await freshWith([GW1, GW2]) + enroll.mockRejectedValueOnce( + new GatewayError('unreachable', 'ECONNRESET', undefined, 'possibly-sent') + ) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(enrollTargets()).toEqual([GW1]) + }) + }) + + describe('deferVaultCreate (§2.5)', () => { + it('enroll ok, vault write fails → device revoked with the in-memory key', async () => { + const item = await installPlugin(file()) + unwritableVaults.add(item.id) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + expect(enroll).toHaveBeenCalledOnce() + expect(revoke).toHaveBeenCalledOnce() + expect(vaults[item.id]).toBeUndefined() + }) + it('enroll ok, vault created, metadata patch fails → revoked and vault removed', async () => { + const item = await installPlugin(file()) + const { patchPluginItem: patchMock } = await import('../../config/plugin') + let armed = false + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + if (armed && id === item.id) throw new Error('disk full') + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + // 让 enroll 经过路由执行器,使 commit 有 lastGoodRoute 可写 + enroll.mockImplementationOnce(async (t: { gateway: string }, _b: unknown, requester) => { + await (requester as { request: (u: string, o: unknown) => Promise }).request( + t.gateway + '/enroll', + { method: 'POST', maxBytes: 1 } + ) + armed = true + }) + requestOnce.mockResolvedValueOnce({ status: 200, headers: {}, body: '{}' }) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + expect(revoke).toHaveBeenCalledOnce() + expect(vaults[item.id]).toBeUndefined() + expect(pluginItems[item.id].status).toBe('needs-login') + }) + }) +}) + +// §3 静态多信任根 +describe('discoveryUrls (§3)', () => { + it('previewPlugin exposes backup discovery hosts; installPlugin persists the origins', async () => { + const f = file({ discoveryUrls: ['https://cdn.xx.com', 'https://gw.xx.com:8443'] }) + const p = await previewPlugin(f) + expect(p.discoveryHosts).toEqual(['cdn.xx.com', 'gw.xx.com:8443']) + const item = await installPlugin(f) + expect(item.discoveryUrls).toEqual(['https://cdn.xx.com', 'https://gw.xx.com:8443']) + }) + + it('login discovery and recovery rediscovery pass [loginOrigin, ...discoveryUrls]', async () => { + const item = await installPlugin(file({ discoveryUrls: ['https://cdn.xx.com'] })) + await loginPlugin(item.id) + expect(discoverGateway).toHaveBeenCalledWith( + { sources: ['https://panel.xx.com', 'https://cdn.xx.com'] }, + expect.any(Object) + ) + discoverGateway.mockClear() + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW2] }) + await updatePluginProfile(item.id, true) + expect(discoverGateway).toHaveBeenCalledWith( + { sources: ['https://panel.xx.com', 'https://cdn.xx.com'] }, + expect.any(Object) + ) + expect(vaults[item.id].gateway.gateways).toEqual([GW2]) + }) +}) + +// §4 机场消息 +describe('provider messages (§4)', () => { + it('records lastProviderMessage and reason on failure, clears both on the next success', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const err = new GatewayError('transient', '503', 503) + err.providerMessage = '维护中,请稍后再试' + fetchConfig.mockRejectedValueOnce(err) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastProviderMessage).toBe('维护中,请稍后再试') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('server') + fetchConfig.mockResolvedValueOnce({ yaml: CLASH }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastProviderMessage).toBeUndefined() + expect(pluginItems[item.id].lastUpdateErrorReason).toBeUndefined() + }) + + it('login failure records the message but still throws the sanitized constant', async () => { + const item = await installPlugin(file()) + const err = new GatewayError('revoked', 'revoked', 403) + err.providerMessage = '账号已停用' + enroll.mockRejectedValueOnce(err) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_REVOKED') + expect(pluginItems[item.id].lastProviderMessage).toBe('账号已停用') + expect(pluginItems[item.id].status).toBe('needs-login') + }) + + it('blocked → reason=blocked and no provider message', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + fetchConfig.mockRejectedValueOnce(new GatewayError('blocked', 'refused')) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('blocked') + expect(pluginItems[item.id].lastProviderMessage).toBeUndefined() + }) + + it('provider.description flows into preview and the installed record', async () => { + const f = file({ provider: { name: 'XX', description: '第一行\n第二行' } }) + expect((await previewPlugin(f)).description).toBe('第一行\n第二行') + expect((await installPlugin(f)).description).toBe('第一行\n第二行') + }) +}) + +// §5a 签名发现文档:X-CPX-Discovery 消费与提交合并 +describe('signed discovery via X-CPX-Discovery (§5a)', () => { + interface Vector { + seedB64: string + pubKeyB64: string + payloadJson: string + } + const vectors: Vector[] = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') + ) + const V = vectors[0] + const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + function envelope(payload: Record): { signed: string; digest: string } { + const bytes = Buffer.from(JSON.stringify(payload), 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(V.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + return { + signed: `${bytes.toString('base64')}.${sign(null, buildDiscoverySignInput(bytes), key).toString('base64')}`, + digest: sha256Hex(bytes) + } + } + const payload = (seq: number, gateways: string[]): Record => ({ + spec: 'cpx-plugin/2', + seq, + gateways, + endpoints: WK.endpoints + }) + async function keyedActive(seq = 12): Promise { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const { digest } = envelope(payload(seq, [GW1])) + // 登录用的发现文档带 seq + discoverGateway.mockResolvedValue({ ...WK, gateways: [GW1], seq, digest }) + await loginPlugin(item.id) + expect(pluginItems[item.id].discoverySeq).toBe(seq) + expect(pluginItems[item.id].discoveryDigest).toBe(digest) + vaults[item.id] = { ...vaults[item.id], gateway: state([GW1, GW2], GW2) } + return item.id + } + const fetchWithHeader = (signed: string | string[]): void => { + fetchConfig.mockResolvedValueOnce({ yaml: CLASH, discovery: signed as string }) + } + + it('login persists seq/digest from the signed discovery document', async () => { + await keyedActive(12) + }) + + it('header seq 13 > 12 → vault list replaced, lastGood cleared, plugin.yaml seq/digest advanced', async () => { + const id = await keyedActive(12) + const { signed, digest } = envelope(payload(13, [GW3, GW2])) + fetchWithHeader(signed) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW3, GW2]) + expect(vaults[id].gateway.lastGood).toBeUndefined() + expect(vaults[id].gateway.gateway).toBe(GW3) + expect(pluginItems[id].discoverySeq).toBe(13) + expect(pluginItems[id].discoveryDigest).toBe(digest) + expect(pluginItems[id].status).toBe('active') + }) + + it('header seq 11 < 12 → ignored, vault and plugin.yaml unchanged', async () => { + const id = await keyedActive(12) + const before = pluginItems[id].discoveryDigest + fetchWithHeader(envelope(payload(11, [GW3])).signed) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2]) + expect(vaults[id].gateway.lastGood).toBe(GW2) + expect(pluginItems[id].discoverySeq).toBe(12) + expect(pluginItems[id].discoveryDigest).toBe(before) + }) + + it('header seq 12 with a different digest → rejected (equivocation)', async () => { + const id = await keyedActive(12) + fetchWithHeader(envelope(payload(12, [GW3])).signed) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2]) + expect(pluginItems[id].discoverySeq).toBe(12) + }) + + it('header seq 12 with the same digest aligns idempotently and keeps lastGood', async () => { + const id = await keyedActive(12) + fetchWithHeader(envelope(payload(12, [GW1])).signed) + await updatePluginProfile(id, true) + // 对齐:列表按签名文档重放为 [GW1];lastGood=GW2 不在其中 → 清空 + expect(vaults[id].gateway.gateways).toEqual([GW1]) + expect(pluginItems[id].discoverySeq).toBe(12) + }) + + it('R2-ISS-005: a higher seq with an identical list still clears lastGood; only same-seq alignment keeps it', async () => { + const id = await keyedActive(12) + // vault [GW1, GW2] with lastGood=GW2; a NEW document (seq 13) with the same list → lastGood cleared + fetchWithHeader(envelope(payload(13, [GW1, GW2])).signed) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2]) + expect(vaults[id].gateway.lastGood).toBeUndefined() + expect(vaults[id].gateway.gateway).toBe(GW1) + expect(pluginItems[id].discoverySeq).toBe(13) + // the SAME document (seq 13, same digest) replayed after a success on GW2 → alignment keeps lastGood + vaults[id] = { ...vaults[id], gateway: state([GW1, GW2], GW2) } + fetchWithHeader(envelope(payload(13, [GW1, GW2])).signed) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.lastGood).toBe(GW2) + expect(pluginItems[id].discoverySeq).toBe(13) + }) + + it.each([ + ['bad signature', 'AAAA.BBBB'], + ['array header', ['a.b', 'c.d']], + ['unknown key', 'unknown'] + ] as const)( + '%s header → ignored with a warning; config still saved, status active', + async (_n, hdr) => { + const id = await keyedActive(12) + const signed = + hdr === 'unknown' + ? envelope({ ...payload(13, [GW3]), extra: true }).signed + : (hdr as string | string[]) + fetchWithHeader(signed) + await updatePluginProfile(id, true) + expect(pluginItems[id].status).toBe('active') + expect(vaults[id].gateway.gateways).toEqual([GW1, GW2]) + expect(pluginItems[id].discoverySeq).toBe(12) + } + ) + + it('an unkeyed plugin ignores the header completely', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + fetchWithHeader(envelope(payload(13, [GW3])).signed) + await updatePluginProfile(item.id, true) + expect(vaults[item.id].gateway.gateways).toEqual([GW1]) + expect(pluginItems[item.id].discoverySeq).toBeUndefined() + }) + + it('recovery lastGood=gw2 and a higher-seq header candidate in the same op → candidate replaces the list, lastGood cleared', async () => { + const id = await keyedActive(12) + vaults[id] = { ...vaults[id], gateway: state([GW1, GW2]) } + const { signed } = envelope(payload(13, [GW3])) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH, discovery: signed }) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW3]) + expect(vaults[id].gateway.lastGood).toBeUndefined() + expect(pluginItems[id].discoverySeq).toBe(13) + }) + + it('keyed plugin: rediscovery passes the signer and persists the new seq', async () => { + const id = await keyedActive(12) + const { digest } = envelope(payload(13, [GW3])) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH }) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW3], seq: 13, digest }) + await updatePluginProfile(id, true) + expect(discoverGateway).toHaveBeenLastCalledWith( + expect.objectContaining({ + signer: { pubKeyB64: V.pubKeyB64, minSeq: 12, currentDigest: expect.any(String) } + }), + expect.any(Object) + ) + expect(pluginItems[id].discoverySeq).toBe(13) + expect(vaults[id].gateway.gateways).toEqual([GW3]) + }) +}) + +// §5b 公开字段轮换与首次登录顺序 +describe('signed discovery: public field rotation (§5b)', () => { + interface Vector { + seedB64: string + pubKeyB64: string + } + const V: Vector = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') + )[0] + const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + function envelope(payload: Record): { signed: string; digest: string } { + const bytes = Buffer.from(JSON.stringify(payload), 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(V.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + return { + signed: `${bytes.toString('base64')}.${sign(null, buildDiscoverySignInput(bytes), key).toString('base64')}`, + digest: sha256Hex(bytes) + } + } + const payload = (seq: number, extra: Record = {}): Record => ({ + spec: 'cpx-plugin/2', + seq, + gateways: [GW1], + endpoints: WK.endpoints, + ...extra + }) + const NEW_LOGIN = 'https://panel-new.xx.com/oauth/authorize' + + it('first login: a rotated loginUrl is persisted with seq/digest before the browser opens and the browser gets the new URL', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const { digest } = envelope( + payload(13, { loginUrl: NEW_LOGIN, discoveryUrls: ['https://cdn.xx.com'] }) + ) + discoverGateway.mockResolvedValueOnce({ + ...WK, + seq: 13, + digest, + loginUrl: NEW_LOGIN, + discoveryUrls: ['https://cdn.xx.com'] + }) + let seenAtBrowser: Partial = {} + let urlAtBrowser = '' + browserLogin.mockImplementationOnce(async (url: string) => { + seenAtBrowser = { ...pluginItems[item.id] } + urlAtBrowser = url + return { code: 'C', verifier: 'V', redirectUri: 'http://127.0.0.1:1/callback' } + }) + await loginPlugin(item.id) + expect(urlAtBrowser).toBe(NEW_LOGIN) + expect(seenAtBrowser.discoverySeq).toBe(13) + expect(seenAtBrowser.discoveryDigest).toBe(digest) + expect(seenAtBrowser.loginUrl).toBe(NEW_LOGIN) + expect(seenAtBrowser.discoveryUrls).toEqual(['https://cdn.xx.com']) + expect(pluginItems[item.id].status).toBe('active') + expect(pluginItems[item.id].loginUrl).toBe(NEW_LOGIN) + expect(pluginItems[item.id].discoveryUrls).toEqual(['https://cdn.xx.com']) + }) + + it('browser cancelled after a rotation → seq stays; the next discovery is signed with minSeq 13', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const { digest } = envelope(payload(13, { loginUrl: NEW_LOGIN })) + discoverGateway.mockResolvedValueOnce({ ...WK, seq: 13, digest, loginUrl: NEW_LOGIN }) + browserLogin.mockRejectedValueOnce(new Error('Login timed out')) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + expect(pluginItems[item.id].discoverySeq).toBe(13) + expect(pluginItems[item.id].loginUrl).toBe(NEW_LOGIN) + discoverGateway.mockClear() + discoverGateway.mockResolvedValueOnce({ ...WK, seq: 13, digest }) + await loginPlugin(item.id) + expect(discoverGateway).toHaveBeenCalledWith( + expect.objectContaining({ + signer: { pubKeyB64: V.pubKeyB64, minSeq: 13, currentDigest: digest } + }), + expect.any(Object) + ) + }) + + it('header rotates loginUrl and discoveryUrls; [] clears; missing leaves them unchanged', async () => { + const item = await installPlugin( + file({ providerPubKey: V.pubKeyB64, discoveryUrls: ['https://old.xx.com'] }) + ) + const first = envelope(payload(12)) + discoverGateway.mockResolvedValue({ ...WK, seq: 12, digest: first.digest }) + await loginPlugin(item.id) + expect(pluginItems[item.id].discoveryUrls).toEqual(['https://old.xx.com']) + + fetchConfig.mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope( + payload(13, { loginUrl: NEW_LOGIN, discoveryUrls: ['https://cdn.xx.com'] }) + ).signed + }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].loginUrl).toBe(NEW_LOGIN) + expect(pluginItems[item.id].discoveryUrls).toEqual(['https://cdn.xx.com']) + expect(pluginItems[item.id].discoverySeq).toBe(13) + + // no loginUrl in the payload: an entry equal to the CURRENT login origin is dropped at apply time + fetchConfig.mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope( + payload(14, { discoveryUrls: ['https://cdn.xx.com', 'https://panel-new.xx.com'] }) + ).signed + }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].loginUrl).toBe(NEW_LOGIN) + expect(pluginItems[item.id].discoveryUrls).toEqual(['https://cdn.xx.com']) + + // neither field present → unchanged + fetchConfig.mockResolvedValueOnce({ yaml: CLASH, discovery: envelope(payload(15)).signed }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].loginUrl).toBe(NEW_LOGIN) + expect(pluginItems[item.id].discoveryUrls).toEqual(['https://cdn.xx.com']) + + // [] clears + fetchConfig.mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope(payload(16, { discoveryUrls: [] })).signed + }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].discoveryUrls).toBeUndefined() + expect(pluginItems[item.id].discoverySeq).toBe(16) + }) + + it('failure between the vault write and the plugin.yaml write is repaired by the next same-seq arrival', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const first = envelope(payload(12)) + discoverGateway.mockResolvedValue({ ...WK, seq: 12, digest: first.digest }) + await loginPlugin(item.id) + const next = envelope(payload(13, { gateways: [GW2] })) + const { patchPluginItem: patchMock } = await import('../../config/plugin') + let failOnce = true + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + if (failOnce && id === item.id && patch.discoverySeq === 13) { + failOnce = false + throw new Error('disk full') + } + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + fetchConfig.mockResolvedValueOnce({ yaml: CLASH, discovery: next.signed }) + await expect(updatePluginProfile(item.id, true)).rejects.toThrow('disk full') + // vault advanced, marker did not + expect(vaults[item.id].gateway.gateways).toEqual([GW2]) + expect(pluginItems[item.id].discoverySeq).toBe(12) + fetchConfig.mockResolvedValueOnce({ yaml: CLASH, discovery: next.signed }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].discoverySeq).toBe(13) + expect(pluginItems[item.id].discoveryDigest).toBe(next.digest) + expect(vaults[item.id].gateway.gateways).toEqual([GW2]) + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + }) +}) + +// codex-review round 2 (cpx-v2-hardening-20260907) — login flow / deletion critical section +describe('R2 login flow and deletion fixes', () => { + interface Vector { + seedB64: string + pubKeyB64: string + } + const V: Vector = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') + )[0] + const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + function envelope(payload: Record): { signed: string; digest: string } { + const bytes = Buffer.from(JSON.stringify(payload), 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(V.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + return { + signed: `${bytes.toString('base64')}.${sign(null, buildDiscoverySignInput(bytes), key).toString('base64')}`, + digest: sha256Hex(bytes) + } + } + const payload = (seq: number, gateways: string[]): Record => ({ + spec: 'cpx-plugin/2', + seq, + gateways, + endpoints: WK.endpoints + }) + function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } + } + // keyed plugin logged in at seq 12 / [GW1] + async function keyedLoggedIn(): Promise { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const { digest } = envelope(payload(12, [GW1])) + discoverGateway.mockResolvedValue({ ...WK, gateways: [GW1], seq: 12, digest }) + await loginPlugin(item.id) + expect(pluginItems[item.id].discoverySeq).toBe(12) + return item.id + } + + it('R2-ISS-003: re-login with an existing vault syncs the vault before the browser opens; a cancelled browser leaves vault and seq consistent', async () => { + const id = await keyedLoggedIn() + pluginItems[id] = { ...pluginItems[id], status: 'needs-reauth' } + const next = envelope(payload(13, [GW2])) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW2], seq: 13, digest: next.digest }) + let vaultAtBrowser: string[] = [] + browserLogin.mockImplementationOnce(async () => { + vaultAtBrowser = [...vaults[id].gateway.gateways] + throw new Error('Login timed out') + }) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + expect(vaultAtBrowser).toEqual([GW2]) + expect(vaults[id].gateway.gateways).toEqual([GW2]) + expect(pluginItems[id].discoverySeq).toBe(13) + expect(pluginItems[id].discoveryDigest).toBe(next.digest) + }) + + it('R2-ISS-020: an enroll that rediscovers and then fails still commits the rediscovered list into the existing vault', async () => { + const id = await keyedLoggedIn() + pluginItems[id] = { ...pluginItems[id], status: 'needs-reauth' } + const { digest: d12 } = envelope(payload(12, [GW1])) + const { digest: d13 } = envelope(payload(13, [GW3])) + discoverGateway + .mockResolvedValueOnce({ ...WK, gateways: [GW1], seq: 12, digest: d12 }) // login discovery + .mockResolvedValueOnce({ ...WK, gateways: [GW3], seq: 13, digest: d13 }) // rediscovery in enroll + enroll + .mockRejectedValueOnce(new GatewayError('unreachable', 'refused', undefined, 'pre-send')) + .mockRejectedValueOnce(new GatewayError('transient', '503', 503)) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(vaults[id].gateway.gateways).toEqual([GW3]) + expect(pluginItems[id].discoverySeq).toBe(13) + expect(pluginItems[id].status).toBe('needs-reauth') + }) + + it('R2-ISS-008: a header accepted during the browser wait redirects the enroll and the new vault', async () => { + const id = await keyedLoggedIn() + const oldDevice = vaults[id].deviceId + const browser = deferred<{ code: string; verifier: string; redirectUri: string }>() + browserLogin.mockReturnValueOnce(browser.promise) + const login = loginPlugin(id) + await new Promise((r) => setTimeout(r, 5)) + // meanwhile a scheduled update (old device, still valid) accepts seq 13 / [GW2] + fetchConfig.mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope(payload(13, [GW2])).signed + }) + await updatePluginProfile(id, true) + expect(vaults[id].gateway.gateways).toEqual([GW2]) + browser.resolve({ code: 'C', verifier: 'V', redirectUri: 'http://127.0.0.1:1/callback' }) + await login + const enrollTarget = enroll.mock.calls[enroll.mock.calls.length - 1][0] as { gateway: string } + expect(enrollTarget.gateway).toBe(GW2) + expect(vaults[id].deviceId).not.toBe(oldDevice) + expect(vaults[id].gateway.gateways).toEqual([GW2]) + expect(pluginItems[id].discoverySeq).toBe(13) + }) + + it('R2-ISS-001: an update queued while enroll is in flight runs after the new vault exists and cannot be rolled back', async () => { + const id = await keyedLoggedIn() + const slowEnroll = deferred() + enroll.mockReturnValueOnce(slowEnroll.promise) + const login = loginPlugin(id) + await new Promise((r) => setTimeout(r, 5)) + fetchConfig.mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope(payload(14, [GW3])).signed + }) + const update = updatePluginProfile(id, true) + await new Promise((r) => setTimeout(r, 5)) + expect(fetchConfig).toHaveBeenCalledTimes(1) // the update is still queued behind the enroll op + slowEnroll.resolve() + await Promise.all([login, update]) + expect(pluginItems[id].discoverySeq).toBe(14) + expect(vaults[id].gateway.gateways).toEqual([GW3]) + expect(pluginItems[id].status).toBe('active') + }) + + it('R2-ISS-021: a re-login whose vault write fails keeps the still-valid old vault and revokes only the new device', async () => { + const id = await keyedLoggedIn() + const oldDevice = vaults[id].deviceId + pluginItems[id] = { ...pluginItems[id], status: 'needs-reauth' } + unwritableVaults.add(id) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + expect(vaults[id].deviceId).toBe(oldDevice) + expect(revoke).toHaveBeenCalledOnce() + expect((revoke.mock.calls[0][1] as { deviceId: string }).deviceId).not.toBe(oldDevice) + }) + + it('R2-ISS-036: needs-reauth re-login that enrolls but fails its first fetch reuses the new device next time', async () => { + const id = await keyedLoggedIn() + const oldDevice = vaults[id].deviceId + pluginItems[id] = { ...pluginItems[id], status: 'needs-reauth' } + fetchConfig.mockRejectedValueOnce(new GatewayError('transient', '503', 503)) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(pluginItems[id].status).toBe('needs-login') + const newDevice = vaults[id].deviceId + expect(newDevice).not.toBe(oldDevice) + const browserCalls = browserLogin.mock.calls.length + const enrollCalls = enroll.mock.calls.length + await loginPlugin(id) + expect(browserLogin.mock.calls.length).toBe(browserCalls) // orphan-device branch: no browser + expect(enroll.mock.calls.length).toBe(enrollCalls) // and no new device + expect(vaults[id].deviceId).toBe(newDevice) + expect(pluginItems[id].status).toBe('active') + }) + + it('R2-ISS-037: a possibly-sent enroll failure best-effort revokes the in-memory device; a pre-send failure does not', async () => { + const a = await installPlugin(file()) + enroll.mockRejectedValueOnce( + new GatewayError('unreachable', 'reset', undefined, 'possibly-sent') + ) + await expect(loginPlugin(a.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(revoke).toHaveBeenCalledOnce() + const enrolledDevice = (enroll.mock.calls[0][1] as { deviceId: string }).deviceId + expect((revoke.mock.calls[0][1] as { deviceId: string }).deviceId).toBe(enrolledDevice) + expect(vaults[a.id]).toBeUndefined() + + revoke.mockClear() + const b = await installPlugin(file()) + enroll.mockRejectedValueOnce(new GatewayError('unreachable', 'refused', undefined, 'pre-send')) + await expect(loginPlugin(b.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(revoke).not.toHaveBeenCalled() + }) + + it('R2-ISS-037b: the uncertain-enroll compensation also runs when the metadata patch throws or the commit was skipped by a tombstone', async () => { + // (1) possibly-sent failure carrying a provider message (so the failure patch is non-empty) + the patch throws + // → the compensation still revokes the device + const a = await installPlugin(file()) + const { patchPluginItem: patchMock } = await import('../../config/plugin') + const uncertain = new GatewayError('unreachable', 'reset', undefined, 'possibly-sent') + uncertain.providerMessage = '维护中' + enroll.mockRejectedValueOnce(uncertain) + let armed = false + browserLogin.mockImplementationOnce(async () => { + armed = true + return { code: 'C', verifier: 'V', redirectUri: 'http://127.0.0.1:1/callback' } + }) + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + if (armed && id === a.id) throw new Error('disk full') + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + await expect(loginPlugin(a.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + vi.mocked(patchMock).mockImplementation(async (id: string, patch: Partial) => { + pluginItems[id] = { ...pluginItems[id], ...patch } + }) + expect(revoke).toHaveBeenCalledOnce() + + // (2) removal queued while a possibly-sent enroll failure is in flight → commit skipped by the tombstone, + // the compensation still revokes the device + revoke.mockClear() + const c = await installPlugin(file()) + let rejectEnroll!: (e: unknown) => void + enroll.mockReturnValueOnce(new Promise((_, rej) => (rejectEnroll = rej))) + const login = loginPlugin(c.id) + await new Promise((r) => setTimeout(r, 5)) + const removal = removePlugin(c.id) + await new Promise((r) => setTimeout(r, 5)) + rejectEnroll(new GatewayError('unreachable', 'reset', undefined, 'possibly-sent')) + await expect(login).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + await removal + expect(revoke).toHaveBeenCalledOnce() + expect(pluginItems[c.id]).toBeUndefined() + }) + + it('R2-ISS-038: a failed profile creation on first login does not produce a duplicate profile on retry', async () => { + const item = await installPlugin(file()) + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockRejectedValueOnce(new Error('disk full')) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + const reserved = pluginItems[item.id].profileId + expect(reserved).toBeDefined() + expect(Object.keys(profiles)).toHaveLength(0) + await loginPlugin(item.id) // orphan-device branch → fetch → finishLogin + expect(pluginItems[item.id].profileId).toBe(reserved) + expect(Object.keys(profiles)).toEqual([reserved]) + expect(pluginItems[item.id].status).toBe('active') + }) + + it('R2-ISS-064: a re-login that replaces a still-valid device revokes the old one after the login completes', async () => { + const id = await keyedLoggedIn() + const oldDevice = vaults[id].deviceId + revoke.mockClear() + await loginPlugin(id) // active → fresh browser login + new device + expect(vaults[id].deviceId).not.toBe(oldDevice) + expect(revoke).toHaveBeenCalledOnce() + expect((revoke.mock.calls[0][1] as { deviceId: string }).deviceId).toBe(oldDevice) + expect(vaults[id].staleDevices).toBeUndefined() + }) + + it('R2-ISS-064: when the old device cannot be revoked it stays in the vault and is retried on the next successful fetch', async () => { + const id = await keyedLoggedIn() + const oldDevice = vaults[id].deviceId + revoke.mockClear() + revoke.mockRejectedValueOnce(new GatewayError('unreachable', 'gateway down')) + await loginPlugin(id) + expect(vaults[id].staleDevices?.map((d) => d.deviceId)).toEqual([oldDevice]) + expect(pluginItems[id].status).toBe('active') // the login itself is not affected + revoke.mockClear() + await updatePluginProfile(id, true) + expect(revoke).toHaveBeenCalledOnce() + expect((revoke.mock.calls[0][1] as { deviceId: string }).deviceId).toBe(oldDevice) + expect(vaults[id].staleDevices).toBeUndefined() + }) + + it('R2-ISS-064: plugin removal revokes the stale devices as well as the current one', async () => { + const id = await keyedLoggedIn() + const current = vaults[id].deviceId + vaults[id] = { + ...vaults[id], + staleDevices: [ + { + deviceId: '33333333-3333-4333-8333-333333333333', + devicePrivKey: vaults[id].devicePrivKey + } + ] + } + revoke.mockClear() + await removePlugin(id) + expect(revoke.mock.calls.map((c) => (c[1] as { deviceId: string }).deviceId)).toEqual([ + '33333333-3333-4333-8333-333333333333', + current + ]) + }) + + it('R2-ISS-068: retiring several stale devices — the second one starts from the gateway state the first one committed', async () => { + const id = await keyedLoggedIn() // seq 12 / GW1 + const key = vaults[id].devicePrivKey + const X = '33333333-3333-4333-8333-333333333333' + const Y = '44444444-4444-4444-8444-444444444444' + vaults[id] = { + ...vaults[id], + staleDevices: [ + { deviceId: X, devicePrivKey: key }, + { deviceId: Y, devicePrivKey: key } + ] + } + // X's revoke finds GW1 unreachable → rediscovery yields seq 13 / GW2 → X is revoked on GW2 + const next = envelope(payload(13, [GW2])) + discoverGateway.mockResolvedValueOnce({ ...WK, gateways: [GW2], seq: 13, digest: next.digest }) + revoke.mockClear() + revoke.mockRejectedValueOnce(new GatewayError('unreachable', 'refused', undefined, 'pre-send')) + await updatePluginProfile(id, true) + expect(vaults[id].staleDevices).toBeUndefined() + // the committed state is the rediscovered one, and Y did not drag the pre-rediscovery gateway list back in + expect(vaults[id].gateway.gateways).toEqual([GW2]) + expect(pluginItems[id].discoverySeq).toBe(13) + expect(revoke.mock.calls.map((c) => (c[1] as { deviceId: string }).deviceId)).toEqual([X, X, Y]) + }) + + it('R2-ISS-069: a stale device the server no longer knows is pruned instead of blocking the ones behind it', async () => { + const id = await keyedLoggedIn() + const key = vaults[id].devicePrivKey + const X = '33333333-3333-4333-8333-333333333333' + const Y = '44444444-4444-4444-8444-444444444444' + vaults[id] = { + ...vaults[id], + staleDevices: [ + { deviceId: X, devicePrivKey: key }, + { deviceId: Y, devicePrivKey: key } + ] + } + revoke.mockClear() + revoke.mockRejectedValueOnce(new GatewayError('revoked', 'device_revoked')) + await updatePluginProfile(id, true) + expect(vaults[id].staleDevices).toBeUndefined() + expect(revoke.mock.calls.map((c) => (c[1] as { deviceId: string }).deviceId)).toEqual([X, Y]) + }) + + it('R2-ISS-070: a skipped tick (autoUpdate off / backoff) does not start a retirement', async () => { + const id = await keyedLoggedIn() + vaults[id] = { + ...vaults[id], + staleDevices: [ + { + deviceId: '33333333-3333-4333-8333-333333333333', + devicePrivKey: vaults[id].devicePrivKey + } + ] + } + pluginItems[id] = { ...pluginItems[id], autoUpdate: false } + revoke.mockClear() + fetchConfig.mockClear() + await updatePluginProfile(id) + expect(fetchConfig).not.toHaveBeenCalled() + expect(revoke).not.toHaveBeenCalled() + expect(vaults[id].staleDevices).toHaveLength(1) + }) + + it('R2-ISS-070 (V2): a fetch whose subscription fails core validation does not start a retirement', async () => { + const id = await keyedLoggedIn() + vaults[id] = { + ...vaults[id], + staleDevices: [ + { + deviceId: '33333333-3333-4333-8333-333333333333', + devicePrivKey: vaults[id].devicePrivKey + } + ] + } + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockRejectedValueOnce( + Object.assign(new Error('bad config'), { code: 'PLUGIN_PROFILE_INVALID' }) + ) + revoke.mockClear() + await updatePluginProfile(id, true) + expect(pluginItems[id].lastUpdateErrorType).toBe('transient') + expect(revoke).not.toHaveBeenCalled() + expect(vaults[id].staleDevices).toHaveLength(1) + }) + + it('R2-ISS-041: a re-login whose metadata patch fails after the vault swap restores the old device vault', async () => { + const id = await keyedLoggedIn() // seq 12 / device A + const oldVault = vaults[id] + const oldDevice = oldVault.deviceId + // enroll rediscovers seq 13 so the success commit's metadata patch is non-empty (seq/digest) + const next = envelope(payload(13, [GW2])) + discoverGateway + .mockResolvedValueOnce({ + ...WK, + gateways: [GW1], + seq: 12, + digest: envelope(payload(12, [GW1])).digest + }) + .mockResolvedValueOnce({ ...WK, gateways: [GW2], seq: 13, digest: next.digest }) + enroll + .mockRejectedValueOnce(new GatewayError('unreachable', 'refused', undefined, 'pre-send')) + .mockResolvedValueOnce(undefined) // second enroll (after rediscovery) succeeds → new device B written + const { patchPluginItem: patchMock } = await import('../../config/plugin') + vi.mocked(patchMock).mockImplementation(async (pid: string, patch: Partial) => { + if (pid === id && patch.discoverySeq === 13) throw new Error('disk full') + pluginItems[pid] = { ...pluginItems[pid], ...patch } + }) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + vi.mocked(patchMock).mockImplementation(async (pid: string, patch: Partial) => { + pluginItems[pid] = { ...pluginItems[pid], ...patch } + }) + // the old device vault is restored (still usable / revocable); the new device was best-effort revoked + expect(vaults[id].deviceId).toBe(oldDevice) + expect(revoke).toHaveBeenCalledOnce() + expect((revoke.mock.calls[0][1] as { deviceId: string }).deviceId).not.toBe(oldDevice) + }) + + it('R2-ISS-041 (follow-up): a new device whose compensation revoke fails is kept in the restored vault and retired by a later fetch', async () => { + const id = await keyedLoggedIn() // seq 12 / device A + const oldDevice = vaults[id].deviceId + const next = envelope(payload(13, [GW2])) + discoverGateway + .mockResolvedValueOnce({ + ...WK, + gateways: [GW1], + seq: 12, + digest: envelope(payload(12, [GW1])).digest + }) + .mockResolvedValueOnce({ ...WK, gateways: [GW2], seq: 13, digest: next.digest }) + enroll + .mockRejectedValueOnce(new GatewayError('unreachable', 'refused', undefined, 'pre-send')) + .mockResolvedValueOnce(undefined) + const { patchPluginItem: patchMock } = await import('../../config/plugin') + vi.mocked(patchMock).mockImplementation(async (pid: string, patch: Partial) => { + if (pid === id && patch.discoverySeq === 13) throw new Error('disk full') + pluginItems[pid] = { ...pluginItems[pid], ...patch } + }) + revoke.mockClear() + // the compensation cannot reach any gateway (recovery retries included) + revoke.mockRejectedValue(new GatewayError('unreachable', 'gateway down')) + await expect(loginPlugin(id)).rejects.toThrow('PLUGIN_LOGIN_FAILED') + vi.mocked(patchMock).mockImplementation(async (pid: string, patch: Partial) => { + pluginItems[pid] = { ...pluginItems[pid], ...patch } + }) + expect(revoke).toHaveBeenCalled() + const newDevice = (revoke.mock.calls[0][1] as { deviceId: string }).deviceId + revoke.mockReset().mockResolvedValue(undefined) + expect(newDevice).not.toBe(oldDevice) + // the old device vault is back, and it remembers the un-revoked new device + expect(vaults[id].deviceId).toBe(oldDevice) + expect(vaults[id].staleDevices?.map((d) => d.deviceId)).toEqual([newDevice]) + // a later successful fetch retires it + revoke.mockClear() + await updatePluginProfile(id, true) + expect(revoke.mock.calls.map((c) => (c[1] as { deviceId: string }).deviceId)).toEqual([ + newDevice + ]) + expect(vaults[id].staleDevices).toBeUndefined() + }) + + it('BL-002: a fetched subscription rejected by core validation keeps the old profile and backs off as server', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const pid = pluginItems[item.id].profileId! + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockRejectedValueOnce( + Object.assign(new Error("proxy 'x' not found"), { code: 'PLUGIN_PROFILE_INVALID' }) + ) + fetchConfig.mockResolvedValueOnce({ yaml: 'proxies:\n - {name: broken}\n' }) + await updatePluginProfile(item.id, true) + expect(profiles[pid]).toBe(CLASH) // old subscription untouched + expect(pluginItems[item.id].status).toBe('active') + expect(pluginItems[item.id].lastUpdateErrorType).toBe('transient') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('server') + expect(pluginItems[item.id].nextRetryAt).toBeDefined() + // a later good fetch clears the failure state + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastUpdateErrorReason).toBeUndefined() + }) + + it('BL-002: a first login whose subscription fails validation ends as NETWORK and the next login reuses the device', async () => { + const item = await installPlugin(file()) + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockRejectedValueOnce( + Object.assign(new Error('bad config'), { code: 'PLUGIN_PROFILE_INVALID' }) + ) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(pluginItems[item.id].status).toBe('needs-login') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('server') + expect(vaults[item.id]).toBeDefined() + expect(Object.keys(profiles)).toHaveLength(0) + const enrollCalls = enroll.mock.calls.length + await loginPlugin(item.id) // orphan-device branch: same device, valid config now + expect(enroll.mock.calls.length).toBe(enrollCalls) + expect(pluginItems[item.id].status).toBe('active') + expect(Object.keys(profiles)).toHaveLength(1) + }) + + it('BL-003: changing autoUpdate / interval through the IPC patch syncs the profile schedule', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const pid = pluginItems[item.id].profileId! + const { syncPluginProfileSchedule: sync } = await import('../../config/profile') + vi.mocked(sync).mockClear() + await patchPluginItem(item.id, { autoUpdate: false }) + expect(sync).toHaveBeenCalledWith(pid, { autoUpdate: false }) + await patchPluginItem(item.id, { interval: 30 }) + expect(sync).toHaveBeenLastCalledWith(pid, { interval: 30 }) + await patchPluginItem(item.id, { routeMode: 'proxy' }) // unrelated field → no schedule sync + expect(sync).toHaveBeenCalledTimes(2) + }) + + it('BL-003: a scheduled (non-forced) update is skipped once autoUpdate is off; a forced one still fetches', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + pluginItems[item.id] = { ...pluginItems[item.id], autoUpdate: false } + fetchConfig.mockClear() + await updatePluginProfile(item.id) + expect(fetchConfig).not.toHaveBeenCalled() + await updatePluginProfile(item.id, true) + expect(fetchConfig).toHaveBeenCalledOnce() + }) + + it('BL-002 (V6): the commit signal is forwarded to the profile write on login and on update', async () => { + const item = await installPlugin(file()) + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockClear() + await loginPlugin(item.id) + expect(vi.mocked(upsert).mock.calls[0][2]).toBeInstanceOf(AbortSignal) + await updatePluginProfile(item.id, true) + expect(vi.mocked(upsert).mock.calls[1][2]).toBeInstanceOf(AbortSignal) + }) + + it('R2-ISS-052: the profile write carries no schedule snapshot — the schedule is read at write time', async () => { + const item = await installPlugin(file()) + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockClear() + await loginPlugin(item.id) + await updatePluginProfile(item.id, true) + for (const call of vi.mocked(upsert).mock.calls) { + expect(Object.keys(call[0]).sort()).toEqual(['name', 'pluginId', 'profileId']) + } + }) + + it('R2-ISS-051: a validation rejected because the op budget ran out is recorded as network, not server', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const { upsertPluginProfile: upsert } = await import('../../config/profile') + vi.mocked(upsert).mockImplementationOnce(async (_m, _c, signal?: AbortSignal) => { + // the core check outlived the budget: the signal fires, the profile write refuses to land + await new Promise((resolve) => signal!.addEventListener('abort', () => resolve())) + throw Object.assign(new Error('aborted'), { code: 'PLUGIN_PROFILE_INVALID' }) + }) + vi.useFakeTimers() + try { + const p = updatePluginProfile(item.id, true) + await vi.advanceTimersByTimeAsync(130_000) + await p + } finally { + vi.useRealTimers() + } + expect(pluginItems[item.id].lastUpdateErrorType).toBe('transient') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('network') + expect(pluginItems[item.id].status).toBe('active') + }) + + it('R2-ISS-012: a profile deletion that throws (core restart) still removes the record and the vault', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const { removePluginProfileContent: rm } = await import('../../config/profile') + vi.mocked(rm).mockRejectedValueOnce(new Error('core restart failed')) + await expect(removePlugin(item.id)).rejects.toThrow('core restart failed') + expect(pluginItems[item.id]).toBeUndefined() + expect(vaults[item.id]).toBeUndefined() + expect(revoke).toHaveBeenCalledOnce() + }) +}) + +// codex-review cycle 1 — 修复回归测试 +describe('codex-review fixes', () => { + function deferred(): { + promise: Promise + resolve: (v: T) => void + reject: (e: unknown) => void + } { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((r, j) => { + resolve = r + reject = j + }) + return { promise, resolve, reject } + } + interface Vector { + seedB64: string + pubKeyB64: string + } + const V: Vector = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', 'discovery-vectors.json'), 'utf-8') + )[0] + const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex') + function envelope(payload: Record): { signed: string; digest: string } { + const bytes = Buffer.from(JSON.stringify(payload), 'utf-8') + const key = createPrivateKey({ + key: Buffer.concat([PKCS8, Buffer.from(V.seedB64, 'base64')]), + format: 'der', + type: 'pkcs8' + }) + return { + signed: `${bytes.toString('base64')}.${sign(null, buildDiscoverySignInput(bytes), key).toString('base64')}`, + digest: sha256Hex(bytes) + } + } + const payload = (seq: number, extra: Record = {}): Record => ({ + spec: 'cpx-plugin/2', + seq, + gateways: [GW1], + endpoints: WK.endpoints, + ...extra + }) + + it('ISS-001: profiles-list cascade removes the profile even when an in-flight update re-created it', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const pid = pluginItems[item.id].profileId! + const slow = deferred<{ yaml: string }>() + fetchConfig.mockReturnValueOnce(slow.promise) + const update = updatePluginProfile(item.id, true) + await new Promise((r) => setTimeout(r, 5)) + // 模拟 profile.ts 的级联入口:删除排在在途更新之后 + const removal = removePluginForProfile(item.id, pid) + await new Promise((r) => setTimeout(r, 5)) + slow.resolve({ yaml: CLASH }) + await Promise.all([update, removal]) + expect(profiles[pid]).toBeUndefined() + expect(pluginItems[item.id]).toBeUndefined() + expect(vaults[item.id]).toBeUndefined() + }) + + it('ISS-006: removePlugin while /enroll is in flight still revokes the new device with the in-memory key', async () => { + const item = await installPlugin(file()) + const slow = deferred() + enroll.mockReturnValueOnce(slow.promise) + const login = loginPlugin(item.id) + await new Promise((r) => setTimeout(r, 10)) // discovery + browser done, enroll in flight + expect(enroll).toHaveBeenCalledTimes(1) + const removal = removePlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + slow.resolve() + await expect(login).rejects.toThrow('PLUGIN_LOGIN_FAILED') + await removal + const [, cred] = revoke.mock.calls[revoke.mock.calls.length - 1] + expect(revoke).toHaveBeenCalled() + expect((cred as { deviceId: string }).deviceId).toBe( + (enroll.mock.calls[0][1] as { deviceId: string }).deviceId + ) + expect(pluginItems[item.id]).toBeUndefined() + expect(vaults[item.id]).toBeUndefined() + }) + + it('ISS-023: removePlugin during the discovery op fails the login before the browser opens', async () => { + const item = await installPlugin(file()) + const slow = deferred() + discoverGateway.mockReturnValueOnce(slow.promise) + browserLogin.mockClear() + const login = loginPlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + const removal = removePlugin(item.id) + await new Promise((r) => setTimeout(r, 5)) + slow.resolve(WK) + await expect(login).rejects.toThrow('PLUGIN_LOGIN_FAILED') + await removal + expect(browserLogin).not.toHaveBeenCalled() + expect(enroll).not.toHaveBeenCalled() + }) + + it('ISS-002: enroll receiving retired (410) does not switch gateways or replay the code', async () => { + const item = await installPlugin(file()) + discoverGateway.mockResolvedValue({ ...WK, gateways: [GW1, GW2] }) + enroll.mockReset().mockRejectedValueOnce(new GatewayError('retired', 'gone', 410)) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(enroll).toHaveBeenCalledTimes(1) + expect((enroll.mock.calls[0][0] as { gateway: string }).gateway).toBe(GW1) + }) + + it('ISS-004: enroll failure after a rediscovery still persists the rediscovered seq and loginUrl', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const first = envelope(payload(12)) + const second = envelope(payload(13, { loginUrl: 'https://panel-new.xx.com/oauth/authorize' })) + discoverGateway + .mockResolvedValueOnce({ ...WK, seq: 12, digest: first.digest }) + .mockResolvedValueOnce({ + ...WK, + gateways: [GW2], + seq: 13, + digest: second.digest, + loginUrl: 'https://panel-new.xx.com/oauth/authorize' + }) + enroll + .mockRejectedValueOnce(new GatewayError('unreachable', 'ECONNREFUSED', undefined, 'pre-send')) + .mockRejectedValueOnce(new GatewayError('transient', '503', 503)) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + expect(pluginItems[item.id].discoverySeq).toBe(13) + expect(pluginItems[item.id].discoveryDigest).toBe(second.digest) + expect(pluginItems[item.id].loginUrl).toBe('https://panel-new.xx.com/oauth/authorize') + expect(vaults[item.id]).toBeUndefined() + }) + + it('ISS-008: a skipped scheduled update keeps lastProviderMessage (needs-reauth and backoff)', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const err = new GatewayError('revoked', 'revoked', 403) + err.providerMessage = '账号已停用' + fetchConfig.mockRejectedValueOnce(err) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].status).toBe('needs-reauth') + expect(pluginItems[item.id].lastProviderMessage).toBe('账号已停用') + fetchConfig.mockClear() + await updatePluginProfile(item.id) // scheduler tick + expect(fetchConfig).not.toHaveBeenCalled() + expect(pluginItems[item.id].lastProviderMessage).toBe('账号已停用') + + pluginItems[item.id] = { + ...pluginItems[item.id], + status: 'active', + nextRetryAt: Date.now() + 60_000, + lastProviderMessage: '维护中' + } + await updatePluginProfile(item.id) + expect(pluginItems[item.id].lastProviderMessage).toBe('维护中') + }) + + it('ISS-009: signed mode — the same origin with a higher rediscovered seq is tried again', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const first = envelope(payload(12)) + discoverGateway.mockResolvedValue({ ...WK, seq: 12, digest: first.digest }) + await loginPlugin(item.id) + const next = envelope(payload(13)) + discoverGateway.mockClear().mockResolvedValueOnce({ ...WK, seq: 13, digest: next.digest }) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH }) + await updatePluginProfile(item.id, true) + expect(fetchConfig).toHaveBeenCalledTimes(3) // login + failed + retried after rediscovery + expect(pluginItems[item.id].status).toBe('active') + expect(pluginItems[item.id].discoverySeq).toBe(13) + }) + + it('ISS-012: a stale header after an accepted rediscovery is ignored; a newer one is applied', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const first = envelope(payload(12)) + discoverGateway.mockResolvedValue({ ...WK, seq: 12, digest: first.digest }) + await loginPlugin(item.id) + const redisc = envelope(payload(13, { gateways: [GW3] })) + discoverGateway + .mockClear() + .mockResolvedValueOnce({ ...WK, gateways: [GW3], seq: 13, digest: redisc.digest }) + fetchConfig.mockRejectedValueOnce(new GatewayError('unreachable', 'x')).mockResolvedValueOnce({ + yaml: CLASH, + discovery: envelope(payload(12, { gateways: [GW2] })).signed + }) + await updatePluginProfile(item.id, true) + expect(vaults[item.id].gateway.gateways).toEqual([GW3]) + expect(pluginItems[item.id].discoverySeq).toBe(13) + + discoverGateway.mockResolvedValueOnce({ + ...WK, + gateways: [GW3], + seq: 13, + digest: redisc.digest + }) + vaults[item.id] = { ...vaults[item.id], gateway: state([GW1]) } + const newer = envelope(payload(14, { gateways: [GW2] })) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH, discovery: newer.signed }) + await updatePluginProfile(item.id, true) + expect(vaults[item.id].gateway.gateways).toEqual([GW2]) + expect(pluginItems[item.id].discoverySeq).toBe(14) + }) + + it('ISS-012 (H2): a newer header applies its public fields relative to the rediscovered candidate', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + const first = envelope(payload(12)) + discoverGateway.mockResolvedValue({ ...WK, seq: 12, digest: first.digest }) + await loginPlugin(item.id) + const original = pluginItems[item.id].loginUrl + const redisc = envelope( + payload(13, { + loginUrl: 'https://panel-new.xx.com/oauth/authorize', + discoveryUrls: ['https://cdn.xx.com'] + }) + ) + discoverGateway.mockClear().mockResolvedValueOnce({ + ...WK, + seq: 13, + digest: redisc.digest, + loginUrl: 'https://panel-new.xx.com/oauth/authorize', + discoveryUrls: ['https://cdn.xx.com'] + }) + // 更高 seq 的响应头把 loginUrl 恢复为原值并清空 discoveryUrls + const newer = envelope(payload(14, { loginUrl: original, discoveryUrls: [] })) + fetchConfig + .mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + .mockResolvedValueOnce({ yaml: CLASH, discovery: newer.signed }) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].discoverySeq).toBe(14) + expect(pluginItems[item.id].loginUrl).toBe(original) + expect(pluginItems[item.id].discoveryUrls).toBeUndefined() + }) + + it('ISS-024: retired with a provider message followed by a failed rediscovery keeps the message and the network classification', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + const retired = new GatewayError('retired', 'gone', 410) + retired.providerMessage = '服务地址已更换' + fetchConfig.mockRejectedValueOnce(retired) + discoverGateway + .mockClear() + .mockRejectedValueOnce( + Object.assign(new Error('Discovery failed: status 404'), { status: 404 }) + ) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].status).toBe('active') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('network') + expect(pluginItems[item.id].lastProviderMessage).toBe('服务地址已更换') + + // 登录路径:同样脱敏为 NETWORK 而不是 FAILED + pluginItems[item.id] = { ...pluginItems[item.id], status: 'needs-login' } + fetchConfig.mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + discoverGateway.mockRejectedValueOnce(new Error('Invalid gateway discovery: not valid JSON')) + await expect(loginPlugin(item.id)).rejects.toThrow('PLUGIN_LOGIN_NETWORK') + }) + + it('ISS-024 (multi-gateway): the retired message survives a later switchable error without one', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + vaults[item.id] = { ...vaults[item.id], gateway: state([GW1, GW2]) } + const retired = new GatewayError('retired', 'gone', 410) + retired.providerMessage = '服务地址已更换' + fetchConfig + .mockRejectedValueOnce(retired) + .mockRejectedValueOnce(new GatewayError('unreachable', 'ENOTFOUND')) + discoverGateway.mockClear().mockRejectedValueOnce(new Error('Discovery failed: status 404')) + await updatePluginProfile(item.id, true) + expect(fetchConfig).toHaveBeenCalledTimes(3) + expect(pluginItems[item.id].lastProviderMessage).toBe('服务地址已更换') + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('network') + }) + + it('REG-2: a retired message is not carried into a blocked terminal', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + vaults[item.id] = { ...vaults[item.id], gateway: state([GW1, GW2]) } + const retired = new GatewayError('retired', 'gone', 410) + retired.providerMessage = '服务地址已更换' + fetchConfig + .mockRejectedValueOnce(retired) + .mockRejectedValueOnce(new GatewayError('unreachable', 'ENOTFOUND')) + discoverGateway.mockClear().mockRejectedValueOnce( + Object.assign(new Error('Refusing to connect to non-public address: 10.0.0.1'), { + code: 'CPX_GUARD_REFUSED', + phase: 'pre-send' + }) + ) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('blocked') + expect(pluginItems[item.id].lastProviderMessage).toBeUndefined() + }) + + it('ISS-020: a discovery source refused by the guard is classified as blocked', async () => { + const item = await installPlugin(file()) + await loginPlugin(item.id) + fetchConfig.mockRejectedValueOnce(new GatewayError('unreachable', 'x')) + discoverGateway.mockClear().mockRejectedValueOnce( + Object.assign(new Error('Refusing to connect to non-public address: 10.0.0.1'), { + code: 'CPX_GUARD_REFUSED', + phase: 'pre-send' + }) + ) + await updatePluginProfile(item.id, true) + expect(pluginItems[item.id].lastUpdateErrorReason).toBe('blocked') + }) + + it('ISS-015/022: the IPC patch entry only accepts editable fields and a valid routeMode', async () => { + const item = await installPlugin(file({ providerPubKey: V.pubKeyB64 })) + await expect(patchPluginItem(item.id, { providerPubKey: undefined })).rejects.toThrow( + /not editable/ + ) + await expect( + patchPluginItem(item.id, { discoverySeq: 1 } as Partial) + ).rejects.toThrow(/not editable/) + await expect( + patchPluginItem(item.id, { routeMode: 'true' as unknown as IPluginRouteMode }) + ).rejects.toThrow(/routeMode/) + expect(pluginItems[item.id].providerPubKey).toBe(V.pubKeyB64) + expect(pluginItems[item.id].routeMode).toBe('auto') + await patchPluginItem(item.id, { routeMode: 'proxy', interval: 30, autoUpdate: false }) + expect(pluginItems[item.id]).toMatchObject({ routeMode: 'proxy', useProxy: true, interval: 30 }) + }) +}) diff --git a/src/main/resolve/plugin/index.ts b/src/main/resolve/plugin/index.ts index a09cd279..fe28a5d7 100644 --- a/src/main/resolve/plugin/index.ts +++ b/src/main/resolve/plugin/index.ts @@ -2,22 +2,35 @@ import { randomUUID } from 'crypto' import { getPluginItem, addPluginItem, - updatePluginItem, removePluginItem, - patchPluginItem as patchConfig + patchPluginItem as patchConfig, + pluginSchedule, + DEFAULT_PLUGIN_INTERVAL_MIN } from '../../config/plugin' -import { upsertPluginProfile, removePluginProfileContent } from '../../config/profile' +import { + upsertPluginProfile, + removePluginProfileContent, + isPluginProfileInvalidError, + syncPluginProfileSchedule +} from '../../config/profile' import { getAppConfig } from '../../config/app' import { mainWindow } from '../../window' import { parseDescriptor } from './descriptor' -import { discoverGateway } from './discovery' +import { discoverGateway, originOf } from './discovery' +import { checkSeq, parseSigned } from './discovery-sig' +import { CPX_GUARD_REFUSED, codeOf } from './errors' +import { warnLog } from './log' import { browserLogin, CLIENT_ID } from './oauth' -import { generateDevice } from './device' +import { generateDevice, type DeviceKeys } from './device' import { enroll, fetchConfig, revoke, GatewayError, type GatewayTarget } from './gateway' +import { effectiveRouteMode, isBaseRoute, isRouteMode, type BaseRoute } from './route' +import { normalizeEndpointPath } from './gateway-url' import { - writeVault, readVault, + writeVault, + updateVault, removeVault, + removeVaultIfDevice, hasVaultMaterial, ensureVaultWritable, VaultUnavailableError @@ -25,29 +38,26 @@ import { import { computeBackoff } from './backoff' import { MAX_PLUGIN_FILE_BYTES } from './constants' import { fetchRemotePlugin } from './remote' - -const DEFAULT_PLUGIN_INTERVAL_MIN = 1440 // 24h +import { + runOperation, + runPluginOperation, + withPluginLock, + markPluginRemoved, + isPluginRemoved, + createBudget, + DEFAULT_BUDGET_MS, + PluginNotFoundError, + type OperationBudget, + type OperationContext, + type OperationResult, + type RetryPolicy +} from './operation' function notifyRenderer(): void { mainWindow?.webContents.send('pluginConfigUpdated') mainWindow?.webContents.send('profileConfigUpdated') } -interface NetOpts { - timeout: number - proxy?: { host: string; port: number } -} - -async function netOpts(item?: IPluginItem): Promise { - const { subscriptionTimeout = 30000, pluginUseProxy: globalUseProxy = false } = - await getAppConfig() - const useProxy = typeof item?.useProxy === 'boolean' ? item.useProxy : globalUseProxy - if (!useProxy) 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') @@ -64,7 +74,9 @@ export async function previewPlugin(fileBytesB64: string): Promise new URL(u).host) } : {}), + ...(d.provider.description ? { description: d.provider.description } : {}) } } @@ -80,9 +92,14 @@ export async function installPlugin(fileBytesB64: string): Promise site: d.provider.site, loginUrl: d.loginUrl, spec: d.spec, + ...(d.discoveryUrls ? { discoveryUrls: d.discoveryUrls } : {}), + ...(d.provider.description ? { description: d.provider.description } : {}), + ...(d.providerPubKey ? { providerPubKey: d.providerPubKey } : {}), status: 'needs-login', interval: DEFAULT_PLUGIN_INTERVAL_MIN, autoUpdate: true, + // 全局 pluginUseProxy 语义为“新装插件默认模式”:true → proxy,false → auto;镜像写 useProxy + routeMode: pluginUseProxy ? 'proxy' : 'auto', useProxy: pluginUseProxy, created: now, updated: now @@ -96,29 +113,155 @@ export async function installRemotePlugin(url: string): Promise { return installPlugin(await fetchRemotePlugin(url)) } -// 写订阅 profile + 回填 profileId + 置 active + 清失败状态(首次登录与复用设备登录共用) -async function finishLogin(id: string, record: IPluginItem, content: string): Promise { - 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, +// ---- 提交出口(§0.4 规则 3):一个 op 一次持久化,成功失败都先提交元数据 ---- + +// 先经 updateVault 提交 gatewayState(如有且 vault 存在),返回待并入 plugin.yaml 的元数据 patch: +// selectedRoute → lastGoodRoute(仅 auto 模式)、itemPatch。 +// vaultExtra:调用方需要在同一次 vault 写入里附带的其它修改(如剪除已回收的旧设备)——先 vault 后 plugin.yaml +// 的提交顺序不变,且不会出现"元数据已提交、vault 的另一半没写"的半截状态 +async function commitMeta( + id: string, + item: IPluginItem, + app: IAppConfig, + result: OperationResult, + signal?: AbortSignal, + vaultExtra?: (v: IPluginVault) => IPluginVault +): Promise> { + if (result.gatewayState || result.signedCandidate || vaultExtra) { + const staged = result.gatewayState + const cand = result.signedCandidate + const align = result.signedAlign === true + await updateVault( + id, + (v) => { + let next = staged ?? v.gateway + if (cand) next = mergeSignedCandidate(next, cand, align) + const withGateway = sameGatewayState(v.gateway, next) ? v : { ...v, gateway: next } + return vaultExtra ? vaultExtra(withGateway) : withGateway + }, + signal + ) + } + return { + ...(result.itemPatch ?? {}), + ...routePatch(item, app, result), + ...messagePatch(item, result) + } +} + +// §4.2:op 失败 → 写 lastProviderMessage(如机场给了 message);op 成功 → 清空。 +function messagePatch(item: IPluginItem, result: OperationResult): Partial { + if (result.ok) return item.lastProviderMessage ? { lastProviderMessage: undefined } : {} + const message = result.error instanceof GatewayError ? result.error.providerMessage : undefined + if (message === item.lastProviderMessage) return {} + return { lastProviderMessage: message } +} + +// selectedRoute → lastGoodRoute(仅 auto 模式且有变化) +function routePatch( + item: IPluginItem, + app: IAppConfig, + result: OperationResult +): Partial { + if ( + effectiveRouteMode(item, app) === 'auto' && + result.selectedRoute && + result.selectedRoute !== item.lastGoodRoute + ) { + return { lastGoodRoute: result.selectedRoute } + } + return {} +} + +// §4.2:由 commit 按错误推导的客户端原因枚举,不含 host / IP。 +function errorReasonOf(e: unknown): NonNullable { + // 发现阶段被 guard 拒绝的底层错误没有经过 gateway.ts 的映射,按 code 直接归为 blocked + if (codeOf(e) === CPX_GUARD_REFUSED) return 'blocked' + if (e instanceof GatewayError) { + if (e.kind === 'blocked') return 'blocked' + return e.status === undefined ? 'network' : 'server' + } + const status = (e as { status?: unknown } | null)?.status + return typeof status === 'number' ? 'server' : 'network' +} + +// 订阅内容没过核心校验时的原因:op 预算已耗尽(校验被中止或中止后拒绝落盘)按 network,核心拒绝内容按 server +function validationFailureReason( + signal?: AbortSignal +): NonNullable { + return signal?.aborted ? 'network' : 'server' +} + +// 连续 op 之间传递上一个 op 的成功路由作为首选(§0.4 规则 4) +function nextInitialRoute(result: OperationResult): BaseRoute | undefined { + return isBaseRoute(result.selectedRoute) ? result.selectedRoute : undefined +} + +async function patchItem(id: string, patch: Partial): Promise { + if (Object.keys(patch).length === 0) return + await patchConfig(id, patch) +} + +function transientFailurePatch( + record: IPluginItem, + reason?: IPluginItem['lastUpdateErrorReason'] +): Partial { + const now = Date.now() + const failureCount = (record.failureCount ?? 0) + 1 + const { nextRetryAt } = computeBackoff(failureCount, now) + return { + lastUpdateErrorType: 'transient', + lastUpdateErrorAt: now, + lastUpdateErrorReason: reason, + failureCount, + nextRetryAt + } +} + +function activePatch(): Partial { + return { status: 'active', updated: Date.now(), failureCount: 0, lastUpdateErrorType: undefined, lastUpdateErrorAt: undefined, + lastUpdateErrorReason: undefined, nextRetryAt: undefined - }) + } +} + +function reauthPatch(): Partial { + return { status: 'needs-reauth', updated: Date.now(), nextRetryAt: undefined } +} + +// 写订阅 profile + 回填 profileId + 置 active + 清失败状态(首次登录与复用设备登录共用) +async function finishLogin( + id: string, + record: IPluginItem, + content: string, + meta: Partial, + signal?: AbortSignal +): Promise { + const profileId = record.profileId ?? randomUUID() + // 先把关联写入记录,再创建 profile:若创建成功后关联写入失败,下次登录会生成新 id 并留下含订阅内容的 + // 孤儿 profile;反过来一个悬空的 profileId 由下一次拉取时 upsertPluginProfile 补建,能自愈 + if (!record.profileId) await patchItem(id, { profileId }) + try { + await upsertPluginProfile({ profileId, pluginId: id, name: record.name }, content, signal) + } catch (e) { + // 订阅内容没过核心校验(BL-002):按瞬时失败记录(reason=server;预算耗尽则 network)并退避, + // 不写 profile;登录以 NETWORK 类失败结束,设备与 vault 已持久化,下次登录走孤儿分支复用 + if (!isPluginProfileInvalidError(e)) throw e + void warnLog('plugin subscription rejected by core validation', e) + await patchItem(id, { + ...meta, + ...transientFailurePatch(record, validationFailureReason(signal)), + profileId + }) + notifyRenderer() + throw new GatewayError('transient', 'subscription rejected by core validation') + } + await patchItem(id, { ...meta, ...activePatch(), profileId }) notifyRenderer() } @@ -130,204 +273,730 @@ function sanitizeLoginError(e: unknown): Error { return new Error('PLUGIN_LOGIN_FAILED') } +// 同一插件同一时刻只允许一个登录流程(浏览器等待不在任何 op / 锁内,靠这个标记防止并发双登录)。 +const loginsInFlight = new Set() + // 登录(首次登录与重新认证同一入口)。对外抛错经 sanitizeLoginError 脱敏。 export async function loginPlugin(id: string): Promise { + if (loginsInFlight.has(id)) throw new Error('PLUGIN_LOGIN_FAILED') + loginsInFlight.add(id) try { await runLogin(id) } catch (e) { throw sanitizeLoginError(e) + } finally { + loginsInFlight.delete(id) } } +type OrphanOutcome = { kind: 'fresh' } | { kind: 'revoked' } | { kind: 'fetched'; yaml: string } + // 设备复用仅限「needs-login 且已有 vault」这一种情形:上次 enroll 成功但首份订阅拉取失败留下的 // “孤儿设备”,重拉即可,避免每次重试都 enroll 新设备、消耗服务端设备数上限。 // 其它情形——needs-reauth(显式重新登录)、active(刷新)、无 vault(首装/换机/Linux 无 safeStorage)—— // 一律走全新浏览器登录 + 新设备,与 spec §9「reauth = 再走一次 login 流程、新设备密钥」一致。 +// 新设备登录流程 = discovery op → 浏览器(不在任何 op 内)→ enroll op → 创建 vault → fetchConfig op。 async function runLogin(id: string): Promise { - const record = await getPluginItem(id) - if (!record) throw new Error('Plugin not found') - const net = await netOpts(record) + const app = await getAppConfig() - const existingResult = await readVault(id) - if (existingResult.kind === 'unavailable') throw new VaultUnavailableError() - const existing = existingResult.kind === 'ok' ? existingResult.vault : undefined - 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 orphan = await runPluginOperation( + id, + { app, retryPolicy: 'safe' }, + async (ctx, item, vault) => { + if (vault.kind === 'unavailable') throw new VaultUnavailableError() + if (vault.kind !== 'ok' || item.status !== 'needs-login') return { kind: 'fresh' } + try { + return { kind: 'fetched', yaml: await fetchWithRecovery(ctx, item, vault.vault) } + } catch (e) { + // 孤儿设备已被吊销 → 丢弃旧 vault,落到下面的全新浏览器登录 + 新设备 + if (e instanceof GatewayError && e.kind === 'revoked') return { kind: 'revoked' } + throw e + } + }, + async (result, item, signal) => { + const meta = await commitMeta(id, item, app, result, signal) + if (!result.ok) return patchItem(id, meta) + if (result.value.kind === 'fetched') { + return finishLogin(id, item, result.value.yaml, meta, signal) + } + await patchItem(id, meta) + if (result.value.kind === 'revoked') await removeVault(id, signal) } - } + ) + if (!orphan.ok) throw orphan.error + // tombstone 只跳过 commit:删除已排队时不能把未提交的结果当成功继续 + if (isPluginRemoved(id)) throw new PluginNotFoundError() + if (orphan.value.kind === 'fetched') return + let initialRoute = nextInitialRoute(orphan) // 先确认 Keychain/secret store 可以实际加密,再打开 OAuth 和 enroll,避免用户完成 // 浏览器登录后才发现私钥无法持久化。旧 Electron 兼容包会在这里走同步探测。 await ensureVaultWritable() - 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 + const discovered = await runPluginOperation<{ state: IPluginGatewayState; loginUrl: string }>( + id, + { app, retryPolicy: 'safe', initialRoute }, + async (ctx, item, vault) => { + const candidate = await discoverGateway( + { sources: discoverySourcesOf(item), signer: signerOf(item) }, + ctx + ) + // §5.3 / §5.4:seq / digest 与轮换后的公开字段(loginUrl / discoveryUrls)在打开浏览器前就 + // 持久化,浏览器取消或后续失败都不会打开回滚窗口;浏览器使用新的 loginUrl。 + // 已有 vault(重新登录)时网关状态也先同步进 vault,再推进 seq(§5.3 提交顺序);无 vault 时 + // updateVault 是 no-op,首次安装仍走延后创建。 + const patch = candidatePatch(item, candidate) + const align = candidate.seq !== undefined && candidate.seq === item.discoverySeq + const state = + vault.kind === 'ok' + ? mergeSignedCandidate(vault.vault.gateway, candidate, align) + : stateFromCandidate(candidate) + ctx.stage({ gatewayState: state, itemPatch: patch }) + return { state, loginUrl: patch.loginUrl ?? item.loginUrl } }, - net - ) - const newVault: IPluginVault = { - devicePrivKey: dev.privKeyB64, - deviceId: dev.deviceId, - gateway: target - } - try { - await writeVault(id, newVault) - } catch (error) { - // enroll 已成功但凭据无法持久化时,立即用仍在内存中的新密钥回收设备, - // 避免用户重试登录不断消耗服务端设备额度。 - try { - await revoke(target, { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, net) - } catch { - // best-effort + async (result, item, signal) => { + const meta = await commitMeta(id, item, app, result, signal) + await patchItem(id, meta) + if (meta.loginUrl) notifyRenderer() } - throw error - } - const content = await fetchConfig( - target, - { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 }, - net ) - await finishLogin(id, record, content) + if (!discovered.ok) throw discovered.error + if (isPluginRemoved(id)) throw new PluginNotFoundError() + const { state: discoveredState, loginUrl } = discovered.value + initialRoute = nextInitialRoute(discovered) ?? initialRoute + + const dev = generateDevice() + const oauth = await browserLogin(loginUrl) + + // enroll 消费一次性 code,不自动重放:pre-send-only(路由层与网关层同时生效)。 + // 提交模式 deferVaultCreate(§2.5):成功时在同一次 plugin 临界区内先用内存密钥 + gatewayState 创建 + // vault,再 patch 非秘密元数据——不再释放锁后二次取锁,排队在中间的更新无法回滚刚提交的更高版本。 + // 从 enroll 成功到 vault 创建成功之间的任何异常(含 patch 失败)都在同一临界区内用内存密钥 best-effort + // 回收设备,并只删除本次写入的 vault(按 deviceId 核对,替换失败时仍有效的旧设备 vault 保留)。 + // 已有 vault 的重新登录(§5.4):起始网关状态取锁内最新的 vault,而不是浏览器等待前的快照——等待期间 + // 的并发更新可能已经推进 seq / 轮换网关。 + // 删除落在 enroll 在途时 commit 会被 tombstone 跳过、记录随后被移除:补偿 revoke 只能依赖 op 内保存的 + // 记录快照与内存中的密钥。 + let enrollItem: IPluginItem | undefined + let enrollState: IPluginGatewayState = discoveredState + // 重新登录时仍然有效的旧设备 vault:新 vault 已覆盖它而元数据提交失败时用来恢复(旧设备可继续使用、可被回收) + let priorVault: IPluginVault | undefined + const enrolled = await runPluginOperation( + id, + { app, retryPolicy: 'pre-send-only', initialRoute }, + async (ctx, item, vault) => { + enrollItem = item + priorVault = vault.kind === 'ok' ? vault.vault : undefined + enrollState = vault.kind === 'ok' ? vault.vault.gateway : discoveredState + ctx.stage({ gatewayState: enrollState }) + return withGatewayRecovery(ctx, item, enrollState, 'pre-send-only', (target) => + enroll( + target, + { + code: oauth.code, + code_verifier: oauth.verifier, + redirect_uri: oauth.redirectUri, + client_id: CLIENT_ID, + devicePubKey: dev.pubKeyB64, + deviceId: dev.deviceId + }, + ctx.requester + ) + ) + }, + async (result, item, signal) => { + // 失败:提交全部非秘密元数据(重发现 stage 的 seq/digest/公开字段、成功路由、机场 message); + // 已有 vault 时 gatewayState 先经 commitMeta 同步进 vault(§5.3 提交顺序),无 vault 时为 no-op + if (!result.ok) { + // 结果不确定的失败(请求可能已到达服务端:possibly-sent 的网络错误、任何 HTTP 响应):设备可能已被 + // 创建,用内存密钥 best-effort 回收,避免服务端留下客户端无法再管理的设备并耗尽设备额度 + // (revoke 对不存在的设备幂等,spec §10)。pre-send 失败与 blocked / revoked 不可能创建设备。 + // 元数据提交本身抛错时补偿也必须执行(finally)。 + try { + await patchItem(id, await commitMeta(id, item, app, result, signal)) + } finally { + if (mayHaveEnrolled(result.error)) { + await compensateEnroll(item, app, result.gatewayState ?? enrollState, dev) + } + } + return + } + const gatewayState = result.gatewayState ?? enrollState + try { + await writeVault( + id, + { + devicePrivKey: dev.privKeyB64, + deviceId: dev.deviceId, + gateway: gatewayState, + ...staleDevicesAfterReplace(priorVault, dev.deviceId) + }, + signal + ) + await patchItem(id, { + ...(result.itemPatch ?? {}), + ...routePatch(item, app, result), + ...messagePatch(item, result), + // 新设备已持久化:needs-reauth 转为 needs-login,若随后的首次拉取失败,下次登录走孤儿设备复用分支 + // (§2.5 步骤 1)而不是再 enroll 一台设备;active 保持不变(定时更新直接使用新 vault) + ...(item.status === 'needs-reauth' ? { status: 'needs-login' as const } : {}) + }) + } catch (error) { + await compensateEnroll(item, app, gatewayState, dev, priorVault) + throw error + } + } + ) + if (isPluginRemoved(id)) { + // commit 被 tombstone 跳过:enroll 成功(设备已在服务端创建但本地不会持久化)或结果不确定时, + // 都用快照 + 内存密钥回收(与删除串行) + if (enrollItem && (enrolled.ok || mayHaveEnrolled(enrolled.error))) { + const snapshot = enrollItem + await withPluginLock(id, () => + compensateEnroll(snapshot, app, enrolled.gatewayState ?? enrollState, dev) + ) + } + if (!enrolled.ok) throw enrolled.error + throw new PluginNotFoundError() + } + if (!enrolled.ok) throw enrolled.error + initialRoute = nextInitialRoute(enrolled) ?? initialRoute + + const fetched = await runPluginOperation( + id, + { app, retryPolicy: 'safe', initialRoute }, + async (ctx, item, vault) => { + if (vault.kind !== 'ok') throw new Error('vault missing after enroll') + return fetchWithRecovery(ctx, item, vault.vault) + }, + async (result, item, signal) => { + const meta = await commitMeta(id, item, app, result, signal) + if (result.ok) return finishLogin(id, item, result.value, meta, signal) + await patchItem(id, meta) + } + ) + if (!fetched.ok) throw fetched.error + // 换了新设备:旧设备仍占着服务端额度,现在回收(失败留在 vault 里等下次拉取 / 删除) + await retireStaleDevices(id, app) } -// 对一次网关操作做“缓存网关 retired/unreachable(410、gateway_retired,或 DNS/连接/TLS 失败)时, -// 用 loginUrl 重新发现并重试一次”的包装(支撑可轮换网关、旧域名退役自愈,spec §5)。 -// 拉订阅与 revoke 共用,确保网关轮换后删除插件仍能解绑服务端设备。 -async function withGatewayRediscovery( - id: string, - loginUrl: string, - vault: IPluginVault, - net: NetOpts, - op: (target: GatewayTarget) => Promise -): Promise { +// 重新登录换了新设备:旧设备仍在服务端占着额度,记入新 vault 的待回收列表。已在列表里的更旧设备一并保留, +// 同一设备不重复;新设备自身永不入列 +function staleDevicesAfterReplace( + prior: IPluginVault | undefined, + newDeviceId: string +): Pick { + if (!prior) return {} + const seen = new Set([newDeviceId]) + const list: IPluginStaleDevice[] = [] + for (const d of [ + ...(prior.staleDevices ?? []), + { deviceId: prior.deviceId, devicePrivKey: prior.devicePrivKey } + ]) { + if (seen.has(d.deviceId)) continue + seen.add(d.deviceId) + list.push(d) + } + return list.length > 0 ? { staleDevices: list } : {} +} + +// 回收待回收的旧设备:独立的小预算 op(登录 / 拉取已经成功,不能让它拖住用户可见的流程)。 +// 每个 op 只回收一台——多台共用一次 op 会让第二台沿用第一台重发现之前的网关状态;每台都从刚提交的 vault 出发。 +// 回收成功(或服务端已不认识这台设备)就在同一次 vault 写入里剪掉它;失败留到下次。不抛。 +const STALE_REVOKE_BUDGET_MS = 30_000 +const MAX_STALE_RETIRED_PER_RUN = 5 + +async function retireStaleDevices(id: string, app: IAppConfig): Promise { + for (let i = 0; i < MAX_STALE_RETIRED_PER_RUN; i++) { + if (!(await retireOneStaleDevice(id, app))) return + } +} + +function withoutStaleDevice(v: IPluginVault, deviceId: string): IPluginVault { + const rest = (v.staleDevices ?? []).filter((d) => d.deviceId !== deviceId) + if (rest.length === (v.staleDevices ?? []).length) return v + return { ...v, staleDevices: rest.length > 0 ? rest : undefined } +} + +// 返回"回收了一台且可能还有剩余";无剩余 / 失败 / 插件已删除 → false +async function retireOneStaleDevice(id: string, app: IAppConfig): Promise { + let retired: string | undefined try { - return await op(vault.gateway) + const r = await runPluginOperation( + id, + { app, retryPolicy: 'safe', budgetMs: STALE_REVOKE_BUDGET_MS }, + async (ctx, item, vault) => { + if (vault.kind !== 'ok') return false + const target = vault.vault.staleDevices?.[0] + if (!target) return false + try { + await withGatewayRecovery(ctx, item, vault.vault.gateway, 'safe', (t) => + revoke( + t, + { deviceId: target.deviceId, privKeyB64: target.devicePrivKey }, + ctx.requester + ) + ) + } catch (e) { + // 服务端已不认识这台设备(challenge 返回 device_revoked):清理目标已达成,同样从列表移除 + if (!(e instanceof GatewayError && e.kind === 'revoked')) throw e + } + retired = target.deviceId + return (vault.vault.staleDevices?.length ?? 0) > 1 + }, + async (result, item, signal) => { + const done = retired + const patch = await commitMeta( + id, + item, + app, + result, + signal, + done ? (v) => withoutStaleDevice(v, done) : undefined + ) + await patchItem(id, patch) + } + ) + return r.ok && r.value } 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 + if (!(e instanceof PluginNotFoundError)) void warnLog('stale device retirement failed', e) + return false } } -function fetchWithRediscovery( - id: string, - record: IPluginItem, +// enroll 失败但服务端可能已处理请求:possibly-sent 的网络错误,或任何已收到的 HTTP 响应(有 status)。 +// pre-send 失败、guard 拦截(blocked)与账号被吊销(revoked)都不可能创建设备;无 phase 无 status 的 +// 内部错误(如预算耗尽)发生在请求之前,同样排除。 +function mayHaveEnrolled(e: unknown): boolean { + if (!(e instanceof GatewayError)) return false + if (e.kind === 'blocked' || e.kind === 'revoked') return false + return e.phase === 'possibly-sent' || e.status !== undefined +} + +// 登录补偿(§2.5):用仍在内存中的新密钥 best-effort 回收设备,避免用户重试登录不断消耗服务端设备额度; +// 只删除本次登录写入的 vault(deviceId 核对)。补偿也是一次业务操作:自建预算(op 的预算可能已耗尽), +// 在调用方已持有的 plugin 临界区内串行执行;vault 锁在 plugin 锁之下。失败不抛。 +async function compensateEnroll( + item: IPluginItem, + app: IAppConfig, + state: IPluginGatewayState, + dev: DeviceKeys, + restore?: IPluginVault +): Promise { + const budget = createBudget(DEFAULT_BUDGET_MS) + let revoked = false + try { + revoked = await bestEffortRevokeWithKeys(item, app, state, dev, budget) + } catch { + // best-effort + } finally { + budget.dispose() + } + if (restore) { + // 重新登录:恢复被新 vault 覆盖的旧设备 vault(若新 vault 从未写入,旧文件仍在,重写等价于无操作)。 + // 新设备没能回收时把它记进旧 vault 的待回收列表,交给之后的拉取 / 删除再试,而不是永久留在服务端 + const vault = revoked + ? restore + : { + ...restore, + ...staleDevicesAfterReplace( + { ...restore, deviceId: dev.deviceId, devicePrivKey: dev.privKeyB64 }, + restore.deviceId + ) + } + await writeVault(item.id, vault).catch(() => {}) + } else { + await removeVaultIfDevice(item.id, dev.deviceId).catch(() => {}) + } +} + +// 用记录快照而不是重读 plugin.yaml:调用时记录可能已被排队的删除移除 +async function bestEffortRevokeWithKeys( + item: IPluginItem, + app: IAppConfig, + state: IPluginGatewayState, + dev: DeviceKeys, + budget?: OperationBudget +): Promise { + const cred = { deviceId: dev.deviceId, privKeyB64: dev.privKeyB64 } + const r = await runOperation({ item, app, retryPolicy: 'safe', budget }, (ctx) => + withGatewayRecovery(ctx, item, state, 'safe', (target) => revoke(target, cred, ctx.requester)) + ) + // 服务端已不认识这台设备(revoked)同样算回收完成 + return r.ok || (r.error instanceof GatewayError && r.error.kind === 'revoked') +} + +// best-effort 通知服务端解绑设备(调用方已持有 plugin lock)。网关轮换后旧 gateway 可能 +// retired/unreachable:多网关切换 + 重新发现再 revoke,避免设备绑定残留。失败不抛。 +async function revokePluginDeviceUnlocked( + item: IPluginItem, vault: IPluginVault, - net: NetOpts + app: IAppConfig, + budget?: OperationBudget +): Promise { + const cred = { deviceId: vault.deviceId, privKeyB64: vault.devicePrivKey } + await runOperation({ item, vault, app, retryPolicy: 'safe', budget }, (ctx) => + withGatewayRecovery(ctx, item, vault.gateway, 'safe', (target) => + revoke(target, cred, ctx.requester) + ) + ) +} + +// 发现源(§0.3 / §3):loginUrl 的 origin 始终是第一个,其后是 .cpx 的 discoveryUrls。 +function discoverySourcesOf(item: IPluginItem): string[] { + const login = originOf(item.loginUrl) + return [login, ...(item.discoveryUrls ?? []).filter((u) => u !== login)] +} + +function stateFromCandidate(c: IDiscoveryCandidate): IPluginGatewayState { + return { gateway: c.gateways[0], gateways: c.gateways, endpoints: c.endpoints } +} + +// §5:有 providerPubKey 的插件在发现时携带 signer;minSeq / currentDigest 来自 plugin.yaml +function signerOf(item: IPluginItem): DiscoverySigner | undefined { + if (!item.providerPubKey) return undefined + return { + pubKeyB64: item.providerPubKey, + minSeq: item.discoverySeq, + currentDigest: item.discoveryDigest + } +} + +// seq 是提交标记:成对写入 plugin.yaml(vault 之后) +function seqPatch(c: IDiscoveryCandidate): Partial { + if (c.seq === undefined || c.digest === undefined) return {} + return { discoverySeq: c.seq, discoveryDigest: c.digest } +} + +function sameStringList(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]) +} + +// §5.4 公开字段轮换:签名候选携带的 loginUrl / discoveryUrls 与 seq / digest 一起写入 plugin.yaml。 +// discoveryUrls 缺失 = 不改;[] = 清空;非空时排除 loginUrl 的 origin。同 seq 对齐时幂等重放。 +function candidatePatch(item: IPluginItem, c: IDiscoveryCandidate): Partial { + const patch: Partial = seqPatch(c) + if (c.seq === undefined) return patch + const loginUrl = c.loginUrl ?? item.loginUrl + if (c.loginUrl !== undefined && c.loginUrl !== item.loginUrl) patch.loginUrl = c.loginUrl + if (c.discoveryUrls !== undefined) { + const login = originOf(loginUrl) + const next = c.discoveryUrls.filter((u) => u !== login) + if (!sameStringList(item.discoveryUrls ?? [], next)) { + patch.discoveryUrls = next.length ? next : undefined + } + } + return patch +} + +// §5.3 X-CPX-Discovery:只在该插件有 providerPubKey 时读取;任何校验失败只丢弃候选并 warn, +// 绝不撤销已认证成功的 config 响应。 +async function consumeHeaderDiscovery( + ctx: OperationContext, + item: IPluginItem, + header: string | undefined, + accepted?: IDiscoveryCandidate +): Promise { + // 本 op 内重发现已接受过候选时,seq/digest 下限与公开字段的比较基线都以“应用该候选后的记录”为准: + // 旧头不能倒退刚接受的轮换;后到的更高 seq 对 loginUrl/discoveryUrls 具备完整的覆盖语义 + const base: IPluginItem = accepted ? { ...item, ...candidatePatch(item, accepted) } : item + const signer = signerOf(base) + if (!signer || header === undefined) return + try { + const { payload, digest } = parseSigned(header, signer.pubKeyB64) + const verdict = checkSeq(payload.seq, digest, signer) + if (verdict === 'rollback' || verdict === 'equivocation') { + void warnLog(`X-CPX-Discovery ignored: ${verdict} (seq ${payload.seq})`) + return + } + const candidate: IDiscoveryCandidate = { + gateways: payload.gateways, + endpoints: payload.endpoints, + seq: payload.seq, + digest + } + if (payload.loginUrl !== undefined) candidate.loginUrl = payload.loginUrl + if (payload.discoveryUrls !== undefined) candidate.discoveryUrls = payload.discoveryUrls + ctx.stage({ + signedCandidate: candidate, + signedAlign: verdict === 'align', + itemPatch: candidatePatch(base, candidate) + }) + } catch (e) { + void warnLog('X-CPX-Discovery ignored: invalid envelope', e) + } +} + +// 合并优先级(§2.4):先取 recovery 的 gatewayState;若本 op 还拿到了签名候选,用候选的 +// gateways / endpoints 整体替换并清空 lastGood。只有同 seq 对齐(幂等重放)且列表与端点完全相同时 +// 才保留 lastGood;更高 seq 即使列表相同也按“新文档”处理。 +function mergeSignedCandidate( + base: IPluginGatewayState, + cand: IDiscoveryCandidate, + align: boolean +): IPluginGatewayState { + const unchanged = + base.gateways.length === cand.gateways.length && + base.gateways.every((g, i) => g === cand.gateways[i]) && + base.endpoints.enroll === cand.endpoints.enroll && + base.endpoints.challenge === cand.endpoints.challenge && + base.endpoints.config === cand.endpoints.config && + base.endpoints.revoke === cand.endpoints.revoke + const lastGood = align && unchanged ? base.lastGood : undefined + const next: IPluginGatewayState = { + gateway: lastGood ?? cand.gateways[0], + gateways: cand.gateways, + endpoints: cand.endpoints + } + if (lastGood) next.lastGood = lastGood + return next +} + +function withLastGood(state: IPluginGatewayState, gateway: string): IPluginGatewayState { + return { ...state, gateway, lastGood: gateway } +} + +// 候选顺序:[lastGood ?? gateways[0], …其余按原序] +function orderedGateways(state: IPluginGatewayState): string[] { + const first = + state.lastGood && state.gateways.includes(state.lastGood) ? state.lastGood : undefined + if (!first) return [...state.gateways] + return [first, ...state.gateways.filter((g) => g !== first)] +} + +// 去重键:origin + 归一化 endpoints(签名模式再加 seq/digest);同 origin 换 endpoints 或换版本视为新目标(§2.4)。 +// 端点在解析时已归一化(discovery / discovery-sig / parseVault),这里再归一化一次是幂等的防御: +// 保证 "/a/../config" 与 "/config" 永远是同一个目标。JSON 元组编码避免分隔符与路径字符碰撞。 +function targetKey(t: GatewayTarget, seq?: number, digest?: string): string { + const e = t.endpoints + return JSON.stringify([ + t.gateway, + normalizeEndpointPath(e.enroll), + normalizeEndpointPath(e.challenge), + normalizeEndpointPath(e.config), + normalizeEndpointPath(e.revoke), + seq ?? null, + digest ?? null + ]) +} + +// §2.4 候选结果表:unreachable / 无 status 的 transient / retired → 下一个;其余停止抛出。 +// pre-send-only 时“下一个”只对 phase === 'pre-send' 的 unreachable 成立。 +function shouldTryNextGateway(e: unknown, policy: RetryPolicy): boolean { + if (!(e instanceof GatewayError)) return false + // 410 是 HTTP 响应:pre-send-only(enroll)下服务器已收到请求,不得换网关重放一次性 code + if (e.kind === 'retired') return policy === 'safe' + if (e.kind === 'unreachable') return policy === 'safe' || e.phase === 'pre-send' + if (e.kind === 'transient' && e.status === undefined) return policy === 'safe' + return false +} + +function sameGatewayState(a: IPluginGatewayState, b: IPluginGatewayState): boolean { + return ( + a.gateway === b.gateway && + a.lastGood === b.lastGood && + a.gateways.length === b.gateways.length && + a.gateways.every((g, i) => g === b.gateways[i]) && + a.endpoints.enroll === b.endpoints.enroll && + a.endpoints.challenge === b.endpoints.challenge && + a.endpoints.config === b.endpoints.config && + a.endpoints.revoke === b.endpoints.revoke + ) +} + +// 多网关切换 + 一次重发现(§2.4)。op 是完整业务动作(如 challenge + config),保证 nonce 与网关一致。 +// 逐个候选执行;全部以“下一个”类失败结束 → 用 sources 重发现一次,新列表排除本 op 已尝试过的目标; +// 仍失败 → unreachable('all gateways failed')。gatewayState 一律经 ctx.stage() 上交:每次列表变化、 +// 每次成功都 stage,失败时 throw 前已 stage。拉订阅、enroll 与 revoke 共用。 +async function withGatewayRecovery( + ctx: OperationContext, + item: IPluginItem, + state: IPluginGatewayState, + policy: RetryPolicy, + op: (target: GatewayTarget) => Promise, + onRediscovered?: (candidate: IDiscoveryCandidate) => void +): Promise { + const sources = discoverySourcesOf(item) + const signer = signerOf(item) + const tried = new Set() + // 最后一个“可切换”的网关错误:它可能携带机场 message(如 retired 的说明),终态错误要保留它 + let lastSwitchable: GatewayError | undefined + const attempt = async ( + list: IPluginGatewayState, + seq: number | undefined, + digest: string | undefined + ): Promise<{ done: true; value: T } | null> => { + for (const gateway of orderedGateways(list)) { + const target: GatewayTarget = { gateway, endpoints: list.endpoints } + const key = targetKey(target, seq, digest) + if (tried.has(key)) continue + tried.add(key) + try { + const value = await op(target) + ctx.stage({ gatewayState: withLastGood(list, gateway) }) + return { done: true, value } + } catch (e) { + if (!shouldTryNextGateway(e, policy)) throw e + // 后一个可切换错误没有机场 message 时继承前一个的(多网关:retired 带说明、下一个只是不可达) + lastSwitchable = carryProviderMessage(e as GatewayError, lastSwitchable) + } + } + return null + } + + const first = await attempt( + state, + signer ? item.discoverySeq : undefined, + signer ? item.discoveryDigest : undefined + ) + if (first) return first.value + + let candidate: IDiscoveryCandidate + try { + candidate = await discoverGateway({ sources, signer }, ctx) + } catch (e) { + throw rediscoveryFailure(e, lastSwitchable) + } + const rediscovered = stateFromCandidate(candidate) + ctx.stage({ gatewayState: rediscovered, itemPatch: candidatePatch(item, candidate) }) + onRediscovered?.(candidate) + const second = await attempt(rediscovered, candidate.seq, candidate.digest) + if (second) return second.value + throw carryProviderMessage(new GatewayError('unreachable', 'all gateways failed'), lastSwitchable) +} + +function carryProviderMessage(err: GatewayError, from: GatewayError | undefined): GatewayError { + if (!err.providerMessage && from?.providerMessage) err.providerMessage = from.providerMessage + return err +} + +// 重发现失败的终态(§2.4):发现阶段的底层错误不是 GatewayError,统一规范化——guard 拒绝 → blocked, +// 其余 → unreachable;并保留最后一个可切换网关错误携带的机场 message。 +function rediscoveryFailure(e: unknown, last: GatewayError | undefined): GatewayError { + // blocked 发生在发请求之前,revoked 是终态:都不携带早先网关的机场 message(§4.2) + if (e instanceof GatewayError) { + return e.kind === 'blocked' || e.kind === 'revoked' ? e : carryProviderMessage(e, last) + } + const detail = e instanceof Error ? e.message : String(e) + if (codeOf(e) === CPX_GUARD_REFUSED) { + return new GatewayError('blocked', `rediscovery failed: ${detail}`) + } + return carryProviderMessage( + new GatewayError('unreachable', `rediscovery failed: ${detail}`), + last + ) +} + +async function fetchWithRecovery( + ctx: OperationContext, + item: IPluginItem, + vault: IPluginVault ): Promise { const cred = { deviceId: vault.deviceId, privKeyB64: vault.devicePrivKey } - return withGatewayRediscovery(id, record.loginUrl, vault, net, (target) => - fetchConfig(target, cred, net) + let accepted: IDiscoveryCandidate | undefined + const { yaml, discovery } = await withGatewayRecovery( + ctx, + item, + vault.gateway, + 'safe', + (target) => fetchConfig(target, cred, ctx.requester), + (candidate) => { + accepted = candidate + } ) + await consumeHeaderDiscovery(ctx, item, discovery, accepted) + return yaml } -async function recordTransientUpdateFailure(record: IPluginItem): Promise { - const now = Date.now() - const failureCount = (record.failureCount ?? 0) + 1 - const { nextRetryAt } = computeBackoff(failureCount, now) - await updatePluginItem({ - ...record, - lastUpdateErrorType: 'transient', - lastUpdateErrorAt: now, - failureCount, - nextRetryAt - }) -} +type UpdateOutcome = + | { kind: 'skipped' } + | { kind: 'corrupt' } + | { kind: 'vault-missing' } + | { kind: 'vault-unavailable' } + | { kind: 'fetched'; yaml: string } // 自动/手动更新(静默,不弹浏览器) export async function updatePluginProfile(id: string, force = false): Promise { - 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 vaultResult = await readVault(id) - if (vaultResult.kind === 'unavailable') { - await recordTransientUpdateFailure(record) - notifyRenderer() - return - } - if (vaultResult.kind !== 'ok') { - await updatePluginItem({ - ...record, - status: 'needs-reauth', - updated: Date.now(), - nextRetryAt: undefined - }) - notifyRenderer() - return - } - const vault = vaultResult.vault - const net = await netOpts(record) + const app = await getAppConfig() + let outcome: OperationResult + // 订阅是否真的提交成功:核心校验失败被 commit 记为退避后 op 结果仍是 ok/fetched,不能拿它当依据 + let committed = false 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 + outcome = await runPluginOperation( + id, + { app, retryPolicy: 'safe' }, + async (ctx, item, vault) => { + if (item.status === 'needs-login' || item.status === 'needs-reauth') { + return { kind: 'skipped' } + } + // active/needs-reauth 态必须有 profileId(spec §10)。损坏/迁移异常导致 active 无 profileId 时, + // 标 needs-reauth 而非用 undefined 拼出 profiles/undefined.yaml。 + if (!item.profileId) return { kind: 'corrupt' } + // 非强制(定时器)触发时以插件记录的最新 autoUpdate 为准:关掉开关后残留的一次 tick 不再联网(BL-003) + if (!force && item.autoUpdate === false) return { kind: 'skipped' } + if (!force && item.nextRetryAt && Date.now() < item.nextRetryAt) return { kind: 'skipped' } + if (vault.kind === 'unavailable') return { kind: 'vault-unavailable' } + if (vault.kind !== 'ok') return { kind: 'vault-missing' } + return { kind: 'fetched', yaml: await fetchWithRecovery(ctx, item, vault.vault) } }, - content + async (result, item, signal) => { + // 跳过的 op 没有发出任何请求:不提交、不清空机场消息、不通知 + if (result.ok && result.value.kind === 'skipped') return + const meta = await commitMeta(id, item, app, result, signal) + if (result.ok) { + const outcome = result.value + if (outcome.kind === 'corrupt' || outcome.kind === 'vault-missing' || !item.profileId) { + await patchItem(id, { ...meta, ...reauthPatch() }) + } else if (outcome.kind === 'vault-unavailable') { + await patchItem(id, { ...meta, ...transientFailurePatch(item) }) + } else if (outcome.kind === 'fetched') { + try { + await upsertPluginProfile( + { profileId: item.profileId, pluginId: id, name: item.name }, + outcome.yaml, + signal + ) + await patchItem(id, { ...meta, ...activePatch() }) + committed = true + } catch (e) { + // 订阅内容没过核心校验(BL-002):旧 profile 原样保留,按瞬时失败退避(server;预算耗尽则 network) + if (!isPluginProfileInvalidError(e)) throw e + void warnLog('plugin subscription rejected by core validation', e) + await patchItem(id, { + ...meta, + ...transientFailurePatch(item, validationFailureReason(signal)) + }) + } + } + } else if (result.error instanceof GatewayError && result.error.kind === 'revoked') { + await patchItem(id, { + ...meta, + status: 'needs-reauth', + lastUpdateErrorType: 'auth', + lastUpdateErrorAt: Date.now(), + lastUpdateErrorReason: undefined, + nextRetryAt: undefined + }) + } else { + // blocked(§1.4)与网络/服务端失败一样按 transient 退避,卡片按 reason 显示固定文案 + await patchItem(id, { + ...meta, + ...transientFailurePatch(item, errorReasonOf(result.error)) + }) + } + notifyRenderer() + } ) - 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 { - await recordTransientUpdateFailure(record) - } + if (e instanceof PluginNotFoundError) return + throw e + } + // 只有真正拉取并提交成功(不是跳过 / 退避 / 失败)才顺手回收上次没回收掉的旧设备(缓存命中,不解密) + if (!outcome.ok || outcome.value.kind !== 'fetched' || !committed) return + const vault = await readVault(id) + if (vault.kind === 'ok' && (vault.vault.staleDevices?.length ?? 0) > 0) { + await retireStaleDevices(id, app) } - notifyRenderer() } // 启动审计只检查 vault 文件/内存是否存在,不触发 safeStorage/Keychain 解密。 @@ -336,50 +1005,106 @@ export async function auditPluginVault(id: string): Promise { const record = await getPluginItem(id) if (!record || record.status !== 'active') return if (hasVaultMaterial(id)) return - await updatePluginItem({ - ...record, - status: 'needs-reauth', - updated: Date.now(), - nextRetryAt: undefined - }) + await patchItem(id, reauthPatch()) notifyRenderer() } -// best-effort 通知服务端解绑设备。删除插件的两个入口——插件管理 removePlugin 与 profiles 列表 -// 删除(profile.ts removeProfileItem 级联)——都经此函数,避免服务端设备绑定残留。失败不抛。 -export async function revokePluginDevice(id: string): Promise { - const vaultResult = await readVault(id) - if (vaultResult.kind !== 'ok') return - const vault = vaultResult.vault +// 删除临界区(§0.5):tombstone → revoke → profile → item → vault,在同一个 plugin lock 内完成。 +// 两个删除入口——插件管理 removePlugin 与 profiles 列表删除(profile.ts removeProfileItem 级联)—— +// 都只在最外层取一次锁再调用这里。 +async function removePluginLocked( + id: string, + profileId: string | undefined, + budget: OperationBudget +): Promise { const record = await getPluginItem(id) - const cred = { deviceId: vault.deviceId, privKeyB64: vault.devicePrivKey } - const net = await netOpts(record ?? undefined) - 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) + if (record) { + const vaultResult = await readVault(id) + if (vaultResult.kind === 'ok') { + const app = await getAppConfig() + // 待回收的旧设备也一并解绑(同一预算,best-effort),再解绑当前设备 + for (const d of vaultResult.vault.staleDevices ?? []) { + await revokePluginDeviceUnlocked( + record, + { ...vaultResult.vault, deviceId: d.deviceId, devicePrivKey: d.devicePrivKey }, + app, + budget + ) + } + await revokePluginDeviceUnlocked(record, vaultResult.vault, app, budget) } - } catch { - // best-effort: 服务端解绑失败不阻塞本地删除 + } + // profile 在锁内删除:在途更新的 commit 已经被 tombstone 拦住,不会再把它重建出来。 + // profile 删除可能触发核心重启并抛错:本地清理仍必须完成——tombstone 已置,残留的 item / vault + // 会变成每个操作都被拒绝、却仍显示在列表里的僵尸插件。错误在清理之后再抛出。 + const pid = profileId ?? record?.profileId + let profileError: unknown + let profileFailed = false + if (pid) { + try { + await removePluginProfileContent(pid) + } catch (e) { + profileError = e + profileFailed = true + } + } + if (record) await removePluginItem(id) + await removeVault(id) + if (profileFailed) throw profileError +} + +// 删除入口的唯一预算只约束网络(revoke,耗尽即跳过);锁等待与本地清理必须完成,不接收 signal(§0.5)。 +export async function removePlugin(id: string): Promise { + markPluginRemoved(id) + const budget = createBudget(DEFAULT_BUDGET_MS) + try { + await withPluginLock(id, () => removePluginLocked(id, undefined, budget)) + } finally { + budget.dispose() + } + notifyRenderer() +} + +// profiles 列表删除的级联入口:profile.ts 只做判定,记录、文件与插件侧都在这里的临界区内删除。 +export async function removePluginForProfile(id: string, profileId: string): Promise { + markPluginRemoved(id) + const budget = createBudget(DEFAULT_BUDGET_MS) + try { + await withPluginLock(id, () => removePluginLocked(id, profileId, budget)) + } finally { + budget.dispose() } } -export async function removePlugin(id: string): Promise { - 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() -} +// 渲染层可编辑的字段白名单(IPC 边界):信任根(providerPubKey)、发现标记(seq/digest)、loginUrl / +// discoveryUrls、状态与身份字段只能由主进程业务逻辑写入。 +const EDITABLE_PLUGIN_FIELDS: ReadonlySet = new Set([ + 'routeMode', + 'useProxy', + 'interval', + 'autoUpdate' +]) export async function patchPluginItem(id: string, patch: Partial): Promise { - await patchConfig(id, patch) + for (const key of Object.keys(patch)) { + if (!EDITABLE_PLUGIN_FIELDS.has(key)) throw new Error(`Plugin field "${key}" is not editable`) + } + if (patch.routeMode !== undefined && !isRouteMode(patch.routeMode)) { + throw new Error('Invalid routeMode') + } + // 过渡期镜像写:routeMode 变化时同步 useProxy,供降级到旧版本读取(最多丢失 auto/direct 之分) + const next = patch.routeMode ? { ...patch, useProxy: patch.routeMode === 'proxy' } : patch + await patchConfig(id, next) + // 调度以关联的 profile item 为准:interval / autoUpdate 变化时同步过去并重建定时器(BL-003) + if ('interval' in patch || 'autoUpdate' in patch) { + const record = await getPluginItem(id) + if (record?.profileId) { + const schedule = pluginSchedule(record) + await syncPluginProfileSchedule(record.profileId, { + ...('interval' in patch ? { interval: schedule.interval } : {}), + ...('autoUpdate' in patch ? { autoUpdate: schedule.autoUpdate } : {}) + }) + } + } notifyRenderer() } diff --git a/src/main/resolve/plugin/log.ts b/src/main/resolve/plugin/log.ts new file mode 100644 index 00000000..6ecfa713 --- /dev/null +++ b/src/main/resolve/plugin/log.ts @@ -0,0 +1,10 @@ +// 插件模块的惰性日志:只有真正需要写日志时才加载应用 logger(它依赖 electron), +// 单测环境下加载失败则静默忽略。 +export async function warnLog(message: string, error?: unknown): Promise { + try { + const { logger } = await import('../../utils/logger') + await logger.warn(`[Plugin] ${message}`, error) + } catch { + // logging is best-effort + } +} diff --git a/src/main/resolve/plugin/net-guard.test.ts b/src/main/resolve/plugin/net-guard.test.ts index 6094366d..d489ec5b 100644 --- a/src/main/resolve/plugin/net-guard.test.ts +++ b/src/main/resolve/plugin/net-guard.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { isPrivateIp, isForbiddenHost, createGuardedLookup } from './net-guard' +import { + isPrivateIp, + isForbiddenHost, + createGuardedLookup, + resolveAllPublicOrThrow +} from './net-guard' describe('isForbiddenHost', () => { it('forbids localhost and its variants', () => { @@ -131,3 +136,51 @@ describe('createGuardedLookup', () => { expect(r.err).toBeTruthy() }) }) + +describe('resolveAllPublicOrThrow', () => { + it('returns all addresses when every one is public', async () => { + const addrs = await resolveAllPublicOrThrow('gw.front.com', async () => [ + { address: '1.1.1.1', family: 4 }, + { address: '2606:4700::1', family: 6 } + ]) + expect(addrs).toHaveLength(2) + }) + it('throws CPX_GUARD_REFUSED when any address is private', async () => { + await expect( + resolveAllPublicOrThrow('gw.front.com', async () => [ + { address: '1.1.1.1', family: 4 }, + { address: '192.168.1.1', family: 4 } + ]) + ).rejects.toMatchObject({ code: 'CPX_GUARD_REFUSED', phase: 'pre-send' }) + }) + it('throws an ENOTFOUND-coded error when nothing resolves', async () => { + await expect(resolveAllPublicOrThrow('gw.front.com', async () => [])).rejects.toMatchObject({ + code: 'ENOTFOUND' + }) + }) +}) + +describe('R2-ISS-044: 192.0.0.0/16 is not reserved as a whole', () => { + it('R2-ISS-062: the deprecated site-local range fec0::/10 is non-public', () => { + expect(isPrivateIp('fec0::1')).toBe(true) + expect(isPrivateIp('feff:ffff::1')).toBe(true) // top of the /10 + expect(isPrivateIp('fe80::1')).toBe(true) // link-local still covered + expect(isPrivateIp('fe00::1')).toBe(false) // just outside both ranges + expect(isForbiddenHost('[fec0::1]')).toBe(true) + }) + + it('R2-ISS-058: NAT64 — the local-use prefix 64:ff9b:1::/48 is non-public; the well-known prefix follows its embedded IPv4', () => { + expect(isPrivateIp('64:ff9b:1::c0a8:1')).toBe(true) // 192.168.0.1 behind a local NAT64 + expect(isPrivateIp('64:ff9b:1:ffff::1')).toBe(true) // anywhere in the /48 + expect(isPrivateIp('64:ff9b::c0a8:1')).toBe(true) // well-known prefix embedding 192.168.0.1 + expect(isPrivateIp('64:ff9b::808:808')).toBe(false) // well-known prefix embedding 8.8.8.8 + expect(isPrivateIp('64:ff9c::1')).toBe(false) // adjacent public space untouched + }) + + it('rejects only the IETF /24 and TEST-NET-1, not public 192.0.x.x space', () => { + expect(isPrivateIp('192.0.0.5')).toBe(true) // 192.0.0.0/24 IETF protocol assignments + expect(isPrivateIp('192.0.2.1')).toBe(true) // TEST-NET-1 + expect(isPrivateIp('192.0.78.24')).toBe(false) // public (192.0.64.0/18) + expect(isPrivateIp('192.0.1.1')).toBe(false) + }) +}) diff --git a/src/main/resolve/plugin/net-guard.ts b/src/main/resolve/plugin/net-guard.ts index ba9fa801..2ebee7cd 100644 --- a/src/main/resolve/plugin/net-guard.ts +++ b/src/main/resolve/plugin/net-guard.ts @@ -1,5 +1,6 @@ import { lookup as dnsLookup, type LookupAddress } from 'dns' import { isIP, type LookupFunction } from 'net' +import { CPX_GUARD_REFUSED, codedError } from './errors' function isPrivateIpv4(ip: string): boolean { const parts = ip.split('.').map((s) => Number(s)) @@ -10,7 +11,7 @@ function isPrivateIpv4(ip: string): boolean { 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] === 0) return true // IETF protocol assignments 192.0.0.0/24 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 @@ -73,18 +74,28 @@ function isPrivateIpv6(ip: string): boolean { 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] & 0xffc0) === 0xfec0) return true // deprecated site-local fec0::/10 (reserved, still routed on some LANs) 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 isPrivateIpv4(embeddedIpv4(h)) + } + // NAT64:本地翻译前缀 64:ff9b:1::/48(RFC 8215,IANA 标为非全局可达)整体视为非公网; + // 公认前缀 64:ff9b::/96(RFC 6052)按其内嵌的 IPv4 判定 + if (h[0] === 0x0064 && h[1] === 0xff9b) { + if (h[2] === 1) return true + if (h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0) return isPrivateIpv4(embeddedIpv4(h)) } return false } +function embeddedIpv4(h: number[]): string { + return `${(h[6] >> 8) & 0xff}.${h[6] & 0xff}.${(h[7] >> 8) & 0xff}.${h[7] & 0xff}` +} + export function isPrivateIp(ip: string): boolean { const fam = isIP(ip) if (fam === 4) return isPrivateIpv4(ip) @@ -115,6 +126,27 @@ const defaultResolveAll: ResolveAll = (hostname) => }) }) +// 解析全部地址;无地址 → ENOTFOUND 类错误;任一地址为私网 → CPX_GUARD_REFUSED(终态)。 +// guarded lookup 与 §1.4 的代理路由预检共用同一套判定。 +export async function resolveAllPublicOrThrow( + hostname: string, + resolveAll: ResolveAll = defaultResolveAll +): Promise { + const addrs = await resolveAll(hostname) + if (!addrs || addrs.length === 0) { + throw codedError('No addresses resolved', 'ENOTFOUND', 'pre-send') + } + const bad = addrs.find((a) => isPrivateIp(a.address)) + if (bad) { + throw codedError( + `Refusing to connect to non-public address: ${bad.address}`, + CPX_GUARD_REFUSED, + 'pre-send' + ) + } + return addrs +} + // 返回一个 Node 风格 lookup:先解析全部地址,全部为公网才放行,并把连接钉到已校验地址,挡 DNS rebinding。 // 必须尊重 options.all:Node 的 autoSelectFamily(Happy Eyeballs,现代 Node/Electron 默认开启)会用 // { all: true } 调用 lookup 并期望回调返回 LookupAddress[];此时若只回单个地址,Node 抛 @@ -127,17 +159,8 @@ export function createGuardedLookup(resolveAll: ResolveAll = defaultResolveAll): address: string | LookupAddress[], family?: number ) => void - resolveAll(hostname) + resolveAllPublicOrThrow(hostname, resolveAll) .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) }) diff --git a/src/main/resolve/plugin/operation.test.ts b/src/main/resolve/plugin/operation.test.ts new file mode 100644 index 00000000..f29931c7 --- /dev/null +++ b/src/main/resolve/plugin/operation.test.ts @@ -0,0 +1,660 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const requestOnce = vi.fn() +vi.mock('./http-client', () => ({ requestOnce: (...a: unknown[]) => requestOnce(...a) })) + +const items: Record = {} +const vaults: Record = {} +vi.mock('../../config/plugin', () => ({ + getPluginItem: vi.fn(async (id: string) => items[id]) +})) +vi.mock('./vault', () => ({ + readVault: vi.fn(async (id: string) => + vaults[id] ? { kind: 'ok' as const, vault: vaults[id] } : { kind: 'missing' as const } + ) +})) + +import { CPX_GUARD_REFUSED, CPX_REDIRECT_REFUSED, CPX_TIMEOUT, codedError } from './errors' +import { + createBudget, + runOperation, + runPluginOperation, + withPluginLock, + markPluginRemoved, + PluginNotFoundError, + type OperationContext +} from './operation' +import { abortable, autoRouteProvider, singleRouteProvider, type RouteProvider } from './route' +import { KeyedWriteQueue } from '../../utils/safeFile' + +const ITEM: IPluginItem = { + id: 'p1', + name: 'XX', + loginUrl: 'https://panel.xx.com/oauth/authorize', + spec: 'cpx-plugin/2', + status: 'active', + created: 0, + updated: 0 +} +const APP: IAppConfig = { subscriptionTimeout: 5000 } +const PROXY = { host: '127.0.0.1', port: 7890 } +const GW = { + gateway: 'https://gw.front.com', + endpoints: { enroll: '/enroll', challenge: '/challenge', config: '/config', revoke: '/revoke' } +} + +function reply(body = '{}', status = 200): void { + requestOnce.mockResolvedValueOnce({ status, headers: {}, body }) +} +function lastOpts(): Record { + const call = requestOnce.mock.calls[requestOnce.mock.calls.length - 1] + return call[1] as Record +} +function spyProvider( + route: 'direct' | 'proxy', + resolveProxy = async () => PROXY +): RouteProvider & { + dispose: ReturnType +} { + const inner = singleRouteProvider(route, resolveProxy) + return { ...inner, dispose: vi.fn(async () => {}) } +} + +beforeEach(() => { + requestOnce.mockReset() + for (const k of Object.keys(items)) delete items[k] + for (const k of Object.keys(vaults)) delete vaults[k] +}) + +describe('runOperation', () => { + it('sends through the single direct route with a guarded lookup and no proxy', async () => { + reply() + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(true) + const opts = lastOpts() + expect(opts.proxy).toBeUndefined() + expect(typeof opts.lookup).toBe('function') + expect(opts.signal).toBeInstanceOf(AbortSignal) + expect(opts.timeout).toBeLessThanOrEqual(5000) + expect(r.selectedRoute).toBe('direct') + }) + + it('sends through the proxy route with the lazily resolved local proxy and no lookup', async () => { + reply() + reply() + const resolveProxy = vi.fn(async () => PROXY) + const r = await runOperation( + { + item: ITEM, + app: APP, + retryPolicy: 'safe', + routeProvider: spyProvider('proxy', resolveProxy) + }, + async (ctx) => { + await ctx.requester.request('https://gw.front.com/a', { method: 'GET', maxBytes: 10 }) + await ctx.requester.request('https://gw.front.com/b', { method: 'GET', maxBytes: 10 }) + } + ) + expect(r.ok).toBe(true) + expect(lastOpts().proxy).toEqual(PROXY) + expect(lastOpts().lookup).toBeUndefined() + expect(resolveProxy).toHaveBeenCalledTimes(1) + expect(r.selectedRoute).toBe('proxy') + }) + + it('derives the single route from item.useProxy over the global default (no provider injected)', async () => { + reply() + const r = await runOperation( + { item: { ...ITEM, useProxy: false }, app: { pluginUseProxy: true }, retryPolicy: 'safe' }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(true) + expect(lastOpts().proxy).toBeUndefined() + expect(r.selectedRoute).toBe('direct') + }) + + it('snapshots staged metadata on success', async () => { + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async (ctx) => { + ctx.stage({ gatewayState: GW, itemPatch: { name: 'a' } }) + ctx.stage({ itemPatch: { site: 'https://s' } }) + return 1 + } + ) + expect(r.ok).toBe(true) + expect(r.gatewayState).toEqual(GW) + expect(r.itemPatch).toEqual({ name: 'a', site: 'https://s' }) + }) + + it('keeps staged metadata when fn throws (ok:false still carries gatewayState)', async () => { + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async (ctx) => { + ctx.stage({ gatewayState: GW }) + throw new Error('boom') + } + ) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as Error).message).toBe('boom') + expect(r.gatewayState).toEqual(GW) + expect(r.selectedRoute).toBeUndefined() + }) + + it('calls dispose(false) after a normal finish, even when fn throws', async () => { + const provider = spyProvider('direct') + await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: provider }, + async () => { + throw new Error('x') + } + ) + expect(provider.dispose).toHaveBeenCalledWith(false) + }) + + it('calls dispose(true) and maps the aborted request when the budget runs out', async () => { + const provider = spyProvider('direct') + requestOnce.mockImplementationOnce( + (_url: string, opts: { signal: AbortSignal }) => + new Promise((_, reject) => + opts.signal.addEventListener('abort', () => + reject(codedError('Request timed out', CPX_TIMEOUT)) + ) + ) + ) + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: provider, budgetMs: 1500 }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as { code?: string }).code).toBe(CPX_TIMEOUT) + expect(provider.dispose).toHaveBeenCalledWith(true) + }) + + it('refuses to send when less than 1s of budget remains', async () => { + const r = await runOperation( + { + item: ITEM, + app: APP, + retryPolicy: 'safe', + routeProvider: spyProvider('direct'), + budgetMs: 500 + }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatchObject({ kind: 'transient', message: 'budget exhausted' }) + expect(requestOnce).not.toHaveBeenCalled() + }) + + it('caps each request timeout at the remaining budget', async () => { + reply() + await runOperation( + { + item: ITEM, + app: { subscriptionTimeout: 30000 }, + retryPolicy: 'safe', + routeProvider: spyProvider('direct'), + budgetMs: 3000 + }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(lastOpts().timeout).toBeLessThanOrEqual(3000) + }) +}) + +describe('R2 lifecycle fixes (transport / budget)', () => { + it('R2-ISS-009: an error carrying an HTTP status fixes the route and never falls back', async () => { + const provider = { + ...autoRouteProvider('direct', async () => PROXY), + dispose: vi.fn(async () => {}) + } + const reset = Object.assign(codedError('socket hang up', 'ECONNRESET', 'possibly-sent'), { + status: 503 + }) + requestOnce.mockRejectedValueOnce(reset) + reply() + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: provider }, + async (ctx) => { + await expect( + ctx.requester.request('https://gw.front.com/a', { method: 'GET', maxBytes: 10 }) + ).rejects.toMatchObject({ status: 503 }) + // the origin is now fixed to direct: no proxy probe for the second request + await ctx.requester.request('https://gw.front.com/b', { method: 'GET', maxBytes: 10 }) + return 'ok' + } + ) + expect(r.ok).toBe(true) + expect(r.selectedRoute).toBe('direct') + expect(requestOnce).toHaveBeenCalledTimes(2) + expect(lastOpts().proxy).toBeUndefined() + }) + + it('R2-ISS-039: a failed proxy tunnel falls back to direct instead of fixing the proxy route', async () => { + const provider = { + ...autoRouteProvider('proxy', async () => PROXY), + dispose: vi.fn(async () => {}) + } + requestOnce.mockRejectedValueOnce(codedError('tunnel', 'CPX_PROXY_CONNECT_FAILED', 'pre-send')) + reply() + const r = await runOperation( + { + item: ITEM, + app: APP, + retryPolicy: 'pre-send-only', + routeProvider: provider, + resolveAll: async () => [{ address: '1.1.1.1', family: 4 }] + }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(true) + expect(r.selectedRoute).toBe('direct') + expect(requestOnce).toHaveBeenCalledTimes(2) + expect(lastOpts().proxy).toBeUndefined() + }) + + it('R2-ISS-010: a proxy resolver that never resolves is cut off by the budget', async () => { + const provider = spyProvider('proxy', () => new Promise(() => {})) + const r = await runOperation( + { item: ITEM, app: APP, retryPolicy: 'safe', routeProvider: provider, budgetMs: 1200 }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as { code?: string }).code).toBe(CPX_TIMEOUT) + expect(requestOnce).not.toHaveBeenCalled() + }) + + it('R2-ISS-002/032: the network phase ends at budget − reserve and the commit still runs on the (single) live signal', async () => { + items.p1 = ITEM + const commit = vi.fn(async (_r: unknown, _i: unknown, signal: AbortSignal) => { + expect(signal.aborted).toBe(false) + // a lock wait keyed on the same signal still goes through + await new KeyedWriteQueue().run('k', async () => 'written', signal) + }) + const r = await runPluginOperation( + 'p1', + { app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct'), budgetMs: 800 }, + async (ctx) => { + // a network-phase wait bounded by the network remaining budget (reserve 100 → ~700ms) + await abortable(new Promise(() => {}), ctx.signal, ctx.remainingMs()) + return 'unreachable' + }, + commit + ) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as { code?: string }).code).toBe(CPX_TIMEOUT) + expect(commit).toHaveBeenCalledOnce() + }) + + it('R2-ISS-032: one signal per budget; networkRemainingMs reaches zero before the signal aborts', async () => { + const b = createBudget(800) // reserve 100 → network phase ends at 700 + expect(b.networkRemainingMs()).toBeLessThanOrEqual(700) + expect(b.remainingMs()).toBeGreaterThan(b.networkRemainingMs()) + await new Promise((r) => setTimeout(r, 750)) + expect(b.networkRemainingMs()).toBe(0) + expect(b.signal.aborted).toBe(false) + await new Promise((r) => setTimeout(r, 100)) + expect(b.signal.aborted).toBe(true) + b.dispose() + }) +}) + +describe('createBudget', () => { + it('uses a monotonic clock: a clock rollback never extends the budget', () => { + let t = 1000 + const b = createBudget(1000, () => t) + t = 1400 + expect(b.remainingMs()).toBe(600) + t = 1400 // a Date.now() rollback would not move a monotonic clock backwards + expect(b.remainingMs()).toBe(600) + t = 2100 + expect(b.remainingMs()).toBe(0) + b.dispose() + }) + + it('aborts its signal when the budget elapses', async () => { + const b = createBudget(20) + expect(b.signal.aborted).toBe(false) + await new Promise((r) => setTimeout(r, 40)) + expect(b.signal.aborted).toBe(true) + b.dispose() + }) +}) + +describe('runPluginOperation', () => { + it('reads the item and vault, runs fn, then commits the result with the same signal', async () => { + items.p1 = ITEM + vaults.p1 = { devicePrivKey: 'k', deviceId: 'd', gateway: GW } + const commit = vi.fn(async () => {}) + const r = await runPluginOperation( + 'p1', + { app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async (ctx, item, vault) => { + expect(item.id).toBe('p1') + expect(vault.kind).toBe('ok') + expect(ctx.remainingMs()).toBeGreaterThan(0) + return 'v' + }, + commit + ) + expect(r.ok).toBe(true) + expect(commit).toHaveBeenCalledWith( + expect.objectContaining({ ok: true, value: 'v' }), + ITEM, + expect.any(AbortSignal) + ) + }) + + it('passes a missing vault through as { kind: "missing" }', async () => { + items.p1 = ITEM + const r = await runPluginOperation( + 'p1', + { app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async (_ctx, _item, vault) => vault.kind, + async () => {} + ) + expect(r.ok && r.value).toBe('missing') + }) + + it('throws PluginNotFoundError for an unknown id without committing', async () => { + const commit = vi.fn(async () => {}) + await expect( + runPluginOperation('nope', { app: APP, retryPolicy: 'safe' }, async () => 1, commit) + ).rejects.toBeInstanceOf(PluginNotFoundError) + expect(commit).not.toHaveBeenCalled() + }) + + it('turns fn errors into ok:false and still commits', async () => { + items.p1 = ITEM + const commit = vi.fn(async () => {}) + const r = await runPluginOperation( + 'p1', + { app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async () => { + throw new Error('net') + }, + commit + ) + expect(r.ok).toBe(false) + expect(commit).toHaveBeenCalledWith( + expect.objectContaining({ ok: false }), + ITEM, + expect.anything() + ) + }) +}) + +describe('plugin lock (§0.5)', () => { + it('aborts the lock wait when the op budget runs out', async () => { + items.p1 = ITEM + let release!: () => void + const holding = withPluginLock( + 'p1', + () => + new Promise((r) => { + release = r + }) + ) + const fn = vi.fn(async () => 1) + const commit = vi.fn(async () => {}) + await expect( + runPluginOperation('p1', { app: APP, retryPolicy: 'safe', budgetMs: 30 }, fn, commit) + ).rejects.toThrow('budget exhausted') + expect(fn).not.toHaveBeenCalled() + expect(commit).not.toHaveBeenCalled() + release() + await holding + }) + + it('a queued op sees the tombstone and gives up without running or committing', async () => { + items.gone = { ...ITEM, id: 'gone' } + markPluginRemoved('gone') + const fn = vi.fn(async () => 1) + const commit = vi.fn(async () => {}) + await expect( + runPluginOperation('gone', { app: APP, retryPolicy: 'safe' }, fn, commit) + ).rejects.toBeInstanceOf(PluginNotFoundError) + expect(fn).not.toHaveBeenCalled() + expect(commit).not.toHaveBeenCalled() + }) + + it('skips the commit when the plugin is tombstoned while the op runs', async () => { + items.late = { ...ITEM, id: 'late' } + const commit = vi.fn(async () => {}) + const r = await runPluginOperation( + 'late', + { app: APP, retryPolicy: 'safe', routeProvider: spyProvider('direct') }, + async () => { + markPluginRemoved('late') + return 1 + }, + commit + ) + expect(r.ok).toBe(true) + expect(commit).not.toHaveBeenCalled() + }) +}) + +// §1 路由自动回退 +describe('route fallback (§1)', () => { + const PUBLIC = async (): Promise<{ address: string; family: number }[]> => [ + { address: '1.1.1.1', family: 4 } + ] + const PRIVATE = async (): Promise<{ address: string; family: number }[]> => [ + { address: '10.0.0.1', family: 4 } + ] + const NXDOMAIN = async (): Promise => { + throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }) + } + const auto = (first: 'direct' | 'proxy'): RouteProvider => + autoRouteProvider(first, async () => PROXY) + const timeoutErr = (): Error => codedError('Request timed out', CPX_TIMEOUT, 'pre-send') + const errno = (code: string, phase: 'pre-send' | 'possibly-sent'): Error => + codedError(code, code, phase) + const req = (ctx: OperationContext, url = 'https://gw.front.com/x'): Promise => + ctx.requester.request(url, { method: 'GET', maxBytes: 10 }) + const base = (over: Record = {}) => ({ + item: { ...ITEM, routeMode: 'auto' as const }, + app: { subscriptionTimeout: 30000 }, + retryPolicy: 'safe' as const, + resolveAll: PUBLIC, + ...over + }) + + it('direct timeout → falls back to proxy → 200 fixes proxy; later requests only use proxy with the full timeout', async () => { + requestOnce.mockRejectedValueOnce(timeoutErr()) + reply() + reply() + const r = await runOperation(base({ routeProvider: auto('direct') }), async (ctx) => { + await req(ctx) + await req(ctx, 'https://gw.front.com/y') + }) + expect(r.ok).toBe(true) + expect(requestOnce).toHaveBeenCalledTimes(3) + const [first, second, third] = requestOnce.mock.calls.map( + (c) => c[1] as Record + ) + expect(first.proxy).toBeUndefined() + expect(first.timeout).toBeLessThanOrEqual(10000) + expect(second.proxy).toEqual(PROXY) + expect(second.timeout).toBeLessThanOrEqual(10000) + expect(third.proxy).toEqual(PROXY) + expect(third.timeout).toBeGreaterThan(10000) + expect(r.selectedRoute).toBe('proxy') + }) + + it('direct 503 → no fallback, route fixed to direct', async () => { + reply('', 503) + reply('', 200) + const r = await runOperation(base({ routeProvider: auto('direct') }), async (ctx) => { + const a = await req(ctx) + const b = await req(ctx, 'https://gw.front.com/y') + return [a.status, b.status] + }) + expect(r.ok && r.value).toEqual([503, 200]) + expect(requestOnce).toHaveBeenCalledTimes(2) + expect( + requestOnce.mock.calls.every((c) => (c[1] as { proxy?: unknown }).proxy === undefined) + ).toBe(true) + expect(r.selectedRoute).toBe('direct') + }) + + it.each([CPX_REDIRECT_REFUSED, 'CPX_RESPONSE_TOO_LARGE'])( + 'direct %s → no fallback', + async (code) => { + requestOnce.mockRejectedValueOnce(codedError(code, code, 'possibly-sent')) + const r = await runOperation(base({ routeProvider: auto('direct') }), (ctx) => req(ctx)) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as { code?: string }).code).toBe(code) + expect(requestOnce).toHaveBeenCalledTimes(1) + } + ) + + it('stickiness is scoped per origin', async () => { + reply('', 404) // source 1 direct → fixed direct + requestOnce.mockRejectedValueOnce(timeoutErr()) // source 2 direct times out + reply() // source 2 proxy ok + const r = await runOperation(base({ routeProvider: auto('direct') }), async (ctx) => { + await req(ctx, 'https://one.example/.well-known/cpx-gateway') + await req(ctx, 'https://two.example/.well-known/cpx-gateway') + }) + expect(r.ok).toBe(true) + const calls = requestOnce.mock.calls.map((c) => [c[0], (c[1] as { proxy?: unknown }).proxy]) + expect(calls).toEqual([ + ['https://one.example/.well-known/cpx-gateway', undefined], + ['https://two.example/.well-known/cpx-gateway', undefined], + ['https://two.example/.well-known/cpx-gateway', PROXY] + ]) + }) + + describe('pre-send-only (enroll)', () => { + it('direct timeout → no fallback', async () => { + requestOnce.mockRejectedValueOnce(timeoutErr()) + const r = await runOperation( + base({ routeProvider: auto('direct'), retryPolicy: 'pre-send-only' }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(false) + expect(requestOnce).toHaveBeenCalledTimes(1) + }) + it('direct ECONNRESET after the request may have been sent → no fallback', async () => { + requestOnce.mockRejectedValueOnce(errno('ECONNRESET', 'possibly-sent')) + const r = await runOperation( + base({ routeProvider: auto('direct'), retryPolicy: 'pre-send-only' }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(false) + expect(requestOnce).toHaveBeenCalledTimes(1) + }) + it('direct ECONNREFUSED before sending → falls back to proxy', async () => { + requestOnce.mockRejectedValueOnce(errno('ECONNREFUSED', 'pre-send')) + reply() + const r = await runOperation( + base({ routeProvider: auto('direct'), retryPolicy: 'pre-send-only' }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(true) + expect(requestOnce).toHaveBeenCalledTimes(2) + expect(lastOpts().proxy).toEqual(PROXY) + }) + }) + + describe('preflight guard (§1.4)', () => { + it('lastGoodRoute=proxy and target resolves to 10.0.0.1 → blocked before any request', async () => { + const r = await runOperation( + base({ routeProvider: auto('proxy'), resolveAll: PRIVATE }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect((r.error as { code?: string }).code).toBe(CPX_GUARD_REFUSED) + expect(requestOnce).not.toHaveBeenCalled() + }) + it('direct guard refusal is terminal: no proxy attempt follows', async () => { + requestOnce.mockRejectedValueOnce(codedError('refused', CPX_GUARD_REFUSED, 'pre-send')) + const r = await runOperation( + base({ routeProvider: auto('direct'), resolveAll: PRIVATE }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(false) + expect(requestOnce).toHaveBeenCalledTimes(1) + }) + it('lastGoodRoute=proxy and NXDOMAIN → allowed through the proxy', async () => { + reply() + const r = await runOperation( + base({ routeProvider: auto('proxy'), resolveAll: NXDOMAIN }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(true) + expect(lastOpts().proxy).toEqual(PROXY) + }) + it('explicit routeMode=proxy → no preflight, single route', async () => { + const resolveAll = vi.fn(PRIVATE) + reply() + const r = await runOperation( + base({ + item: { ...ITEM, routeMode: 'proxy' }, + routeProvider: singleRouteProvider('proxy', async () => PROXY), + resolveAll + }), + (ctx) => req(ctx) + ) + expect(r.ok).toBe(true) + expect(resolveAll).not.toHaveBeenCalled() + expect(lastOpts().proxy).toEqual(PROXY) + }) + it('preflight runs once per origin', async () => { + const resolveAll = vi.fn(PUBLIC) + reply() + reply() + await runOperation(base({ routeProvider: auto('proxy'), resolveAll }), async (ctx) => { + await req(ctx) + await req(ctx, 'https://gw.front.com/y') + }) + expect(resolveAll).toHaveBeenCalledTimes(1) + }) + }) + + it('budget bounds the whole route fallback: an aborted direct attempt leaves no room for proxy', async () => { + requestOnce.mockImplementationOnce( + (_url: string, opts: { signal: AbortSignal }) => + new Promise((_, reject) => + opts.signal.addEventListener('abort', () => + reject(codedError('Request timed out', CPX_TIMEOUT, 'pre-send')) + ) + ) + ) + const started = Date.now() + const r = await runOperation(base({ routeProvider: auto('direct'), budgetMs: 1200 }), (ctx) => + req(ctx) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatchObject({ kind: 'transient', message: 'budget exhausted' }) + expect(requestOnce).toHaveBeenCalledTimes(1) + expect(Date.now() - started).toBeLessThan(1200 + 500) + }) +}) + +describe('codex-review fixes (operation)', () => { + it('ISS-003: a preflight resolution that never returns is cut off by the op budget', async () => { + const started = Date.now() + const r = await runOperation( + { + item: { ...ITEM, routeMode: 'auto' }, + app: { subscriptionTimeout: 30000 }, + retryPolicy: 'safe', + routeProvider: autoRouteProvider('proxy', async () => PROXY), + resolveAll: () => new Promise(() => {}), + budgetMs: 1200 + }, + (ctx) => ctx.requester.request('https://gw.front.com/x', { method: 'GET', maxBytes: 10 }) + ) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatchObject({ kind: 'transient', message: 'budget exhausted' }) + expect(requestOnce).not.toHaveBeenCalled() + expect(Date.now() - started).toBeLessThan(1200 + 500) + }) +}) diff --git a/src/main/resolve/plugin/operation.ts b/src/main/resolve/plugin/operation.ts new file mode 100644 index 00000000..87181ef2 --- /dev/null +++ b/src/main/resolve/plugin/operation.ts @@ -0,0 +1,338 @@ +// 统一网络操作模型(§0.4):每个业务动作(discovery / enroll / fetchConfig / revoke)是一个 op, +// 拥有唯一的预算(deadline + AbortSignal)、唯一的路由执行器(RoutedRequester)与唯一的持久化出口 +// (commit)。op 内不写 plugin.yaml / vault,只 stage 元数据;runOperation 在成功与失败两条路径 +// 都快照 stage,因此失败也不丢元数据。 +import { getPluginItem } from '../../config/plugin' +import { KeyedWriteQueue } from '../../utils/safeFile' +import { CPX_GUARD_REFUSED, GatewayError, codeOf, statusOf } from './errors' +import { requestOnce, type PluginRequestOptions, type PluginResponse } from './http-client' +import { createGuardedLookup, type ResolveAll } from './net-guard' +import { + abortable, + baseRouteOf, + createRouteProvider, + isFailoverError, + preflightGuard, + type BaseRoute, + type RouteKey, + type RouteProvider +} from './route' +import { readVault, type VaultReadResult } from './vault' + +export type RetryPolicy = 'safe' | 'pre-send-only' + +export const DEFAULT_BUDGET_MS = 120_000 +const DEFAULT_TIMEOUT_MS = 30_000 +// §1.2:该 origin 尚未固定路由时,每个候选的探测超时上限 +const PROBE_TIMEOUT_MS = 10_000 +const MIN_REMAINING_MS = 1000 +// 提交预留:网络阶段在总 deadline 之前这么久结束,留给唯一的 commit(vault / plugin.yaml / profile 写入)。 +// 一个 op 一个 deadline、一个 AbortSignal(§0.4 规则 1):signal 在 budgetMs 处中止;网络阶段的每个等待 +// (HTTP 超时、DNS 预检、代理配置读取)都按 networkRemainingMs 限时,因此网络阶段在 deadline 前 reserve +// 就结束,commit 接收同一个 signal 时它仍然有效(§0.4 规则 3“成功失败都提交”)。 +export const COMMIT_RESERVE_MS = 10_000 + +export interface OperationBudget { + // 唯一的 signal:HTTP、锁等待、DNS 预检、代理配置读取与 commit 内的 vault 锁等待都接收它;在 budgetMs 处中止 + readonly signal: AbortSignal + // 距总 deadline 的余量 + remainingMs(): number + // 距网络阶段结束点(budgetMs − reserve)的余量:ensureBudget 与每个网络等待的限时都按它计算 + networkRemainingMs(): number + dispose(): void +} + +// 单调时钟:系统时钟回拨不延长预算。 +export function createBudget( + budgetMs: number, + now: () => number = () => performance.now() +): OperationBudget { + const start = now() + const reserve = Math.min(COMMIT_RESERVE_MS, Math.floor(budgetMs / 8)) + const networkMs = budgetMs - reserve + const ac = new AbortController() + const timer = setTimeout(() => ac.abort(new Error('budget exhausted')), budgetMs) + timer.unref?.() + return { + signal: ac.signal, + remainingMs: () => Math.max(0, budgetMs - (now() - start)), + networkRemainingMs: () => Math.max(0, networkMs - (now() - start)), + dispose: () => clearTimeout(timer) + } +} + +export type RoutedRequestOptions = Omit< + PluginRequestOptions, + 'proxy' | 'timeout' | 'lookup' | 'signal' +> + +export interface RoutedRequester { + request(url: string, opts: RoutedRequestOptions): Promise +} + +export interface StagedMeta { + gatewayState?: IPluginVault['gateway'] + itemPatch?: Partial + // §5:本 op 拿到的签名候选(X-CPX-Discovery);commit 用它整体替换 gateways / endpoints + signedCandidate?: IDiscoveryCandidate + // §5.3:该候选是否为同 seq 对齐(幂等重放)。只有对齐且列表 / 端点未变时 commit 才保留 lastGood + signedAlign?: boolean +} + +export interface OperationContext { + readonly signal: AbortSignal + remainingMs(): number + readonly requester: RoutedRequester + stage(meta: StagedMeta): void +} + +export interface OperationInput { + item: IPluginItem + vault?: IPluginVault + app: IAppConfig + retryPolicy: RetryPolicy + budgetMs?: number + initialRoute?: BaseRoute + routeProvider?: RouteProvider + // runPluginOperation 先于 plugin lock 创建预算,再交给 runOperation 共用同一个 deadline。 + budget?: OperationBudget + // 测试注入:guarded lookup 与 §1.4 预检共用的解析器 + resolveAll?: ResolveAll +} + +export interface OperationMeta { + selectedRoute?: BaseRoute + gatewayState?: IPluginVault['gateway'] + itemPatch?: Partial + signedCandidate?: IDiscoveryCandidate + signedAlign?: boolean +} + +export type OperationResult = + ({ ok: true; value: T } & OperationMeta) | ({ ok: false; error: unknown } & OperationMeta) + +export class PluginNotFoundError extends Error { + constructor() { + super('Plugin not found') + this.name = 'PluginNotFoundError' + } +} + +// plugin lock(§0.5):按插件 id 串行化业务操作。只由 runPluginOperation 与删除入口在最外层 +// 获取一次;锁不可重入,内部一律使用 *Unlocked 原语。删除先置 tombstone,排队中的后续操作 +// 拿到锁时看到即放弃。 +const pluginLocks = new KeyedWriteQueue() +const removedPlugins = new Set() + +export function withPluginLock( + id: string, + task: () => Promise, + signal?: AbortSignal +): Promise { + return pluginLocks.run(id, task, signal) +} + +export function markPluginRemoved(id: string): void { + removedPlugins.add(id) +} + +export function isPluginRemoved(id: string): boolean { + return removedPlugins.has(id) +} + +function stripBrackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname +} + +// 路由执行器(§1)。粘性按 URL.origin 分作用域:同一 origin 第一次收到任意 HTTP 响应后固定路由; +// 换网关 / 换发现源 = 换 origin = 自动重新选择。未固定前逐个候选尝试,只对 failover-class 错误换路。 +class Requester implements RoutedRequester { + private readonly sticky = new Map() + private readonly preflights = new Map>() + private lastResponded: RouteKey | undefined + + constructor( + private readonly provider: RouteProvider, + private readonly budget: OperationBudget, + private readonly timeoutMs: number, + private readonly policy: RetryPolicy, + private readonly resolveAll?: ResolveAll + ) {} + + async request(url: string, opts: RoutedRequestOptions): Promise { + const u = new URL(url) + const origin = u.origin + const fixed = this.sticky.get(origin) + if (fixed) return this.send(url, opts, fixed, false) + + const keys = await this.provider.candidates() + const probing = keys.length > 1 + let lastError: unknown + for (const key of keys) { + try { + // 预算检查先于预检:剩余不足时以 budget exhausted 终态结束,而不是让预检的中止被当作可回退超时 + this.ensureBudget() + if (key !== 'direct' && this.provider.guardNonDirect) { + await this.preflight(origin, stripBrackets(u.hostname)) + } + const res = await this.send(url, opts, key, probing) + this.sticky.set(origin, key) + this.lastResponded = key + return res + } catch (e) { + // 已收到 HTTP 响应头(body 阶段才失败):服务器已到达,固定路由并结束,不换路(§1.3) + if (statusOf(e) !== undefined) { + this.sticky.set(origin, key) + this.lastResponded = key + throw e + } + // blocked 终态:不再试任何路由;非 failover-class:直接结束(不固定路由) + if (codeOf(e) === CPX_GUARD_REFUSED || !isFailoverError(e, this.policy)) throw e + lastError = e + } + } + throw lastError ?? new GatewayError('unreachable', 'no route available') + } + + // 每个 origin 只预检一次;预检等待按网络余量限时(§0.4 规则 1) + private preflight(origin: string, hostname: string): Promise { + let p = this.preflights.get(origin) + if (!p) { + p = preflightGuard( + hostname, + this.resolveAll, + this.budget.signal, + this.budget.networkRemainingMs() + ) + this.preflights.set(origin, p) + } + return p + } + + private ensureBudget(): number { + const remaining = this.budget.networkRemainingMs() + if (remaining < MIN_REMAINING_MS) throw new GatewayError('transient', 'budget exhausted') + return remaining + } + + private async send( + url: string, + opts: RoutedRequestOptions, + key: RouteKey, + probing: boolean + ): Promise { + const before = this.ensureBudget() + // 代理配置读取也是 op 内的等待:接收同一个 signal 并按网络余量限时,等待之后再按最新余量重算(§0.4 规则 1) + const proxy = await abortable(this.provider.proxyFor(key), this.budget.signal, before) + const remaining = this.ensureBudget() + const timeout = probing + ? Math.min(this.timeoutMs, PROBE_TIMEOUT_MS, remaining) + : Math.min(this.timeoutMs, remaining) + const res = await requestOnce(url, { + ...opts, + timeout, + signal: this.budget.signal, + proxy, + // 代理模式:目标由代理解析,本地 SSRF guarded lookup 不再适用(安全保证降级) + lookup: proxy ? undefined : createGuardedLookup(this.resolveAll) + }) + this.lastResponded = key + return res + } + + selectedRoute(): BaseRoute | undefined { + return this.lastResponded ? baseRouteOf(this.lastResponded) : undefined + } +} + +// 网络执行层:创建 ctx → 执行 → finally dispose → 快照 stage → 返回。 +export async function runOperation( + input: OperationInput, + fn: (ctx: OperationContext) => Promise +): Promise> { + const ownBudget = !input.budget + const budget = input.budget ?? createBudget(input.budgetMs ?? DEFAULT_BUDGET_MS) + const provider = + input.routeProvider ?? createRouteProvider(input.item, input.app, input.initialRoute) + const requester = new Requester( + provider, + budget, + input.app.subscriptionTimeout ?? DEFAULT_TIMEOUT_MS, + input.retryPolicy, + input.resolveAll + ) + const staged: StagedMeta = {} + const ctx: OperationContext = { + signal: budget.signal, + remainingMs: () => budget.networkRemainingMs(), + requester, + stage(meta) { + if (meta.gatewayState) staged.gatewayState = meta.gatewayState + if (meta.itemPatch) staged.itemPatch = { ...staged.itemPatch, ...meta.itemPatch } + if (meta.signedCandidate) { + staged.signedCandidate = meta.signedCandidate + staged.signedAlign = meta.signedAlign === true + } + } + } + + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + outcome = { ok: true, value: await fn(ctx) } + } catch (error) { + outcome = { ok: false, error } + } finally { + try { + await provider.dispose(budget.signal.aborted) + } catch { + // dispose 是 best-effort 清理,不覆盖业务结果 + } + if (ownBudget) budget.dispose() + } + + const meta: OperationMeta = { selectedRoute: requester.selectedRoute() } + if (staged.gatewayState) meta.gatewayState = staged.gatewayState + if (staged.itemPatch) meta.itemPatch = staged.itemPatch + if (staged.signedCandidate) { + meta.signedCandidate = staged.signedCandidate + meta.signedAlign = staged.signedAlign === true + } + return { ...outcome, ...meta } +} + +export type PluginOperationInput = Omit + +// 外层:deadline 与 AbortController 先于一切创建;读取 item / vault、网络执行、最终提交都在 +// 同一个 deadline 内。index.ts 的所有业务入口只用这个。 +export async function runPluginOperation( + id: string, + input: PluginOperationInput, + fn: (ctx: OperationContext, item: IPluginItem, vault: VaultReadResult) => Promise, + commit: (result: OperationResult, item: IPluginItem, signal: AbortSignal) => Promise +): Promise> { + const budget = createBudget(input.budgetMs ?? DEFAULT_BUDGET_MS) + try { + return await withPluginLock( + id, + async () => { + if (isPluginRemoved(id)) throw new PluginNotFoundError() + const item = await getPluginItem(id) + if (!item) throw new PluginNotFoundError() + const vaultResult = await readVault(id, budget.signal) + const vault = vaultResult.kind === 'ok' ? vaultResult.vault : undefined + const result = await runOperation({ ...input, item, vault, budget }, (ctx) => + fn(ctx, item, vaultResult) + ) + // 删除已排队(tombstone)时放弃提交:不写 plugin.yaml、不写 vault,避免删除中复活。 + if (isPluginRemoved(id)) return result + // 提交接收同一个 signal:网络阶段按 networkRemainingMs 限时,在 deadline 前 reserve 就结束, + // 因此 commit 运行时 signal 仍然有效 + await commit(result, item, budget.signal) + return result + }, + budget.signal + ) + } finally { + budget.dispose() + } +} diff --git a/src/main/resolve/plugin/remote.test.ts b/src/main/resolve/plugin/remote.test.ts index f2384396..f1d79a7a 100644 --- a/src/main/resolve/plugin/remote.test.ts +++ b/src/main/resolve/plugin/remote.test.ts @@ -44,6 +44,26 @@ describe('fetchRemotePlugin', () => { expect(requestOnce.mock.calls[0][1].proxy).toEqual({ host: '127.0.0.1', port: 17890 }) }) + it('R2-ISS-066: carries the core inbound credentials to the local proxy, like plugin requests do', async () => { + getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true }) + getControledMihomoConfig.mockResolvedValue({ 'mixed-port': 17890, authentication: ['u:p:w'] }) + await fetchRemotePlugin('https://provider.example/app.cpx') + expect(requestOnce.mock.calls[0][1].proxy).toEqual({ + host: '127.0.0.1', + port: 17890, + auth: { user: 'u', pass: 'p:w' } + }) + }) + + it('R2-ISS-066: a disabled mixed-port makes the proxied download fail instead of targeting port 80', async () => { + getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true }) + getControledMihomoConfig.mockResolvedValue({ 'mixed-port': 0 }) + await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toMatchObject({ + code: 'CPX_PROXY_CONNECT_FAILED' + }) + expect(requestOnce).not.toHaveBeenCalled() + }) + it.each([ 'http://provider.example/app.cpx', 'https://user:password@provider.example/app.cpx', diff --git a/src/main/resolve/plugin/remote.ts b/src/main/resolve/plugin/remote.ts index abffa6b0..2002c0c3 100644 --- a/src/main/resolve/plugin/remote.ts +++ b/src/main/resolve/plugin/remote.ts @@ -1,7 +1,8 @@ import { getAppConfig } from '../../config/app' import { MAX_PLUGIN_FILE_BYTES } from './constants' -import { requestOnce } from './http-client' +import { requestOnce, type PluginProxy } from './http-client' import { createGuardedLookup, isForbiddenHost } from './net-guard' +import { resolveLocalProxy } from './route' function parseDownloadUrl(url: string): URL { let parsed: URL @@ -20,12 +21,9 @@ function parseDownloadUrl(url: string): URL { export async function fetchRemotePlugin(url: string): Promise { const parsed = parseDownloadUrl(url) const { subscriptionTimeout = 30000, pluginUseProxy } = await getAppConfig() - let proxy: { host: string; port: number } | undefined - if (pluginUseProxy) { - const { getControledMihomoConfig } = await import('../../config/controledMihomo') - const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() - proxy = { host: '127.0.0.1', port } - } + // 与插件请求同一套本地代理解析:端口校验(混合端口关闭 → 代理不可用)与核心 inbound 认证凭据 + let proxy: PluginProxy | undefined + if (pluginUseProxy) proxy = await resolveLocalProxy() const response = await requestOnce(parsed.toString(), { method: 'GET', diff --git a/src/main/resolve/plugin/route.test.ts b/src/main/resolve/plugin/route.test.ts new file mode 100644 index 00000000..9ed09d9b --- /dev/null +++ b/src/main/resolve/plugin/route.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi } from 'vitest' +import { + CPX_GUARD_REFUSED, + CPX_REDIRECT_REFUSED, + CPX_RESPONSE_TOO_LARGE, + CPX_TIMEOUT, + codedError +} from './errors' +import { + autoRouteProvider, + baseRouteOf, + createRouteProvider, + effectiveRouteMode, + isFailoverError, + preflightGuard, + resolveLocalProxy, + singleRouteProvider +} from './route' + +const controledConfig: { 'mixed-port': number; authentication?: string[] } = { 'mixed-port': 7890 } +vi.mock('../../config/controledMihomo', () => ({ + getControledMihomoConfig: async () => controledConfig +})) + +const ITEM: IPluginItem = { + id: 'p1', + name: 'XX', + loginUrl: 'https://panel.xx.com/oauth/authorize', + spec: 'cpx-plugin/2', + status: 'active', + created: 0, + updated: 0 +} +const PROXY = async (): Promise<{ host: string; port: number }> => ({ + host: '127.0.0.1', + port: 7890 +}) + +describe('effectiveRouteMode (§1.5 five-row migration table)', () => { + it.each([ + ['proxy', true, false, 'proxy'], + ['auto', undefined, true, 'auto'], + ['direct', true, true, 'direct'], + [undefined, true, undefined, 'proxy'], + [undefined, false, true, 'auto'], + [undefined, undefined, true, 'proxy'], + [undefined, undefined, false, 'auto'], + [undefined, undefined, undefined, 'auto'] + ] as const)( + 'routeMode=%s useProxy=%s global=%s → %s', + (routeMode, useProxy, pluginUseProxy, expected) => { + expect(effectiveRouteMode({ ...ITEM, routeMode, useProxy }, { pluginUseProxy })).toBe( + expected + ) + } + ) +}) + +describe('createRouteProvider', () => { + it('auto without lastGoodRoute → [direct, proxy], guarded', async () => { + const p = createRouteProvider({ ...ITEM, routeMode: 'auto' }, {}, undefined, PROXY) + expect(await p.candidates()).toEqual(['direct', 'proxy']) + expect(p.guardNonDirect).toBe(true) + }) + it('auto with lastGoodRoute=proxy → [proxy, direct]', async () => { + const p = createRouteProvider( + { ...ITEM, routeMode: 'auto', lastGoodRoute: 'proxy' }, + {}, + undefined, + PROXY + ) + expect(await p.candidates()).toEqual(['proxy', 'direct']) + }) + it('initialRoute overrides lastGoodRoute', async () => { + const p = createRouteProvider( + { ...ITEM, routeMode: 'auto', lastGoodRoute: 'direct' }, + {}, + 'proxy', + PROXY + ) + expect(await p.candidates()).toEqual(['proxy', 'direct']) + }) + it('ignores an invalid persisted lastGoodRoute', async () => { + const p = createRouteProvider( + { ...ITEM, routeMode: 'auto', lastGoodRoute: 'bootstrap:0' as never }, + {}, + undefined, + PROXY + ) + expect(await p.candidates()).toEqual(['direct', 'proxy']) + }) + it('explicit proxy → single unguarded route; explicit direct → single route', async () => { + const proxy = createRouteProvider({ ...ITEM, routeMode: 'proxy' }, {}, undefined, PROXY) + expect(await proxy.candidates()).toEqual(['proxy']) + expect(proxy.guardNonDirect).toBe(false) + const direct = createRouteProvider({ ...ITEM, routeMode: 'direct' }, {}, undefined, PROXY) + expect(await direct.candidates()).toEqual(['direct']) + }) +}) + +describe('singleRouteProvider / autoRouteProvider', () => { + it('resolves the proxy lazily, once, and only for the proxy key', async () => { + const resolve = vi.fn(async () => ({ host: '127.0.0.1', port: 7890 })) + const p = autoRouteProvider('direct', resolve) + expect(resolve).not.toHaveBeenCalled() + expect(await p.proxyFor('direct')).toBeUndefined() + expect(await p.proxyFor('proxy')).toEqual({ host: '127.0.0.1', port: 7890 }) + expect(await p.proxyFor('proxy')).toEqual({ host: '127.0.0.1', port: 7890 }) + expect(resolve).toHaveBeenCalledTimes(1) + }) + it('dispose is a no-op', async () => { + await expect(singleRouteProvider('direct').dispose(true)).resolves.toBeUndefined() + }) + it('baseRouteOf maps bootstrap keys to undefined (never persisted as lastGoodRoute)', () => { + expect(baseRouteOf('direct')).toBe('direct') + expect(baseRouteOf('proxy')).toBe('proxy') + expect(baseRouteOf('bootstrap:0')).toBeUndefined() + }) +}) + +describe('isFailoverError (§1.3)', () => { + const e = (code: string, phase?: 'pre-send' | 'possibly-sent'): Error => + codedError('x', code, phase) + it('safe: network errno and CPX_TIMEOUT fail over; responses/guard/size/redirect do not', () => { + expect(isFailoverError(e(CPX_TIMEOUT, 'pre-send'), 'safe')).toBe(true) + expect(isFailoverError(e('ENOTFOUND', 'pre-send'), 'safe')).toBe(true) + expect(isFailoverError(e('ECONNRESET', 'possibly-sent'), 'safe')).toBe(true) + expect(isFailoverError(e('CERT_HAS_EXPIRED', 'pre-send'), 'safe')).toBe(true) + expect(isFailoverError(e(CPX_REDIRECT_REFUSED, 'possibly-sent'), 'safe')).toBe(false) + expect(isFailoverError(e(CPX_RESPONSE_TOO_LARGE, 'possibly-sent'), 'safe')).toBe(false) + expect(isFailoverError(e(CPX_GUARD_REFUSED, 'pre-send'), 'safe')).toBe(false) + expect(isFailoverError(new Error('plain'), 'safe')).toBe(false) + }) + it('pre-send-only: only pre-send network errno; never timeouts or possibly-sent errors', () => { + expect(isFailoverError(e(CPX_TIMEOUT, 'pre-send'), 'pre-send-only')).toBe(false) + expect(isFailoverError(e('ECONNRESET', 'possibly-sent'), 'pre-send-only')).toBe(false) + expect(isFailoverError(e('ECONNREFUSED', 'possibly-sent'), 'pre-send-only')).toBe(false) + expect(isFailoverError(e('ECONNREFUSED', 'pre-send'), 'pre-send-only')).toBe(true) + expect(isFailoverError(e('ENOTFOUND', 'pre-send'), 'pre-send-only')).toBe(true) + expect(isFailoverError(e('ECONNREFUSED'), 'pre-send-only')).toBe(false) + }) +}) + +describe('R2-ISS-039: proxy tunnel failure', () => { + it('is failover-class under both policies (pre-send connection failure)', () => { + const e = codedError('tunnel', 'CPX_PROXY_CONNECT_FAILED', 'pre-send') + expect(isFailoverError(e, 'safe')).toBe(true) + expect(isFailoverError(e, 'pre-send-only')).toBe(true) + }) +}) + +describe('R2-ISS-009: errors that carry an HTTP status', () => { + const e = (code: string, phase?: 'pre-send' | 'possibly-sent'): unknown => + codedError('x', code, phase) + it('never fail over, under either policy', () => { + expect( + isFailoverError(Object.assign(e('ECONNRESET', 'possibly-sent'), { status: 503 }), 'safe') + ).toBe(false) + expect( + isFailoverError(Object.assign(e(CPX_TIMEOUT, 'possibly-sent'), { status: 200 }), 'safe') + ).toBe(false) + expect( + isFailoverError( + Object.assign(e('ECONNREFUSED', 'pre-send'), { status: 500 }), + 'pre-send-only' + ) + ).toBe(false) + }) +}) + +describe('preflightGuard (§1.4)', () => { + it('rejects when any resolved address is private', async () => { + const resolveAll = async (): Promise<{ address: string; family: number }[]> => [ + { address: '1.1.1.1', family: 4 }, + { address: '10.0.0.1', family: 4 } + ] + await expect(preflightGuard('gw.front.com', resolveAll)).rejects.toMatchObject({ + code: CPX_GUARD_REFUSED + }) + }) + it('allows a target that fails to resolve (proxy side resolves it)', async () => { + const resolveAll = async (): Promise => { + throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }) + } + await expect(preflightGuard('gw.front.com', resolveAll)).resolves.toBeUndefined() + }) + it('ISS-003: rejects with CPX_TIMEOUT when the signal aborts while resolving', async () => { + const ac = new AbortController() + const pending = preflightGuard('gw.front.com', () => new Promise(() => {}), ac.signal) + ac.abort(new Error('budget exhausted')) + await expect(pending).rejects.toMatchObject({ code: CPX_TIMEOUT, phase: 'pre-send' }) + ac.abort() + await expect( + preflightGuard('gw.front.com', async () => [{ address: '1.1.1.1', family: 4 }], ac.signal) + ).rejects.toMatchObject({ code: CPX_TIMEOUT }) + }) + it('allows all-public targets and strips IPv6 brackets before resolving', async () => { + const seen: string[] = [] + const resolveAll = async (h: string): Promise<{ address: string; family: number }[]> => { + seen.push(h) + return [{ address: '2606:4700::1', family: 6 }] + } + await expect(preflightGuard('[2606:4700::1]', resolveAll)).resolves.toBeUndefined() + expect(seen).toEqual(['2606:4700::1']) + }) +}) + +describe('resolveLocalProxy (R2-ISS-047)', () => { + it('has no auth when the core has no inbound authentication', async () => { + controledConfig.authentication = [] + expect(await resolveLocalProxy()).toEqual({ host: '127.0.0.1', port: 7890 }) + }) + it('R2-ISS-054: a disabled mixed-port (0) makes the proxy unavailable instead of falling back to port 80', async () => { + const saved = controledConfig['mixed-port'] + controledConfig.authentication = ['user:pass'] + try { + for (const bad of [0, -1, 70000, 1.5]) { + controledConfig['mixed-port'] = bad + await expect(resolveLocalProxy()).rejects.toMatchObject({ + code: 'CPX_PROXY_CONNECT_FAILED', + phase: 'pre-send' + }) + } + } finally { + controledConfig['mixed-port'] = saved + controledConfig.authentication = [] + } + }) + + it('carries the first user:pass credential, split on the first colon', async () => { + controledConfig.authentication = ['user:p:a:ss'] + expect(await resolveLocalProxy()).toEqual({ + host: '127.0.0.1', + port: 7890, + auth: { user: 'user', pass: 'p:a:ss' } + }) + }) +}) diff --git a/src/main/resolve/plugin/route.ts b/src/main/resolve/plugin/route.ts new file mode 100644 index 00000000..9ea983dd --- /dev/null +++ b/src/main/resolve/plugin/route.ts @@ -0,0 +1,167 @@ +// 路由(route):一次 HTTP 请求走哪条出口。direct / proxy(本地混合端口)/ bootstrap:(§6 临时核心)。 +// RouteProvider 是 operation.ts 与具体路由来源之间的 seam:auto 模式给出 [首选, 另一条] 两个候选, +// 由 operation.ts 按 §1.3 决定是否回退;direct / proxy 为用户显式覆盖,只有一条候选;bootstrap 在 §6c 注入。 +import { + CPX_GUARD_REFUSED, + CPX_PROXY_CONNECT_FAILED, + CPX_TIMEOUT, + codedError, + codeOf, + isUnreachableCode, + phaseOf, + statusOf +} from './errors' +import { resolveAllPublicOrThrow, type ResolveAll } from './net-guard' +import { abortable } from './abortable' +import type { RetryPolicy } from './operation' + +export type BaseRoute = 'direct' | 'proxy' +export type RouteKey = BaseRoute | `bootstrap:${number}` + +export interface RouteProxy { + host: string + port: number + auth?: { user: string; pass: string } +} + +export interface RouteProvider { + candidates(): Promise + proxyFor(key: RouteKey): Promise + // §1.4:非 direct 路由对某 origin 发第一个请求前必须先过一次 guarded 判定(auto 模式)。 + // 显式 routeMode=proxy 不做预检,保持今天已接受的降级。 + readonly guardNonDirect: boolean + // §0.4 规则 5:aborted=false 用剩余预算做正常清理;aborted=true 执行不可取消的紧急清理。 + dispose(aborted: boolean): Promise +} + +export type ProxyResolver = () => Promise + +// lastGoodRoute 只能是 direct | proxy;bootstrap 不持久化。 +export function baseRouteOf(key: RouteKey): BaseRoute | undefined { + return key === 'direct' || key === 'proxy' ? key : undefined +} + +export function isBaseRoute(v: unknown): v is BaseRoute { + return v === 'direct' || v === 'proxy' +} + +export function isRouteMode(v: unknown): v is IPluginRouteMode { + return v === 'auto' || v === 'direct' || v === 'proxy' +} + +// 本地混合端口代理。惰性解析:只有真正走 proxy 路由时才读取核心配置。 +export async function resolveLocalProxy(): Promise { + const { getControledMihomoConfig } = await import('../../config/controledMihomo') + const { 'mixed-port': port = 7890, authentication = [] } = await getControledMihomoConfig() + // 混合端口关闭(0)或非法:代理不可用。不能回落到别的端口——那会把请求和核心的代理凭据送给无关的本地服务。 + // 按隧道建立失败(pre-send)抛出:auto 回退直连,显式 proxy 按不可达处理 + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw codedError( + 'Local proxy is not available (mixed-port disabled)', + CPX_PROXY_CONNECT_FAILED, + 'pre-send' + ) + } + // 核心开启了 inbound 认证时,环回请求也可能被要求认证(skip-auth-prefixes 未含环回):带上第一组凭据。 + // 环回被跳过认证时多带的凭据会被忽略,无副作用。凭据按第一个冒号切分 user:pass。 + const cred = authentication.find((a) => typeof a === 'string' && a.includes(':')) + if (!cred) return { host: '127.0.0.1', port } + const i = cred.indexOf(':') + return { host: '127.0.0.1', port, auth: { user: cred.slice(0, i), pass: cred.slice(i + 1) } } +} + +function lazyProxy( + resolveProxy: ProxyResolver +): (key: RouteKey) => Promise { + let proxy: Promise | undefined + return async (key) => { + if (key !== 'proxy') return undefined + proxy ??= resolveProxy() + return proxy + } +} + +export function singleRouteProvider( + route: BaseRoute, + resolveProxy: ProxyResolver = resolveLocalProxy +): RouteProvider { + return { + candidates: async () => [route], + proxyFor: lazyProxy(resolveProxy), + guardNonDirect: false, + dispose: async () => {} + } +} + +// auto:候选顺序 [initialRoute ?? lastGoodRoute ?? 'direct', 另一条]。 +export function autoRouteProvider( + first: BaseRoute, + resolveProxy: ProxyResolver = resolveLocalProxy +): RouteProvider { + const second: BaseRoute = first === 'direct' ? 'proxy' : 'direct' + return { + candidates: async () => [first, second], + proxyFor: lazyProxy(resolveProxy), + guardNonDirect: true, + dispose: async () => {} + } +} + +// 读时推导(§1.5 五行迁移表),严格对应旧 netOpts 的继承语义: +// routeMode 有 → 取其值;无 → useProxy=true → proxy,false → auto;都无 → 全局 true → proxy,否则 auto。 +export function effectiveRouteMode(item: IPluginItem, app: IAppConfig): IPluginRouteMode { + if (isRouteMode(item.routeMode)) return item.routeMode + if (typeof item.useProxy === 'boolean') return item.useProxy ? 'proxy' : 'auto' + return app.pluginUseProxy ? 'proxy' : 'auto' +} + +export function createRouteProvider( + item: IPluginItem, + app: IAppConfig, + initialRoute?: BaseRoute, + resolveProxy?: ProxyResolver +): RouteProvider { + const mode = effectiveRouteMode(item, app) + if (mode !== 'auto') return singleRouteProvider(mode, resolveProxy) + const lastGood = isBaseRoute(item.lastGoodRoute) ? item.lastGoodRoute : undefined + return autoRouteProvider(initialRoute ?? lastGood ?? 'direct', resolveProxy) +} + +// §1.3:可回退失败 = 网络 errno(UNREACHABLE_CODES / TLS)与 CPX_TIMEOUT。其余(任意 HTTP 响应、 +// 拒绝重定向、响应过大、guard 拒绝)一律不回退。pre-send-only(enroll):只有 phase === 'pre-send' +// 且非超时的可回退错误才回退。 +export function isFailoverError(e: unknown, policy: RetryPolicy): boolean { + const code = codeOf(e) + if (code === CPX_GUARD_REFUSED) return false + // 已收到 HTTP 响应头(body 阶段才失败):服务器已到达,换路无用(§1.3 “任意 HTTP 响应”) + if (statusOf(e) !== undefined) return false + const failoverClass = code === CPX_TIMEOUT || isUnreachableCode(code) + if (!failoverClass) return false + if (policy === 'safe') return true + return code !== CPX_TIMEOUT && phaseOf(e) === 'pre-send' +} + +function stripBrackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname +} + +// §1.4 预检:在非 guarded 路由(proxy / bootstrap)之前用同一套 guarded 判定解析目标 host。 +// 任一地址为私网 → CPX_GUARD_REFUSED(终态);解析失败(NXDOMAIN / EAI_AGAIN)→ 允许经代理 +// (代理侧会自行解析,这正是 DNS 被封的用户需要代理的原因);全部公网 → 允许。 +// 预检也是 op 内的等待(§0.4 规则 1),必须接收同一个 signal:中止映射为 CPX_TIMEOUT。 +export async function preflightGuard( + hostname: string, + resolveAll?: ResolveAll, + signal?: AbortSignal, + timeoutMs?: number +): Promise { + try { + await abortable(resolveAllPublicOrThrow(stripBrackets(hostname), resolveAll), signal, timeoutMs) + } catch (e) { + const code = codeOf(e) + if (code === CPX_GUARD_REFUSED || code === CPX_TIMEOUT) throw e + } +} + +// op 内不受底层取消机制约束的等待统一经 abortable 接收同一个 signal(实现见 abortable.ts;这里保留导出) +export { abortable } diff --git a/src/main/resolve/plugin/text.test.ts b/src/main/resolve/plugin/text.test.ts new file mode 100644 index 00000000..8f56c242 --- /dev/null +++ b/src/main/resolve/plugin/text.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { sanitizeProviderText } from './text' + +describe('sanitizeProviderText (§4.2)', () => { + it('trims and keeps newlines while stripping other control characters', () => { + expect(sanitizeProviderText(' a\u0001b\nc\u007fd\te ', 200)).toBe('ab\ncde') + }) + it('truncates by code points, not UTF-16 units', () => { + const emoji = '😀'.repeat(201) + expect(Array.from(sanitizeProviderText(emoji, 200) ?? '')).toHaveLength(200) + }) + it('treats empty / whitespace-only / non-string as absent', () => { + expect(sanitizeProviderText(' ', 10)).toBeUndefined() + expect(sanitizeProviderText('\u0000\u0001', 10)).toBeUndefined() + expect(sanitizeProviderText(42, 10)).toBeUndefined() + expect(sanitizeProviderText(undefined, 10)).toBeUndefined() + }) +}) diff --git a/src/main/resolve/plugin/text.ts b/src/main/resolve/plugin/text.ts new file mode 100644 index 00000000..93c08090 --- /dev/null +++ b/src/main/resolve/plugin/text.ts @@ -0,0 +1,14 @@ +// 机场可写的自由文本(§4.2):错误 JSON 的 message、.cpx 的 provider.description。 +// 清洗:trim、去除除换行(U+000A)外的控制字符(U+0000–U+001F、U+007F)、按码点截断;空串视为无。 +// 渲染层只作为 React 文本节点显示(whitespace-pre-line),不做 Markdown、不识别链接。 +export function sanitizeProviderText(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined + // eslint-disable-next-line no-control-regex + const cleaned = value.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim() + if (cleaned === '') return undefined + const points = Array.from(cleaned) + return points.length > max ? points.slice(0, max).join('') : cleaned +} + +export const MAX_PROVIDER_MESSAGE = 200 +export const MAX_PROVIDER_DESCRIPTION = 500 diff --git a/src/main/resolve/plugin/vault.test.ts b/src/main/resolve/plugin/vault.test.ts index 594b97b6..2fc78bb1 100644 --- a/src/main/resolve/plugin/vault.test.ts +++ b/src/main/resolve/plugin/vault.test.ts @@ -5,7 +5,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { writeVault, readVault, + updateVault, removeVault, + removeVaultIfDevice, + withVaultLock, + parseVault, isVaultPersistent, VaultUnavailableError } from './vault' @@ -17,10 +21,14 @@ let decryptError: Error | undefined let encryptError: Error | undefined let shouldReEncrypt = false let encryptionPrefix = 'enc:' +// Linux only: the password store Electron selected (basic_text = fixed-key fallback) +let linuxBackend = 'gnome_libsecret' let encryptCalls = 0 let decryptCalls = 0 let syncEncryptCalls = 0 let syncDecryptCalls = 0 +// when set, async decrypts wait on it (a decrypt that outlives the op budget) +let decryptGate: Promise | undefined vi.mock('electron', () => ({ safeStorage: { @@ -40,6 +48,7 @@ vi.mock('electron', () => ({ return asyncApiSupported ? async (encrypted: Buffer) => { decryptCalls++ + if (decryptGate) await decryptGate if (decryptError) throw decryptError const result = Buffer.from(encrypted) .toString('utf-8') @@ -49,6 +58,7 @@ vi.mock('electron', () => ({ : undefined }, isEncryptionAvailable: () => encryptionAvailable, + getSelectedStorageBackend: () => linuxBackend, encryptString: (value: string) => { syncEncryptCalls++ if (encryptError) throw encryptError @@ -80,6 +90,7 @@ function sampleVault(): IPluginVault { deviceId: '11111111-1111-4111-8111-111111111111', gateway: { gateway: 'https://gw.front.com', + gateways: ['https://gw.front.com'], endpoints: { enroll: '/enroll', challenge: '/challenge', @@ -90,22 +101,34 @@ function sampleVault(): IPluginVault { } } +// 旧形态(升级前写出的 vault):只有 gateway.gateway,没有 gateways / lastGood +function legacyVault(): unknown { + const v = sampleVault() as unknown as { gateway: Record } + delete v.gateway.gateways + return v +} + function encryptedVault(vault: unknown = sampleVault()): Buffer { return Buffer.from('enc:' + JSON.stringify(vault), 'utf-8') } beforeEach(() => { TMP = mkdtempSync(join(tmpdir(), 'cpxvault-')) + // the generic cases describe the non-Linux path (no backend / canary checks); Linux cases opt in explicitly. + // restored by afterEach's vi.restoreAllMocks(), so the CI runner's real platform never leaks in + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') encryptionAvailable = true asyncApiSupported = true decryptError = undefined encryptError = undefined shouldReEncrypt = false encryptionPrefix = 'enc:' + linuxBackend = 'gnome_libsecret' encryptCalls = 0 decryptCalls = 0 syncEncryptCalls = 0 syncDecryptCalls = 0 + decryptGate = undefined }) afterEach(() => { @@ -231,6 +254,262 @@ describe('persistent async vault', () => { }) }) +describe('parseVault / normalization (§2.3)', () => { + it('normalizes a legacy vault (only gateway.gateway) into gateways: [gateway]', () => { + const v = parseVault(legacyVault()) + expect(v?.gateway.gateways).toEqual(['https://gw.front.com']) + expect(v?.gateway.gateway).toBe('https://gw.front.com') + expect(v?.gateway.lastGood).toBeUndefined() + }) + it('readVault returns the normalized object for a legacy ciphertext', async () => { + writeFileSync(join(TMP, 'legacy2.bin'), encryptedVault(legacyVault())) + vi.resetModules() + const fresh = await import('./vault') + const out = await fresh.readVault('legacy2') + expect(out.kind).toBe('ok') + if (out.kind === 'ok') expect(out.vault.gateway.gateways).toEqual(['https://gw.front.com']) + }) + it('drops lastGood when it is not in gateways, keeping the rest', () => { + const raw = sampleVault() + raw.gateway.gateways = ['https://gw.front.com', 'https://gw2.front.com'] + raw.gateway.lastGood = 'https://gw9.front.com' + const v = parseVault(raw) + expect(v?.gateway.lastGood).toBeUndefined() + expect(v?.gateway.gateways).toEqual(['https://gw.front.com', 'https://gw2.front.com']) + expect(v?.gateway.gateway).toBe('https://gw.front.com') + }) + it('keeps a valid lastGood and mirrors it into gateway.gateway', () => { + const raw = sampleVault() + raw.gateway.gateways = ['https://gw.front.com', 'https://gw2.front.com'] + raw.gateway.lastGood = 'https://gw2.front.com' + const v = parseVault(raw) + expect(v?.gateway.lastGood).toBe('https://gw2.front.com') + expect(v?.gateway.gateway).toBe('https://gw2.front.com') + }) + it('R2-ISS-006: a non-canonical lastGood spelling is normalized instead of silently dropped', () => { + const raw = sampleVault() + raw.gateway.gateways = ['https://gw.front.com', 'https://GW2.front.com:443/'] + raw.gateway.lastGood = 'https://GW2.front.com:443/' + const v = parseVault(raw) + expect(v?.gateway.gateways).toEqual(['https://gw.front.com', 'https://gw2.front.com']) + expect(v?.gateway.lastGood).toBe('https://gw2.front.com') + expect(v?.gateway.gateway).toBe('https://gw2.front.com') + }) + it('R2-ISS-004: endpoint paths are normalized on read ("/v1/../config" → "/config")', () => { + const raw = sampleVault() + raw.gateway.endpoints = { + ...raw.gateway.endpoints, + config: '/v1/../config', + revoke: '/./revoke' + } + const v = parseVault(raw) + expect(v?.gateway.endpoints.config).toBe('/config') + expect(v?.gateway.endpoints.revoke).toBe('/revoke') + }) + it('R2-ISS-064: staleDevices round-trips and malformed entries are dropped', async () => { + const good = { + deviceId: '33333333-3333-4333-8333-333333333333', + devicePrivKey: sampleVault().devicePrivKey + } + const raw = { + ...sampleVault(), + staleDevices: [ + good, + { deviceId: 'nope', devicePrivKey: good.devicePrivKey }, + { deviceId: good.deviceId, devicePrivKey: 'short' }, + 42 + ] + } + expect(parseVault(raw)?.staleDevices).toEqual([good]) + expect(parseVault({ ...sampleVault(), staleDevices: 'x' })?.staleDevices).toBeUndefined() + await writeVault('stale', { ...sampleVault(), staleDevices: [good] }) + const out = await readVault('stale') + expect(out.kind === 'ok' && out.vault.staleDevices).toEqual([good]) + // pruning to an empty list drops the field entirely + await updateVault('stale', (v) => ({ ...v, staleDevices: [] })) + const pruned = await readVault('stale') + expect(pruned.kind === 'ok' && 'staleDevices' in pruned.vault).toBe(false) + }) + + it('ignores unknown fields (e.g. a bootstrap block from a newer version)', () => { + const raw = { ...sampleVault(), bootstrap: { proxies: 'garbage' } } + const v = parseVault(raw) + expect(v).not.toBeNull() + expect((v as unknown as Record).bootstrap).toBeUndefined() + }) + it('rejects a malformed gateways list', () => { + const raw = sampleVault() + raw.gateway.gateways = ['https://gw.front.com', 'http://plain'] + expect(parseVault(raw)).toBeNull() + }) + it('writes gateway.gateway = lastGood ?? gateways[0] to disk (downgrade mirror)', async () => { + const v = sampleVault() + v.gateway.gateways = ['https://gw.front.com', 'https://gw2.front.com'] + v.gateway.lastGood = 'https://gw2.front.com' + v.gateway.gateway = 'https://stale.example' + await writeVault('mirror', v) + const onDisk = JSON.parse(readFileSync(join(TMP, 'mirror.bin'), 'utf-8').replace(/^enc:/, '')) + expect(onDisk.gateway.gateway).toBe('https://gw2.front.com') + expect(onDisk.gateway.lastGood).toBe('https://gw2.front.com') + const again = { + ...sampleVault(), + gateway: { ...sampleVault().gateway, gateways: ['https://gw.front.com'] } + } + await writeVault('mirror2', again) + const onDisk2 = JSON.parse(readFileSync(join(TMP, 'mirror2.bin'), 'utf-8').replace(/^enc:/, '')) + expect(onDisk2.gateway.gateway).toBe('https://gw.front.com') + expect('lastGood' in onDisk2.gateway).toBe(false) + }) + it('updateVault skips the write when the mutator returns the same object', async () => { + await writeVault('same', sampleVault()) + const before = readFileSync(join(TMP, 'same.bin')) + encryptionPrefix = 'rot:' + expect(await updateVault('same', (v) => v)).toBe(true) + expect(readFileSync(join(TMP, 'same.bin'))).toEqual(before) + }) +}) + +describe('removeVaultIfDevice (R2-ISS-021)', () => { + it("removes only a vault that belongs to the given device; keeps another device's vault", async () => { + const v = sampleVault() as unknown as IPluginVault + await writeVault('dev', v) + expect(await removeVaultIfDevice('dev', 'not-this-device')).toBe(false) + expect((await readVault('dev')).kind).toBe('ok') + expect(await removeVaultIfDevice('dev', v.deviceId)).toBe(true) + expect((await readVault('dev')).kind).toBe('missing') + expect(await removeVaultIfDevice('dev', v.deviceId)).toBe(false) + }) +}) + +describe('vault lock', () => { + it('serializes concurrent updateVault calls so neither change is lost', async () => { + const base = sampleVault() + base.gateway.gateways = ['https://gw.front.com', 'https://gw2.front.com'] + await writeVault('lock', base) + const ep = sampleVault().gateway.endpoints + await Promise.all([ + updateVault('lock', (v) => ({ + ...v, + gateway: { ...v.gateway, lastGood: 'https://gw2.front.com' } + })), + updateVault('lock', (v) => ({ + ...v, + gateway: { ...v.gateway, endpoints: { ...ep, config: '/cfg2' } } + })) + ]) + const out = await readVault('lock') + expect(out.kind).toBe('ok') + if (out.kind === 'ok') { + expect(out.vault.gateway.lastGood).toBe('https://gw2.front.com') + expect(out.vault.gateway.gateway).toBe('https://gw2.front.com') + expect(out.vault.gateway.endpoints.config).toBe('/cfg2') + } + }) + + it('does not resurrect a vault through a queued updateVault after removeVault', async () => { + await writeVault('gone', sampleVault()) + const removed = removeVault('gone') + const updated = updateVault('gone', (v) => ({ ...v, deviceId: v.deviceId })) + await removed + expect(await updated).toBe(false) + expect(existsSync(join(TMP, 'gone.bin'))).toBe(false) + expect(await readVault('gone')).toEqual({ kind: 'missing' }) + }) + + it('does not resurrect a vault through a cache-miss readVault queued behind removeVault', async () => { + writeFileSync(join(TMP, 'miss.bin'), encryptedVault()) + vi.resetModules() + const fresh = await import('./vault') + const removed = fresh.removeVault('miss') + const read = fresh.readVault('miss') + await removed + expect(await read).toEqual({ kind: 'missing' }) + expect(fresh.hasVaultMaterial('miss')).toBe(false) + }) + + it('BL-005 (ISS-019): a decrypt that outlives the budget releases the caller, keeps the lock, and lands late', async () => { + writeFileSync(join(TMP, 'slow.bin'), encryptedVault()) + let open!: () => void + decryptGate = new Promise((r) => { + open = r + }) + const ac = new AbortController() + const read = readVault('slow', ac.signal) + await new Promise((r) => setTimeout(r, 10)) + expect(decryptCalls).toBe(1) // inside the lock, decrypting + let mutated = false + const queued = updateVault('slow', (v) => { + mutated = true + return { ...v, gateway: { ...v.gateway, lastGood: 'https://gw.front.com' } } + }) + ac.abort(new Error('budget exhausted')) + // the caller is released at the budget with the op's timeout code… + await expect(read).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) + await new Promise((r) => setTimeout(r, 10)) + // …but the lock is still held by the in-flight decrypt: the queued write has not run + expect(mutated).toBe(false) + open() + expect(await queued).toBe(true) + expect(mutated).toBe(true) + // the late decrypt filled the cache; the queued write re-read from it rather than decrypting again + expect(decryptCalls).toBe(1) + const out = await readVault('slow') + expect(out.kind).toBe('ok') + if (out.kind === 'ok') expect(out.vault.gateway.lastGood).toBe('https://gw.front.com') + }) + + it('R2-ISS-055: an already-aborted signal rejects with CPX_TIMEOUT and leaves no unhandled rejection', async () => { + writeFileSync(join(TMP, 'pre.bin'), encryptedVault()) + let unhandled = 0 + const onUnhandled = (): void => { + unhandled++ + } + process.on('unhandledRejection', onUnhandled) + try { + const ac = new AbortController() + ac.abort(new Error('budget exhausted')) + await expect(readVault('pre', ac.signal)).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) + await new Promise((r) => setTimeout(r, 20)) + expect(unhandled).toBe(0) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('R2-ISS-019: the synchronous compat decrypt is not started once the budget is gone', async () => { + asyncApiSupported = false + writeFileSync(join(TMP, 'syncv.bin'), encryptedVault()) + const ac = new AbortController() + const read = readVault('syncv', ac.signal) + ac.abort(new Error('budget exhausted')) // before the lock task reaches the decrypt + await expect(read).rejects.toMatchObject({ code: 'CPX_TIMEOUT' }) + await new Promise((r) => setTimeout(r, 20)) + expect(syncDecryptCalls).toBe(0) + // a later read (no budget pressure) decrypts normally + expect((await readVault('syncv')).kind).toBe('ok') + expect(syncDecryptCalls).toBe(1) + }) + + it('aborts a queued write when the signal fires while waiting for the lock', async () => { + await writeVault('abort', sampleVault()) + let release!: () => void + const holding = withVaultLock( + 'abort', + () => + new Promise((r) => { + release = r + }) + ) + const ac = new AbortController() + const queued = updateVault('abort', (v) => ({ ...v, deviceId: v.deviceId }), ac.signal) + ac.abort(new Error('budget exhausted')) + await expect(queued).rejects.toThrow('budget exhausted') + release() + await holding + expect((await readVault('abort')).kind).toBe('ok') + }) +}) + describe('storage backend unavailable', () => { it('treats unavailable macOS/Windows storage as transient', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') @@ -245,6 +524,78 @@ describe('storage backend unavailable', () => { }) }) + it('R2-ISS-061: Linux with the basic_text (fixed-key) backend keeps the vault in memory even though async encryption reports available', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + linuxBackend = 'basic_text' + writeFileSync(join(TMP, 'fixed.bin'), encryptedVault()) + vi.resetModules() + const fresh = await import('./vault') + + await fresh.writeVault('linux-basic', sampleVault()) + expect(existsSync(join(TMP, 'linux-basic.bin'))).toBe(false) + expect(encryptCalls).toBe(0) // never encrypts with the fixed key + expect(await fresh.isVaultPersistent()).toBe(false) + expect((await fresh.readVault('linux-basic')).kind).toBe('ok') + // an existing ciphertext is not decrypted / re-encrypted through that backend either + expect(await fresh.readVault('fixed')).toEqual({ kind: 'unavailable' }) + expect(decryptCalls).toBe(0) + }) + + it('R2-ISS-061 (V1): a secure backend name whose ciphertext carries the fixed-key prefix is still kept in memory', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + linuxBackend = 'kwallet6' + encryptionPrefix = 'v10' // the store failed to initialise → Chromium fell back to the hardcoded key + vi.resetModules() + const fresh = await import('./vault') + await fresh.writeVault('linux-v10', sampleVault()) + expect(existsSync(join(TMP, 'linux-v10.bin'))).toBe(false) + expect(await fresh.isVaultPersistent()).toBe(false) + expect(encryptCalls).toBe(1) // the canary only; the vault itself was never encrypted with that key + }) + + it('R2-ISS-061 (V1): a canary that throws (secret store momentarily unavailable) is not a fixed-key verdict', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + linuxBackend = 'gnome_libsecret' + writeFileSync(join(TMP, 'flaky.bin'), encryptedVault()) + vi.resetModules() + const fresh = await import('./vault') + encryptError = new Error( + 'safeStorage.encryptStringAsync is temporarily unavailable. Please try again.' + ) + // reads still work: the decrypt path is judged on its own + expect((await fresh.readVault('flaky')).kind).toBe('ok') + // writes fail on their own path, nothing lands under an unverified key + await expect(fresh.writeVault('flaky-w', sampleVault())).rejects.toMatchObject({ + name: VaultUnavailableError.name + }) + expect(existsSync(join(TMP, 'flaky-w.bin'))).toBe(false) + // once the store is back the verdict is taken normally and persistence resumes + encryptError = undefined + await fresh.writeVault('flaky-w', sampleVault()) + expect(existsSync(join(TMP, 'flaky-w.bin'))).toBe(true) + }) + + it('R2-ISS-061 (V1): a v11 (system secret store) ciphertext prefix persists normally', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + linuxBackend = 'gnome_libsecret' + encryptionPrefix = 'v11' + vi.resetModules() + const fresh = await import('./vault') + await fresh.writeVault('linux-v11', sampleVault()) + expect(existsSync(join(TMP, 'linux-v11.bin'))).toBe(true) + expect((await fresh.readVault('linux-v11')).kind).toBe('ok') + }) + + it('R2-ISS-061: Linux with a system secret store persists normally', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + linuxBackend = 'kwallet6' + vi.resetModules() + const fresh = await import('./vault') + await fresh.writeVault('linux-kw', sampleVault()) + expect(existsSync(join(TMP, 'linux-kw.bin'))).toBe(true) + expect(await fresh.isVaultPersistent()).toBe(true) + }) + it('keeps Linux vaults in memory when no async backend exists', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') encryptionAvailable = false diff --git a/src/main/resolve/plugin/vault.ts b/src/main/resolve/plugin/vault.ts index b9e1ca42..d7b995e3 100644 --- a/src/main/resolve/plugin/vault.ts +++ b/src/main/resolve/plugin/vault.ts @@ -2,13 +2,24 @@ import { mkdir, readFile, rm } from 'fs/promises' import { existsSync } from 'fs' import { safeStorage } from 'electron' import { pluginVaultDir, pluginVaultPath } from '../../utils/dirs' -import { atomicWriteFile } from '../../utils/safeFile' +import { atomicWriteFile, KeyedWriteQueue } from '../../utils/safeFile' import { logger } from '../../utils/logger' -import { parseGatewayOrigin, isValidEndpointPath } from './gateway-url' +import { abortable } from './abortable' +import { + parseGatewayOrigin, + parseGatewayList, + isValidEndpointPath, + normalizeEndpointPath +} from './gateway-url' // safeStorage 不可用时的会话内内存兜底(仅 Linux;重启即丢)。 const memoryVaults = new Map() +// vault lock(§0.5):按 id 串行化所有读写。writeVault / updateVault / removeVault / re-encrypt +// 以及 readVault 的 cache-miss 全路径都在锁内,removeVault 之后排队的写与读都不会让 vault 复活。 +// 层级固定:先 plugin lock,再 vault lock;vault lock 内不得再取 plugin lock。 +const vaultLocks = new KeyedWriteQueue() + 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}$/ const TEMPORARILY_UNAVAILABLE = 'temporarily unavailable' @@ -33,49 +44,164 @@ type OptionalAsyncSafeStorage = { decryptStringAsync?: (encrypted: Buffer) => Promise<{ result: string; shouldReEncrypt: boolean }> } -// 校验从磁盘解密出来的 vault 结构(私钥 32 字节、deviceId 为 UUIDv4、网关为 https origin、 -// 四个端点为相对 path)。坏/被篡改的数据按 invalid 处理,避免畸形私钥/网关进入签名或网络路径。 -function isValidVault(v: unknown): v is IPluginVault { - if (typeof v !== 'object' || v === null) return false - const o = v as Record +const ENDPOINT_KEYS = ['enroll', 'challenge', 'config', 'revoke'] as const + +// 写出前归一化:镜像 gateway.gateway = lastGood ?? gateways[0],直到明确停止支持应用降级(§2.3)。 +function normalizeGatewayState(g: IPluginGatewayState): IPluginGatewayState { + const lastGood = g.lastGood && g.gateways.includes(g.lastGood) ? g.lastGood : undefined + const out: IPluginGatewayState = { + gateway: lastGood ?? g.gateways[0], + gateways: [...g.gateways], + endpoints: { ...g.endpoints } + } + if (lastGood) out.lastGood = lastGood + return out +} + +function normalizeVault(v: IPluginVault): IPluginVault { + const stale = (v.staleDevices ?? []).map((d) => ({ + deviceId: d.deviceId, + devicePrivKey: d.devicePrivKey + })) + return { + devicePrivKey: v.devicePrivKey, + deviceId: v.deviceId, + gateway: normalizeGatewayState(v.gateway), + ...(stale.length > 0 ? { staleDevices: stale } : {}) + } +} + +function isDeviceKey(v: unknown): v is string { + return typeof v === 'string' && Buffer.from(v, 'base64').length === 32 +} + +// 待回收的旧设备列表:逐项校验,坏的条目丢弃(不让畸形私钥进入签名路径),缺失即空 +function parseStaleDevices(raw: unknown): IPluginStaleDevice[] { + if (!Array.isArray(raw)) return [] + const out: IPluginStaleDevice[] = [] + for (const e of raw) { + if (typeof e !== 'object' || e === null) continue + const d = e as Record + if ( + typeof d.deviceId !== 'string' || + !UUID_V4.test(d.deviceId) || + !isDeviceKey(d.devicePrivKey) + ) { + continue + } + out.push({ deviceId: d.deviceId, devicePrivKey: d.devicePrivKey }) + } + return out +} + +// 校验并归一化从磁盘解密出来的 vault(§2.3):私钥 32 字节、deviceId 为 UUIDv4、网关为 https origin、 +// 四个端点为相对 path。接受旧形态(只有 gateway.gateway)并补出 gateways: [gateway];lastGood ∉ gateways +// 时丢弃该字段。坏/被篡改的数据返回 null,避免畸形私钥/网关进入签名或网络路径。 +export function parseVault(raw: unknown): IPluginVault | null { + if (typeof raw !== 'object' || raw === null) return null + const o = raw as Record if (typeof o.devicePrivKey !== 'string' || Buffer.from(o.devicePrivKey, 'base64').length !== 32) { - return false + return null } - if (typeof o.deviceId !== 'string' || !UUID_V4.test(o.deviceId)) return false + if (typeof o.deviceId !== 'string' || !UUID_V4.test(o.deviceId)) return null const g = o.gateway as Record | undefined - if (!g || parseGatewayOrigin(g.gateway) === null) return false - const e = g.endpoints as Record | undefined - if (!e) return false - for (const k of ['enroll', 'challenge', 'config', 'revoke']) { - if (!isValidEndpointPath(e[k])) return false + if (!g) return null + const primary = parseGatewayOrigin(g.gateway) + if (!primary) return null + let gateways: string[] + if (g.gateways === undefined) { + gateways = [primary] + } else { + const list = parseGatewayList(g.gateways) + if (!list) return null + gateways = list } - return true + const e = g.endpoints as Record | undefined + if (!e) return null + const endpoints = {} as IGatewayEndpoints + for (const k of ENDPOINT_KEYS) { + const v = e[k] + if (!isValidEndpointPath(v)) return null + endpoints[k] = normalizeEndpointPath(v) + } + // lastGood 与列表同样经 parseGatewayOrigin 归一化后再比较归属(normalizeGatewayState), + // 否则大小写 / 默认端口 / 尾部斜杠的差异会让有效首选网关被静默丢弃 + const lastGood = parseGatewayOrigin(g.lastGood) ?? undefined + return normalizeVault({ + devicePrivKey: o.devicePrivKey, + deviceId: o.deviceId, + gateway: { gateway: primary, gateways, endpoints, lastGood }, + staleDevices: parseStaleDevices(o.staleDevices) + }) } function isTemporarilyUnavailable(error: unknown): boolean { return error instanceof Error && error.message.toLowerCase().includes(TEMPORARILY_UNAVAILABLE) } +// Linux:只有系统 secret store(libsecret / kwallet)才是安全后端。没有可用的密码管理器时 Electron 回退到 +// basic_text——用固定密钥"加密",等于明文——不能把设备私钥持久化到它,也不能向它自动重加密:走内存兜底。 +// 异步 API 的 isAsyncEncryptionAvailable 在初始化完成后就返回 true,不区分后端,所以要单独看后端名。 +const SECURE_LINUX_BACKENDS = new Set(['gnome_libsecret', 'kwallet', 'kwallet5', 'kwallet6']) + +function linuxBackendIsSecure(): boolean { + if (process.platform !== 'linux') return true + const s = safeStorage as typeof safeStorage & { getSelectedStorageBackend?: () => string } + if (typeof s.getSelectedStorageBackend !== 'function') return false // 无法确认 → 不落盘 + try { + return SECURE_LINUX_BACKENDS.has(s.getSelectedStorageBackend()) + } catch { + return false + } +} + +// 后端名只是启动时的选择:选中的 secret store 之后仍可能初始化失败并回退到固定密钥。Chromium 用固定密钥 +// 加密的密文带确定性的 "v10" 前缀(系统 secret store 为 "v11"):首次判定时加密一段金丝雀,按前缀识别实际 +// 使用的密钥提供者。结果按进程缓存。金丝雀加密本身抛错(keyring 暂时锁定等)不算判定、不缓存:固定密钥后端 +// 不会抛错,抛错说明 secret store 此刻不可用——随后的解密 / 加密会在各自路径上如实失败,不会有任何数据以 +// 未经确认的密钥落盘,而一次暂时的加密失败也不应让本可成功的读取变成 unavailable。 +const FIXED_KEY_CIPHERTEXT_PREFIX = 'v10' +let linuxFixedKeyBackend: boolean | undefined + +async function linuxUsesFixedKey(mode: 'persistent-async' | 'persistent-sync'): Promise { + if (process.platform !== 'linux') return false + if (linuxFixedKeyBackend !== undefined) return linuxFixedKeyBackend + try { + const canary = + mode === 'persistent-async' + ? await safeStorage.encryptStringAsync('plugin-vault-canary') + : safeStorage.encryptString('plugin-vault-canary') + linuxFixedKeyBackend = canary.subarray(0, 3).toString('latin1') === FIXED_KEY_CIPHERTEXT_PREFIX + return linuxFixedKeyBackend + } catch { + return false + } +} + async function storageMode(): Promise { + if (!linuxBackendIsSecure()) return 'memory' const asyncStorage = safeStorage as typeof safeStorage & OptionalAsyncSafeStorage + let mode: 'persistent-async' | 'persistent-sync' | undefined if ( typeof asyncStorage.isAsyncEncryptionAvailable === 'function' && typeof asyncStorage.encryptStringAsync === 'function' && typeof asyncStorage.decryptStringAsync === 'function' ) { try { - if (await asyncStorage.isAsyncEncryptionAvailable()) return 'persistent-async' + if (await asyncStorage.isAsyncEncryptionAvailable()) mode = 'persistent-async' } catch { // Fall through to the platform-specific unavailable behavior below. } } else { // Win7/Catalina 兼容包仍使用 Electron 22/32,只有同步 safeStorage API。 try { - if (safeStorage.isEncryptionAvailable()) return 'persistent-sync' + if (safeStorage.isEncryptionAvailable()) mode = 'persistent-sync' } catch { // Fall through to the platform-specific unavailable behavior below. } } + if (mode && (await linuxUsesFixedKey(mode))) return 'memory' + if (mode) return mode return process.platform === 'linux' ? 'memory' : 'unavailable' } @@ -107,7 +233,15 @@ export function hasVaultMaterial(id: string): boolean { return memoryVaults.has(id) || existsSync(pluginVaultPath(id)) } -export async function writeVault(id: string, vault: IPluginVault): Promise { +export function withVaultLock( + id: string, + task: () => Promise, + signal?: AbortSignal +): Promise { + return vaultLocks.run(id, task, signal) +} + +async function writeVaultRaw(id: string, vault: IPluginVault): Promise { const mode = await storageMode() if (mode === 'memory') { memoryVaults.set(id, vault) @@ -130,6 +264,14 @@ export async function writeVault(id: string, vault: IPluginVault): Promise memoryVaults.set(id, vault) } +async function writeVaultUnlocked(id: string, vault: IPluginVault): Promise { + return writeVaultRaw(id, normalizeVault(vault)) +} + +export function writeVault(id: string, vault: IPluginVault, signal?: AbortSignal): Promise { + return withVaultLock(id, () => writeVaultUnlocked(id, vault), signal) +} + async function bestEffortReEncrypt(id: string, vault: IPluginVault): Promise { try { const encrypted = await safeStorage.encryptStringAsync(JSON.stringify(vault)) @@ -139,7 +281,7 @@ async function bestEffortReEncrypt(id: string, vault: IPluginVault): Promise { +async function readVaultUnlocked(id: string, signal?: AbortSignal): Promise { const cached = memoryVaults.get(id) if (cached) return { kind: 'ok', vault: cached } @@ -152,13 +294,17 @@ export async function readVault(id: string): Promise { try { const encrypted = await readFile(path) + // 同步兼容 API(旧 Electron)会阻塞主线程直到返回,任何代码都无法中途打断它——能做的只有 + // 预算已耗尽时不去启动;异步 API 的迟到结果由 readVault 的 abortable 交给调用方之外处理 + if (mode === 'persistent-sync' && signal?.aborted) return { kind: 'unavailable' } const { result, shouldReEncrypt } = mode === 'persistent-async' ? await safeStorage.decryptStringAsync(encrypted) : { result: safeStorage.decryptString(encrypted), shouldReEncrypt: false } - const parsed = JSON.parse(result) as unknown - if (!isValidVault(parsed)) return { kind: 'invalid' } + const parsed = parseVault(JSON.parse(result)) + if (!parsed) return { kind: 'invalid' } + // 缓存并返回归一化后的对象 memoryVaults.set(id, parsed) if (shouldReEncrypt) await bestEffortReEncrypt(id, parsed) return { kind: 'ok', vault: parsed } @@ -168,7 +314,68 @@ export async function readVault(id: string): Promise { } } -export async function removeVault(id: string): Promise { - memoryVaults.delete(id) - await rm(pluginVaultPath(id), { force: true }) +// 缓存命中不取锁;cache-miss 全路径(存在检查 → 读取 → 解密 → parse → 更新缓存 → 可选 re-encrypt)在锁内。 +// 锁内的读取本身不可中断(解密 / re-encrypt 必须独占,同步 safeStorage API 也无法取消),但调用方不必等它: +// 预算耗尽时按 CPX_TIMEOUT 返回,op 结束、plugin lock 释放。迟到的结果只在锁内填充缓存(那就是 vault 的 +// 真实内容,后续等待者拿到的是它);锁握到读取真正结束,排队的写不会与它并发。 +export async function readVault(id: string, signal?: AbortSignal): Promise { + const cached = memoryVaults.get(id) + if (cached) return { kind: 'ok', vault: cached } + return abortable( + withVaultLock(id, () => readVaultUnlocked(id, signal), signal), + signal + ) +} + +// 在锁内重新读取最新 vault 再应用修改并整体写回(§0.4 提交出口用它提交 gatewayState)。 +// vault 不存在 / 不可读时不写任何东西,返回 false——removeVault 之后排队的 updateVault 不会复活它。 +export function updateVault( + id: string, + mutator: (vault: IPluginVault) => IPluginVault, + signal?: AbortSignal +): Promise { + return withVaultLock( + id, + async () => { + const current = await readVaultUnlocked(id) + if (current.kind !== 'ok') return false + const next = mutator(current.vault) + // mutator 返回同一对象表示无变化:不重新加密落盘 + if (next !== current.vault) await writeVaultUnlocked(id, next) + return true + }, + signal + ) +} + +// 登录补偿专用(§2.5):只删除属于指定设备的 vault——本次登录写入的新 vault 才删,替换失败时仍然有效的 +// 旧设备 vault 保留。missing → 无事可做;invalid → 删除(垃圾);unavailable → 无法核对归属,保留。 +export function removeVaultIfDevice( + id: string, + deviceId: string, + signal?: AbortSignal +): Promise { + return withVaultLock( + id, + async () => { + const current = await readVaultUnlocked(id) + if (current.kind === 'missing' || current.kind === 'unavailable') return false + if (current.kind === 'ok' && current.vault.deviceId !== deviceId) return false + memoryVaults.delete(id) + await rm(pluginVaultPath(id), { force: true }) + return true + }, + signal + ) +} + +export function removeVault(id: string, signal?: AbortSignal): Promise { + return withVaultLock( + id, + async () => { + memoryVaults.delete(id) + await rm(pluginVaultPath(id), { force: true }) + }, + signal + ) } diff --git a/src/main/utils/safeFile.test.ts b/src/main/utils/safeFile.test.ts new file mode 100644 index 00000000..319499c2 --- /dev/null +++ b/src/main/utils/safeFile.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { WriteQueue, KeyedWriteQueue } from './safeFile' + +const tick = (ms = 0): Promise => new Promise((r) => setTimeout(r, ms)) + +describe('WriteQueue', () => { + it('serializes tasks in order and survives a failed task', async () => { + const q = new WriteQueue() + const log: string[] = [] + const a = q.run(async () => { + await tick(10) + log.push('a') + throw new Error('a failed') + }) + const b = q.run(async () => { + log.push('b') + return 'b' + }) + await expect(a).rejects.toThrow('a failed') + expect(await b).toBe('b') + expect(log).toEqual(['a', 'b']) + }) + + it('rejects a waiter whose signal aborts before it reaches the task, keeping order', async () => { + const q = new WriteQueue() + const log: string[] = [] + let releaseA!: () => void + const a = q.run( + () => + new Promise((r) => { + releaseA = (): void => { + log.push('a') + r() + } + }) + ) + const ac = new AbortController() + const b = q.run(async () => { + log.push('b') + }, ac.signal) + const c = q.run(async () => { + log.push('c') + }) + ac.abort(new Error('budget exhausted')) + await expect(b).rejects.toThrow('budget exhausted') + // c must still wait for a: nothing has run yet + await tick(5) + expect(log).toEqual([]) + releaseA() + await a + await c + expect(log).toEqual(['a', 'c']) + }) + + it('R2-ISS-029: an abort landing between the predecessor settling and the task starting still cancels', async () => { + const q = new WriteQueue() + const ac = new AbortController() + const task = vi.fn(async () => 'ran') + const r = q.run(task, ac.signal) + await Promise.resolve() // waitFor(prev) has resolved; the task has not started yet + ac.abort() + await expect(r).rejects.toMatchObject({ name: 'AbortError' }) + expect(task).not.toHaveBeenCalled() + }) + + it('rejects immediately when the signal is already aborted', async () => { + const q = new WriteQueue() + const ac = new AbortController() + ac.abort() + await expect(q.run(async () => 1, ac.signal)).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +describe('KeyedWriteQueue', () => { + it('serializes per key and runs different keys concurrently', async () => { + const q = new KeyedWriteQueue() + const log: string[] = [] + const a1 = q.run('a', async () => { + await tick(20) + log.push('a1') + }) + const b1 = q.run('b', async () => { + log.push('b1') + }) + const a2 = q.run('a', async () => { + log.push('a2') + }) + await Promise.all([a1, b1, a2]) + expect(log).toEqual(['b1', 'a1', 'a2']) + }) + + it('drops a key once idle', async () => { + const q = new KeyedWriteQueue() + expect(q.isIdle('k')).toBe(true) + const p = q.run('k', async () => tick(5)) + expect(q.isIdle('k')).toBe(false) + await p + expect(q.isIdle('k')).toBe(true) + }) +}) diff --git a/src/main/utils/safeFile.ts b/src/main/utils/safeFile.ts index 1b4a2184..f05c1aa4 100644 --- a/src/main/utils/safeFile.ts +++ b/src/main/utils/safeFile.ts @@ -71,16 +71,77 @@ export function atomicWriteFileSync( } } -/** Keeps writes serialized without allowing one failed write to block later retries. */ +function abortError(signal: AbortSignal): Error { + const reason = signal.reason + if (reason instanceof Error) return reason + const err = new Error('The operation was aborted') as Error & { code?: string } + err.name = 'AbortError' + err.code = 'ABORT_ERR' + return err +} + +// Resolves when `p` settles; rejects early if `signal` aborts while still waiting. +function waitFor(p: Promise, signal?: AbortSignal): Promise { + if (!signal) return p + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(abortError(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + p.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve() + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve() + } + ) + }) +} + +const noop = (): void => undefined + +/** + * Keeps writes serialized without allowing one failed write to block later retries. + * A waiter may hand in an AbortSignal: if it fires before the queue reaches the task, + * the task never runs and the caller gets the abort reason; the queue order is preserved. + */ export class WriteQueue { private tail: Promise = Promise.resolve() - run(task: () => Promise): Promise { - const current = this.tail.then(task, task) - this.tail = current.then( - () => undefined, - () => undefined - ) + run(task: () => Promise, signal?: AbortSignal): Promise { + const prev = this.tail + // waitFor 的 resolve 与 task 启动之间可能插入一次 abort(微任务间隙):启动前再检查一次 + const current = waitFor(prev, signal).then(() => { + if (signal?.aborted) throw abortError(signal) + return task() + }) + // Successors wait for both the previous holder and this task, even when this waiter aborted. + this.tail = current.then(noop, noop).then(() => prev) return current } } + +/** One WriteQueue per key; a queue is dropped once nothing is pending on it. */ +export class KeyedWriteQueue { + private readonly queues = new Map() + + run(key: string, task: () => Promise, signal?: AbortSignal): Promise { + let entry = this.queues.get(key) + if (!entry) { + entry = { queue: new WriteQueue(), pending: 0 } + this.queues.set(key, entry) + } + const held = entry + held.pending++ + return held.queue.run(task, signal).finally(() => { + held.pending-- + if (held.pending === 0 && this.queues.get(key) === held) this.queues.delete(key) + }) + } + + isIdle(key: string): boolean { + return !this.queues.has(key) + } +} diff --git a/src/renderer/src/components/plugins/plugin-install-modal.tsx b/src/renderer/src/components/plugins/plugin-install-modal.tsx index 45744e6d..1cf76384 100644 --- a/src/renderer/src/components/plugins/plugin-install-modal.tsx +++ b/src/renderer/src/components/plugins/plugin-install-modal.tsx @@ -169,6 +169,16 @@ const PluginInstallModal: React.FC = ({ onClose, initialFile, initialData
{t('plugins.loginUrl')}: {hostOf(preview.loginUrl)}
+ {preview.description && ( +
+ {preview.description} +
+ )} + {preview.discoveryHosts && preview.discoveryHosts.length > 0 && ( +
+ {t('plugins.discoveryHosts')}: {preview.discoveryHosts.join(', ')} +
+ )}
{t('plugins.installNotice')}
)} @@ -180,7 +190,7 @@ const PluginInstallModal: React.FC = ({ onClose, initialFile, initialData isSelected={pluginUseProxy} onValueChange={(v) => patchAppConfig({ pluginUseProxy: v })} > - {t('plugins.useProxy')} + {t('plugins.defaultUseProxy')}
diff --git a/src/renderer/src/components/plugins/plugin-item.tsx b/src/renderer/src/components/plugins/plugin-item.tsx index c1169242..259937e3 100644 --- a/src/renderer/src/components/plugins/plugin-item.tsx +++ b/src/renderer/src/components/plugins/plugin-item.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody, Chip, Button, Checkbox, Tooltip } from '@heroui/react' +import { Card, CardBody, Chip, Button, Select, SelectItem, Tooltip } from '@heroui/react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@renderer/components/base/toast' @@ -18,11 +18,20 @@ const statusColor: Record = { 'needs-reauth': 'warning' } +const ROUTE_MODES: IPluginRouteMode[] = ['auto', 'direct', 'proxy'] + +// 与主进程 route.ts 的五行迁移表一致:routeMode 有 → 取其值;无 → useProxy=true → proxy,false → auto; +// 都无 → 全局 pluginUseProxy=true → proxy,否则 auto。 +function effectiveRouteMode(item: IPluginItem, globalUseProxy: boolean): IPluginRouteMode { + if (item.routeMode && ROUTE_MODES.includes(item.routeMode)) return item.routeMode + if (typeof item.useProxy === 'boolean') return item.useProxy ? 'proxy' : 'auto' + return globalUseProxy ? 'proxy' : 'auto' +} + const PluginItem: React.FC = ({ item, onChanged }) => { const { t } = useTranslation() const { appConfig } = useAppConfig() - const useProxy = - typeof item.useProxy === 'boolean' ? item.useProxy : (appConfig?.pluginUseProxy ?? false) + const routeMode = effectiveRouteMode(item, appConfig?.pluginUseProxy ?? false) const [busy, setBusy] = useState(false) const [showRemove, setShowRemove] = useState(false) @@ -41,9 +50,11 @@ const PluginItem: React.FC = ({ item, onChanged }) => { } } - const handleProxyChange = async (v: boolean): Promise => { + const handleRouteChange = async (mode: IPluginRouteMode): Promise => { + if (!ROUTE_MODES.includes(mode) || mode === routeMode) return try { - await patchPluginItem(item.id, { useProxy: v }) + // 镜像写 useProxy,供降级到旧版本读取 + await patchPluginItem(item.id, { routeMode: mode, useProxy: mode === 'proxy' }) onChanged() } catch (e) { toast.error(String(e)) @@ -71,6 +82,21 @@ const PluginItem: React.FC = ({ item, onChanged }) => { {needsLogin &&
{t('plugins.needsLoginTip')}
} {needsReauth &&
{t('plugins.reauthTip')}
} + {item.lastUpdateErrorReason && ( +
+ {t(`plugins.errorReason.${item.lastUpdateErrorReason}`)} +
+ )} + {item.lastProviderMessage && ( +
+ {t('plugins.providerMessage')}: {item.lastProviderMessage} +
+ )} + {item.description && ( +
+ {item.description} +
+ )}
@@ -89,9 +115,24 @@ const PluginItem: React.FC = ({ item, onChanged }) => {
- - {t('plugins.useProxy')} - +
+ {t('plugins.routeMode')} + +
diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 111d4c60..3ca09a8d 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -836,6 +836,8 @@ "provider": "Provider", "site": "Website", "loginUrl": "Login domain", + "discoveryHosts": "Backup discovery domains", + "providerMessage": "Message from provider", "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", @@ -845,7 +847,19 @@ "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.", + "defaultUseProxy": "New plugins default to proxy", + "routeMode": "Route", + "route": { + "auto": "Auto", + "direct": "Direct", + "proxy": "Via proxy" + }, + "errorReason": { + "blocked": "Blocked: the gateway resolved to a private address, so no request was sent", + "network": "Last update failed: the gateway was unreachable or timed out", + "server": "Last update failed: the gateway returned an error" + }, + "useProxyWarning": "Requests go through the local mixed-port proxy, so the final resolved IP cannot be verified (SSRF protection is reduced). In Auto mode the proxy is only tried after a direct attempt fails, and only once the target resolves to a public address locally. Remaining risks, same as explicit proxy mode: DNS rebinding between that check and the proxy's own lookup, and a target that fails to resolve locally is still allowed through the proxy.", "remove": "Remove", "removeConfirm": "Remove this plugin and its subscription?", "status": { diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index bdb5f0b2..f04e109d 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -814,6 +814,8 @@ "provider": "سرویس‌دهنده", "site": "وب‌سایت", "loginUrl": "دامنه ورود", + "discoveryHosts": "دامنه‌های پشتیبان کشف", + "providerMessage": "پیام ارائه‌دهنده", "installNotice": "پس از نصب، مرورگر سیستم این دامنه را برای ورود باز می‌کند. رمز عبور فقط در سایت سرویس‌دهنده وارد می‌شود و این برنامه هرگز به آن دسترسی ندارد.", "login": "ورود", "relogin": "ورود دوباره", @@ -823,7 +825,19 @@ "needsLoginTip": "نصب شد — برای دریافت اشتراک وارد شوید", "reauthTip": "نشست منقضی شده است، دوباره وارد شوید", "useProxy": "از طریق پروکسی", - "useProxyWarning": "درخواست‌های افزونه از پروکسی mixed-port محلی عبور می‌کنند. بررسی https، میزبان، تغییر مسیر و اندازه همچنان اعمال می‌شود، اما IP نهایی قابل تأیید نیست و محافظت در برابر SSRF کاهش می‌یابد.", + "defaultUseProxy": "افزونه‌های جدید به‌طور پیش‌فرض از پروکسی", + "routeMode": "مسیر", + "route": { + "auto": "خودکار", + "direct": "مستقیم", + "proxy": "از طریق پروکسی" + }, + "errorReason": { + "blocked": "مسدود شد: درگاه به یک آدرس خصوصی resolve شد و هیچ درخواستی ارسال نشد", + "network": "آخرین به‌روزرسانی ناموفق بود: درگاه در دسترس نیست یا زمان آن تمام شد", + "server": "آخرین به‌روزرسانی ناموفق بود: درگاه خطا برگرداند" + }, + "useProxyWarning": "درخواست‌ها از پروکسی mixed-port محلی عبور می‌کنند، بنابراین IP نهایی قابل تأیید نیست (محافظت در برابر SSRF کاهش می‌یابد). در حالت خودکار، پروکسی فقط پس از شکست اتصال مستقیم و تنها زمانی امتحان می‌شود که مقصد به‌صورت محلی به یک آدرس عمومی resolve شود. ریسک‌های باقی‌مانده مانند حالت پروکسی صریح است: DNS rebinding بین این بررسی و resolve پروکسی؛ مقصدی که به‌صورت محلی resolve نشود همچنان از طریق پروکسی مجاز است.", "remove": "حذف", "removeConfirm": "این افزونه و اشتراک آن حذف شود؟", "status": { diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index ee4bfd7b..ea8b1a4d 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -820,6 +820,8 @@ "provider": "Провайдер", "site": "Сайт", "loginUrl": "Домен входа", + "discoveryHosts": "Резервные домены обнаружения", + "providerMessage": "Сообщение провайдера", "installNotice": "После установки системный браузер откроет этот домен для входа. Пароль вводится только на сайте провайдера — приложение его не получает.", "login": "Войти", "relogin": "Войти снова", @@ -829,7 +831,19 @@ "needsLoginTip": "Установлено — войдите, чтобы получить подписку", "reauthTip": "Сессия истекла, войдите снова", "useProxy": "Через прокси", - "useProxyWarning": "Запросы плагина идут через локальный mixed-port прокси. Проверки https, хоста, редиректа и размера сохраняются, но итоговый IP не проверяется — защита от SSRF снижена.", + "defaultUseProxy": "Новые плагины по умолчанию через прокси", + "routeMode": "Маршрут", + "route": { + "auto": "Авто", + "direct": "Напрямую", + "proxy": "Через прокси" + }, + "errorReason": { + "blocked": "Заблокировано: шлюз разрешился в частный адрес, запрос не отправлялся", + "network": "Последнее обновление не удалось: шлюз недоступен или превышено время ожидания", + "server": "Последнее обновление не удалось: шлюз вернул ошибку" + }, + "useProxyWarning": "Запросы идут через локальный mixed-port прокси, поэтому итоговый IP не проверяется (защита от SSRF снижена). В режиме «Авто» прокси используется только после неудачного прямого подключения и только если цель локально разрешается в публичный адрес. Остаточные риски те же, что и в явном режиме прокси: DNS rebinding между проверкой и разрешением на стороне прокси; цель, которая не разрешается локально, всё равно пропускается через прокси.", "remove": "Удалить", "removeConfirm": "Удалить этот плагин и его подписку?", "status": { diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 50601a96..c5f855c4 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -836,6 +836,8 @@ "provider": "机场", "site": "官网", "loginUrl": "登录域名", + "discoveryHosts": "备用发现域名", + "providerMessage": "机场消息", "installNotice": "安装后将在系统浏览器打开该域名登录;密码只输入在机场官网,本应用不会接触你的密码。", "login": "登录", "relogin": "重新登录", @@ -845,7 +847,19 @@ "needsLoginTip": "已安装,请登录以获取订阅", "reauthTip": "登录已失效,请重新登录", "useProxy": "走代理", - "useProxyWarning": "插件请求经由本地混合端口代理发出。仍校验 https、主机、重定向和大小,但无法验证代理最终解析到的 IP,SSRF 防护降级。", + "defaultUseProxy": "新装插件默认走代理", + "routeMode": "路由", + "route": { + "auto": "自动", + "direct": "直连", + "proxy": "走代理" + }, + "errorReason": { + "blocked": "已阻止:网关解析到内网地址,未发出任何请求", + "network": "上次更新失败:网关不可达或超时", + "server": "上次更新失败:网关返回错误" + }, + "useProxyWarning": "请求经由本地混合端口代理发出,无法验证代理最终解析到的 IP(SSRF 防护降级)。自动模式只在直连失败后才尝试代理,且先在本地确认目标解析为公网地址。剩余风险与显式代理模式相同:预检与代理解析之间的 DNS rebinding;本地解析失败的目标仍会放行经代理。", "remove": "删除", "removeConfirm": "确认删除该插件及其订阅?", "status": { diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 053699df..e9692e8f 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -836,6 +836,8 @@ "provider": "機場", "site": "官網", "loginUrl": "登入網域", + "discoveryHosts": "備用發現網域", + "providerMessage": "機場訊息", "installNotice": "安裝後將在系統瀏覽器開啟該網域登入;密碼只輸入在機場官網,本應用不會接觸你的密碼。", "login": "登入", "relogin": "重新登入", @@ -845,7 +847,19 @@ "needsLoginTip": "已安裝,請登入以取得訂閱", "reauthTip": "登入已失效,請重新登入", "useProxy": "走代理", - "useProxyWarning": "外掛請求經由本機混合埠代理發出。仍校驗 https、主機、重新導向與大小,但無法驗證代理最終解析到的 IP,SSRF 防護降級。", + "defaultUseProxy": "新安裝外掛預設走代理", + "routeMode": "路由", + "route": { + "auto": "自動", + "direct": "直連", + "proxy": "走代理" + }, + "errorReason": { + "blocked": "已阻止:閘道解析到內網位址,未發出任何請求", + "network": "上次更新失敗:閘道無法連線或逾時", + "server": "上次更新失敗:閘道回傳錯誤" + }, + "useProxyWarning": "請求經由本機混合埠代理發出,無法驗證代理最終解析到的 IP(SSRF 防護降級)。自動模式只在直連失敗後才嘗試代理,且先在本機確認目標解析為公網位址。剩餘風險與明確代理模式相同:預檢與代理解析之間的 DNS rebinding;本機解析失敗的目標仍會放行經代理。", "remove": "刪除", "removeConfirm": "確認刪除該外掛及其訂閱?", "status": { diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 34b6eb2a..1ea58aaf 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -329,7 +329,7 @@ interface IAppConfig { autoQuitWithoutCoreMode?: 'core' | 'tray' useCustomSubStore?: boolean useProxyInSubStore?: boolean - pluginUseProxy?: boolean // 插件网关请求经由本地混合端口代理(安全保证降级,默认关闭) + pluginUseProxy?: boolean // 新装插件默认路由模式:true → proxy(安全保证降级),false → auto mihomoCpuPriority?: Priority coreStartupMode?: 'log' | 'post-up' customSubStoreUrl?: string @@ -601,6 +601,7 @@ interface IPluginProvider { name: string icon?: string site?: string + description?: string // §4 机场静态说明,≤500 码点,已清洗 } // .cpx v2 — public, unencrypted descriptor. Contains NO secrets. @@ -610,6 +611,8 @@ interface IPluginDescriptor { spec: 'cpx-plugin/2' loginUrl: string // OAuth authorize endpoint, https, no query/fragment provider: IPluginProvider + discoveryUrls?: string[] // §3 备用发现源:公网 https origin,1..8,去重,不含 loginUrl 的 origin + providerPubKey?: string // §5 Ed25519 原始 32 字节公钥,标准 base64 带 padding;每个 .cpx 谱系独立密钥 } // Subset returned by previewPlugin for the install-confirm page (no records, no network) @@ -619,6 +622,35 @@ interface IPluginDescriptorPreview { site?: string loginUrl: string // full url; UI shows the host spec: string + discoveryHosts?: string[] // §3 备用发现域名(纯文本 host) + description?: string // §4 +} + +// 发现结果(§3/§5):来自任一发现源的归一化候选。seq 与 digest 成对出现(仅签名文档)。 +interface IDiscoveryCandidate { + gateways: string[] + endpoints: IGatewayEndpoints + seq?: number + digest?: string + loginUrl?: string + discoveryUrls?: string[] +} + +// 签名发现文档的 payload(§5.2) +interface IDiscoveryPayload { + spec: 'cpx-plugin/2' + seq: number // 1 ≤ seq ≤ 2^53−1 + gateways: string[] + endpoints: IGatewayEndpoints + loginUrl?: string + discoveryUrls?: string[] // 缺失 = 不改;[] = 清空 +} + +// 有 providerPubKey 的插件在发现时携带:minSeq / currentDigest 来自 plugin.yaml +interface DiscoverySigner { + pubKeyB64: string + minSeq?: number + currentDigest?: string } interface IPluginFilePayload { @@ -633,15 +665,18 @@ interface IGatewayEndpoints { revoke: string } -// /.well-known/cpx-gateway discovery response +// /.well-known/cpx-gateway discovery response(归一化后)。线格式仍带 `gateway`(= gateways[0])供旧客户端读取。 interface IGatewayWellKnown { spec: 'cpx-plugin/2' - gateway: string // https origin, no path/query/fragment + gateways: string[] // https origins, no path/query/fragment; 1..3, deduplicated endpoints: IGatewayEndpoints } type IPluginStatus = 'needs-login' | 'active' | 'needs-reauth' +// 路由模式:auto = 直连优先、失败回退代理(§1);direct / proxy 为用户显式覆盖,不回退。 +type IPluginRouteMode = 'auto' | 'direct' | 'proxy' + interface IPluginItem { id: string name: string @@ -653,7 +688,16 @@ interface IPluginItem { status: IPluginStatus interval?: number autoUpdate?: boolean - useProxy?: boolean // 插件请求经由代理开关(可选,覆盖全局配置) + useProxy?: boolean // 过渡期镜像写:routeMode === 'proxy';供降级到旧版本读取 + routeMode?: IPluginRouteMode // §1;缺失时按 useProxy / 全局 pluginUseProxy 推导 + lastGoodRoute?: 'direct' | 'proxy' // §1;仅 auto 模式读写,不是秘密 + lastUpdateErrorReason?: 'blocked' | 'network' | 'server' // §4.2;客户端侧枚举,不含 host/IP + discoveryUrls?: string[] // §3 公开元数据,与 loginUrl 同级的静态信任根 + description?: string // §4 机场静态说明 + lastProviderMessage?: string // §4 上次失败时机场返回的 message;成功后清空 + providerPubKey?: string // §5 公开元数据 + discoverySeq?: number // §5 提交标记,与 discoveryDigest 成对(同时存在或同时缺失) + discoveryDigest?: string // §5 SHA-256(payloadBytes) hex created: number updated: number lastUpdateErrorType?: 'auth' | 'transient' @@ -666,12 +710,24 @@ interface IPluginConfig { items: IPluginItem[] } +// 缓存的网关状态(§2.3)。写出时始终镜像 gateway.gateway = lastGood ?? gateways[0],供降级到旧版本读取。 +interface IPluginGatewayState { + gateway: string // 过渡期镜像:= lastGood ?? gateways[0] + gateways: string[] // 归一化后,1..3 + endpoints: IGatewayEndpoints + lastGood?: string // 必须 ∈ gateways,否则视为未设置 +} + // 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 - } + gateway: IPluginGatewayState + // 被新设备替换、但尚未在服务端回收的旧设备:重新登录成功后 best-effort 回收,失败留在这里等下次拉取 / 删除 + staleDevices?: IPluginStaleDevice[] +} + +interface IPluginStaleDevice { + deviceId: string + devicePrivKey: string }