diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7245bfbc..8446e43ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@ant-design/icons": "^6.3.2", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", + "@noble/hashes": "^2.2.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "antd": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 6a6e50198..15f65ef3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@ant-design/icons": "^6.3.2", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", + "@noble/hashes": "^2.2.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "antd": "^6.5.0", diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index c15bb2a8c..a43c0d1da 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -13,6 +13,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; import { getHeaderValue } from './headers'; import { canEnableTlsFlow } from './protocol-capabilities'; +import { deriveSpiderX } from './spider-x'; // Share-link generators. Each per-protocol fn takes a typed inbound plus // client overrides and returns a URL (or '' when the protocol doesn't @@ -322,6 +323,7 @@ export interface GenVlessLinkInput { forceTls?: ForceTls; remark?: string; clientId: string; + clientKey?: string; flow?: VlessClient['flow']; externalProxy?: ExternalProxyEntry | null; } @@ -350,6 +352,7 @@ export function genVlessLink(input: GenVlessLinkInput): string { forceTls = 'same', remark = '', clientId, + clientKey = '', flow = '', externalProxy = null, } = input; @@ -430,7 +433,8 @@ export function genVlessLink(input: GenVlessLinkInput): string { if (sni && sni.length > 0) params.set('sni', sni); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); - if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX); + const spx = deriveSpiderX(reality.settings.spiderX, clientKey); + if (spx.length > 0) params.set('spx', spx); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); } } else { @@ -512,7 +516,7 @@ function writeTlsParams(stream: NonNullable, params: // Reality query-string writer shared by VLESS and Trojan. Preserves the // legacy SNI-omission quirk (see genVlessLink for the full story). -function writeRealityParams(stream: NonNullable, params: URLSearchParams): void { +function writeRealityParams(stream: NonNullable, params: URLSearchParams, clientKey: string): void { if (stream.security !== 'reality') return; const reality = stream.realitySettings; params.set('pbk', reality.settings.publicKey); @@ -526,7 +530,8 @@ function writeRealityParams(stream: NonNullable, para if (sni && sni.length > 0) params.set('sni', sni); if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); - if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX); + const spx = deriveSpiderX(reality.settings.spiderX, clientKey); + if (spx.length > 0) params.set('spx', spx); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); } @@ -537,6 +542,7 @@ export interface GenTrojanLinkInput { forceTls?: ForceTls; remark?: string; clientPassword: string; + clientKey?: string; externalProxy?: ExternalProxyEntry | null; } @@ -551,6 +557,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { forceTls = 'same', remark = '', clientPassword, + clientKey = '', externalProxy = null, } = input; @@ -571,7 +578,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { params.set('security', 'reality'); - writeRealityParams(stream, params); + writeRealityParams(stream, params, clientKey); } else { params.set('security', 'none'); } @@ -1017,7 +1024,13 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin // `this.clients` getter, which used isSSMultiUser to gate). Returns null // for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without- // clients, and any protocol without a clients array. -type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string }; +type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string }; + +// Mirror of the Go subKey: the stable per-client identity spx derivation +// keys on — subscription id first, unique email as the fallback. +function clientSubKey(client: ClientShape): string { + return client.subId || client.email || ''; +} export function getInboundClients(inbound: Inbound): ClientShape[] | null { switch (inbound.protocol) { @@ -1066,6 +1079,7 @@ export function genLink(input: GenLinkInput): string { return genVlessLink({ inbound, address, port, forceTls, remark, clientId: client.id ?? '', + clientKey: clientSubKey(client), flow: client.flow, externalProxy, }); @@ -1081,6 +1095,7 @@ export function genLink(input: GenLinkInput): string { return genTrojanLink({ inbound, address, port, forceTls, remark, clientPassword: client.password ?? '', + clientKey: clientSubKey(client), externalProxy, }); case 'hysteria': diff --git a/frontend/src/lib/xray/spider-x.ts b/frontend/src/lib/xray/spider-x.ts new file mode 100644 index 000000000..b70003abc --- /dev/null +++ b/frontend/src/lib/xray/spider-x.ts @@ -0,0 +1,10 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'; + +// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel +// links and subscription links agree; returns '' when there is no seed and +// no client key (the caller then omits spx, as the legacy builder did). +export function deriveSpiderX(seed: string, clientKey: string): string { + if (!seed && !clientKey) return ''; + return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`; +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index c4d8e3218..850029932 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -248,6 +248,7 @@ export default function InboundFormModal({ scanRealityCandidates, applyRealityScanResult, randomizeShortIds, + randomizeSpiderX, getNewEchCert, clearEchCert, pinFromCert, @@ -896,6 +897,7 @@ export default function InboundFormModal({ scanRealityCandidates={scanRealityCandidates} applyRealityScanResult={applyRealityScanResult} randomizeShortIds={randomizeShortIds} + randomizeSpiderX={randomizeSpiderX} genRealityKeypair={genRealityKeypair} clearRealityKeypair={clearRealityKeypair} genMldsa65={genMldsa65} diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx index 2fae92d26..bbf61e9c8 100644 --- a/frontend/src/pages/inbounds/form/security/reality.tsx +++ b/frontend/src/pages/inbounds/form/security/reality.tsx @@ -16,6 +16,7 @@ interface RealityFormProps { scanRealityCandidates: (targets?: string) => Promise; applyRealityScanResult: (result: RealityScanResult) => void; randomizeShortIds: () => void; + randomizeSpiderX: () => void; genRealityKeypair: () => void; clearRealityKeypair: () => void; genMldsa65: () => void; @@ -30,6 +31,7 @@ export default function RealityForm({ scanRealityCandidates, applyRealityScanResult, randomizeShortIds, + randomizeSpiderX, genRealityKeypair, clearRealityKeypair, genMldsa65, @@ -147,10 +149,18 @@ export default function RealityForm({ - + + + + +