From 852b53db7987895f126a5aecf9eea08434a8b87b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 23 Jun 2026 13:24:16 +0200 Subject: [PATCH] feat(xray): add loopback sniffing and per-segment fragment masks - Loopback outbound: add sniffing support (xray-core #6320) - FinalMask fragment: support per-segment lengths/delays arrays with legacy length/delay migration (xray-core #6334) - Consolidate sniffing into a shared SniffingFields component and the canonical SniffingSchema across inbound, VLESS reverse, and loopback --- .../src/lib/xray/forms/SniffingFields.tsx | 63 ++++++++ .../xray/forms/transport/FinalMaskForm.tsx | 135 ++++++++++++++++-- .../src/lib/xray/outbound-form-adapter.ts | 42 ++++-- .../pages/inbounds/form/InboundFormModal.tsx | 3 +- .../src/pages/inbounds/form/SniffingTab.tsx | 69 ++------- .../xray/outbounds/OutboundFormModal.tsx | 71 +-------- .../xray/outbounds/protocols/loopback.tsx | 18 ++- frontend/src/schemas/forms/inbound-form.ts | 20 --- frontend/src/schemas/forms/outbound-form.ts | 82 ++++------- .../src/schemas/protocols/inbound/vless.ts | 6 - .../src/schemas/protocols/outbound/index.ts | 4 - .../schemas/protocols/outbound/loopback.ts | 5 +- .../src/schemas/protocols/outbound/trojan.ts | 3 - .../src/schemas/protocols/outbound/vmess.ts | 3 - .../schemas/protocols/outbound/wireguard.ts | 5 - .../test/__snapshots__/finalmask.test.ts.snap | 23 +++ .../finalmask/tcp-fragment-segments.json | 13 ++ .../src/test/outbound-form-adapter.test.ts | 33 +++++ 18 files changed, 343 insertions(+), 255 deletions(-) create mode 100644 frontend/src/lib/xray/forms/SniffingFields.tsx create mode 100644 frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json diff --git a/frontend/src/lib/xray/forms/SniffingFields.tsx b/frontend/src/lib/xray/forms/SniffingFields.tsx new file mode 100644 index 000000000..139b2a761 --- /dev/null +++ b/frontend/src/lib/xray/forms/SniffingFields.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { Form, Select, Switch } from 'antd'; +import type { FormInstance } from 'antd/es/form'; + +import { SNIFFING_OPTION } from '@/schemas/primitives'; + +const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label })); + +export interface SniffingFieldsProps { + // Base path to the sniffing object in the form, e.g. ['sniffing'] (inbound), + // ['settings', 'reverseSniffing'] (VLESS reverse), ['settings', 'sniffing'] + // (loopback). All sub-fields hang off this path. + name: (string | number)[]; + form: FormInstance; + // Label for the enable toggle — Enable / Reverse Sniffing / Sniffing differ + // per host. + enableLabel: string; +} + +// Shared sniffing form fragment used everywhere the panel edits an xray +// SniffingConfig: the inbound Sniffing tab, VLESS reverse sniffing, and the +// loopback outbound. Renders the enable toggle plus the destOverride / +// metadataOnly / routeOnly / excluded fields when enabled. +export default function SniffingFields({ name, form, enableLabel }: SniffingFieldsProps) { + const { t } = useTranslation(); + const enabled = Form.useWatch([...name, 'enabled'], form) ?? false; + + return ( + <> + + + + + {enabled && ( + <> + + + + + - - - - + + @@ -321,9 +376,6 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3')); } -// Walks a deep object path safely. Used inside shouldUpdate which gets -// the whole form values blob; we need to compare a deep field across -// prev/curr without crashing on missing intermediates. function validateFragmentLength(_rule: unknown, value: unknown): Promise { const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); if (str.length === 0) { @@ -336,6 +388,61 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise { return Promise.resolve(); } +// A delay segment is a millisecond value or range; 0 is allowed (no delay), +// but an empty row would serialize as "" and break xray's Int32Range parse, +// so require a value and let the user remove the row instead. +function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise { + const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim(); + if (str.length === 0) { + return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay")); + } + if (!/^\d+(?:-\d+)?$/.test(str)) { + return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20')); + } + return Promise.resolve(); +} + +// Per-segment range list for a fragment mask's `lengths`/`delays` (xray-core +// #6334): an editable list of dash-range strings. xray applies entry N to +// fragment segment N, clamping to the last entry. `minItems` keeps at least +// one length row so the config never collapses to an empty (rejected) list. +function FragmentRangeList({ + listName, label, placeholder, validator, minItems = 0, +}: { + listName: (string | number)[]; + label: string; + placeholder: string; + validator?: (rule: unknown, value: unknown) => Promise; + minItems?: number; +}) { + return ( + + {(fields, { add, remove }) => ( + <> + +