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