mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-13 14:50:59 +00:00
feat(tls,reality): port xray TLS/REALITY fields, cert-hash helpers, fallback UX
TLS: add verifyPeerCertByName (vcn) to inbound settings + emit in both share-link generators (frontend + Go sub) and outbound parser; the allowInsecure replacement xray removed after 2026-06-01. Add server-side curvePreferences, masterKeyLog, echSockopt (passthrough + form) at tlsSettings top-level so they survive the panel-only settings strip. REALITY: add limitFallbackUpload/Download (afterBytes/bytesPerSec/burstBytesPerSec) with per-field tooltips, plus masterKeyLog. Verified field names/semantics against pinned xray v1.260327.1 (bytesPerSec=0 disables). Hosts: fix verify_peer_cert_by_name column bool->string (xray expects comma-separated names) with an idempotent, history-gate-free migration (SQLite typeof blank; Postgres ALTER once); emit vcn for hosts/external proxies. Server: add getCertHash (local cert DER SHA-256) and getRemoteCertHash (xray tls ping) endpoints + api-docs; wire pinned-cert field buttons. Drop the meaningless random-hash button. Xray UI: metrics endpoint (listen/tag) config in Basics; import/export for routing rules and outbounds. Fallbacks card: compact empty state, header-aligned actions, responsive labeled grid rows. i18n: add all new keys to every locale; drop unused generateRandomPin.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Empty, Input, InputNumber, Select, Space } from 'antd';
|
||||
import { Button, Card, Col, Empty, Input, InputNumber, Row, Select, Space } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import type { FallbackRow } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface FallbacksCardProps {
|
||||
@@ -25,100 +24,120 @@ export default function FallbacksCard({
|
||||
addAllFallbacks,
|
||||
}: FallbacksCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addButtons = (
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" ghost size="small" icon={<PlusOutlined />} onClick={addFallback}>
|
||||
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={addAllFallbacks}
|
||||
disabled={fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length}
|
||||
title={t('pages.inbounds.form.addAllFallbackTooltip')}
|
||||
>
|
||||
{t('pages.inbounds.form.addAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
|
||||
{fallbacks.length === 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
className="mt-12"
|
||||
title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}
|
||||
extra={addButtons}
|
||||
>
|
||||
{fallbacks.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
styles={{ image: { height: 36 } }}
|
||||
description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
|
||||
styles={{ image: { height: 40 } }}
|
||||
style={{ margin: '8px 0 12px' }}
|
||||
style={{ margin: '4px 0 12px' }}
|
||||
/>
|
||||
) : (
|
||||
fallbacks.map((record, idx) => (
|
||||
<Card
|
||||
key={record.rowKey}
|
||||
type="inner"
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Space.Compact block style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
value={record.childId}
|
||||
options={fallbackChildOptions}
|
||||
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
|
||||
allowClear
|
||||
showSearch={{
|
||||
filterOption: (input, option) =>
|
||||
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
|
||||
/>
|
||||
<Button
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveFallback(idx, -1)}
|
||||
title={t('pages.inbounds.form.moveUp')}
|
||||
icon={<ArrowUpOutlined />}
|
||||
/>
|
||||
<Button
|
||||
disabled={idx === fallbacks.length - 1}
|
||||
onClick={() => moveFallback(idx, 1)}
|
||||
title={t('pages.inbounds.form.moveDown')}
|
||||
icon={<ArrowDownOutlined />}
|
||||
/>
|
||||
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
|
||||
</Space.Compact>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="SNI"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.name}
|
||||
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="ALPN"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.alpn}
|
||||
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="Path"
|
||||
placeholder="/"
|
||||
value={record.path}
|
||||
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="Dest"
|
||||
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
|
||||
value={record.dest}
|
||||
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<InputNumber
|
||||
addonBefore="xver"
|
||||
min={0}
|
||||
max={2}
|
||||
style={{ width: '100%' }}
|
||||
value={record.xver}
|
||||
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
{fallbacks.map((record, idx) => (
|
||||
<div
|
||||
key={record.rowKey}
|
||||
style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
|
||||
>
|
||||
<Space.Compact block style={{ marginBottom: 6 }}>
|
||||
<Select
|
||||
value={record.childId}
|
||||
options={fallbackChildOptions}
|
||||
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
|
||||
allowClear
|
||||
showSearch={{
|
||||
filterOption: (input, option) =>
|
||||
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
|
||||
/>
|
||||
<Button
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveFallback(idx, -1)}
|
||||
title={t('pages.inbounds.form.moveUp')}
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={idx === fallbacks.length - 1}
|
||||
onClick={() => moveFallback(idx, 1)}
|
||||
title={t('pages.inbounds.form.moveDown')}
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
</Button>
|
||||
<Button danger onClick={() => removeFallback(idx)}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact block>
|
||||
<InputAddon>SNI</InputAddon>
|
||||
<Input
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.name}
|
||||
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
|
||||
/>
|
||||
<InputAddon>ALPN</InputAddon>
|
||||
<Input
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.alpn}
|
||||
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
|
||||
/>
|
||||
<InputAddon>Path</InputAddon>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={record.path}
|
||||
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
|
||||
/>
|
||||
<InputAddon>Dest</InputAddon>
|
||||
<Input
|
||||
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
|
||||
value={record.dest}
|
||||
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
|
||||
/>
|
||||
<InputAddon>xver</InputAddon>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={2}
|
||||
value={record.xver}
|
||||
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
))}
|
||||
<Space>
|
||||
<Button size="small" onClick={addFallback}>
|
||||
<PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={addAllFallbacks}
|
||||
disabled={fallbackChildOptions.length === 0
|
||||
|| fallbacks.length >= fallbackChildOptions.length}
|
||||
title={t('pages.inbounds.form.addAllFallbackTooltip')}
|
||||
>
|
||||
{t('pages.inbounds.form.addAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ export default function InboundFormModal({
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
generateRandomPinHash,
|
||||
pinFromCert,
|
||||
pinFromRemote,
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
onSecurityChange,
|
||||
@@ -854,7 +855,8 @@ export default function InboundFormModal({
|
||||
saving={saving}
|
||||
setCertFromPanel={setCertFromPanel}
|
||||
clearCertFiles={clearCertFiles}
|
||||
generateRandomPinHash={generateRandomPinHash}
|
||||
pinFromCert={pinFromCert}
|
||||
pinFromRemote={pinFromRemote}
|
||||
getNewEchCert={getNewEchCert}
|
||||
clearEchCert={clearEchCert}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
@@ -153,6 +153,54 @@ export default function RealityForm({
|
||||
<Button danger onClick={clearMldsa65}>{t('clear')}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'masterKeyLog']}
|
||||
label={t('pages.inbounds.form.masterKeyLog')}
|
||||
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
|
||||
>
|
||||
<Input placeholder="/path/to/sslkeylog.txt" />
|
||||
</Form.Item>
|
||||
<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>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
|
||||
label={t('pages.inbounds.form.afterBytes')}
|
||||
tooltip={t('pages.inbounds.form.afterBytesTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
|
||||
label={t('pages.inbounds.form.bytesPerSec')}
|
||||
tooltip={t('pages.inbounds.form.bytesPerSecTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
|
||||
label={t('pages.inbounds.form.burstBytesPerSec')}
|
||||
tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
ALPN_OPTION,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TLS_CIPHER_OPTION,
|
||||
TLS_VERSION_OPTION,
|
||||
USAGE_OPTION,
|
||||
UTLS_FINGERPRINT,
|
||||
} from '@/schemas/primitives';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -16,7 +18,8 @@ interface TlsFormProps {
|
||||
saving: boolean;
|
||||
setCertFromPanel: (certName: number) => void;
|
||||
clearCertFiles: (certName: number) => void;
|
||||
generateRandomPinHash: () => void;
|
||||
pinFromCert: () => void;
|
||||
pinFromRemote: () => void;
|
||||
getNewEchCert: () => void;
|
||||
clearEchCert: () => void;
|
||||
}
|
||||
@@ -25,7 +28,8 @@ export default function TlsForm({
|
||||
saving,
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
generateRandomPinHash,
|
||||
pinFromCert,
|
||||
pinFromRemote,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
}: TlsFormProps) {
|
||||
@@ -78,6 +82,21 @@ export default function TlsForm({
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'curvePreferences']}
|
||||
label={t('pages.inbounds.form.curvePreferences')}
|
||||
tooltip={t('pages.inbounds.form.curvePreferencesTip')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
style={{ width: '100%' }}
|
||||
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
|
||||
value: c,
|
||||
label: c,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
|
||||
label={t('pages.inbounds.form.rejectUnknownSni')}
|
||||
@@ -270,7 +289,71 @@ export default function TlsForm({
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'masterKeyLog']}
|
||||
label={t('pages.inbounds.form.masterKeyLog')}
|
||||
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
|
||||
>
|
||||
<Input placeholder="/path/to/sslkeylog.txt" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
!!(prev.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
|
||||
!== !!(curr.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
|
||||
}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const on = !!getFieldValue(['streamSettings', 'tlsSettings', 'echSockopt']);
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
|
||||
<Switch
|
||||
checked={on}
|
||||
onChange={(v) =>
|
||||
setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'echSockopt'],
|
||||
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
@@ -280,6 +363,14 @@ export default function TlsForm({
|
||||
>
|
||||
<Input />
|
||||
</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
|
||||
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
|
||||
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
|
||||
@@ -293,23 +384,29 @@ export default function TlsForm({
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
style={{ width: 'calc(100% - 64px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={generateRandomPinHash}
|
||||
title={t('pages.inbounds.form.generateRandomPin')}
|
||||
icon={<FileProtectOutlined />}
|
||||
onClick={pinFromCert}
|
||||
loading={saving}
|
||||
title={t('pages.inbounds.form.pinFromCert')}
|
||||
/>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={pinFromRemote}
|
||||
loading={saving}
|
||||
title={t('pages.inbounds.form.pinFromRemote')}
|
||||
/>
|
||||
</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
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
|
||||
label={t('pages.inbounds.form.verifyPeerCertByName')}
|
||||
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
|
||||
>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -100,17 +100,66 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
|
||||
};
|
||||
|
||||
const generateRandomPinHash = () => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const current = (form.getFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
) as string[] | undefined) ?? [];
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
[...current, hash],
|
||||
);
|
||||
// Fill the pinned-cert field from the inbound's own certificate: read the
|
||||
// first configured cert (file path or inline content) and ask the server for
|
||||
// its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
|
||||
const pinFromCert = async () => {
|
||||
const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{
|
||||
certificateFile?: string;
|
||||
certificate?: string[];
|
||||
}>;
|
||||
const first = certs[0];
|
||||
const certFile = first?.certificateFile?.trim() ?? '';
|
||||
const certContent = Array.isArray(first?.certificate) ? first.certificate.join('\n').trim() : '';
|
||||
if (!certFile && !certContent) {
|
||||
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/getCertHash', { certFile, certContent });
|
||||
if (!msg?.success) {
|
||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||
return;
|
||||
}
|
||||
const hashes = (msg.obj as string[] | undefined) ?? [];
|
||||
if (hashes.length === 0) return;
|
||||
const current = (form.getFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
) as string[] | undefined) ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hashes]));
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fill the pinned-cert field by pinging the configured SNI: fetches the live
|
||||
// remote certificate hash via `xray tls ping`. Useful when the panel doesn't
|
||||
// hold the cert file (a CDN front / external endpoint).
|
||||
const pinFromRemote = async () => {
|
||||
const server = ((form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']) as string | undefined) ?? '').trim();
|
||||
if (!server) {
|
||||
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server });
|
||||
if (!msg?.success) {
|
||||
messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
|
||||
return;
|
||||
}
|
||||
const hashes = (msg.obj as string[] | undefined) ?? [];
|
||||
if (hashes.length === 0) return;
|
||||
const current = (form.getFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
) as string[] | undefined) ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hashes]));
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setCertFromPanel = async (certName: number) => {
|
||||
@@ -194,7 +243,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
generateRandomPinHash,
|
||||
pinFromCert,
|
||||
pinFromRemote,
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
onSecurityChange,
|
||||
|
||||
Reference in New Issue
Block a user