refactor(frontend): extract inbound security forms into security/ folder

Move the inbound TLS and Reality stream-security blocks out of
InboundFormModal into presentational components under
inbounds/form/security/. The Radio.Group security selector stays in the
modal; TlsForm and RealityForm receive their cert/key/ECH generation
handlers and the saving flag as props. InboundFormModal drops from 2105
to 1708 lines.

Add inbound-form-blocks.test.tsx: render-snapshot coverage for each
extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security
(tls/reality) component in isolation inside a minimal Form. The full
modal cannot exercise the stream/security tabs in jsdom because they are
gated behind Form.useWatch values that do not propagate in the test
harness, so component-level snapshots are the regression net for these
blocks. No behavior change.
This commit is contained in:
MHSanaei 2026-05-30 20:29:18 +02:00
parent 57d66ec9ff
commit 66348db37d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 663 additions and 419 deletions

View file

@ -24,7 +24,6 @@ import {
DeleteOutlined, DeleteOutlined,
MinusOutlined, MinusOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
@ -54,9 +53,6 @@ import {
Protocols, Protocols,
SNIFFING_OPTION, SNIFFING_OPTION,
TCP_CONGESTION_OPTION, TCP_CONGESTION_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION,
USAGE_OPTION,
UTLS_FINGERPRINT, UTLS_FINGERPRINT,
} from '@/schemas/primitives'; } from '@/schemas/primitives';
import { import {
@ -97,8 +93,8 @@ import {
WsForm, WsForm,
XhttpForm, XhttpForm,
} from './transport'; } from './transport';
import { RealityForm, TlsForm } from './security';
const { TextArea } = Input;
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
@ -1481,421 +1477,28 @@ export default function InboundFormModal({
</Form.Item> </Form.Item>
</Form.Item> </Form.Item>
<Form.Item {security === 'tls' && (
noStyle <TlsForm
shouldUpdate={(prev, curr) => saving={saving}
prev.streamSettings?.security !== curr.streamSettings?.security setCertFromPanel={setCertFromPanel}
} clearCertFiles={clearCertFiles}
> generateRandomPinHash={generateRandomPinHash}
{({ getFieldValue }) => { getNewEchCert={getNewEchCert}
const sec = getFieldValue(['streamSettings', 'security']); clearEchCert={clearEchCert}
if (sec !== 'tls') return null;
return (
<>
<Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
<Select
options={[
{ value: '', label: t('pages.inbounds.form.autoOption') },
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
]}
/> />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select
options={[
{ value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
]}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select
mode="multiple"
tokenSeparators={[',']}
style={{ width: '100%' }}
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
label={t('pages.inbounds.form.rejectUnknownSni')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
label={t('pages.inbounds.form.disableSystemRoot')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
label={t('pages.inbounds.form.sessionResumption')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
{(certFields, { add, remove }) => (
<>
<Form.Item label={t('certificate')}>
<Button
type="primary"
size="small"
onClick={() => add({
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
})}
>
<PlusOutlined />
</Button>
</Form.Item>
{certFields.map((certField, idx) => (
<div key={certField.key}>
<Form.Item
name={[certField.name, 'useFile']}
label={`${t('certificate')} ${idx + 1}`}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value={true}>
{t('pages.inbounds.certificatePath')}
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
</Radio.Group>
</Form.Item>
{certFields.length > 1 && (
<Form.Item label=" ">
<Button
size="small"
danger
onClick={() => remove(certField.name)}
>
<MinusOutlined /> {t('remove')}
</Button>
</Form.Item>
)} )}
<Form.Item
noStyle {security === 'reality' && (
shouldUpdate={(prev, curr) => <RealityForm
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile saving={saving}
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile randomizeRealityTarget={randomizeRealityTarget}
} randomizeShortIds={randomizeShortIds}
> genRealityKeypair={genRealityKeypair}
{({ getFieldValue }) => { clearRealityKeypair={clearRealityKeypair}
const useFile = getFieldValue([ genMldsa65={genMldsa65}
'streamSettings', 'tlsSettings', 'certificates', clearMldsa65={clearMldsa65}
certField.name, 'useFile',
]);
return useFile ? (
<>
<Form.Item
name={[certField.name, 'certificateFile']}
label={t('pages.inbounds.publicKey')}
>
<Input />
</Form.Item>
<Form.Item
name={[certField.name, 'keyFile']}
label={t('pages.inbounds.privatekey')}
>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button
type="primary"
loading={saving}
onClick={() => setCertFromPanel(certField.name)}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button danger onClick={() => clearCertFiles(certField.name)}>
{t('clear')}
</Button>
</Space>
</Form.Item>
</>
) : (
<>
<Form.Item
name={[certField.name, 'certificate']}
label={t('pages.inbounds.publicKey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
<Form.Item
name={[certField.name, 'key']}
label={t('pages.inbounds.privatekey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
name={[certField.name, 'oneTimeLoading']}
label={t('pages.inbounds.form.oneTimeLoading')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={[certField.name, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/> />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
}
>
{({ getFieldValue }) => {
const usage = getFieldValue([
'streamSettings', 'tlsSettings', 'certificates',
certField.name, 'usage',
]);
if (usage !== 'issue') return null;
return (
<Form.Item
name={[certField.name, 'buildChain']}
label={t('pages.inbounds.form.buildChain')}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)} )}
</Form.List>
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label={t('pages.inbounds.form.echConfig')}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
>
<Space.Compact block>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
noStyle
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={generateRandomPinHash}
title={t('pages.inbounds.form.generateRandomPin')}
/>
</Space.Compact>
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>{t('clear')}</Button>
</Space>
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.security !== curr.streamSettings?.security
}
>
{({ getFieldValue }) => {
const sec = getFieldValue(['streamSettings', 'security']);
if (sec !== 'reality') return null;
return (
<>
<Form.Item
name={['streamSettings', 'realitySettings', 'show']}
label={t('pages.inbounds.form.show')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.target')}>
<Space.Compact block>
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
</Form.Item>
<Form.Item label="SNI">
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'serverNames']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxTimediff']}
label={t('pages.inbounds.form.maxTimeDiff')}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'minClientVer']}
label={t('pages.inbounds.form.minClientVer')}
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxClientVer']}
label={t('pages.inbounds.form.maxClientVer')}
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'shortIds']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
label={t('pages.inbounds.form.spiderX')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
label={t('pages.inbounds.publicKey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'privateKey']}
label={t('pages.inbounds.privatekey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<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>
<Form.Item
name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
label={t('pages.inbounds.form.mldsa65Seed')}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
label={t('pages.inbounds.form.mldsa65Verify')}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<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>
</>
);
}}
</Form.Item>
</> </>
); );

View file

@ -0,0 +1,2 @@
export { default as TlsForm } from './tls';
export { default as RealityForm } from './reality';

View file

@ -0,0 +1,143 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
interface RealityFormProps {
saving: boolean;
randomizeRealityTarget: () => void;
randomizeShortIds: () => void;
genRealityKeypair: () => void;
clearRealityKeypair: () => void;
genMldsa65: () => void;
clearMldsa65: () => void;
}
export default function RealityForm({
saving,
randomizeRealityTarget,
randomizeShortIds,
genRealityKeypair,
clearRealityKeypair,
genMldsa65,
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
return (
<>
<Form.Item
name={['streamSettings', 'realitySettings', 'show']}
label={t('pages.inbounds.form.show')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.target')}>
<Space.Compact block>
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
</Form.Item>
<Form.Item label="SNI">
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'serverNames']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxTimediff']}
label={t('pages.inbounds.form.maxTimeDiff')}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'minClientVer']}
label={t('pages.inbounds.form.minClientVer')}
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxClientVer']}
label={t('pages.inbounds.form.maxClientVer')}
>
<Input placeholder="25.9.11" />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'shortIds']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
label={t('pages.inbounds.form.spiderX')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
label={t('pages.inbounds.publicKey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'privateKey']}
label={t('pages.inbounds.privatekey')}
>
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
</Form.Item>
<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>
<Form.Item
name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
label={t('pages.inbounds.form.mldsa65Seed')}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
label={t('pages.inbounds.form.mldsa65Verify')}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
</Form.Item>
<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>
</>
);
}

View file

@ -0,0 +1,309 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Radio, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
ALPN_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION,
USAGE_OPTION,
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
const { TextArea } = Input;
interface TlsFormProps {
saving: boolean;
setCertFromPanel: (certName: number) => void;
clearCertFiles: (certName: number) => void;
generateRandomPinHash: () => void;
getNewEchCert: () => void;
clearEchCert: () => void;
}
export default function TlsForm({
saving,
setCertFromPanel,
clearCertFiles,
generateRandomPinHash,
getNewEchCert,
clearEchCert,
}: TlsFormProps) {
const { t } = useTranslation();
return (
<>
<Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
<Select
options={[
{ value: '', label: t('pages.inbounds.form.autoOption') },
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
]}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select
options={[
{ value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
]}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select
mode="multiple"
tokenSeparators={[',']}
style={{ width: '100%' }}
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
label={t('pages.inbounds.form.rejectUnknownSni')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
label={t('pages.inbounds.form.disableSystemRoot')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
label={t('pages.inbounds.form.sessionResumption')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
{(certFields, { add, remove }) => (
<>
<Form.Item label={t('certificate')}>
<Button
type="primary"
size="small"
onClick={() => add({
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
})}
>
<PlusOutlined />
</Button>
</Form.Item>
{certFields.map((certField, idx) => (
<div key={certField.key}>
<Form.Item
name={[certField.name, 'useFile']}
label={`${t('certificate')} ${idx + 1}`}
>
<Radio.Group buttonStyle="solid">
<Radio.Button value={true}>
{t('pages.inbounds.certificatePath')}
</Radio.Button>
<Radio.Button value={false}>
{t('pages.inbounds.certificateContent')}
</Radio.Button>
</Radio.Group>
</Form.Item>
{certFields.length > 1 && (
<Form.Item label=" ">
<Button
size="small"
danger
onClick={() => remove(certField.name)}
>
<MinusOutlined /> {t('remove')}
</Button>
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
}
>
{({ getFieldValue }) => {
const useFile = getFieldValue([
'streamSettings', 'tlsSettings', 'certificates',
certField.name, 'useFile',
]);
return useFile ? (
<>
<Form.Item
name={[certField.name, 'certificateFile']}
label={t('pages.inbounds.publicKey')}
>
<Input />
</Form.Item>
<Form.Item
name={[certField.name, 'keyFile']}
label={t('pages.inbounds.privatekey')}
>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button
type="primary"
loading={saving}
onClick={() => setCertFromPanel(certField.name)}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button danger onClick={() => clearCertFiles(certField.name)}>
{t('clear')}
</Button>
</Space>
</Form.Item>
</>
) : (
<>
<Form.Item
name={[certField.name, 'certificate']}
label={t('pages.inbounds.publicKey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
<Form.Item
name={[certField.name, 'key']}
label={t('pages.inbounds.privatekey')}
normalize={(v) => typeof v === 'string'
? v.split('\n')
: v}
getValueProps={(v) => ({
value: Array.isArray(v) ? v.join('\n') : v,
})}
>
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
name={[certField.name, 'oneTimeLoading']}
label={t('pages.inbounds.form.oneTimeLoading')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={[certField.name, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
}
>
{({ getFieldValue }) => {
const usage = getFieldValue([
'streamSettings', 'tlsSettings', 'certificates',
certField.name, 'usage',
]);
if (usage !== 'issue') return null;
return (
<Form.Item
name={[certField.name, 'buildChain']}
label={t('pages.inbounds.form.buildChain')}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label={t('pages.inbounds.form.echConfig')}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
>
<Space.Compact block>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
noStyle
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={generateRandomPinHash}
title={t('pages.inbounds.form.generateRandomPin')}
/>
</Space.Compact>
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>{t('clear')}</Button>
</Space>
</Form.Item>
</>
);
}

View file

@ -0,0 +1,98 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`inbound security forms > RealityForm field structure is stable 1`] = `
[
"Show",
"Xver",
"uTLS",
"Target",
"SNI",
"Max Time Diff (ms)",
"Min Client Ver",
"Max Client Ver",
"Short IDs",
"SpiderX",
"Public Key",
"Private Key",
"mldsa65 Seed",
"mldsa65 Verify",
]
`;
exports[`inbound security forms > TlsForm field structure is stable 1`] = `
[
"SNI",
"Cipher Suites",
"Min/Max Version",
"uTLS",
"ALPN",
"Reject Unknown SNI",
"Disable System Root",
"Session Resumption",
"Digital Certificate",
"ECH key",
"ECH config",
"Pinned Peer Cert SHA-256",
]
`;
exports[`inbound transport forms > GrpcForm field structure is stable 1`] = `
[
"Service Name",
"Authority",
"Multi Mode",
]
`;
exports[`inbound transport forms > HttpUpgradeForm field structure is stable 1`] = `
[
"Proxy Protocol",
"Host",
"Path",
"Headers",
]
`;
exports[`inbound transport forms > KcpForm field structure is stable 1`] = `
[
"MTU",
"TTI (ms)",
"Uplink (MB/s)",
"Downlink (MB/s)",
"CWND Multiplier",
"Max Sending Window",
]
`;
exports[`inbound transport forms > RawForm field structure is stable 1`] = `
[
"Proxy Protocol",
"HTTP Obfuscation",
]
`;
exports[`inbound transport forms > WsForm field structure is stable 1`] = `
[
"Proxy Protocol",
"Host",
"Path",
"Heartbeat Period",
"Headers",
]
`;
exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
[
"Host",
"Path",
"Mode",
"Server Max Header Bytes",
"Padding Bytes",
"Headers",
"Uplink HTTP Method",
"Padding Obfs Mode",
"Session Placement",
"Sequence Placement",
"No SSE Header",
]
`;

View file

@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { Form, type FormInstance } from 'antd';
import type { ReactNode } from 'react';
import {
GrpcForm,
HttpUpgradeForm,
KcpForm,
RawForm,
WsForm,
XhttpForm,
} from '@/pages/inbounds/form/transport';
import { RealityForm, TlsForm } from '@/pages/inbounds/form/security';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { renderWithProviders, fieldLabels } from './test-utils';
function FormHarness({ children }: { children: (form: FormInstance<InboundFormValues>) => ReactNode }) {
const [form] = Form.useForm<InboundFormValues>();
return <Form form={form}>{children(form)}</Form>;
}
function renderInForm(node: (form: FormInstance<InboundFormValues>) => ReactNode) {
return renderWithProviders(<FormHarness>{node}</FormHarness>);
}
const noop = () => {};
describe('inbound transport forms', () => {
it('RawForm field structure is stable', () => {
renderInForm(() => <RawForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('WsForm field structure is stable', () => {
renderInForm(() => <WsForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('GrpcForm field structure is stable', () => {
renderInForm(() => <GrpcForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('KcpForm field structure is stable', () => {
renderInForm(() => <KcpForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('HttpUpgradeForm field structure is stable', () => {
renderInForm(() => <HttpUpgradeForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('XhttpForm field structure is stable', () => {
renderInForm((form) => <XhttpForm form={form} />);
expect(fieldLabels()).toMatchSnapshot();
});
});
describe('inbound security forms', () => {
it('TlsForm field structure is stable', () => {
renderInForm(() => (
<TlsForm
saving={false}
setCertFromPanel={noop}
clearCertFiles={noop}
generateRandomPinHash={noop}
getNewEchCert={noop}
clearEchCert={noop}
/>
));
expect(fieldLabels()).toMatchSnapshot();
});
it('RealityForm field structure is stable', () => {
renderInForm(() => (
<RealityForm
saving={false}
randomizeRealityTarget={noop}
randomizeShortIds={noop}
genRealityKeypair={noop}
clearRealityKeypair={noop}
genMldsa65={noop}
clearMldsa65={noop}
/>
));
expect(fieldLabels()).toMatchSnapshot();
});
});