mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
57d66ec9ff
commit
66348db37d
6 changed files with 663 additions and 419 deletions
|
|
@ -24,7 +24,6 @@ import {
|
|||
DeleteOutlined,
|
||||
MinusOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
|
|
@ -54,9 +53,6 @@ import {
|
|||
Protocols,
|
||||
SNIFFING_OPTION,
|
||||
TCP_CONGESTION_OPTION,
|
||||
TLS_CIPHER_OPTION,
|
||||
TLS_VERSION_OPTION,
|
||||
USAGE_OPTION,
|
||||
UTLS_FINGERPRINT,
|
||||
} from '@/schemas/primitives';
|
||||
import {
|
||||
|
|
@ -97,8 +93,8 @@ import {
|
|||
WsForm,
|
||||
XhttpForm,
|
||||
} from './transport';
|
||||
import { RealityForm, TlsForm } from './security';
|
||||
|
||||
const { TextArea } = Input;
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
|
||||
|
|
@ -1481,421 +1477,28 @@ export default function InboundFormModal({
|
|||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.security !== curr.streamSettings?.security
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const sec = getFieldValue(['streamSettings', 'security']);
|
||||
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>
|
||||
{security === 'tls' && (
|
||||
<TlsForm
|
||||
saving={saving}
|
||||
setCertFromPanel={setCertFromPanel}
|
||||
clearCertFiles={clearCertFiles}
|
||||
generateRandomPinHash={generateRandomPinHash}
|
||||
getNewEchCert={getNewEchCert}
|
||||
clearEchCert={clearEchCert}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</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>
|
||||
{security === 'reality' && (
|
||||
<RealityForm
|
||||
saving={saving}
|
||||
randomizeRealityTarget={randomizeRealityTarget}
|
||||
randomizeShortIds={randomizeShortIds}
|
||||
genRealityKeypair={genRealityKeypair}
|
||||
clearRealityKeypair={clearRealityKeypair}
|
||||
genMldsa65={genMldsa65}
|
||||
clearMldsa65={clearMldsa65}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
2
frontend/src/pages/inbounds/form/security/index.ts
Normal file
2
frontend/src/pages/inbounds/form/security/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as TlsForm } from './tls';
|
||||
export { default as RealityForm } from './reality';
|
||||
143
frontend/src/pages/inbounds/form/security/reality.tsx
Normal file
143
frontend/src/pages/inbounds/form/security/reality.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
309
frontend/src/pages/inbounds/form/security/tls.tsx
Normal file
309
frontend/src/pages/inbounds/form/security/tls.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
`;
|
||||
89
frontend/src/test/inbound-form-blocks.test.tsx
Normal file
89
frontend/src/test/inbound-form-blocks.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue