mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
1bdb517b88
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.
397 lines
14 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|