Files
clash-party/scripts/plugin/gen-cpx.mjs
ezequielnick a092eb50c3 feat(plugin): CPX v2 availability hardening (squash of cpx-v2-availability-hardening)
Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:

- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
  persistence commit per operation; per-plugin lock → vault lock hierarchy;
  tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
  vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
  builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
  invalid local-proxy ports are refused instead of falling back to :80; the
  core's inbound credentials are carried to the local proxy; NAT64 and
  site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
  normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
  accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
  against the current override set before it replaces the profile, inside
  the profile write critical section (profile.yaml and override.yaml share
  one write queue); schedule fields are read at write time; the first
  subscription is activated through the real switch flow; profile deletion
  removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
  vault (staleDevices) and retires it after the login, after later
  successful fetches and on removal; enroll compensation restores the old
  vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
  (backend name + ciphertext-prefix canary); otherwise it stays in memory.
  A cache-miss read releases the caller at the budget while the lock is held
  until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
  rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
  same-origin rule, /revoke after re-login); https-proxy-agent dropped.

Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 22:44:13 +08:00

118 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Usage: node scripts/plugin/gen-cpx.mjs <loginUrl> <providerName> [site] [out.cpx] [--discovery <origin>]... [--pubkey <b64>]
// --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 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 <loginUrl https authorize> <providerName> [site] [out.cpx] [--discovery <origin>]...'
)
process.exit(1)
}
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 } : {}) },
...(origins.length ? { discoveryUrls: origins } : {}),
...(pubkey ? { providerPubKey: pubkey } : {})
}
writeFileSync(out, JSON.stringify(descriptor, null, 2) + '\n')
console.log('wrote', out)