Bladeren bron

feat(reality): derive a stable per-client spiderX for shared links

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.
MHSanaei 1 dag geleden
bovenliggende
commit
c8ef1b1f68

+ 1 - 0
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",

+ 1 - 0
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",

+ 20 - 5
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<Inbound['streamSettings']>, 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<Inbound['streamSettings']>, params: URLSearchParams): void {
+function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, 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<Inbound['streamSettings']>, 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':

+ 10 - 0
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)}`;
+}

+ 2 - 0
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}

+ 12 - 2
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -16,6 +16,7 @@ interface RealityFormProps {
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
   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({
         </Space.Compact>
       </Form.Item>
       <Form.Item
-        name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
         label={t('pages.inbounds.form.spiderX')}
+        tooltip={t('pages.inbounds.form.spiderXHint')}
       >
-        <Input />
+        <Space.Compact block style={{ display: 'flex' }}>
+          <Form.Item
+            name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
+            noStyle
+          >
+            <Input style={{ flex: 1 }} />
+          </Form.Item>
+          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
+        </Space.Compact>
       </Form.Item>
       <Form.Item
         name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}

+ 9 - 0
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -124,6 +124,13 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     );
   };
 
+  const randomizeSpiderX = () => {
+    form.setFieldValue(
+      ['streamSettings', 'realitySettings', 'settings', 'spiderX'],
+      `/${RandomUtil.randomSeq(15)}`,
+    );
+  };
+
   const getNewEchCert = async () => {
     const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
     setSaving(true);
@@ -270,6 +277,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     }
     form.setFieldValue('streamSettings', cleaned);
     if (next === 'reality') {
+      randomizeSpiderX();
       try {
         const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
         if (msg?.success) {
@@ -292,6 +300,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     scanRealityCandidates,
     applyRealityScanResult,
     randomizeShortIds,
+    randomizeSpiderX,
     getNewEchCert,
     clearEchCert,
     pinFromCert,

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
+exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
 
 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
+exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 

+ 1 - 0
frontend/src/test/inbound-form-blocks.test.tsx

@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
         scanRealityCandidates={async () => []}
         applyRealityScanResult={noop}
         randomizeShortIds={noop}
+        randomizeSpiderX={noop}
         genRealityKeypair={noop}
         clearRealityKeypair={noop}
         genMldsa65={noop}

+ 27 - 0
frontend/src/test/spider-x.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { deriveSpiderX } from '@/lib/xray/spider-x';
+
+// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
+// in internal/sub/service_sharelink_test.go: subscription links come from Go,
+// panel links from this module, and the two must agree byte-for-byte.
+describe('deriveSpiderX', () => {
+  it('matches the Go deriveSpiderX vectors', () => {
+    expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
+    expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
+  });
+
+  it('is stable per client, distinct across clients, and rotates with the seed', () => {
+    expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
+    expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
+    expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
+  });
+
+  it('returns empty when there is nothing to derive from', () => {
+    expect(deriveSpiderX('', '')).toBe('');
+  });
+
+  it('emits a /-prefixed 15-hex-char path', () => {
+    expect(deriveSpiderX('/some-seed', '[email protected]')).toMatch(/^\/[0-9a-f]{15}$/);
+  });
+});

+ 18 - 10
internal/sub/json_service.go

@@ -138,7 +138,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 
 func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
 	var newJsonArray []json_util.RawMessage
-	stream := s.streamData(inbound.StreamSettings)
+	stream := s.streamData(inbound.StreamSettings, subKey(client))
 
 	// When externalProxy is empty the JSON config falls back to a
 	// synthetic one whose `dest` is the host the client connects to.
@@ -234,15 +234,25 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 	return newJsonArray
 }
 
-func (s *SubJsonService) streamData(stream string) map[string]any {
+func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
 	var streamSettings map[string]any
-	_ = json.Unmarshal([]byte(stream), &streamSettings)
+	if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
+		streamSettings = map[string]any{}
+	}
 	security, _ := streamSettings["security"].(string)
 	switch security {
 	case "tls":
-		streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any))
+		if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
+			streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
+		} else {
+			delete(streamSettings, "tlsSettings")
+		}
 	case "reality":
-		streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any))
+		if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
+			streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
+		} else {
+			delete(streamSettings, "realitySettings")
+		}
 	}
 	delete(streamSettings, "sockopt")
 
@@ -322,7 +332,7 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	return tlsData
 }
 
-func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
+func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
 	rltyData := make(map[string]any, 1)
 	rltyClientSettings, _ := rData["settings"].(map[string]any)
 
@@ -331,10 +341,8 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
 	rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
 	rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
 
-	rltyData["spiderX"] = "/" + random.Seq(15)
-	if spx, ok := rltyClientSettings["spiderX"].(string); ok && spx != "" {
-		rltyData["spiderX"] = spx
-	}
+	seed, _ := rltyClientSettings["spiderX"].(string)
+	rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
 	shortIds, ok := rData["shortIds"].([]any)
 	if ok && len(shortIds) > 0 {
 		rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)

+ 50 - 16
internal/sub/json_service_test.go

@@ -41,7 +41,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 		t.Fatal("direct_out outbound must never be emitted")
 	}
 
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["sockopt"]; ok {
 		t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
 	}
@@ -77,7 +77,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 	stream := svc.streamData(`{
 		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
 		"finalmask":{"tcp":[{"type":"sudoku"}]}
-	}`)
+	}`, "")
 
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	tcp, _ := finalmask["tcp"].([]any)
@@ -93,7 +93,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 
 func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 	svc := NewSubJsonService("", "", "", nil)
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
 	}
@@ -107,7 +107,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 // to import the config (#5401).
 func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
 	svc := NewSubJsonService("", "", "", nil)
-	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
 
 	tls, _ := stream["tlsSettings"].(map[string]any)
 	if tls == nil {
@@ -181,7 +181,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 	// When xmux is present in xhttpSettings, the per-inbound xmux handles
 	// multiplexing and the legacy outbound.Mux must NOT be set.
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
-	parsed := svc.streamData(stream)
+	parsed := svc.streamData(stream, "")
 
 	mux := globalMux
 	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -227,7 +227,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 
 	// When no xmux is present, the global subJsonMux should be used.
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
-	parsed := svc.streamData(stream)
+	parsed := svc.streamData(stream, "")
 
 	mux := globalMux
 	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -255,28 +255,62 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 	}
 }
 
-func TestSubJsonServiceRealityDataUsesConfiguredSpiderX(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
-
+func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
+	t.Helper()
 	stream := svc.streamData(`{
 		"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
 		"realitySettings":{
 			"serverNames":["reality.example.com"],
 			"shortIds":["ab12cd"],
-			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
+			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
 		}
-	}`)
-
+	}`, clientKey)
 	rlty, _ := stream["realitySettings"].(map[string]any)
 	if rlty == nil {
 		t.Fatal("streamData dropped realitySettings")
 	}
-	if rlty["spiderX"] != "/mypath" {
-		t.Fatalf("spiderX = %v, want configured /mypath (#5718)", rlty["spiderX"])
+	spx, _ := rlty["spiderX"].(string)
+	if len(spx) != 16 || spx[0] != '/' {
+		t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
+	}
+	return spx
+}
+
+func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+
+	alice := realitySpiderXFromStream(t, svc, "subAlice")
+	if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
+		t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
+	}
+	if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
+		t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
+	}
+}
+
+// streamData must tolerate malformed stored inbounds: unparseable stream JSON
+// (with a finalMask configured, which writes into the map) and tls/reality
+// security whose settings key is missing or null previously panicked the
+// subscription request.
+func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
+	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	stream := withMask.streamData("not-json", "clientKey")
+	if _, ok := stream["finalmask"]; !ok {
+		t.Fatal("finalMask must still apply when stream settings fail to parse")
+	}
+
+	svc := NewSubJsonService("", "", "", nil)
+	noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
+	if v, ok := noReality["realitySettings"]; ok {
+		t.Fatalf("missing realitySettings must stay absent, got %v", v)
+	}
+	nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
+	if v, ok := nullTls["tlsSettings"]; ok {
+		t.Fatalf("null tlsSettings must be dropped, got %v", v)
 	}
 }
 
-func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
+func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
 	svc := NewSubJsonService("", "", "", nil)
 
 	stream := svc.streamData(`{
@@ -286,7 +320,7 @@ func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
 			"shortIds":["ab12cd"],
 			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
 		}
-	}`)
+	}`, "")
 
 	rlty, _ := stream["realitySettings"].(map[string]any)
 	if rlty == nil {

+ 5 - 5
internal/sub/mutation_audit_test.go

@@ -95,15 +95,15 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 	}
 }
 
-// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
+// --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
 // not add the finalmask key (the `len(merged) > 0` guard). ---
 
 func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 	// finalMask is non-empty (passes the len(fm)==0 early return) but its only
 	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
-	// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
+	// so applyGlobalFinalMask must NOT set finalmask.
 	svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
 	}
@@ -111,13 +111,13 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 	// Sanity: a finalMask that DOES merge to something still gets set, so the
 	// guard is the only distinguishing factor.
 	svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
-	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream2["finalmask"]; !ok {
 		t.Fatal("non-empty finalMask must be set")
 	}
 }
 
-// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
+// --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
 
 func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
 	base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}

+ 25 - 7
internal/sub/service.go

@@ -683,7 +683,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params)
+		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
 	default:
 		params["security"] = "none"
 	}
@@ -734,7 +734,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params)
+		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
 		if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
 			params["flow"] = clients[clientIndex].Flow
 		}
@@ -1330,7 +1330,7 @@ func hysteriaPinHex(pin string) string {
 	return pin
 }
 
-func applyShareRealityParams(stream map[string]any, params map[string]string) {
+func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) {
 	params["security"] = "reality"
 	realitySetting, _ := stream["realitySettings"].(map[string]any)
 	realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1356,13 +1356,31 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
 				params["pqv"] = pqv
 			}
 		}
-		params["spx"] = "/" + random.Seq(15)
+		seed := ""
 		if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
-			if spx, ok := spxValue.(string); ok && len(spx) > 0 {
-				params["spx"] = spx
-			}
+			seed, _ = spxValue.(string)
 		}
+		params["spx"] = deriveSpiderX(seed, clientKey)
+	}
+}
+
+// subKey returns a stable per-client identity for deterministic derivations,
+// preferring the subscription id and falling back to the (unique) email.
+func subKey(c model.Client) string {
+	if c.SubID != "" {
+		return c.SubID
+	}
+	return c.Email
+}
+
+// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
+// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
+func deriveSpiderX(seed, clientKey string) string {
+	if seed == "" && clientKey == "" {
+		return "/" + random.Seq(15)
 	}
+	sum := sha256.Sum256([]byte(seed + "|" + clientKey))
+	return "/" + hex.EncodeToString(sum[:])[:15]
 }
 
 func buildVmessLink(obj map[string]any) string {

+ 89 - 18
internal/sub/service_sharelink_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"net/url"
 	"strings"
 	"testing"
 
@@ -52,8 +53,8 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
 	}
 }
 
-// Locks the reality field mapping of applyShareRealityParams; a configured
-// spiderX must round-trip verbatim (#5718), distinct pbk/sid catch a swap mutant.
+// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
+// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
 func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 	stream := `{
 		"network":"tcp","security":"reality",
@@ -73,7 +74,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 		"pbk=PBKvalue",
 		"sid=ab12cd",
 		"fp=firefox",
-		"spx=%2Fmypath",
+		"spx=%2F",
 	}
 	for _, w := range wants {
 		if !strings.Contains(link, w) {
@@ -86,22 +87,92 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 	}
 }
 
-// Without a configured spiderX, spx must still fall back to a random
-// "/"-prefixed value so clients always receive a plausible path.
-func TestGenVlessLink_RealitySpiderXFallsBackToRandom(t *testing.T) {
-	stream := `{
-		"network":"tcp","security":"reality",
-		"tcpSettings":{"header":{"type":"none"}},
-		"realitySettings":{
-			"serverNames":["reality.example.com"],
-			"shortIds":["ab12cd"],
-			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
-		}
-	}`
+// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
+// with distinct subIds so the per-client spx derivation can be exercised.
+func realityTwoClientInbound() *model.Inbound {
+	return &model.Inbound{
+		Listen:   "203.0.113.1",
+		Port:     443,
+		Protocol: model.VLESS,
+		Remark:   "sharelink",
+		Settings: `{"clients":[
+			{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
+			{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
+		],"decryption":"none","encryption":"none"}`,
+		StreamSettings: `{
+			"network":"tcp","security":"reality",
+			"tcpSettings":{"header":{"type":"none"}},
+			"realitySettings":{
+				"serverNames":["reality.example.com"],
+				"shortIds":["ab12cd"],
+				"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
+			}
+		}`,
+	}
+}
+
+func spxParam(t *testing.T, link string) string {
+	t.Helper()
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse link %q: %v", link, err)
+	}
+	spx := u.Query().Get("spx")
+	if spx == "" || spx[0] != '/' {
+		t.Fatalf("spx missing or not /-prefixed in %q", link)
+	}
+	return spx
+}
+
+// spx must be stable for a given client across repeated exports (the #5718
+// complaint) yet differ between clients so the value can't be fingerprinted.
+func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
 	s := &SubService{}
-	link := s.genVlessLink(shareLinkInbound(stream), "user")
+	inbound := realityTwoClientInbound()
+
+	aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
+	aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
+	bob := spxParam(t, s.genVlessLink(inbound, "bob"))
+
+	if aliceFirst != aliceSecond {
+		t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
+	}
+	if aliceFirst == bob {
+		t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
+	}
+}
 
-	if !strings.Contains(link, "spx=%2F") {
-		t.Fatalf("reality link missing random spx fallback\n got: %s", link)
+func TestDeriveSpiderX(t *testing.T) {
+	if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
+		t.Fatalf("deriveSpiderX not deterministic: %q", got)
+	}
+	if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
+		t.Fatal("deriveSpiderX must differ per client")
+	}
+	if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
+		t.Fatal("rotating the seed must rotate a client's spx")
+	}
+	got := deriveSpiderX("seed", "clientA")
+	if len(got) != 16 || got[0] != '/' {
+		t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
+	}
+	if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
+		t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
+	}
+}
+
+// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
+// panel builds these links in TS, so both derivations must agree byte-for-byte.
+func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
+	vectors := map[string]struct{ seed, clientKey, want string }{
+		"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
+		"seed only":      {"/", "", "/d08ed99bd9afc60"},
+	}
+	for name, v := range vectors {
+		t.Run(name, func(t *testing.T) {
+			if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
+				t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
+			}
+		})
 	}
 }

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -646,6 +646,7 @@
         "scanUse": "استخدام",
         "scanRescan": "إعادة الفحص",
         "spiderX": "SpiderX",
+        "spiderXHint": "بذرة لكل عميل — تشتق اللوحة مسار spx فريدًا لكل عميل منها؛ أعد التوليد لتدوير مسارات الجميع",
         "getNewCert": "احصل على شهادة جديدة",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/en-US.json

@@ -658,6 +658,7 @@
         "scanUse": "Use",
         "scanRescan": "Rescan",
         "spiderX": "SpiderX",
+        "spiderXHint": "Per-client seed — the panel derives a unique spx path for each client from it; regenerate to rotate everyone's paths",
         "getNewCert": "Get New Cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -667,6 +667,7 @@
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
         "spiderX": "SpiderX",
+        "spiderXHint": "Semilla por cliente: el panel deriva de ella una ruta spx única para cada cliente; regenera para rotar las rutas de todos",
         "getNewCert": "Obtener nuevo cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -658,6 +658,7 @@
         "scanUse": "استفاده",
         "scanRescan": "اسکن مجدد",
         "spiderX": "SpiderX",
+        "spiderXHint": "دانه‌ی هر کاربر — پنل از روی آن مسیر spx یکتا برای هر کاربر می‌سازد؛ برای چرخش مسیر همه، دوباره تولید کنید",
         "getNewCert": "دریافت گواهی جدید",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -646,6 +646,7 @@
         "scanUse": "Gunakan",
         "scanRescan": "Pindai ulang",
         "spiderX": "SpiderX",
+        "spiderXHint": "Seed per-klien — panel menurunkan jalur spx unik untuk tiap klien darinya; regenerasi untuk merotasi jalur semua klien",
         "getNewCert": "Dapatkan sertifikat baru",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -667,6 +667,7 @@
         "scanUse": "使用",
         "scanRescan": "再スキャン",
         "spiderX": "SpiderX",
+        "spiderXHint": "クライアントごとのシード。パネルはこれから各クライアント固有の spx パスを生成します。再生成で全員のパスを更新します",
         "getNewCert": "新しい証明書を取得",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -667,6 +667,7 @@
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
         "spiderX": "SpiderX",
+        "spiderXHint": "Semente por cliente — o painel deriva dela um caminho spx único para cada cliente; regenere para rotacionar os caminhos de todos",
         "getNewCert": "Obter novo certificado",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -667,6 +667,7 @@
         "scanUse": "Выбрать",
         "scanRescan": "Пересканировать",
         "spiderX": "SpiderX",
+        "spiderXHint": "Сид на клиента — панель формирует из него уникальный путь spx для каждого клиента; перегенерируйте, чтобы обновить пути всех",
         "getNewCert": "Получить новый сертификат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -646,6 +646,7 @@
         "scanUse": "Kullan",
         "scanRescan": "Yeniden tara",
         "spiderX": "SpiderX",
+        "spiderXHint": "İstemci başına tohum — panel bundan her istemci için benzersiz bir spx yolu türetir; herkesin yolunu döndürmek için yeniden üretin",
         "getNewCert": "Yeni Sertifika Al",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -646,6 +646,7 @@
         "scanUse": "Обрати",
         "scanRescan": "Пересканувати",
         "spiderX": "SpiderX",
+        "spiderXHint": "Сід на клієнта — панель формує з нього унікальний шлях spx для кожного клієнта; перегенеруйте, щоб оновити шляхи всіх",
         "getNewCert": "Отримати новий сертифікат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -667,6 +667,7 @@
         "scanUse": "Dùng",
         "scanRescan": "Quét lại",
         "spiderX": "SpiderX",
+        "spiderXHint": "Hạt giống theo từng client — bảng điều khiển suy ra đường dẫn spx riêng cho mỗi client từ đó; tạo lại để xoay đường dẫn của tất cả",
         "getNewCert": "Lấy chứng chỉ mới",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -666,6 +666,7 @@
         "scanUse": "使用",
         "scanRescan": "重新扫描",
         "spiderX": "SpiderX",
+        "spiderXHint": "按客户端的种子——面板据此为每个客户端派生唯一的 spx 路径;重新生成可轮换所有客户端的路径",
         "getNewCert": "获取新证书",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -646,6 +646,7 @@
         "scanUse": "使用",
         "scanRescan": "重新掃描",
         "spiderX": "SpiderX",
+        "spiderXHint": "各客戶端的種子——面板據此為每個客戶端衍生唯一的 spx 路徑;重新產生可輪換所有客戶端的路徑",
         "getNewCert": "取得新憑證",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",