From c8ef1b1f68a6a459e7e3c9c6a2971bd58a0bf5d5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 12:53:08 +0200 Subject: [PATCH] feat(reality): derive a stable per-client spiderX for shared links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inbound's spiderX now acts as a per-client seed: exports emit sha256(seed|subKey) truncated to a 15-hex "/path", so a client's spx no longer changes on every subscription fetch (#5718) while different clients stop sharing one fingerprintable value. The form gains a regenerate button that rotates every client's path at once. The frontend link builders derive through the same function (lib/xray/spider-x.ts, @noble/hashes) keyed on subId-then-email like the Go subKey, so panel QR/copy links and subscription output agree — cross-language vector tests lock both sides byte-for-byte. streamData now tolerates malformed stored stream settings (unparseable JSON, null tls/reality settings) instead of panicking the subscription request. --- frontend/package-lock.json | 1 + frontend/package.json | 1 + frontend/src/lib/xray/inbound-link.ts | 25 +++- frontend/src/lib/xray/spider-x.ts | 10 ++ .../pages/inbounds/form/InboundFormModal.tsx | 2 + .../pages/inbounds/form/security/reality.tsx | 14 ++- .../pages/inbounds/form/useSecurityActions.ts | 9 ++ .../__snapshots__/inbound-link.test.ts.snap | 4 +- .../src/test/inbound-form-blocks.test.tsx | 1 + frontend/src/test/spider-x.test.ts | 27 +++++ internal/sub/json_service.go | 28 +++-- internal/sub/json_service_test.go | 66 ++++++++--- internal/sub/mutation_audit_test.go | 10 +- internal/sub/service.go | 32 +++-- internal/sub/service_sharelink_test.go | 111 ++++++++++++++---- internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 28 files changed, 287 insertions(+), 67 deletions(-) create mode 100644 frontend/src/lib/xray/spider-x.ts create mode 100644 frontend/src/test/spider-x.test.ts 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({ - + + + + +