feat(tls): add a dynamic DoH helper for echConfigList, with CDN guidance (#6869)

echConfigList already accepted Xray's dynamic form, target domain + DoH URL,
but nothing in the inbound or host forms explained or built it. A helper now
builds that value from presets or a custom domain and resolver, accepts only the
schemes Xray dials (https, h2c, udp; scheme case normalised), warns that a key
generated locally does not work behind a CDN, and asks before overwriting local
ECH keys. The helper's controls carry accessible labels, and the resolver list
holds only identified public resolvers.
This commit is contained in:
55575455
2026-10-24 12:07:11 +08:00
committed by GitHub
parent 41bafb3fd5
commit f43645656c
21 changed files with 1380 additions and 53 deletions
+41 -3
View File
@@ -1,6 +1,17 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import {
Alert,
Form,
Input,
InputNumber,
Modal,
Select,
Switch,
Tabs,
Typography,
message,
} from 'antd';
import {
ProfileOutlined,
SafetyCertificateOutlined,
@@ -18,6 +29,7 @@ import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { CipherSuitesSelect } from '@/components/form';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -94,6 +106,8 @@ export default function HostFormModal({
const [loading, setLoading] = useState(false);
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const hostEchConfig = useWatch({ control: methods.control, name: 'echConfigList' }) ?? '';
const hostSni = useWatch({ control: methods.control, name: 'sni' }) ?? '';
const showTls = security === 'tls' || security === 'reality' || security === 'same';
const showTlsExtras = security === 'tls' || security === 'same';
const watchedHosts = useWatch({ control: methods.control, name: 'hosts' });
@@ -372,10 +386,34 @@ export default function HostFormModal({
</FormField>
<FormField
name="echConfigList"
label={t('pages.hosts.fields.echConfigList')}
label={t('pages.inbounds.form.echConfigList')}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('pages.inbounds.form.echConfigListHint')}
</Typography.Text>
}
>
<Input.TextArea rows={2} />
<Input.TextArea
rows={2}
placeholder={t('pages.inbounds.form.echDynamicPlaceholder')}
/>
</FormField>
<Form.Item label=" " style={{ marginBottom: 8 }}>
<DynamicEchHelper
value={hostEchConfig}
sni={hostSni}
onApply={(next) =>
methods.setValue('echConfigList', next, { shouldDirty: true })
}
/>
</Form.Item>
<Alert
type="info"
showIcon
data-testid="ech-cdn-warning"
title={t('pages.inbounds.form.echCdnWarningTitle')}
description={t('pages.inbounds.form.echCdnWarningBody')}
/>
</>
)}
</>
@@ -1,6 +1,17 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import {
Alert,
Button,
Form,
Input,
InputNumber,
Radio,
Select,
Space,
Switch,
Typography,
} from 'antd';
import {
CloudDownloadOutlined,
FileProtectOutlined,
@@ -10,6 +21,7 @@ import {
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { CipherSuitesSelect } from '@/components/form';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { FormField } from '@/components/form/rhf';
import {
ALPN_OPTION,
@@ -224,6 +236,79 @@ function EchSockoptSection() {
);
}
interface EchKeySectionProps {
saving: boolean;
getNewEchCert: () => void;
clearEchCert: () => void;
}
function EchKeySection({ saving, getNewEchCert, clearEchCert }: EchKeySectionProps) {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const localKeys = useWatch({ control, name: 'streamSettings.tlsSettings.echServerKeys' });
const echConfig = useWatch({
control,
name: 'streamSettings.tlsSettings.settings.echConfigList',
});
const serverName: string =
useWatch({ control, name: 'streamSettings.tlsSettings.serverName' }) ?? '';
const helper = (
<DynamicEchHelper
value={echConfig ?? ''}
sni={serverName}
hasLocalKeys={!!localKeys}
onApply={(next) =>
setValue('streamSettings.tlsSettings.settings.echConfigList', next, {
shouldDirty: true,
})
}
onClearLocalKeys={() => setValue('streamSettings.tlsSettings.echServerKeys', '')}
/>
);
return (
<>
<FormField
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label={t('pages.inbounds.form.echConfigList')}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('pages.inbounds.form.echConfigListHint')}
</Typography.Text>
}
>
<Input placeholder={t('pages.inbounds.form.echDynamicPlaceholder')} />
</FormField>
<Form.Item label=" " style={{ marginBottom: 8 }}>
{helper}
</Form.Item>
<FormField
name={['streamSettings', 'tlsSettings', 'echServerKeys']}
label={t('pages.inbounds.form.echKey')}
>
<Input placeholder={t('pages.inbounds.form.echKeyHint')} />
</FormField>
<Alert
type="info"
showIcon
data-testid="ech-cdn-warning"
style={{ marginBottom: 12 }}
title={t('pages.inbounds.form.echCdnWarningTitle')}
description={t('pages.inbounds.form.echCdnWarningBody')}
/>
<Form.Item label=" ">
<Space wrap>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>
{t('clear')}
</Button>
</Space>
</Form.Item>
</>
);
}
export default function TlsForm({
saving,
setCertFromPanel,
@@ -363,28 +448,7 @@ export default function TlsForm({
<Input placeholder="/path/to/sslkeylog.txt" />
</FormField>
<EchSockoptSection />
<FormField
name={['streamSettings', 'tlsSettings', 'echServerKeys']}
label={t('pages.inbounds.form.echKey')}
>
<Input />
</FormField>
<FormField
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
label={t('pages.inbounds.form.echConfig')}
>
<Input />
</FormField>
<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>
<EchKeySection saving={saving} getNewEchCert={getNewEchCert} clearEchCert={clearEchCert} />
<Form.Item
htmlFor={`${uid}-pinnedPeerCertSha256`}
label={t('pages.inbounds.form.pinnedPeerCertSha256')}