mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user