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:
MHSanaei
2026-10-25 15:54:53 +01:00
parent f992694712
commit 7f82daa42f
3 changed files with 151 additions and 18 deletions
@@ -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 { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { TCP_MASK_PRESETS } from '@/lib/xray/finalmask-presets';
import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask';
import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask';
@@ -333,7 +334,14 @@ export default function FinalMaskForm({
return (
<>
{showTcp && <TcpMasksList base={base} form={form} allowFilter={side === 'server'} />}
{showTcp && (
<TcpMasksList
base={base}
form={form}
allowFilter={side === 'server'}
allowPresets={side === 'client'}
/>
)}
{showUdp && (
<UdpMasksList
base={base}
@@ -373,26 +381,48 @@ function TcpMasksList({
base,
form,
allowFilter,
allowPresets,
}: {
base: (string | number)[];
form: FormInstance;
allowFilter: boolean;
allowPresets: boolean;
}) {
const { t } = useTranslation();
const uid = useId();
return (
<Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="TCP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() =>
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
}
/>
<Space>
<Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
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>
{fields.map((field, mIdx) => (
<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'));
}
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();
if (str.length === 0) {
return Promise.reject(
new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'),
);
return Promise.reject(new Error('Length is required — remove the row if you do not need it'));
}
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.resolve();
@@ -685,7 +715,7 @@ function FragmentRangeList({
listName: (string | number)[];
label: string;
placeholder: string;
validator?: (rule: unknown, value: unknown) => Promise<void>;
validator?: (rule: unknown, value: unknown, isLast: boolean) => Promise<void>;
minItems?: number;
}) {
const { t } = useTranslation();
@@ -707,7 +737,11 @@ function FragmentRangeList({
key={field.key}
label={`#${idx + 1}`}
name={field.name}
rules={validator ? [{ validator }] : undefined}
rules={
validator
? [{ validator: (r, v) => validator(r, v, idx === fields.length - 1) }]
: undefined
}
>
<Input
placeholder={placeholder}
+74 -1
View File
@@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { FinalMaskField } from '@/lib/xray/forms/fields';
import { listSelectOptions, renderWithProviders } from './test-utils';
import { chooseSelectOption, listSelectOptions, renderWithProviders } from './test-utils';
describe('FinalMaskForm item types', () => {
// 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());
});
});
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();
});
});