mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 04:14:21 +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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"scanUse": "استخدام",
|
||||
"scanRescan": "إعادة الفحص",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "بذرة لكل عميل — تشتق اللوحة مسار spx فريدًا لكل عميل منها؛ أعد التوليد لتدوير مسارات الجميع",
|
||||
"getNewCert": "احصل على شهادة جديدة",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -658,6 +658,7 @@
|
||||
"scanUse": "استفاده",
|
||||
"scanRescan": "اسکن مجدد",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "دانهی هر کاربر — پنل از روی آن مسیر spx یکتا برای هر کاربر میسازد؛ برای چرخش مسیر همه، دوباره تولید کنید",
|
||||
"getNewCert": "دریافت گواهی جدید",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -667,6 +667,7 @@
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "再スキャン",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "クライアントごとのシード。パネルはこれから各クライアント固有の spx パスを生成します。再生成で全員のパスを更新します",
|
||||
"getNewCert": "新しい証明書を取得",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -667,6 +667,7 @@
|
||||
"scanUse": "Выбрать",
|
||||
"scanRescan": "Пересканировать",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "Сид на клиента — панель формирует из него уникальный путь spx для каждого клиента; перегенерируйте, чтобы обновить пути всех",
|
||||
"getNewCert": "Получить новый сертификат",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"scanUse": "Обрати",
|
||||
"scanRescan": "Пересканувати",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "Сід на клієнта — панель формує з нього унікальний шлях spx для кожного клієнта; перегенеруйте, щоб оновити шляхи всіх",
|
||||
"getNewCert": "Отримати новий сертифікат",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "重新扫描",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "按客户端的种子——面板据此为每个客户端派生唯一的 spx 路径;重新生成可轮换所有客户端的路径",
|
||||
"getNewCert": "获取新证书",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"scanUse": "使用",
|
||||
"scanRescan": "重新掃描",
|
||||
"spiderX": "SpiderX",
|
||||
"spiderXHint": "各客戶端的種子——面板據此為每個客戶端衍生唯一的 spx 路徑;重新產生可輪換所有客戶端的路徑",
|
||||
"getNewCert": "取得新憑證",
|
||||
"mldsa65Seed": "mldsa65 Seed",
|
||||
"mldsa65Verify": "mldsa65 Verify",
|
||||
|
||||
Reference in New Issue
Block a user