mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +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:
@@ -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