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
@@ -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',
);
});
});