mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 06:31:58 +00:00
feat(finalmask): tlshello fragment presets for client editors
The host, JSON subscription and outbound mask editors now offer PattN's two chained tlshello fragment presets; a pick replaces the TCP mask list. The fragment length validator rejected a 0 minimum in every entry, but xray-core only refuses it in the last lengths entry. An earlier 0 is an empty segment, which the tlshello-0-len preset relies on.
This commit is contained in:
3 files changed
+151
-18
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
import type { TcpMask } from '@/schemas/protocols/stream/finalmask';
|
||||||
|
|
||||||
|
// PattN's client presets: one fragment splits the TLS ClientHello into three segments
|
||||||
|
// (the first may be empty), a second splits the next packet with a 1 ms delay.
|
||||||
|
export const TCP_MASK_PRESETS: Readonly<Record<string, TcpMask[]>> = Object.freeze({
|
||||||
|
'tlshello-0-len': [
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: { packets: 'tlshello', lengths: ['0', '104', '1'], delays: ['0'], maxSplit: '0' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: { packets: '1-1', lengths: ['114', '1'], delays: ['1'], maxSplit: '11' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'tlshello-6-98': [
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: { packets: 'tlshello', lengths: ['6', '98', '1'], delays: ['0'], maxSplit: '0' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: { packets: '1-1', lengths: ['114', '1'], delays: ['1'], maxSplit: '11' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import type { NamePath } from 'antd/es/form/interface';
|
|||||||
import { RandomUtil } from '@/utils';
|
import { RandomUtil } from '@/utils';
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
import { activateOnKey } from '@/utils/a11y';
|
||||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { TCP_MASK_PRESETS } from '@/lib/xray/finalmask-presets';
|
||||||
import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask';
|
import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask';
|
||||||
import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask';
|
import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask';
|
||||||
|
|
||||||
@@ -333,7 +334,14 @@ export default function FinalMaskForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showTcp && <TcpMasksList base={base} form={form} allowFilter={side === 'server'} />}
|
{showTcp && (
|
||||||
|
<TcpMasksList
|
||||||
|
base={base}
|
||||||
|
form={form}
|
||||||
|
allowFilter={side === 'server'}
|
||||||
|
allowPresets={side === 'client'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showUdp && (
|
{showUdp && (
|
||||||
<UdpMasksList
|
<UdpMasksList
|
||||||
base={base}
|
base={base}
|
||||||
@@ -373,26 +381,48 @@ function TcpMasksList({
|
|||||||
base,
|
base,
|
||||||
form,
|
form,
|
||||||
allowFilter,
|
allowFilter,
|
||||||
|
allowPresets,
|
||||||
}: {
|
}: {
|
||||||
base: (string | number)[];
|
base: (string | number)[];
|
||||||
form: FormInstance;
|
form: FormInstance;
|
||||||
allowFilter: boolean;
|
allowFilter: boolean;
|
||||||
|
allowPresets: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const uid = useId();
|
||||||
return (
|
return (
|
||||||
<Form.List name={[...base, 'tcp']}>
|
<Form.List name={[...base, 'tcp']}>
|
||||||
{(fields, { add, remove }) => (
|
{(fields, { add, remove }) => (
|
||||||
<>
|
<>
|
||||||
<Form.Item label="TCP Masks">
|
<Form.Item label="TCP Masks">
|
||||||
<Button
|
<Space>
|
||||||
type="primary"
|
<Button
|
||||||
size="small"
|
type="primary"
|
||||||
icon={<PlusOutlined aria-hidden />}
|
size="small"
|
||||||
aria-label={t('add')}
|
icon={<PlusOutlined aria-hidden />}
|
||||||
onClick={() =>
|
aria-label={t('add')}
|
||||||
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
|
onClick={() =>
|
||||||
}
|
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
{allowPresets && (
|
||||||
|
<Select
|
||||||
|
id={`${uid}-tcp-presets`}
|
||||||
|
size="small"
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
aria-label={t('pages.inbounds.form.presets')}
|
||||||
|
placeholder={t('pages.inbounds.form.presets')}
|
||||||
|
value={null}
|
||||||
|
options={Object.keys(TCP_MASK_PRESETS).map((name) => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
}))}
|
||||||
|
onChange={(name: string) =>
|
||||||
|
form.setFieldValue([...base, 'tcp'], structuredClone(TCP_MASK_PRESETS[name]))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{fields.map((field, mIdx) => (
|
{fields.map((field, mIdx) => (
|
||||||
<TcpMaskItem
|
<TcpMaskItem
|
||||||
@@ -641,15 +671,15 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
|
|||||||
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
|
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
// xray refuses only a 0 minimum in the LAST lengths entry; an earlier 0 is an
|
||||||
|
// empty segment, which PattN's tlshello-0-len preset relies on.
|
||||||
|
function validateFragmentLength(_rule: unknown, value: unknown, isLast: boolean): Promise<void> {
|
||||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
if (str.length === 0) {
|
if (str.length === 0) {
|
||||||
return Promise.reject(
|
return Promise.reject(new Error('Length is required — remove the row if you do not need it'));
|
||||||
new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const min = Number(str.split('-')[0]);
|
const min = Number(str.split('-')[0]);
|
||||||
if (!Number.isFinite(min) || min <= 0) {
|
if (!Number.isFinite(min) || min < 0 || (isLast && min === 0)) {
|
||||||
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
|
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -685,7 +715,7 @@ function FragmentRangeList({
|
|||||||
listName: (string | number)[];
|
listName: (string | number)[];
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
validator?: (rule: unknown, value: unknown) => Promise<void>;
|
validator?: (rule: unknown, value: unknown, isLast: boolean) => Promise<void>;
|
||||||
minItems?: number;
|
minItems?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -707,7 +737,11 @@ function FragmentRangeList({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
label={`#${idx + 1}`}
|
label={`#${idx + 1}`}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
rules={validator ? [{ validator }] : undefined}
|
rules={
|
||||||
|
validator
|
||||||
|
? [{ validator: (r, v) => validator(r, v, idx === fields.length - 1) }]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|||||||
|
|
||||||
import { FinalMaskField } from '@/lib/xray/forms/fields';
|
import { FinalMaskField } from '@/lib/xray/forms/fields';
|
||||||
|
|
||||||
import { listSelectOptions, renderWithProviders } from './test-utils';
|
import { chooseSelectOption, listSelectOptions, renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
describe('FinalMaskForm item types', () => {
|
describe('FinalMaskForm item types', () => {
|
||||||
// Only the noise mask parses tag expressions; header-custom items go through the
|
// Only the noise mask parses tag expressions; header-custom items go through the
|
||||||
@@ -67,3 +67,76 @@ describe('FinalMaskForm salamander password', () => {
|
|||||||
await waitFor(() => expect(screen.queryByText(/at least 4 bytes/)).toBeNull());
|
await waitFor(() => expect(screen.queryByText(/at least 4 bytes/)).toBeNull());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FinalMaskForm fragment lengths', () => {
|
||||||
|
// xray-core refuses only a fragment whose LAST lengths entry has a 0 minimum; an
|
||||||
|
// earlier 0 is a zero-byte segment that PattN's tlshello-0-len preset relies on.
|
||||||
|
function renderLengths(lengths: string[]) {
|
||||||
|
renderWithProviders(
|
||||||
|
<FinalMaskField
|
||||||
|
network="tcp"
|
||||||
|
protocol="vless"
|
||||||
|
value={{ tcp: [{ type: 'fragment', settings: { packets: 'tlshello', lengths } }], udp: [] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return (idx: number) =>
|
||||||
|
document.getElementById(`finalmask_tcp_0_settings_lengths_${idx}`) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts 0 in an entry before the last', async () => {
|
||||||
|
const lengthInput = renderLengths(['5', '104', '1']);
|
||||||
|
fireEvent.change(lengthInput(0), { target: { value: '0' } });
|
||||||
|
// The blank last entry's error proves validation ran before the assertion.
|
||||||
|
fireEvent.change(lengthInput(2), { target: { value: '' } });
|
||||||
|
expect(await screen.findByText(/Length is required/)).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/greater than 0/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects 0 in the last entry', async () => {
|
||||||
|
const lengthInput = renderLengths(['0', '104', '1']);
|
||||||
|
fireEvent.change(lengthInput(2), { target: { value: '0' } });
|
||||||
|
expect(await screen.findByText(/greater than 0/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FinalMaskForm TCP mask presets', () => {
|
||||||
|
// PattN's tlshello presets are two chained fragments, so a pick replaces the TCP list.
|
||||||
|
it('replaces the client TCP masks with the picked preset', async () => {
|
||||||
|
const changes: unknown[] = [];
|
||||||
|
renderWithProviders(
|
||||||
|
<FinalMaskField
|
||||||
|
network="tcp"
|
||||||
|
protocol="vless"
|
||||||
|
value={{ tcp: [{ type: 'sudoku', settings: {} }], udp: [] }}
|
||||||
|
onChange={(next) => changes.push(next)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const presets = screen.getByRole('combobox', { name: 'Presets' });
|
||||||
|
chooseSelectOption(presets.id, 'tlshello-0-len');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((changes.at(-1) as { tcp?: unknown } | undefined)?.tcp).toEqual([
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: {
|
||||||
|
packets: 'tlshello',
|
||||||
|
lengths: ['0', '104', '1'],
|
||||||
|
delays: ['0'],
|
||||||
|
maxSplit: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'fragment',
|
||||||
|
settings: { packets: '1-1', lengths: ['114', '1'], delays: ['1'], maxSplit: '11' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers no presets on the server side', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<FinalMaskField network="tcp" protocol="vless" side="server" value={{ tcp: [], udp: [] }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('combobox', { name: 'Presets' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user