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.
This commit is contained in:
MHSanaei
2026-07-02 12:53:08 +02:00
parent 64c306037f
commit c8ef1b1f68
28 changed files with 287 additions and 67 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`;
}
@@ -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}
@@ -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']}
@@ -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,
@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test: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://22222222-3333-4444-9555-666666666666@override.test: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://22222222-3333-4444-9555-666666666666@override.test: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://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@override.test: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://trojan-test-pw-XYZ@example.test: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://22222222-3333-4444-9555-666666666666@example.test: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://22222222-3333-4444-9555-666666666666@example.test: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://8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02@example.test: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"`;
@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
scanRealityCandidates={async () => []}
applyRealityScanResult={noop}
randomizeShortIds={noop}
randomizeSpiderX={noop}
genRealityKeypair={noop}
clearRealityKeypair={noop}
genMldsa65={noop}
+27
View File
@@ -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', 'client@example.com')).toMatch(/^\/[0-9a-f]{15}$/);
});
});
+18 -10
View File
@@ -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
View File
@@ -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)
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
// 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 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
View File
@@ -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
View File
@@ -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,15 +1356,33 @@ 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 {
jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
+91 -20
View File
@@ -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"}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
if !strings.Contains(link, "spx=%2F") {
t.Fatalf("reality link missing random spx fallback\n got: %s", link)
// 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{}
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)
}
}
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
View File
@@ -646,6 +646,7 @@
"scanUse": "استخدام",
"scanRescan": "إعادة الفحص",
"spiderX": "SpiderX",
"spiderXHint": "بذرة لكل عميل — تشتق اللوحة مسار spx فريدًا لكل عميل منها؛ أعد التوليد لتدوير مسارات الجميع",
"getNewCert": "احصل على شهادة جديدة",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -658,6 +658,7 @@
"scanUse": "استفاده",
"scanRescan": "اسکن مجدد",
"spiderX": "SpiderX",
"spiderXHint": "دانه‌ی هر کاربر — پنل از روی آن مسیر spx یکتا برای هر کاربر می‌سازد؛ برای چرخش مسیر همه، دوباره تولید کنید",
"getNewCert": "دریافت گواهی جدید",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -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
View File
@@ -667,6 +667,7 @@
"scanUse": "使用",
"scanRescan": "再スキャン",
"spiderX": "SpiderX",
"spiderXHint": "クライアントごとのシード。パネルはこれから各クライアント固有の spx パスを生成します。再生成で全員のパスを更新します",
"getNewCert": "新しい証明書を取得",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -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
View File
@@ -667,6 +667,7 @@
"scanUse": "Выбрать",
"scanRescan": "Пересканировать",
"spiderX": "SpiderX",
"spiderXHint": "Сид на клиента — панель формирует из него уникальный путь spx для каждого клиента; перегенерируйте, чтобы обновить пути всех",
"getNewCert": "Получить новый сертификат",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -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
View File
@@ -646,6 +646,7 @@
"scanUse": "Обрати",
"scanRescan": "Пересканувати",
"spiderX": "SpiderX",
"spiderXHint": "Сід на клієнта — панель формує з нього унікальний шлях spx для кожного клієнта; перегенеруйте, щоб оновити шляхи всіх",
"getNewCert": "Отримати новий сертифікат",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -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
View File
@@ -666,6 +666,7 @@
"scanUse": "使用",
"scanRescan": "重新扫描",
"spiderX": "SpiderX",
"spiderXHint": "按客户端的种子——面板据此为每个客户端派生唯一的 spx 路径;重新生成可轮换所有客户端的路径",
"getNewCert": "获取新证书",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",
+1
View File
@@ -646,6 +646,7 @@
"scanUse": "使用",
"scanRescan": "重新掃描",
"spiderX": "SpiderX",
"spiderXHint": "各客戶端的種子——面板據此為每個客戶端衍生唯一的 spx 路徑;重新產生可輪換所有客戶端的路徑",
"getNewCert": "取得新憑證",
"mldsa65Seed": "mldsa65 Seed",
"mldsa65Verify": "mldsa65 Verify",