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>
This commit is contained in:
PathGao
2026-07-29 05:04:03 +08:00
committed by GitHub
parent 411271b454
commit ca6955d88b
16 changed files with 171 additions and 1 deletions
@@ -7,6 +7,8 @@ import {
normalizeSockoptForWire,
normalizeStreamSettingsForWire,
normalizeXhttpForWire,
validateRealityClientVer,
validateRealityMaxClientVer,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
@@ -26,6 +28,64 @@ describe('validateRealityTarget', () => {
});
});
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({