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
This commit is contained in:
MHSanaei
2026-06-23 13:24:16 +02:00
parent 42cd351e4e
commit 852b53db79
18 changed files with 343 additions and 255 deletions
@@ -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 (
<>
<Form.Item label={enableLabel} name={[...name, 'enabled']} valuePropName="checked">
<Switch />
</Form.Item>
{enabled && (
<>
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Select mode="multiple" className="sniffing-options" options={DEST_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}
name={[...name, 'metadataOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingRouteOnly')}
name={[...name, 'routeOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label={t('pages.inbounds.sniffingIpsExcluded')} name={[...name, 'ipsExcluded']}>
<Select mode="tags" tokenSeparators={[',']} placeholder="IP/CIDR/geoip:*/ext:*" style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.sniffingDomainsExcluded')} name={[...name, 'domainsExcluded']}>
<Select mode="tags" tokenSeparators={[',']} placeholder="domain:*/ext:*" style={{ width: '100%' }} />
</Form.Item>
</>
)}
</>
);
}
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { FormInstance } from 'antd/es/form';
@@ -68,7 +69,9 @@ function asPath(name: NamePath): (string | number)[] {
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'fragment':
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
// `lengths`/`delays` are per-segment range arrays (xray-core #6334);
// 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: [],
@@ -81,6 +84,32 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
}
}
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
// fallback). Lift any legacy singular value into a one-element array so the
// list UI shows it, and drop the singular key so we never emit both.
function migrateFragmentSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const out: Record<string, unknown> = { ...settings };
let changed = false;
if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
out.lengths = [out.length];
changed = true;
}
if ('length' in out) {
delete out.length;
changed = true;
}
if (!Array.isArray(out.delays) && typeof out.delay === 'string' && out.delay.trim() !== '') {
out.delays = [out.delay];
changed = true;
}
if ('delay' in out) {
delete out.delay;
changed = true;
}
return { next: out, changed };
}
function defaultUdpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'salamander':
@@ -137,6 +166,29 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
migratedRef.current = true;
const tcp = form.getFieldValue([...base, 'tcp']);
if (!Array.isArray(tcp)) return;
let anyChanged = false;
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (!changed) return mask;
anyChanged = true;
return { ...m, settings: migrated };
});
if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
// Wireguard carries no user-selectable transport (always a UDP listener/
// dialer), so only the UDP mask section applies — TCP masks would never
@@ -261,16 +313,19 @@ function TcpMaskItem({
placeholder="tlshello or n-m, e.g. 1-3"
/>
</Form.Item>
<Form.Item
label="Length"
name={[fieldName, 'settings', 'length']}
rules={[{ validator: validateFragmentLength }]}
>
<Input placeholder="e.g. 100-200" />
</Form.Item>
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input />
</Form.Item>
<FragmentRangeList
listName={[fieldName, 'settings', 'lengths']}
label="Lengths"
placeholder="e.g. 100-200"
minItems={1}
validator={validateFragmentLength}
/>
<FragmentRangeList
listName={[fieldName, 'settings', 'delays']}
label="Delays"
placeholder="e.g. 10-20 or 0"
validator={validateFragmentDelayEntry}
/>
<Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
<Input />
</Form.Item>
@@ -321,9 +376,6 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
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<void> {
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<void> {
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<void> {
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<void>;
minItems?: number;
}) {
return (
<Form.List name={listName}>
{(fields, { add, remove }) => (
<>
<Form.Item label={label}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
</Form.Item>
{fields.map((field, idx) => (
<Form.Item
key={field.key}
label={`#${idx + 1}`}
name={field.name}
rules={validator ? [{ validator }] : undefined}
>
<Input
placeholder={placeholder}
addonAfter={fields.length > minItems
? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
: null}
/>
</Form.Item>
))}
</>
)}
</Form.List>
);
}
// randRange bytes must sit in 0-255 — xray rejects the whole config with
// "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
// xray reorders them).
+29 -13
View File
@@ -1,6 +1,7 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils';
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
import type {
DnsOutboundFormSettings,
@@ -13,7 +14,6 @@ import type {
OutboundFormSettings,
OutboundFormValues,
OutboundStreamFormValues,
ReverseSniffingForm,
ShadowsocksOutboundFormSettings,
TrojanOutboundFormSettings,
VlessOutboundFormSettings,
@@ -55,21 +55,28 @@ function asPort(value: unknown, fallback: number): number {
return n;
}
const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
const SNIFFING_DEFAULT: Sniffing = {
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
destOverride: [...SNIFFING_DEST_VALUES],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
};
function reverseSniffingFromWire(raw: unknown): ReverseSniffingForm {
// Shared by VLESS reverse sniffing and the loopback outbound — both edit the
// same xray SniffingConfig. Unknown destOverride tokens are dropped so the
// value satisfies SniffingSchema's enum.
function sniffingFromWire(raw: unknown): Sniffing {
const r = asObject(raw);
const dest = asArray(r.destOverride).map((x) => asString(x));
const dest = asArray(r.destOverride)
.map((x) => asString(x))
.filter((x): x is SniffingDest => (SNIFFING_DEST_VALUES as readonly string[]).includes(x));
return {
enabled: asBool(r.enabled),
destOverride: dest.length > 0 ? dest : ['http', 'tls', 'quic', 'fakedns'],
destOverride: dest.length > 0 ? dest : [...SNIFFING_DEST_VALUES],
metadataOnly: asBool(r.metadataOnly),
routeOnly: asBool(r.routeOnly),
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
@@ -112,8 +119,8 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
const reverse = asObject(raw.reverse);
const reverseTag = asString(reverse.tag);
const reverseSniffing = reverseTag
? reverseSniffingFromWire(reverse.sniffing)
: REVERSE_SNIFFING_DEFAULT;
? sniffingFromWire(reverse.sniffing)
: SNIFFING_DEFAULT;
const savedSeed = asArray(raw.testseed);
const testseed = savedSeed.length === 4
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
@@ -324,7 +331,10 @@ function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
}
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
return { inboundTag: asString(raw.inboundTag) };
return {
inboundTag: asString(raw.inboundTag),
sniffing: sniffingFromWire(raw.sniffing),
};
}
function muxFromWire(raw: unknown): MuxForm {
@@ -417,7 +427,7 @@ function vmessToWire(s: VmessOutboundFormSettings) {
};
}
function reverseSniffingToWire(s: ReverseSniffingForm) {
function sniffingToWire(s: Sniffing) {
return {
enabled: s.enabled,
destOverride: s.destOverride,
@@ -437,8 +447,8 @@ function vlessToWire(s: VlessOutboundFormSettings) {
encryption: s.encryption || 'none',
};
if (s.reverseTag) {
const sn = reverseSniffingToWire(s.reverseSniffing);
const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
const sn = sniffingToWire(s.reverseSniffing);
const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
result.reverse = {
tag: s.reverseTag,
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
@@ -563,7 +573,13 @@ function dnsToWire(s: DnsOutboundFormSettings) {
}
function loopbackToWire(s: LoopbackOutboundFormSettings) {
return { inboundTag: s.inboundTag || undefined };
const result: Raw = { inboundTag: s.inboundTag || undefined };
// Sniffing rides only when enabled — a disabled block is a no-op for
// xray's BuildSniffingRequest, so omitting it keeps the wire minimal.
if (s.sniffing.enabled) {
result.sniffing = sniffingToWire(s.sniffing);
}
return result;
}
// canEnableMux mirrors the legacy Outbound.canEnableMux().