mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
ca6955d88b
* 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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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 {
|
|
validateRealityClientVer,
|
|
validateRealityMaxClientVer,
|
|
validateRealityTarget,
|
|
} from '@/lib/xray/stream-wire-normalize';
|
|
import type { RealityScanResult } from '@/generated/types';
|
|
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
|
|
|
interface RealityFormProps {
|
|
saving: boolean;
|
|
scanning: boolean;
|
|
scanResult: RealityScanResult | null;
|
|
scanRealityTarget: () => void;
|
|
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
|
applyRealityScanResult: (result: RealityScanResult) => void;
|
|
randomizeShortIds: () => void;
|
|
randomizeSpiderX: () => void;
|
|
genRealityKeypair: () => void;
|
|
clearRealityKeypair: () => void;
|
|
genMldsa65: () => void;
|
|
clearMldsa65: () => void;
|
|
}
|
|
|
|
export default function RealityForm({
|
|
saving,
|
|
scanning,
|
|
scanResult,
|
|
scanRealityTarget,
|
|
scanRealityCandidates,
|
|
applyRealityScanResult,
|
|
randomizeShortIds,
|
|
randomizeSpiderX,
|
|
genRealityKeypair,
|
|
clearRealityKeypair,
|
|
genMldsa65,
|
|
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
|
|
name={['streamSettings', 'realitySettings', 'show']}
|
|
label={t('pages.inbounds.form.show')}
|
|
valueProp="checked"
|
|
>
|
|
<Switch />
|
|
</FormField>
|
|
<FormField name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
|
|
<InputNumber min={0} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
|
label="uTLS"
|
|
>
|
|
<Select
|
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
|
/>
|
|
</FormField>
|
|
<Form.Item
|
|
label={t('pages.inbounds.form.target')}
|
|
tooltip={t('pages.inbounds.form.realityTargetHint')}
|
|
>
|
|
<Space.Compact block style={{ display: 'flex' }}>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'target']}
|
|
noStyle
|
|
rules={{
|
|
validate: (value) => {
|
|
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
|
return errKey ? errKey : true;
|
|
},
|
|
}}
|
|
>
|
|
<Input style={{ flex: 1 }} placeholder="example.com:443" />
|
|
</FormField>
|
|
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
|
|
{t('pages.inbounds.form.scan')}
|
|
</Button>
|
|
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
|
|
{t('pages.inbounds.form.findTargets')}
|
|
</Button>
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
{scanResult && (
|
|
<Form.Item label=" " colon={false}>
|
|
<Alert
|
|
type={scanResult.feasible ? 'success' : 'warning'}
|
|
showIcon
|
|
title={
|
|
scanResult.feasible
|
|
? t('pages.inbounds.form.scanFeasible')
|
|
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
|
|
}
|
|
description={
|
|
<Descriptions size="small" column={1}>
|
|
<Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
|
|
<Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
|
|
{scanResult.curveID || '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
|
|
{scanResult.certValid
|
|
? `${scanResult.certSubject} (${scanResult.certIssuer})`
|
|
: t('pages.inbounds.form.scanCertInvalid')}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
|
|
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
)}
|
|
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
|
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
|
label={t('pages.inbounds.form.maxTimeDiff')}
|
|
>
|
|
<InputNumber min={0} />
|
|
</FormField>
|
|
<FormField
|
|
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>
|
|
<FormField
|
|
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>
|
|
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
|
<Space.Compact block style={{ display: 'flex' }}>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'shortIds']}
|
|
noStyle
|
|
>
|
|
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
|
</FormField>
|
|
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('pages.inbounds.form.spiderX')}
|
|
tooltip={t('pages.inbounds.form.spiderXHint')}
|
|
>
|
|
<Space.Compact block style={{ display: 'flex' }}>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
|
|
noStyle
|
|
>
|
|
<Input style={{ flex: 1 }} />
|
|
</FormField>
|
|
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
|
|
label={t('pages.inbounds.publicKey')}
|
|
>
|
|
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'privateKey']}
|
|
label={t('pages.inbounds.privatekey')}
|
|
>
|
|
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
|
|
</FormField>
|
|
<Form.Item label=" ">
|
|
<Space>
|
|
<Button type="primary" loading={saving} onClick={genRealityKeypair}>
|
|
{t('pages.inbounds.form.getNewCert')}
|
|
</Button>
|
|
<Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
|
|
label={t('pages.inbounds.form.mldsa65Seed')}
|
|
>
|
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
|
|
label={t('pages.inbounds.form.mldsa65Verify')}
|
|
>
|
|
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
|
|
</FormField>
|
|
<Form.Item label=" ">
|
|
<Space>
|
|
<Button type="primary" loading={saving} onClick={genMldsa65}>
|
|
{t('pages.inbounds.form.getNewSeed')}
|
|
</Button>
|
|
<Button danger onClick={clearMldsa65}>{t('clear')}</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', 'masterKeyLog']}
|
|
label={t('pages.inbounds.form.masterKeyLog')}
|
|
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
|
|
>
|
|
<Input placeholder="/path/to/sslkeylog.txt" />
|
|
</FormField>
|
|
<Collapse
|
|
style={{ marginBottom: 14 }}
|
|
items={[
|
|
{
|
|
key: 'limitFallback',
|
|
label: t('pages.inbounds.form.limitFallback'),
|
|
children: (
|
|
<>
|
|
{(['limitFallbackUpload', 'limitFallbackDownload'] as const).map((dir) => (
|
|
<div key={dir}>
|
|
<Divider style={{ margin: '0 0 14px 0' }}>
|
|
{t(`pages.inbounds.form.${dir}`)}
|
|
</Divider>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
|
|
label={t('pages.inbounds.form.afterBytes')}
|
|
tooltip={t('pages.inbounds.form.afterBytesTip')}
|
|
>
|
|
<InputNumber min={0} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
|
|
label={t('pages.inbounds.form.bytesPerSec')}
|
|
tooltip={t('pages.inbounds.form.bytesPerSecTip')}
|
|
>
|
|
<InputNumber min={0} />
|
|
</FormField>
|
|
<FormField
|
|
name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
|
|
label={t('pages.inbounds.form.burstBytesPerSec')}
|
|
tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
|
|
>
|
|
<InputNumber min={0} />
|
|
</FormField>
|
|
</div>
|
|
))}
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
<RealityTargetScannerModal
|
|
open={scannerOpen}
|
|
onClose={() => setScannerOpen(false)}
|
|
scanRealityCandidates={scanRealityCandidates}
|
|
onPick={applyRealityScanResult}
|
|
/>
|
|
</>
|
|
);
|
|
}
|