Files
3x-ui/frontend/src/test/stream-wire-normalize.test.ts
PathGao ca6955d88b feat(ui): validate the REALITY client version range at save time (#6126)
* feat(ui): validate the REALITY client version range at save time

The impossible range from PR #6125 — a max below the effective minimum
— could still be saved; the tooltip only helps a user who hovers it.
Add save-time validation mirroring xray-core's parser (up to three
dot-separated parts, each 0-255) on both fields, plus a cross-field
check that a non-empty max is not below a non-empty min. Errors are
field-level i18n keys following the REALITY target precedent, so the
modal stays open and points at the offending field instead of storing
a config that rejects every client.

A malformed min is reported by its own field and skipped by the max
comparison, so the user sees one precise error per field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): reject untrimmed client versions and revalidate max on min edits

From review: the validators trimmed but the save path ships the value
verbatim, and xray-core's part parser accepts no surrounding
whitespace — so a green form could still save a config the core
refuses to load. Reject any value that differs from its trimmed form.

Also revalidate the max field after a min edit when max already
shows an error, so correcting the min clears the stale cross-field
message without waiting for the next submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:04:03 +02:00

592 lines
19 KiB
TypeScript

/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
import {
normalizeSockoptForWire,
normalizeStreamSettingsForWire,
normalizeXhttpForWire,
validateRealityClientVer,
validateRealityMaxClientVer,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
describe('validateRealityTarget', () => {
it('accepts host:port and bare port', () => {
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
expect(validateRealityTarget('443')).toBeUndefined();
});
it('rejects host without port', () => {
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
});
});
describe('validateRealityClientVer', () => {
it('accepts empty (not set) and core-style versions', () => {
expect(validateRealityClientVer('')).toBeUndefined();
expect(validateRealityClientVer('26.3.27')).toBeUndefined();
expect(validateRealityClientVer('1.0.0')).toBeUndefined();
expect(validateRealityClientVer('26')).toBeUndefined();
expect(validateRealityClientVer('26.3')).toBeUndefined();
expect(validateRealityClientVer('0.0.255')).toBeUndefined();
});
it('rejects untrimmed values because the save path ships them verbatim', () => {
expect(validateRealityClientVer('26.3.27 ')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer(' 26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer(' ')).toBe('pages.inbounds.form.clientVerInvalid');
});
it('rejects what the core parser rejects', () => {
expect(validateRealityClientVer('26.3.27.1')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer('26.3.256')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer('v26.3.27')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer('26..27')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer('26.3.')).toBe('pages.inbounds.form.clientVerInvalid');
expect(validateRealityClientVer('-1.0.0')).toBe('pages.inbounds.form.clientVerInvalid');
});
});
describe('validateRealityMaxClientVer', () => {
it('accepts an empty max, an empty min, and a valid range', () => {
expect(validateRealityMaxClientVer('', '26.3.27')).toBeUndefined();
expect(validateRealityMaxClientVer('27.0.0', '')).toBeUndefined();
expect(validateRealityMaxClientVer('26.3.27', '26.3.27')).toBeUndefined();
expect(validateRealityMaxClientVer('27.1.2', '26.3.27')).toBeUndefined();
});
it('rejects a max below the min, the stale-placeholder trap included', () => {
expect(validateRealityMaxClientVer('25.9.11', '26.3.27')).toBe(
'pages.inbounds.form.maxClientVerBelowMin',
);
expect(validateRealityMaxClientVer('26.3.26', '26.3.27')).toBe(
'pages.inbounds.form.maxClientVerBelowMin',
);
});
it('pads short versions like the core does before comparing', () => {
expect(validateRealityMaxClientVer('26', '26.0.0')).toBeUndefined();
expect(validateRealityMaxClientVer('26', '26.3')).toBe(
'pages.inbounds.form.maxClientVerBelowMin',
);
});
it('reports format errors before range errors and skips a malformed min', () => {
expect(validateRealityMaxClientVer('25.9', 'not-a-version')).toBeUndefined();
expect(validateRealityMaxClientVer('nope', '26.3.27')).toBe(
'pages.inbounds.form.clientVerInvalid',
);
});
});
describe('normalizeXhttpForWire stream-one', () => {
it('drops packet-up and stream-up-only fields on inbound', () => {
const out = normalizeXhttpForWire({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
scMaxBufferedPosts: 30,
scStreamUpServerSecs: '20-80',
enableXmux: false,
headers: {},
}, 'inbound');
expect(out).toMatchObject({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
});
expect(out).not.toHaveProperty('scMaxEachPostBytes');
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
expect(out).not.toHaveProperty('scMaxBufferedPosts');
expect(out).not.toHaveProperty('scStreamUpServerSecs');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('headers');
});
it('preserves non-default scMinPostsIntervalMs on inbound for subscriptions', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'packet-up',
scMinPostsIntervalMs: '50-150',
enableXmux: false,
}, 'inbound');
expect(out.scMinPostsIntervalMs).toBe('50-150');
});
it('strips empty scMinPostsIntervalMs on inbound', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'packet-up',
scMinPostsIntervalMs: '',
enableXmux: false,
}, 'inbound');
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
});
it('keeps xmux on outbound stream-one', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xPaddingBytes: '100-1000',
xmux: { maxConcurrency: '16-32' },
scMaxEachPostBytes: '1000000',
}, 'outbound');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
expect(out).not.toHaveProperty('scMaxEachPostBytes');
});
it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: true,
xmux: { maxConcurrency: '16-32' },
}, 'inbound');
expect(out).not.toHaveProperty('enableXmux');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
});
it('drops inbound xmux when enableXmux is off', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: false,
xmux: { maxConcurrency: '16-32' },
}, 'inbound');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('xmux');
});
// xray-core rejects a config with both maxConnections and maxConcurrency.
it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: true,
xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
}, 'inbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConnections).toBe(4);
expect(xmux.hKeepAlivePeriod).toBe(30);
});
it('keeps maxConcurrency when maxConnections is 0/unset', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xmux: { maxConcurrency: '16-32', maxConnections: 0 },
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('16-32');
expect(xmux.maxConnections).toBe(0);
});
it('applies xmux exclusivity on the outbound side too', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xmux: { maxConcurrency: '16-32', maxConnections: '8' },
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConnections).toBe('8');
});
it('bare schema defaults keep only one exclusive xmux strategy non-zero', () => {
expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0);
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
});
it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
enableXmux: true,
xmux: XMUX_FRESH_DEFAULTS,
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConnections).toBe(3);
expect(xmux.maxConcurrency).toBe('');
});
});
describe('normalizeSockoptForWire', () => {
it('omits doc-example defaults that throttle throughput', () => {
const out = normalizeSockoptForWire({
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
domainStrategy: 'AsIs',
tproxy: 'off',
mark: 0,
});
expect(out).toEqual({
tcpFastOpen: true,
tcpcongestion: 'bbr',
});
});
it('preserves happyEyeballs on freedom-style outbound', () => {
const out = normalizeSockoptForWire({
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
});
expect(out?.happyEyeballs).toMatchObject({
tryDelayMs: 150,
prioritizeIPv6: true,
});
expect(out?.domainStrategy).toBe('UseIP');
});
it('keeps a freshly toggled happyEyeballs (schema defaults) across the wire round trip', () => {
const out = normalizeSockoptForWire({
happyEyeballs: HappyEyeballsSchema.parse({}),
});
expect(out?.happyEyeballs).toEqual({ tryDelayMs: 250 });
});
it('keeps an explicit tryDelayMs of 0 so it cannot rehydrate as the 250 default', () => {
const out = normalizeSockoptForWire({
happyEyeballs: { tryDelayMs: 0, prioritizeIPv6: true, interleave: 1, maxConcurrentTry: 4 },
});
expect(out?.happyEyeballs).toEqual({ tryDelayMs: 0, prioritizeIPv6: true });
});
});
describe('normalizeStreamSettingsForWire reality', () => {
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['abcd'],
settings: {
publicKey: 'pub',
fingerprint: 'chrome',
spiderX: '/',
},
},
}, { side: 'inbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.target).toBe('play.google.com:443');
expect(reality.privateKey).toBe('priv');
const settings = reality.settings as Record<string, unknown>;
expect(settings.publicKey).toBe('pub');
expect(settings.spiderX).toBe('/');
});
it('passes client realitySettings through unchanged on outbound', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
publicKey: 'pub',
fingerprint: 'chrome',
serverName: 'play.google.com',
shortId: 'abcd',
spiderX: '/x',
},
}, { side: 'outbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.publicKey).toBe('pub');
expect(reality.serverName).toBe('play.google.com');
expect(reality.spiderX).toBe('/x');
});
});
describe('normalizeStreamSettingsForWire tls', () => {
it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
const out = normalizeStreamSettingsForWire({
network: 'hysteria',
security: 'tls',
tlsSettings: {
fingerprint: '',
settings: {
fingerprint: '',
echConfigList: '',
},
},
}, { side: 'inbound' });
const tls = out.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(tls).not.toHaveProperty('fingerprint');
expect(settings).not.toHaveProperty('fingerprint');
expect(settings.echConfigList).toBe('');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
enableXmux: false,
},
sockopt: {
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
},
},
} as InboundFormValues;
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
const sockopt = stream.sockopt as Record<string, unknown>;
const reality = stream.realitySettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
expect(sockopt.tcpFastOpen).toBe(true);
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
const values = {
remark: 'hy2',
enable: true,
port: 443,
listen: '',
tag: 'hy2-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'hysteria',
settings: { version: 2, clients: [] },
streamSettings: {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth: 'auth',
udpIdleTimeout: 60,
},
tlsSettings: {
alpn: ['h3'],
settings: {
fingerprint: '',
},
},
},
};
const parsed = InboundFormSchema.safeParse(values);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
const payload = formValuesToWirePayload(parsed.data);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(settings).not.toHaveProperty('fingerprint');
});
it('preserves non-default scMinPostsIntervalMs in packet-up inbound wire payload for subscriptions', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'packet-up',
scMinPostsIntervalMs: '50-150',
},
sockopt: {},
},
};
const parsed = InboundFormSchema.safeParse(values);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
const payload = formValuesToWirePayload(parsed.data);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
expect(xhttp.scMinPostsIntervalMs).toBe('50-150');
});
it('strips default scMinPostsIntervalMs=30 from inbound wire payload', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'packet-up',
scMinPostsIntervalMs: '30',
},
sockopt: {},
},
};
const parsed = InboundFormSchema.safeParse(values);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
const payload = formValuesToWirePayload(parsed.data);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMinPostsIntervalMs');
});
});
describe('freedom outbound sockopt wire payload', () => {
it('preserves happyEyeballs on direct freedom outbound', () => {
const wire = outboundToWire({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: 'UseIP' },
streamSettings: {
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
},
},
} as Parameters<typeof outboundToWire>[0]);
expect(wire.streamSettings).toMatchObject({
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
},
},
});
});
});