diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 7bff0ba7a..5c45dc81b 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AutoComplete, Button, @@ -9,6 +9,7 @@ import { Select, Space, Switch, + Typography, } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -98,14 +99,7 @@ function defaultTcpMaskSettings(type: string): Record { // a single length entry reproduces the legacy single-range behavior. return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' }; case 'sudoku': - return { - password: '', - ascii: '', - customTable: '', - customTables: [], - paddingMin: 0, - paddingMax: 0, - }; + return defaultSudokuSettings(); case 'header-custom': return { clients: [], servers: [] }; case 'xmc': @@ -119,6 +113,17 @@ function defaultTcpMaskSettings(type: string): Record { } } +function defaultSudokuSettings(): Record { + return { + password: '', + ascii: '', + customTable: '', + customTables: [], + paddingMin: 0, + paddingMax: 0, + }; +} + function defaultXmcProfile(): Record { return { username: '', uuid: '', texturesValue: '', texturesSignature: '' }; } @@ -198,6 +203,8 @@ function defaultUdpMaskSettings(type: string): Record { return { client: [], server: [] }; case 'noise': return { reset: 0, noise: [] }; + case 'sudoku': + return defaultSudokuSettings(); case 'udphop': return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' }; default: @@ -471,30 +478,7 @@ function TcpMaskItem({ ); } - if (type === 'sudoku') { - return ( - <> - - - - - - - - - - - + + + + + + + + + + {(['client', 'server'] as const).map((groupKey) => ( {(items, { add, remove }) => ( @@ -1283,6 +1319,7 @@ function UdpHeaderCustom({ fieldName={item.name} form={form} absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]} + allowVariables onRemove={() => remove(item.name)} /> @@ -1463,6 +1500,19 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) { ); } +// xray-core reads reset as an Int32Range: a plain number or a "min-max" string. +function validateNoiseReset(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + const bounds = String(value) + .split('-') + .map((v) => Number(v.trim())); + const valid = + bounds.length <= 2 && + bounds.every((v) => Number.isInteger(v) && v >= 0) && + bounds[0] <= bounds[bounds.length - 1]; + return valid ? Promise.resolve() : Promise.reject(new Error('seconds, e.g. 0 or 60-120')); +} + function NoiseItems({ udpFieldName, form, @@ -1475,8 +1525,13 @@ function NoiseItems({ const { t } = useTranslation(); return ( <> - - + + {(items, { add, remove }) => ( @@ -1519,20 +1574,98 @@ function NoiseItems({ ); } +// xray-core 26.6 header-custom: an item is exactly one of raw bytes, a reused variable +// or a transform ("exactly one item kind must be set"); capture works with any of them. +type ItemKind = 'bytes' | 'reuse' | 'transform'; + +const ITEM_KIND_OPTIONS: { value: ItemKind; label: string }[] = [ + { value: 'bytes', label: 'Bytes' }, + { value: 'reuse', label: 'Reuse variable' }, + { value: 'transform', label: 'Transform' }, +]; + +function itemKindOf(item: unknown): ItemKind { + const fields = (item ?? {}) as Record; + if (typeof fields.reuse === 'string') return 'reuse'; + if (fields.transform != null) return 'transform'; + return 'bytes'; +} + +function validateCustomVarName(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value)) + ? Promise.resolve() + : Promise.reject(new Error('letters, digits and _, not starting with a digit')); +} + +// Hands the form only a parsed JSON object, so half-typed text never reaches the config. +function TransformEditor({ + value, + onChange, +}: { + value?: Record; + onChange?: (next: Record | undefined) => void; +}) { + const [text, setText] = useState(() => (value ? JSON.stringify(value) : '')); + const [invalid, setInvalid] = useState(false); + return ( + <> + { + const next = e.target.value; + setText(next); + if (next.trim() === '') { + setInvalid(false); + onChange?.(undefined); + return; + } + try { + const parsed: unknown = JSON.parse(next); + const isObject = !!parsed && typeof parsed === 'object' && !Array.isArray(parsed); + setInvalid(!isObject); + if (isObject) onChange?.(parsed as Record); + } catch { + setInvalid(true); + } + }} + /> + {invalid && Must be a JSON object} + + ); +} + function ItemEditor({ fieldName, form, absoluteItemPath, delayMode, + allowVariables = false, onRemove: _onRemove, }: { fieldName: number; form: FormInstance; absoluteItemPath: (string | number)[]; delayMode?: 'number' | 'string'; + allowVariables?: boolean; onRemove?: () => void; }) { const { t } = useTranslation(); + const [kind, setKind] = useState(() => + allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes', + ); + const onKindChange = (next: ItemKind) => { + setKind(next); + for (const key of ['type', 'packet', 'rand', 'randRange', 'reuse', 'transform']) { + form.setFieldValue([...absoluteItemPath, key], undefined); + } + if (next === 'bytes') { + form.setFieldValue([...absoluteItemPath, 'type'], 'array'); + form.setFieldValue([...absoluteItemPath, 'rand'], 0); + } + }; /** * Switching to `array` clears the packet instead of emptying it to `[]`: * that branch is rand-driven, and xray-core counts even an empty array as a @@ -1552,19 +1685,26 @@ function ItemEditor({ return ( <> - - + + )} + {kind === 'bytes' && ( + + + + )} + {kind === 'transform' && ( + + + + )} + @@ -1585,6 +1749,7 @@ function ItemEditor({ } > {({ getFieldValue }) => { + if (kind !== 'bytes') return null; const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined; if (type === 'array') { return ( @@ -1635,6 +1800,18 @@ function ItemEditor({ ); }} + + {allowVariables && ( + (v === '' ? undefined : v)} + rules={[{ validator: validateCustomVarName }]} + > + + + )} ); } diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 874e9be3d..3cc880d3b 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -20,7 +20,7 @@ export const TcpMaskSchema = z.object({ export type TcpMask = z.infer; // 'udphop' is client-only in xray-core (it refuses to wrap a server socket), -// so it round-trips here but is deliberately absent from the mask dropdown. +// so only the client-side mask editors offer it. export const UdpMaskTypeSchema = z.enum([ 'salamander', 'mkcp-legacy', diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index 41dc2f825..7b52b03df 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -38,6 +38,107 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1` } `; +exports[`FinalMaskStreamSettingsSchema fixtures > parses header-custom-variables byte-stably 1`] = ` +{ + "tcp": [ + { + "settings": { + "clients": [ + [ + { + "capture": "nonce", + "delay": 0, + "rand": 8, + "type": "array", + }, + { + "delay": 0, + "packet": "HELLO", + "type": "str", + }, + ], + ], + "errors": [ + [ + { + "delay": 0, + "packet": "DENIED", + "type": "str", + }, + ], + ], + "servers": [ + [ + { + "delay": 0, + "reuse": "nonce", + }, + { + "delay": 0, + "transform": { + "args": [ + { + "bytes": "OK ", + "type": "str", + }, + { + "reuse": "nonce", + }, + ], + "op": "concat", + }, + }, + ], + ], + }, + "type": "header-custom", + }, + ], + "udp": [ + { + "settings": { + "client": [ + { + "capture": "token", + "rand": 8, + "type": "array", + }, + ], + "mode": "standalone", + "server": [ + { + "reuse": "token", + }, + ], + }, + "type": "header-custom", + }, + { + "settings": { + "ascii": "prefer_entropy", + "paddingMax": 7, + "paddingMin": 2, + "password": "s3cr3t", + }, + "type": "sudoku", + }, + { + "settings": { + "noise": [ + { + "delay": "10-16", + "rand": "10-20", + "type": "array", + }, + ], + "reset": "60-120", + }, + "type": "noise", + }, + ], +} +`; + exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably 1`] = ` { "quicParams": { diff --git a/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json new file mode 100644 index 000000000..ac17cbb3b --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json @@ -0,0 +1,54 @@ +{ + "tcp": [ + { + "type": "header-custom", + "settings": { + "clients": [ + [ + { "delay": 0, "type": "array", "rand": 8, "capture": "nonce" }, + { "delay": 0, "type": "str", "packet": "HELLO" } + ] + ], + "servers": [ + [ + { "delay": 0, "reuse": "nonce" }, + { + "delay": 0, + "transform": { + "op": "concat", + "args": [{ "type": "str", "bytes": "OK " }, { "reuse": "nonce" }] + } + } + ] + ], + "errors": [[{ "delay": 0, "type": "str", "packet": "DENIED" }]] + } + } + ], + "udp": [ + { + "type": "header-custom", + "settings": { + "mode": "standalone", + "client": [{ "type": "array", "rand": 8, "capture": "token" }], + "server": [{ "reuse": "token" }] + } + }, + { + "type": "sudoku", + "settings": { + "password": "s3cr3t", + "ascii": "prefer_entropy", + "paddingMin": 2, + "paddingMax": 7 + } + }, + { + "type": "noise", + "settings": { + "reset": "60-120", + "noise": [{ "type": "array", "rand": "10-20", "delay": "10-16" }] + } + } + ] +} diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 489cf2b2a..a2c62427b 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -516,3 +516,135 @@ describe('OutboundFormModal UDP hop', () => { expect(payload.streamSettings.finalmask.quicParams?.congestion).toBe('bbr'); }); }); + +describe('OutboundFormModal finalmask variables', () => { + function xhttpOutbound(finalmask: Record) { + return { + protocol: 'vless', + tag: 'masked', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { path: '/', mode: 'auto' }, + finalmask, + }, + }; + } + + async function saveFinalmask( + finalmask: Record, + edit: () => void = () => {}, + ): Promise> { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + edit(); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(onConfirm).toHaveBeenCalledTimes(1); + return ( + onConfirm.mock.calls[0][0] as { streamSettings: { finalmask: Record } } + ).streamSettings.finalmask; + } + + function typeInto(label: string, value: string) { + const input = formItemByLabel(label)?.querySelector('input, textarea'); + if (!input) throw new Error(`${label} input not found`); + fireEvent.change(input, { target: { value } }); + } + + // xray-core 26.6 header-custom variables, error replies and standalone UDP headers, plus + // the sudoku UDP mask; the same fixture is built by Go's TestGoldenStreamFixturesBuildInXray. + it('keeps every header-custom variable, sudoku UDP mask and noise reset range on save', async () => { + const fixture = (await import('./golden/fixtures/finalmask/header-custom-variables.json')) + .default as Record; + + expect(await saveFinalmask(structuredClone(fixture))).toEqual(fixture); + }); + + // The core rejects an item carrying two kinds ("exactly one item kind must be set"). + it('drops the bytes of an item switched to reuse a variable', async () => { + const finalmask = await saveFinalmask( + { + udp: [ + { + type: 'header-custom', + settings: { client: [{ type: 'array', rand: 4, randRange: '0-255' }], server: [] }, + }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Reuse variable'); + typeInto('Reuse', 'nonce'); + }, + ); + + expect(finalmask.udp).toEqual([ + { type: 'header-custom', settings: { client: [{ reuse: 'nonce' }], server: [] } }, + ]); + }); + + // A string transform fails the whole core config, so half-typed JSON must never be saved. + it.each([ + ['{"op": "be16", "args": [{"u64": 7}]}', [{ transform: { op: 'be16', args: [{ u64: 7 }] } }]], + ['{"op": "be16"', [{}]], + ])('saves the typed transform %s only once it parses', async (typed, client) => { + const finalmask = await saveFinalmask( + { + udp: [ + { type: 'header-custom', settings: { client: [{ type: 'array', rand: 4 }], server: [] } }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Transform'); + typeInto('Transform', typed); + }, + ); + + expect(finalmask.udp).toEqual([{ type: 'header-custom', settings: { client, server: [] } }]); + }); + + it('saves a noise reset typed as a range', async () => { + const finalmask = await saveFinalmask( + { udp: [{ type: 'noise', settings: { reset: 0, noise: [] } }] }, + () => typeInto('Reset', '60-120'), + ); + + expect(finalmask.udp).toEqual([{ type: 'noise', settings: { reset: '60-120', noise: [] } }]); + }); +}); diff --git a/internal/sub/service.go b/internal/sub/service.go index feca3d04d..fa1493cdb 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2700,6 +2700,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{ "header-custom": {}, "realm": {}, "udphop": {}, + "sudoku": {}, } var validFinalMaskTCPTypes = map[string]struct{}{ diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 4036e9d91..070a7e9b8 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -1181,6 +1181,20 @@ func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) { } } +// xray-core 26.6 accepts sudoku as a UDP mask too; the link must not drop it from fm=. +func TestMarshalFinalMask_KeepsSudokuUdpMask(t *testing.T) { + fm := map[string]any{ + "udp": []any{map[string]any{"type": "sudoku", "settings": map[string]any{"password": "s3cr3t"}}}, + } + out, ok := marshalFinalMask(fm) + if !ok { + t.Fatal("expected ok=true for a sudoku udp mask") + } + if want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"sudoku"}]}`; out != want { + t.Fatalf("marshalFinalMask = %s, want %s", out, want) + } +} + func TestHasFinalMaskContent(t *testing.T) { if hasFinalMaskContent(nil) { t.Fatal("nil should not count as content")