Files
3x-ui/frontend/src/pages/inbounds/form/security/reality.tsx
T
Hamed f4030c889c fix(frontend): make inbound forms accessible to screen readers (#7025)
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.
2026-11-05 18:08:49 +01:00

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}
/>
</>
);
}