Files
3x-ui/frontend/src/pages/inbounds/form/security/reality.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

397 lines
14 KiB
TypeScript

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