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