mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
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>
118 lines
3.8 KiB
JavaScript
118 lines
3.8 KiB
JavaScript
// 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)
|