mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
f4030c889c
Expose selectable options and selected values, associate validation guidance with controls, and contain keyboard focus in inbound dialogs. Preserve create and edit serialization and add Chromium keyboard and accessibility checks.
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import { NativeSelect } from '@/components/form/NativeSelect';
|
|
import { useState, useId } from 'react';
|
|
import { useFormContext, useWatch } 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 dayjs from 'dayjs';
|
|
|
|
import { selectTagRender } from '@/components/form';
|
|
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, {
|
|
MLDSA65_MIN_CERT_CHAIN_BYTES,
|
|
} from './RealityTargetScannerModal';
|
|
|
|
interface RealityFormProps {
|
|
saving: boolean;
|
|
scanning: boolean;
|
|
scanResult: RealityScanResult | null;
|
|
scanRealityTarget: (allowPrivate?: boolean) => void;
|
|
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
|
applyRealityScanResult: (result: RealityScanResult, replaceServerNames?: boolean) => 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 uid = useId();
|
|
const { getFieldState, trigger } = useFormContext();
|
|
const [scannerOpen, setScannerOpen] = useState(false);
|
|
const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
|
|
const mldsa65Verify = useWatch({
|
|
name: 'streamSettings.realitySettings.settings.mldsa65Verify',
|
|
});
|
|
const mldsa65Enabled =
|
|
(typeof mldsa65Seed === 'string' && mldsa65Seed.trim() !== '') ||
|
|
(typeof mldsa65Verify === 'string' && mldsa65Verify.trim() !== '');
|
|
const mldsaChainTooSmall =
|
|
!!scanResult &&
|
|
mldsa65Enabled &&
|
|
scanResult.certChainBytes > 0 &&
|
|
scanResult.certChainBytes < MLDSA65_MIN_CERT_CHAIN_BYTES;
|
|
/*
|
|
* An untrusted certificate (self-signed fronting service on the LAN) is still
|
|
* worth reading, so subject/issuer stay visible and only the verdict is added.
|
|
*/
|
|
const certSummary = (r: RealityScanResult) => {
|
|
const who =
|
|
r.certSubject && r.certIssuer
|
|
? `${r.certSubject} (${r.certIssuer})`
|
|
: r.certSubject || r.certIssuer;
|
|
if (!who) return '—';
|
|
return r.certValid ? who : `${who} — ${t('pages.inbounds.form.scanCertInvalid')}`;
|
|
};
|
|
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"
|
|
>
|
|
<NativeSelect
|
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
|
/>
|
|
</FormField>
|
|
<Form.Item
|
|
htmlFor={`${uid}-target`}
|
|
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
|
|
id={`${uid}-target`}
|
|
aria-describedby={`${uid}-target-hint`}
|
|
style={{ flex: 1 }}
|
|
placeholder="example.com:443"
|
|
/>
|
|
</FormField>
|
|
<Button
|
|
icon={<RadarChartOutlined aria-hidden />}
|
|
loading={scanning}
|
|
onClick={() => scanRealityTarget()}
|
|
>
|
|
{t('pages.inbounds.form.scan')}
|
|
</Button>
|
|
<Button icon={<SearchOutlined aria-hidden />} onClick={() => setScannerOpen(true)}>
|
|
{t('pages.inbounds.form.findTargets')}
|
|
</Button>
|
|
</Space.Compact>
|
|
<span id={`${uid}-target-hint`} hidden>
|
|
{t('pages.inbounds.form.realityTargetHint')}
|
|
</span>
|
|
</Form.Item>
|
|
{scanResult && (
|
|
<Form.Item label=" " colon={false}>
|
|
<Alert
|
|
type={
|
|
scanResult.feasible && !scanResult.privateTarget && !mldsaChainTooSmall
|
|
? 'success'
|
|
: 'warning'
|
|
}
|
|
showIcon
|
|
title={
|
|
scanResult.feasible
|
|
? t('pages.inbounds.form.scanFeasible')
|
|
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
|
|
}
|
|
description={
|
|
<>
|
|
{mldsaChainTooSmall && (
|
|
<div style={{ marginBottom: 8 }}>
|
|
{t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
|
|
length: scanResult.certChainBytes,
|
|
min: MLDSA65_MIN_CERT_CHAIN_BYTES,
|
|
})}
|
|
</div>
|
|
)}
|
|
{scanResult.privateTarget && (
|
|
<div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
|
|
)}
|
|
<Descriptions size="small" column={1}>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanSniUsed')}>
|
|
{scanResult.host || '—'}
|
|
</Descriptions.Item>
|
|
<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')}>
|
|
{certSummary(scanResult)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanCertExpiry')}>
|
|
{scanResult.notAfter
|
|
? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm')
|
|
: '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('pages.inbounds.form.scanCertChain')}>
|
|
{scanResult.certChainBytes > 0 ? `${scanResult.certChainBytes} B` : '—'}
|
|
</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"
|
|
tagRender={selectTagRender}
|
|
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="x.y.z" />
|
|
</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 htmlFor={`${uid}-shortIds`} label={t('pages.inbounds.form.shortIds')}>
|
|
<Space.Compact block style={{ display: 'flex' }}>
|
|
<FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle>
|
|
<Select
|
|
id={`${uid}-shortIds`}
|
|
mode="tags"
|
|
tagRender={selectTagRender}
|
|
tokenSeparators={[',']}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</FormField>
|
|
<Button
|
|
aria-label={t('regenerate')}
|
|
icon={<ReloadOutlined aria-hidden />}
|
|
onClick={randomizeShortIds}
|
|
/>
|
|
</Space.Compact>
|
|
</Form.Item>
|
|
<Form.Item
|
|
htmlFor={`${uid}-spiderX`}
|
|
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
|
|
id={`${uid}-spiderX`}
|
|
aria-describedby={`${uid}-spiderX-hint`}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</FormField>
|
|
<Button
|
|
aria-label={t('regenerate')}
|
|
icon={<ReloadOutlined aria-hidden />}
|
|
onClick={randomizeSpiderX}
|
|
/>
|
|
</Space.Compact>
|
|
<span id={`${uid}-spiderX-hint`} hidden>
|
|
{t('pages.inbounds.form.spiderXHint')}
|
|
</span>
|
|
</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={(r) => applyRealityScanResult(r, true)}
|
|
mldsa65Enabled={mldsa65Enabled}
|
|
/>
|
|
</>
|
|
);
|
|
}
|