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
@@ -0,0 +1,274 @@
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Divider, Form, Input, Select, Space, Typography } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import {
DEFAULT_DOH_ENDPOINT,
DEFAULT_ECH_DOMAIN,
DEFAULT_ECH_PAIR,
dohEndpointOptions,
echDomainOptions,
formatDynamicEch,
isSupportedDohUrl,
pairPresetOptions,
parseDynamicEch,
} from '@/lib/xray/ech-presets';
interface DynamicEchHelperProps {
/** Current echConfigList, so an existing dynamic pair can be recovered. */
value?: string;
/** Node SNI, used by the "resolve from the proxied hostname" target. */
sni?: string;
/** True when a locally generated ECH keypair is present. */
hasLocalKeys?: boolean;
onApply?: (value: string) => void;
/** Called when the operator accepts dropping CDN-invalid local keys. */
onClearLocalKeys?: () => void;
disabled?: boolean;
}
const CUSTOM = '__custom__';
const AUTO = '__auto__';
const ADVANCED = '__advanced__';
/* Only echConfigList can hold the dynamic `domain+DoH` form; clearing local keys
* is a separate, explicitly confirmed step because behind a CDN they are dead. */
export default function DynamicEchHelper({
value = '',
sni = '',
hasLocalKeys = false,
onApply,
onClearLocalKeys,
disabled = false,
}: DynamicEchHelperProps) {
const { t } = useTranslation();
const uid = useId();
const parsed = parseDynamicEch(value);
const presetValues = new Set(
pairPresetOptions(t).flatMap((group) => group.options.map((o) => o.value)),
);
const [preset, setPreset] = useState<string>(() => {
const current = value.trim();
if (presetValues.has(current)) return current;
if (!current || !parsed) return DEFAULT_ECH_PAIR;
return ADVANCED;
});
const [domainChoice, setDomainChoice] = useState<string>(
() => parsed?.domain ?? DEFAULT_ECH_DOMAIN,
);
const [domainInput, setDomainInput] = useState<string>(() => parsed?.domain ?? '');
const [dohChoice, setDohChoice] = useState<string>(() => parsed?.dohUrl ?? DEFAULT_DOH_ENDPOINT);
const [dohInput, setDohInput] = useState<string>(parsed?.dohUrl ?? '');
const [preview, setPreview] = useState<string | null>(null);
const domain =
domainChoice === CUSTOM ? domainInput : domainChoice === AUTO ? sni.trim() : domainChoice;
const doh = dohChoice === CUSTOM ? dohInput : dohChoice;
const advancedBuilt = formatDynamicEch(domain, doh);
const built = preset === ADVANCED ? advancedBuilt : preset;
const apply = () => {
if (!built) return;
if (hasLocalKeys) setPreview(built);
else onApply?.(built);
};
const pickPreset = (next: string) => {
setPreset(next);
setPreview(null);
};
const pickDomain = (next: string) => {
setDomainChoice(next);
setPreview(null);
};
const pickDoh = (next: string) => {
setDohChoice(next);
setPreview(null);
};
const confirmOverwrite = () => {
if (!built) return;
onApply?.(built);
onClearLocalKeys?.();
setPreview(null);
};
return (
<div data-testid="ech-dynamic-helper" style={{ maxWidth: 640 }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, marginBottom: 4 }}
>
<ThunderboltOutlined aria-hidden />
{t('pages.inbounds.form.echDynamicTitle')}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicIntro')}
</Typography.Paragraph>
{value.trim() !== '' && !parsed && (
<Typography.Paragraph
type="warning"
data-testid="ech-existing-not-dynamic"
style={{ fontSize: 12, marginBottom: 8 }}
>
{t('pages.inbounds.form.echExistingNotDynamic')}
</Typography.Paragraph>
)}
{/* component={false}: rendered inside the inbound/host <form>, and a nested
<form> element is invalid HTML. */}
<Form layout="vertical" size="small" colon={false} component={false}>
<Form.Item htmlFor={`${uid}-preset`} label={t('pages.inbounds.form.echPresetLabel')}>
<Select
id={`${uid}-preset`}
value={preset}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
data-testid="ech-preset-select"
onChange={pickPreset}
options={[
...pairPresetOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: ADVANCED, label: t('pages.inbounds.form.echPresetAdvanced') }],
},
]}
/>
</Form.Item>
{preset === ADVANCED && (
<>
<Form.Item htmlFor={`${uid}-domain`} label={t('pages.inbounds.form.echDynamicDomain')}>
<Select
id={`${uid}-domain`}
value={domainChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDomain}
options={[
{ value: AUTO, label: t('pages.inbounds.form.echDomainAuto') },
...echDomainOptions(t),
{ value: CUSTOM, label: t('pages.inbounds.form.addCustomOption') },
]}
/>
</Form.Item>
{domainChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDomain')}
value={domainInput}
disabled={disabled}
placeholder={DEFAULT_ECH_DOMAIN}
onChange={(e) => {
setDomainInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
<Form.Item htmlFor={`${uid}-doh`} label={t('pages.inbounds.form.echDynamicDoh')}>
<Select
id={`${uid}-doh`}
value={dohChoice}
disabled={disabled}
showSearch={{ optionFilterProp: 'label' }}
onChange={pickDoh}
options={[
...dohEndpointOptions(t),
{
label: t('pages.inbounds.form.addCustomOption'),
options: [{ value: CUSTOM, label: t('pages.inbounds.form.echDynamicCustom') }],
},
]}
/>
</Form.Item>
{dohChoice === CUSTOM && (
<Form.Item>
<Input
aria-label={t('pages.inbounds.form.echDynamicDoh')}
value={dohInput}
disabled={disabled}
placeholder={DEFAULT_DOH_ENDPOINT}
status={dohInput && !isSupportedDohUrl(dohInput) ? 'error' : undefined}
onChange={(e) => {
setDohInput(e.target.value);
setPreview(null);
}}
/>
</Form.Item>
)}
{dohChoice === CUSTOM && dohInput && !isSupportedDohUrl(dohInput) && (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicUnsupportedScheme')}
</Typography.Paragraph>
)}
</>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echMainlandAdvisory')}
</Typography.Paragraph>
</Form>
{built ? (
<Typography.Paragraph
code
copyable={{ text: built }}
data-testid="ech-dynamic-preview"
style={{ fontSize: 12, marginBottom: 8, wordBreak: 'break-all' }}
>
{built}
</Typography.Paragraph>
) : (
<Typography.Paragraph type="danger" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.inbounds.form.echDynamicInvalid')}
</Typography.Paragraph>
)}
<Button
type="primary"
size="small"
icon={<ThunderboltOutlined aria-hidden />}
disabled={disabled || !built}
data-testid="ech-dynamic-apply"
onClick={apply}
>
{t('pages.inbounds.form.echDynamicApply')}
</Button>
{preview && (
<>
<Divider style={{ margin: '12px 0' }} />
<Alert
type="warning"
showIcon
data-testid="ech-dynamic-overwrite-warning"
title={t('pages.inbounds.form.echDynamicOverwriteTitle')}
description={
<>
<div>{t('pages.inbounds.form.echDynamicOverwriteBody')}</div>
<div data-testid="ech-dynamic-overwrite-value" style={{ marginTop: 4 }}>
<Typography.Text code>{preview}</Typography.Text>
</div>
</>
}
action={
<Space orientation="vertical">
<Button size="small" type="primary" onClick={confirmOverwrite}>
{t('pages.inbounds.form.echDynamicOverwriteConfirm')}
</Button>
<Button size="small" onClick={() => setPreview(null)}>
{t('cancel')}
</Button>
</Space>
}
/>
</>
)}
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import type { TFunction } from 'i18next';
/** Xray splits `echConfigList` on the first '+' into ECH target domain and DoH URL. */
export const ECH_DOH_SEPARATOR = '+';
export const DEFAULT_ECH_DOMAIN = 'cloudflare-ech.com';
export const DEFAULT_DOH_ENDPOINT = 'https://dns.alidns.com/dns-query';
interface DohEndpoint {
value: string;
brand: string;
recommended?: boolean;
}
interface DohGroup {
key: 'china' | 'global' | 'europe' | 'udp';
endpoints: readonly DohEndpoint[];
}
/* A resolver only works here if it publishes HTTPS RRs (carrying `ech=`). The
* udp:// entries are plain DNS; Xray dials them the same way, by prefix. */
export const DOH_ENDPOINT_GROUPS: readonly DohGroup[] = [
{
key: 'china',
endpoints: [
{ value: 'https://dns.alidns.com/dns-query', brand: 'AliDNS', recommended: true },
{ value: 'https://sm2.doh.pub/dns-query', brand: 'DNSPod SM2' },
{ value: 'https://doh.360.cn/dns-query', brand: '360' },
{ value: 'https://doh.onedns.net/dns-query', brand: 'OneDNS' },
],
},
{
key: 'global',
endpoints: [
{ value: 'https://cloudflare-dns.com/dns-query', brand: 'Cloudflare' },
{ value: 'https://1.1.1.1/dns-query', brand: 'Cloudflare IP' },
{ value: 'https://dns.google/dns-query', brand: 'Google' },
{ value: 'https://dns.quad9.net/dns-query', brand: 'Quad9' },
],
},
{
key: 'europe',
endpoints: [
{ value: 'https://doh.applied-privacy.net/query', brand: 'Applied Privacy' },
{ value: 'https://odvr.nic.cz/doh', brand: 'CZ.NIC' },
{ value: 'https://dnsforge.de/dns-query', brand: 'DNSforge' },
{ value: 'https://doh.dns.sb/dns-query', brand: 'DNS.SB' },
],
},
{
key: 'udp',
endpoints: [
{ value: 'udp://208.67.220.220:443', brand: 'OpenDNS' },
{ value: 'udp://149.112.112.112:9953', brand: 'Quad9' },
{ value: 'udp://45.90.28.0:5353', brand: 'NextDNS' },
],
},
] as const;
interface EchDomainPreset {
value: string;
brand: string;
recommended?: boolean;
}
/* Targets that publish HTTPS RRs, plus the brand the operator recognises. */
export const ECH_DOMAIN_PRESETS: readonly EchDomainPreset[] = [
{ value: 'cloudflare-ech.com', brand: 'Cloudflare ECH', recommended: true },
{ value: 'crypto.cloudflare.com', brand: 'Cloudflare Crypto' },
{ value: 'encryptedsni.com', brand: 'Cloudflare Encrypted SNI' },
{ value: 'icook.hk', brand: 'iCook HK' },
{ value: 'cm.edu.kg', brand: 'CM University' },
{ value: 'godotengine.org', brand: 'Godot Engine' },
{ value: 'www.britannica.com', brand: 'Britannica' },
{ value: 'www.prometheus.io', brand: 'Prometheus' },
{ value: 'www.kyocera.com', brand: 'Kyocera' },
{ value: 'celestia.org', brand: 'Celestia' },
{ value: 'lido.fi', brand: 'Lido' },
] as const;
const DOMAIN_RE = /^([a-z0-9-]+\.)+[a-z]{2,}$/i;
/* Xray dials the DoH half by case-sensitive prefix match: https:// and h2c://
* over HTTP, udp:// plain DNS. Anything else never resolves. */
export const SUPPORTED_DOH_SCHEMES = ['https', 'h2c', 'udp'] as const;
function normalizeDohUrl(raw: string): string | null {
const url = raw.trim();
if (!url || /\s/.test(url)) return null;
const match = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url);
if (!match) return null;
const scheme = match[1].toLowerCase();
if (!(SUPPORTED_DOH_SCHEMES as readonly string[]).includes(scheme)) return null;
try {
if (!new URL(url).hostname) return null;
} catch {
return null;
}
// Lower-case the scheme only: Xray's prefix match rejects "HTTPS://" otherwise.
return `${scheme}://${url.slice(match[0].length)}`;
}
export function formatDynamicEch(domain: string, dohUrl: string): string | null {
const d = domain.trim();
const u = normalizeDohUrl(dohUrl);
if (!DOMAIN_RE.test(d) || d.includes(ECH_DOH_SEPARATOR)) return null;
if (!u) return null;
return `${d}${ECH_DOH_SEPARATOR}${u}`;
}
export function parseDynamicEch(value: string): { domain: string; dohUrl: string } | null {
const raw = value.trim();
const split = raw.indexOf(ECH_DOH_SEPARATOR);
if (split <= 0) return null;
const domain = raw.slice(0, split).trim();
const dohUrl = normalizeDohUrl(raw.slice(split + 1));
if (!DOMAIN_RE.test(domain) || !dohUrl) return null;
return { domain, dohUrl };
}
export function isSupportedDohUrl(url: string): boolean {
return normalizeDohUrl(url) !== null;
}
interface PresetOption {
value: string;
label: string;
}
export function dohEndpointOptions(t: TFunction): { label: string; options: PresetOption[] }[] {
const groupLabels: Record<(typeof DOH_ENDPOINT_GROUPS)[number]['key'], string> = {
china: t('pages.inbounds.form.echDohGroup.china'),
global: t('pages.inbounds.form.echDohGroup.global'),
europe: t('pages.inbounds.form.echDohGroup.europe'),
udp: t('pages.inbounds.form.echDohGroup.udp'),
};
const recommended = t('pages.inbounds.form.echRecommended');
return DOH_ENDPOINT_GROUPS.map((group) => ({
label: groupLabels[group.key],
options: group.endpoints.map((e) => ({
value: e.value,
label:
'recommended' in e && e.recommended
? `${e.brand} — ${recommended} · ${e.value}`
: `${e.brand} · ${e.value}`,
})),
}));
}
export function echDomainOptions(t: TFunction): PresetOption[] {
const recommended = t('pages.inbounds.form.echRecommended');
return ECH_DOMAIN_PRESETS.map((d) => ({
value: d.value,
label:
'recommended' in d && d.recommended
? `${d.value} — ${recommended} · ${d.brand}`
: `${d.value} · ${d.brand}`,
}));
}
export const DEFAULT_ECH_PAIR = `${DEFAULT_ECH_DOMAIN}${ECH_DOH_SEPARATOR}${DEFAULT_DOH_ENDPOINT}`;
/** Ready-made pairs for the single preset dropdown, grouped by target front. */
export function pairPresetOptions(t: TFunction): { label: string; options: PresetOption[] }[] {
const recommended = t('pages.inbounds.form.echRecommended');
const cloudflare = DOH_ENDPOINT_GROUPS.flatMap((g) => g.endpoints).map((e) => ({
value: `${DEFAULT_ECH_DOMAIN}${ECH_DOH_SEPARATOR}${e.value}`,
label:
'recommended' in e && e.recommended
? `${e.brand} · ${e.value} — ${recommended}`
: `${e.brand} · ${e.value}`,
}));
const others = ECH_DOMAIN_PRESETS.filter((d) => d.value !== DEFAULT_ECH_DOMAIN).map((d) => ({
value: `${d.value}${ECH_DOH_SEPARATOR}${DEFAULT_DOH_ENDPOINT}`,
label: `${d.value} · ${d.brand}`,
}));
return [
{ label: t('pages.inbounds.form.echDynamicTitle'), options: cloudflare },
{ label: t('pages.inbounds.form.echPresetOtherDomains'), options: others },
];
}
+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')}
@@ -34,8 +34,9 @@ exports[`inbound security forms > TlsForm field structure is stable 1`] = `
"Digital Certificate",
"Master Key Log",
"ECH Sockopt",
"ECH config list",
"Preset combination",
"ECH key",
"ECH config",
"Pinned Peer Cert SHA-256",
"Verify Peer Cert By Name",
]
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, within } from '@testing-library/react';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { renderWithProviders } from './test-utils';
function combos() {
const helper = screen.getByTestId('ech-dynamic-helper');
return within(helper).getAllByRole('combobox') as HTMLElement[];
}
/** antd virtualises long option lists, so filter with the search box first. */
function chooseOption(combobox: HTMLElement, search: string, match: string) {
fireEvent.mouseDown(combobox);
fireEvent.change(combobox, { target: { value: search } });
const option = Array.from(
document.querySelectorAll(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((o) => (o.getAttribute('title') ?? o.textContent ?? '').includes(match));
if (!option) throw new Error(`option not found: ${match}`);
fireEvent.click(option);
}
describe('DynamicEchHelper', () => {
it('defaults to the Cloudflare ECH front with AliDNS', () => {
// Behind Cloudflare the ECH record belongs to Cloudflare, not to the proxied
// hostname, so the preset must not depend on the inbound SNI.
const onApply = vi.fn();
renderWithProviders(<DynamicEchHelper onApply={onApply} />);
expect(screen.getByTestId('ech-dynamic-preview').textContent).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
);
fireEvent.click(screen.getByTestId('ech-dynamic-apply'));
expect(onApply).toHaveBeenCalledWith('cloudflare-ech.com+https://dns.alidns.com/dns-query');
});
it('writes the pair chosen from the preset dropdown', () => {
const onApply = vi.fn();
renderWithProviders(<DynamicEchHelper onApply={onApply} />);
chooseOption(combos()[0], 'dns.google', 'https://dns.google/dns-query');
fireEvent.click(screen.getByTestId('ech-dynamic-apply'));
expect(onApply).toHaveBeenCalledWith('cloudflare-ech.com+https://dns.google/dns-query');
});
it('offers the udp:// resolvers from the issue list', () => {
const onApply = vi.fn();
renderWithProviders(<DynamicEchHelper onApply={onApply} />);
chooseOption(combos()[0], 'OpenDNS', 'udp://208.67.220.220:443');
fireEvent.click(screen.getByTestId('ech-dynamic-apply'));
expect(onApply).toHaveBeenCalledWith('cloudflare-ech.com+udp://208.67.220.220:443');
});
it('normalises an upper-case scheme typed into the custom builder', () => {
// Xray prefix-matches case-sensitively, so "HTTPS://" must ship lower-cased.
const onApply = vi.fn();
renderWithProviders(<DynamicEchHelper onApply={onApply} />);
chooseOption(combos()[0], 'Custom combination', 'Custom combination');
const dohSelect = combos()[2];
chooseOption(dohSelect, 'Custom URL', 'Custom URL');
const customInput = screen.getAllByRole('textbox').at(-1) as HTMLInputElement;
fireEvent.change(customInput, { target: { value: 'HTTPS://dns.google/dns-query' } });
fireEvent.click(screen.getByTestId('ech-dynamic-apply'));
expect(onApply).toHaveBeenCalledWith('cloudflare-ech.com+https://dns.google/dns-query');
});
it('refuses a custom resolver URL Xray cannot dial', () => {
renderWithProviders(<DynamicEchHelper onApply={vi.fn()} />);
chooseOption(combos()[0], 'Custom combination', 'Custom combination');
const dohSelect = combos()[2];
chooseOption(dohSelect, 'Custom URL', 'Custom URL');
const customInput = screen.getAllByRole('textbox').at(-1) as HTMLInputElement;
fireEvent.change(customInput, { target: { value: 'tls://1.1.1.1/dns-query' } });
expect(screen.getByText(/https:\/\/, h2c:\/\/ or udp:\/\//)).toBeTruthy();
expect((screen.getByTestId('ech-dynamic-apply') as HTMLButtonElement).disabled).toBe(true);
});
it('warns before replacing a value that is not in the dynamic form', () => {
// A bare DoH URL or a local base64 list is valid for Xray; overwriting it
// silently would destroy a working config.
renderWithProviders(<DynamicEchHelper onApply={vi.fn()} value="AEX+DQBB" />);
expect(screen.getByTestId('ech-existing-not-dynamic')).toBeTruthy();
});
it('shows no such warning when the current value is already dynamic', () => {
renderWithProviders(
<DynamicEchHelper
onApply={vi.fn()}
value="cloudflare-ech.com+https://dns.google/dns-query"
/>,
);
expect(screen.queryByTestId('ech-existing-not-dynamic')).toBeNull();
expect(screen.getByTestId('ech-dynamic-preview').textContent).toBe(
'cloudflare-ech.com+https://dns.google/dns-query',
);
});
it('asks for confirmation before replacing an existing local ECH keypair', () => {
const onApply = vi.fn();
const onClearLocalKeys = vi.fn();
renderWithProviders(
<DynamicEchHelper hasLocalKeys onApply={onApply} onClearLocalKeys={onClearLocalKeys} />,
);
fireEvent.click(screen.getByTestId('ech-dynamic-apply'));
expect(screen.getByTestId('ech-dynamic-overwrite-warning')).toBeTruthy();
expect(onApply).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Replace and clear local key'));
expect(onApply).toHaveBeenCalledWith('cloudflare-ech.com+https://dns.alidns.com/dns-query');
expect(onClearLocalKeys).toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { Form } from 'antd';
import type { ReactNode } from 'react';
import { FormProvider, useForm, useFormContext, useWatch } from 'react-hook-form';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { TlsForm } from '@/pages/inbounds/form/security';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { renderWithProviders } from './test-utils';
function EchProbe() {
const { control } = useFormContext();
const ech = useWatch({ control, name: 'streamSettings.tlsSettings.settings.echConfigList' });
const keys = useWatch({ control, name: 'streamSettings.tlsSettings.echServerKeys' });
return (
<>
<output data-testid="probe-ech">{typeof ech === 'string' ? ech : ''}</output>
<output data-testid="probe-keys">{typeof keys === 'string' ? keys : ''}</output>
</>
);
}
function FormHarness({
children,
defaultValues = {},
}: {
children: ReactNode;
defaultValues?: Record<string, unknown>;
}) {
const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
return (
<FormProvider {...methods}>
<Form>
<EchProbe />
{children}
</Form>
</FormProvider>
);
}
const noop = () => {};
function renderTls(defaultValues?: Record<string, unknown>) {
return renderWithProviders(
<FormHarness defaultValues={defaultValues}>
<TlsForm
saving={false}
setCertFromPanel={noop}
clearCertFiles={noop}
pinFromCert={noop}
pinFromRemote={noop}
getNewEchCert={noop}
clearEchCert={noop}
/>
</FormHarness>,
);
}
/** The generator is inline now, so it is present as soon as the TLS form renders. */
async function openHelper() {
return screen.findByTestId('ech-dynamic-helper');
}
describe('inbound TLS dynamic ECH helper', () => {
it('applies the default Cloudflare + AliDNS value into echConfigList', async () => {
renderTls();
const helper = await openHelper();
expect(within(helper).getByTestId('ech-dynamic-preview').textContent).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
);
fireEvent.click(within(helper).getByTestId('ech-dynamic-apply'));
await waitFor(() =>
expect(screen.getByTestId('probe-ech').textContent).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
),
);
});
it('asks for confirmation before replacing an existing local ECH keypair', async () => {
// The CDN-invalid local key is exactly what this feature exists to prevent
// shipping, so overwriting it silently would hide the mistake.
renderTls({
streamSettings: {
tlsSettings: {
echServerKeys: 'LOCAL-PRIVATE-KEY',
settings: { echConfigList: '' },
},
},
});
const helper = await openHelper();
fireEvent.click(within(helper).getByTestId('ech-dynamic-apply'));
expect(await within(helper).findByTestId('ech-dynamic-overwrite-warning')).toBeTruthy();
expect(screen.getByTestId('probe-ech').textContent).toBe('');
expect(screen.getByTestId('probe-keys').textContent).toBe('LOCAL-PRIVATE-KEY');
});
it('clears the dead local key once the operator confirms', async () => {
renderTls({
streamSettings: {
tlsSettings: {
echServerKeys: 'LOCAL-PRIVATE-KEY',
settings: { echConfigList: '' },
},
},
});
const helper = await openHelper();
fireEvent.click(within(helper).getByTestId('ech-dynamic-apply'));
fireEvent.click(await within(helper).findByText('Replace and clear local key'));
await waitFor(() =>
expect(screen.getByTestId('probe-ech').textContent).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
),
);
expect(screen.getByTestId('probe-keys').textContent).toBe('');
});
it('recovers an existing dynamic value instead of starting from defaults', async () => {
renderTls({
streamSettings: {
tlsSettings: {
settings: { echConfigList: 'crypto.cloudflare.com+https://dns.google/dns-query' },
},
},
});
const helper = await openHelper();
expect(within(helper).getByTestId('ech-dynamic-preview').textContent).toBe(
'crypto.cloudflare.com+https://dns.google/dns-query',
);
});
it('warns that local keys do not survive a CDN proxy', () => {
renderTls();
expect(screen.getByTestId('ech-cdn-warning').textContent).toMatch(/Cloudflare CDN/i);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import type { TFunction } from 'i18next';
import {
DEFAULT_DOH_ENDPOINT,
DEFAULT_ECH_DOMAIN,
DEFAULT_ECH_PAIR,
DOH_ENDPOINT_GROUPS,
ECH_DOMAIN_PRESETS,
ECH_DOH_SEPARATOR,
formatDynamicEch,
isSupportedDohUrl,
pairPresetOptions,
parseDynamicEch,
} from '@/lib/xray/ech-presets';
function optionValues(groups: typeof DOH_ENDPOINT_GROUPS): string[] {
return groups.flatMap((g) => g.endpoints.map((o) => o.value));
}
const t = ((key: string) => key) as unknown as TFunction;
describe('dynamic ECH presets', () => {
it('every shipped DoH preset is one Xray can actually resolve', () => {
// A preset with any other scheme passes review but never resolves, because
// Xray matches the scheme by literal prefix before dialing.
const bad = optionValues(DOH_ENDPOINT_GROUPS).filter((v) => !isSupportedDohUrl(v));
expect(bad).toEqual([]);
});
it('every ECH target domain is a bare registrable name, not a URL', () => {
// Xray splits the string on the first '+', so a scheme in the domain half
// would be sent to the DNS resolver verbatim and never resolve.
const bad = ECH_DOMAIN_PRESETS.filter((d) => !/^([a-z0-9-]+\.)+[a-z]{2,}$/.test(d.value));
expect(bad.map((d) => d.value)).toEqual([]);
});
it('every preset dropdown pair round-trips through the Xray parser', () => {
// The dropdown is a single click away from echConfigList; a pair the parser
// rejects here would ship a value every client fails to resolve.
const pairs = pairPresetOptions(t).flatMap((g) => g.options.map((o) => o.value));
expect(pairs.length).toBeGreaterThan(0);
expect(pairs.filter((p) => parseDynamicEch(p) === null)).toEqual([]);
expect(pairs).toContain(DEFAULT_ECH_PAIR);
});
it('offers every resolver under the Cloudflare front, udp ones included', () => {
const [cloudflare] = pairPresetOptions(t);
expect(cloudflare.options).toHaveLength(optionValues(DOH_ENDPOINT_GROUPS).length);
expect(cloudflare.options.some((o) => o.value.includes('udp://'))).toBe(true);
});
it.each(['https', 'h2c', 'udp'])('builds a value for the %s scheme Xray supports', (scheme) => {
const url = `${scheme}://dns.example.com/dns-query`;
expect(formatDynamicEch('cloudflare-ech.com', url)).toBe(`cloudflare-ech.com+${url}`);
});
it.each(['http', 'quic', 'tcp', 'tls', 'doq', 'ftp'])(
'rejects the %s scheme at every entry point',
(scheme) => {
// Accepting these would ship a config that fails at the client, where the
// operator cannot see the panel's advice any more.
const url = `${scheme}://1.1.1.1/dns-query`;
expect(isSupportedDohUrl(url)).toBe(false);
expect(formatDynamicEch('cloudflare-ech.com', url)).toBeNull();
expect(parseDynamicEch(`cloudflare-ech.com+${url}`)).toBeNull();
},
);
it('preselects Cloudflare over AliDNS, the mainland-China reachable pair', () => {
expect(formatDynamicEch(DEFAULT_ECH_DOMAIN, DEFAULT_DOH_ENDPOINT)).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
);
});
it('trims pasted whitespace instead of emitting an unresolvable string', () => {
expect(formatDynamicEch(' cloudflare-ech.com ', ' https://dns.google/dns-query ')).toBe(
'cloudflare-ech.com+https://dns.google/dns-query',
);
});
it('rejects a domain that already embeds a DoH URL', () => {
// Pasting a full existing value into the domain box would otherwise produce
// "...+https://a+https://b", which no client can parse.
expect(
formatDynamicEch(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
'https://dns.google/dns-query',
),
).toBeNull();
});
it('rejects a DoH half that is not a URI', () => {
expect(formatDynamicEch('cloudflare-ech.com', 'dns.google')).toBeNull();
expect(formatDynamicEch('cloudflare-ech.com', '')).toBeNull();
expect(formatDynamicEch('', 'https://dns.google/dns-query')).toBeNull();
});
it('splits an existing dynamic value back into its two halves', () => {
expect(
parseDynamicEch(`cloudflare-ech.com${ECH_DOH_SEPARATOR}https://dns.google/dns-query`),
).toEqual({ domain: 'cloudflare-ech.com', dohUrl: 'https://dns.google/dns-query' });
});
it('refuses to split a value whose DoH half uses an unsupported scheme', () => {
// Otherwise pasting a quic:// value back into the helper would round-trip an
// endpoint Xray can never reach.
expect(parseDynamicEch('cloudflare-ech.com+quic://1.1.1.1/dns-query')).toBeNull();
});
it('does not mistake a local ECHConfigList (base64) for a dynamic value', () => {
// "Get New ECH Cert" writes a base64 blob; the helper must not try to
// reverse-engineer it.
expect(parseDynamicEch('AEX+DQBB')).toBeNull();
expect(parseDynamicEch('')).toBeNull();
});
it('lower-cases a pasted scheme so Xray prefix-matches it', () => {
// Xray compares with strings.HasPrefix(server, "https://") — case-sensitive.
// Passing "HTTPS://" through unchanged ships a config every client fails on.
expect(formatDynamicEch('cloudflare-ech.com', 'HTTPS://dns.google/dns-query')).toBe(
'cloudflare-ech.com+https://dns.google/dns-query',
);
expect(formatDynamicEch('cloudflare-ech.com', 'UDP://1.1.1.1')).toBe(
'cloudflare-ech.com+udp://1.1.1.1',
);
expect(isSupportedDohUrl('H2C://dns.google/dns-query')).toBe(true);
});
it('rejects a resolver URL carrying whitespace Xray would put on the wire', () => {
// The old prefix-only regex accepted these and the junk went into the value.
expect(isSupportedDohUrl('https://dns.google/dns-query extra')).toBe(false);
expect(isSupportedDohUrl('https://dns.google /dns-query')).toBe(false);
expect(formatDynamicEch('cloudflare-ech.com', 'https://dns.google/dns-query extra')).toBeNull();
});
it('rejects a resolver URL with no host', () => {
// new URL("https://?x") parses but carries an empty hostname: Xray would
// dial nothing and fail the handshake.
expect(isSupportedDohUrl('https://?x')).toBe(false);
expect(formatDynamicEch('cloudflare-ech.com', 'https://?x')).toBeNull();
});
it('defaults the target to Cloudflare, not the inbound SNI', () => {
// Behind Cloudflare the ECH record belongs to Cloudflare's ECH front, so the
// helper must not derive it from the proxied hostname.
expect(DEFAULT_ECH_DOMAIN).toBe('cloudflare-ech.com');
expect(formatDynamicEch(DEFAULT_ECH_DOMAIN, DEFAULT_DOH_ENDPOINT)).toBe(
'cloudflare-ech.com+https://dns.alidns.com/dns-query',
);
});
});