mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
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:
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a REALITY client-version string the way xray-core's config loader
|
||||
* does: one to three dot-separated numeric parts, each 0-255. Returns the
|
||||
* parts padded to three entries, or undefined when the string is not a valid
|
||||
* version.
|
||||
*/
|
||||
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parts = trimmed.split('.');
|
||||
if (parts.length > 3) return undefined;
|
||||
const nums: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^\d+$/.test(part)) return undefined;
|
||||
const n = Number(part);
|
||||
if (n > 255) return undefined;
|
||||
nums.push(n);
|
||||
}
|
||||
while (nums.length < 3) nums.push(0);
|
||||
return nums as [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a REALITY client-version field; empty means "not set" and is
|
||||
* valid. The value is saved exactly as typed and xray-core's part parser
|
||||
* accepts no surrounding whitespace, so a value that differs from its
|
||||
* trimmed form is rejected rather than silently passed to the wire.
|
||||
*/
|
||||
export function validateRealityClientVer(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value !== value.trim() || !parseRealityClientVer(value)) {
|
||||
return 'pages.inbounds.form.clientVerInvalid';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the max client-version field: format first, then that a non-empty
|
||||
* max is not below a non-empty min (an inverted range rejects every client).
|
||||
* An empty or malformed min is left to the min field's own validation.
|
||||
*/
|
||||
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
|
||||
const formatError = validateRealityClientVer(max);
|
||||
if (formatError) return formatError;
|
||||
const maxParts = parseRealityClientVer(max);
|
||||
const minParts = parseRealityClientVer(min);
|
||||
if (!maxParts || !minParts) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (maxParts[i] !== minParts[i]) {
|
||||
return maxParts[i] < minParts[i]
|
||||
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
|
||||
const lift = (legacy: string, renamed: string) => {
|
||||
const v = obj[legacy];
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import {
|
||||
validateRealityClientVer,
|
||||
validateRealityMaxClientVer,
|
||||
validateRealityTarget,
|
||||
} from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||
|
||||
@@ -39,7 +44,14 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getFieldState, trigger } = useFormContext();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
|
||||
const revalidateMaxClientVer = () => {
|
||||
if (getFieldState(maxClientVerPath).error) {
|
||||
void trigger(maxClientVerPath);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@@ -128,6 +140,13 @@ export default function RealityForm({
|
||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||
label={t('pages.inbounds.form.minClientVer')}
|
||||
tooltip={t('pages.inbounds.form.minClientVerHint')}
|
||||
onAfterChange={revalidateMaxClientVer}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="26.3.27" />
|
||||
</FormField>
|
||||
@@ -135,6 +154,14 @@ export default function RealityForm({
|
||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||
label={t('pages.inbounds.form.maxClientVer')}
|
||||
tooltip={t('pages.inbounds.form.maxClientVerHint')}
|
||||
rules={{
|
||||
validate: (value, formValues) => {
|
||||
const max = typeof value === 'string' ? value : '';
|
||||
const min = formValues?.streamSettings?.realitySettings?.minClientVer;
|
||||
const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="x.y.z" />
|
||||
</FormField>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user