mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 00:26:06 +00:00
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:
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user