mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +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().
|
||||
|
||||
@@ -193,7 +193,6 @@ export default function InboundFormModal({
|
||||
// actually live on a node — otherwise the node address it would resolve to is
|
||||
// always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
|
||||
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
||||
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
||||
const isSSWith2022 = isSS2022({
|
||||
@@ -977,7 +976,7 @@ export default function InboundFormModal({
|
||||
</div>
|
||||
);
|
||||
|
||||
const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
|
||||
const sniffingTab = <SniffingTab />;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,67 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Checkbox, Form, Select, Switch } from 'antd';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import { SNIFFING_OPTION } from '@/schemas/primitives';
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
|
||||
export default function SniffingTab({ sniffingEnabled }: { sniffingEnabled: boolean }) {
|
||||
export default function SniffingTab() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{sniffingEnabled && (
|
||||
<>
|
||||
<Form.Item name={['sniffing', 'destOverride']} wrapperCol={{ span: 24 }}>
|
||||
<Checkbox.Group>
|
||||
{Object.entries(SNIFFING_OPTION).map(([key, value]) => (
|
||||
<Checkbox key={key} value={value}>{key}</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['sniffing', 'metadataOnly']}
|
||||
label={t('pages.inbounds.sniffingMetadataOnly')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['sniffing', 'routeOnly']}
|
||||
label={t('pages.inbounds.sniffingRouteOnly')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['sniffing', 'ipsExcluded']}
|
||||
label={t('pages.inbounds.sniffingIpsExcluded')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['sniffing', 'domainsExcluded']}
|
||||
label={t('pages.inbounds.sniffingDomainsExcluded')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<SniffingFields
|
||||
name={['sniffing']}
|
||||
form={form}
|
||||
enableLabel={t('enable')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tabs,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { Wireguard } from '@/utils';
|
||||
import {
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
OutboundFormBaseSchema,
|
||||
type OutboundFormValues,
|
||||
} from '@/schemas/forms/outbound-form';
|
||||
import { SNIFFING_OPTION } from '@/schemas/primitives';
|
||||
import {
|
||||
canEnableReality,
|
||||
canEnableStream,
|
||||
@@ -412,70 +411,12 @@ export default function OutboundFormModal({
|
||||
{() => {
|
||||
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
|
||||
if (!reverseTag) return null;
|
||||
const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
|
||||
enabled?: boolean;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.reverseSniffing')}
|
||||
name={['settings', 'reverseSniffing', 'enabled']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{sniff.enabled && (
|
||||
<>
|
||||
<Form.Item
|
||||
wrapperCol={{ md: { span: 14, offset: 8 } }}
|
||||
name={['settings', 'reverseSniffing', 'destOverride']}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
className="sniffing-options"
|
||||
options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
|
||||
value: v,
|
||||
label: k,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingMetadataOnly')}
|
||||
name={['settings', 'reverseSniffing', 'metadataOnly']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingRouteOnly')}
|
||||
name={['settings', 'reverseSniffing', 'routeOnly']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingIpsExcluded')}
|
||||
name={['settings', 'reverseSniffing', 'ipsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingDomainsExcluded')}
|
||||
name={['settings', 'reverseSniffing', 'domainsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<SniffingFields
|
||||
name={['settings', 'reverseSniffing']}
|
||||
form={form}
|
||||
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
|
||||
export default function LoopbackFields() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
return (
|
||||
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<SniffingFields
|
||||
name={['settings', 'sniffing']}
|
||||
form={form}
|
||||
enableLabel={t('pages.inbounds.sniffingTab')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,6 @@ import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
|
||||
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
|
||||
|
||||
// InboundFormValues = the values shape Form.useForm<T>() carries in
|
||||
// InboundFormModal. Mirrors the wire shape (so submission can hand
|
||||
// values straight to Schema.parse + POST) plus the DB-side fields that
|
||||
// the panel's /panel/api/inbounds/add endpoint expects alongside.
|
||||
//
|
||||
// Differences from schemas/api/inbound.ts InboundSchema:
|
||||
// - settings/streamSettings/sniffing are nested OBJECTS here, not the
|
||||
// JSON strings the endpoint accepts. The form holds typed data; the
|
||||
// submit handler stringifies right before POSTing.
|
||||
// - Adds DB fields not in InboundSchema: up, down, total, trafficReset,
|
||||
// lastTrafficResetTime, nodeId. These flow through the DBInbound row,
|
||||
// not the xray-config slice.
|
||||
|
||||
export const InboundStreamFormSchema = NetworkSettingsSchema
|
||||
.and(SecuritySettingsSchema)
|
||||
.and(StreamExtrasSchema);
|
||||
@@ -43,9 +30,6 @@ export const InboundDbFieldsSchema = z.object({
|
||||
});
|
||||
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
||||
|
||||
// Base fields that apply to every inbound regardless of protocol or
|
||||
// transport. The protocol-specific `settings` and the transport-specific
|
||||
// `streamSettings` are layered on via intersection below.
|
||||
export const InboundFormBaseSchema = z.object({
|
||||
remark: z.string().default(''),
|
||||
enable: z.boolean().default(true),
|
||||
@@ -73,10 +57,6 @@ export const InboundFormSchema = InboundFormBaseSchema
|
||||
.and(InboundSettingsSchema);
|
||||
export type InboundFormValues = z.infer<typeof InboundFormSchema>;
|
||||
|
||||
// Fallback rows ride alongside the inbound submission for VLESS/Trojan
|
||||
// hosts. They're saved via a separate endpoint after the main inbound
|
||||
// POST returns, so the schema lives here but is not part of the wire
|
||||
// inbound payload.
|
||||
export const FallbackRowSchema = z.object({
|
||||
rowKey: z.string(),
|
||||
childId: z.number().int().nullable(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PortSchema } from '@/schemas/primitives';
|
||||
import { PortSchema, SniffingSchema, type Sniffing } from '@/schemas/primitives';
|
||||
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
|
||||
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
|
||||
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||
@@ -15,28 +15,6 @@ import {
|
||||
WireguardDomainStrategySchema,
|
||||
} from '@/schemas/protocols/outbound';
|
||||
|
||||
// OutboundFormValues = the shape Form.useForm<T>() carries inside
|
||||
// OutboundFormModal. Differences from schemas/api wire schemas:
|
||||
//
|
||||
// - vmess vnext / trojan-ss-socks-http servers are FLATTENED into
|
||||
// {address, port, ...auth} at settings root. The adapter handles
|
||||
// nesting on submit.
|
||||
// - wireguard `address` (string[] wire) and `reserved` (number[] wire)
|
||||
// are comma-joined STRINGS in the form. The adapter splits + coerces.
|
||||
// - wireguard `pubKey` is a UI-only field derived from `secretKey`. Not
|
||||
// emitted on the wire — the adapter strips it.
|
||||
// - VLESS `reverseTag` and `reverseSniffing` are flat at settings root;
|
||||
// the adapter wraps them as { reverse: { tag, sniffing } } on the wire.
|
||||
// - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it
|
||||
// as { response: { type } } on the wire (omitted when empty).
|
||||
// - DNS rules carry `qType` and `domain` as comma-joined strings (matches
|
||||
// the legacy DNSRule UI). The adapter normalizes them on submit.
|
||||
//
|
||||
// All flat-form settings types are documented inline so the adapter has a
|
||||
// single source of truth for the shape it converts between.
|
||||
|
||||
// VMess outbound: connect target (address+port) + first user (id+security).
|
||||
// Wire: { vnext: [{ address, port, users: [{ id, security }] }] }.
|
||||
export const VmessOutboundFormSettingsSchema = z.object({
|
||||
address: z.string().default(''),
|
||||
port: PortSchema.default(443),
|
||||
@@ -45,20 +23,18 @@ export const VmessOutboundFormSettingsSchema = z.object({
|
||||
});
|
||||
export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
|
||||
|
||||
// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
|
||||
// match legacy ReverseSniffing constructor.
|
||||
export const ReverseSniffingFormSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
|
||||
metadataOnly: z.boolean().default(false),
|
||||
routeOnly: z.boolean().default(false),
|
||||
ipsExcluded: z.array(z.string()).default([]),
|
||||
domainsExcluded: z.array(z.string()).default([]),
|
||||
});
|
||||
export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
|
||||
// Reverse sniffing (VLESS) and loopback sniffing share the canonical
|
||||
// SniffingSchema — the same definition the inbound Sniffing tab uses — so
|
||||
// there is one source of truth for an xray SniffingConfig across the panel.
|
||||
const DEFAULT_SNIFFING: Sniffing = {
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
};
|
||||
|
||||
// VLESS outbound: flat connect target + auth + Vision-specific knobs +
|
||||
// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
|
||||
export const VlessOutboundFormSettingsSchema = z.object({
|
||||
address: z.string().default(''),
|
||||
port: PortSchema.default(443),
|
||||
@@ -66,14 +42,7 @@ export const VlessOutboundFormSettingsSchema = z.object({
|
||||
flow: z.string().default(''),
|
||||
encryption: z.string().min(1).default('none'),
|
||||
reverseTag: z.string().default(''),
|
||||
reverseSniffing: ReverseSniffingFormSchema.default({
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
ipsExcluded: [],
|
||||
domainsExcluded: [],
|
||||
}),
|
||||
reverseSniffing: SniffingSchema.default(DEFAULT_SNIFFING),
|
||||
testpre: z.number().int().min(0).default(0),
|
||||
testseed: z.array(z.number().int().positive()).default([]),
|
||||
});
|
||||
@@ -205,26 +174,29 @@ export const DnsOutboundFormSettingsSchema = z.object({
|
||||
});
|
||||
export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
|
||||
|
||||
// Loopback reinjects into a named inbound; `sniffing` (same flat shape as
|
||||
// VLESS reverse-sniffing) is only emitted when enabled — see the adapter.
|
||||
export const LoopbackOutboundFormSettingsSchema = z.object({
|
||||
inboundTag: z.string().default(''),
|
||||
sniffing: SniffingSchema.default(DEFAULT_SNIFFING),
|
||||
});
|
||||
export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
|
||||
|
||||
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
|
||||
// inbound side: each branch is { protocol: literal, settings: <flat> }.
|
||||
export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
|
||||
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
|
||||
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
|
||||
]);
|
||||
export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
|
||||
|
||||
|
||||
@@ -23,9 +23,6 @@ export const VlessClientSchema = z.object({
|
||||
subId: z.string().default(''),
|
||||
comment: z.string().default(''),
|
||||
reset: z.number().int().min(0).default(0),
|
||||
// VLESS simple reverse-proxy: which reverse tag this client routes to,
|
||||
// plus an optional sniffing override for that path. Distinct from the
|
||||
// inbound-level `fallbacks` feature.
|
||||
reverse: z
|
||||
.object({
|
||||
tag: z.string(),
|
||||
@@ -42,9 +39,6 @@ export const VlessInboundSettingsSchema = z.object({
|
||||
decryption: z.string().min(1).default('none'),
|
||||
encryption: z.string().min(1).default('none'),
|
||||
fallbacks: z.array(VlessFallbackSchema).default([]),
|
||||
// TODO: narrow to flow === 'xtls-rprx-vision' once a per-flow discriminator
|
||||
// exists. 4-positive-int padding seed for xtls-rprx-vision; backend uses
|
||||
// safe defaults when omitted.
|
||||
testseed: z.array(z.number().int().positive()).length(4).optional(),
|
||||
});
|
||||
export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;
|
||||
|
||||
@@ -26,10 +26,6 @@ export * from './vless';
|
||||
export * from './vmess';
|
||||
export * from './wireguard';
|
||||
|
||||
// Outbound discriminated union spans 13 protocols (mixed/tunnel are
|
||||
// inbound-only; freedom/blackhole/dns/loopback are outbound-only). The wire
|
||||
// shape is `{ protocol, settings }` — same wrapper pattern as the inbound
|
||||
// union, even though some leaf schemas (freedom, blackhole) are sparse.
|
||||
export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
|
||||
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundSettingsSchema }),
|
||||
z.object({ protocol: z.literal('vless'), settings: VlessOutboundSettingsSchema }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Loopback outbound reinjects traffic back into a named inbound for chained
|
||||
// routing. The single `inboundTag` field references an inbound tag by name.
|
||||
import { SniffingSchema } from '@/schemas/primitives';
|
||||
|
||||
export const LoopbackOutboundSettingsSchema = z.object({
|
||||
inboundTag: z.string().optional(),
|
||||
sniffing: SniffingSchema.optional(),
|
||||
});
|
||||
export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;
|
||||
|
||||
@@ -2,9 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
import { PortSchema } from '@/schemas/primitives';
|
||||
|
||||
// Trojan outbound persists as { servers: [{ address, port, password }] }
|
||||
// — distinct from VLESS outbound which stores the connect target flat at
|
||||
// the settings root. The wrapping mirrors what Xray expects.
|
||||
export const TrojanOutboundServerSchema = z.object({
|
||||
address: z.string().min(1),
|
||||
port: PortSchema,
|
||||
|
||||
@@ -3,9 +3,6 @@ import { z } from 'zod';
|
||||
import { PortSchema } from '@/schemas/primitives';
|
||||
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
|
||||
|
||||
// Vmess outbound persists in the standard Xray `vnext` shape:
|
||||
// { vnext: [{ address, port, users: [{ id, security }] }] }
|
||||
// — distinct from VLESS outbound which the panel stores flat.
|
||||
export const VmessOutboundUserSchema = z.object({
|
||||
id: z.uuid(),
|
||||
security: VmessSecuritySchema.default('auto'),
|
||||
|
||||
@@ -9,8 +9,6 @@ export const WireguardDomainStrategySchema = z.enum([
|
||||
]);
|
||||
export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
|
||||
|
||||
// Outbound peer is the remote server we connect to: no privateKey, but an
|
||||
// `endpoint` (host:port) the inbound side does not need.
|
||||
export const WireguardOutboundPeerSchema = z.object({
|
||||
publicKey: z.string().min(1),
|
||||
preSharedKey: z.string().optional(),
|
||||
@@ -20,9 +18,6 @@ export const WireguardOutboundPeerSchema = z.object({
|
||||
});
|
||||
export type WireguardOutboundPeer = z.infer<typeof WireguardOutboundPeerSchema>;
|
||||
|
||||
// Wire format: address is a string[] (Xray expects an array even though the
|
||||
// panel UI stores it comma-joined); reserved is number[] (panel splits the
|
||||
// comma string and Number()-coerces each entry).
|
||||
export const WireguardOutboundSettingsSchema = z.object({
|
||||
mtu: z.number().int().min(1).optional(),
|
||||
secretKey: z.string().min(1),
|
||||
|
||||
@@ -101,6 +101,29 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-s
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-fragment-segments byte-stably 1`] = `
|
||||
{
|
||||
"tcp": [
|
||||
{
|
||||
"settings": {
|
||||
"delays": [
|
||||
"5-10",
|
||||
"0",
|
||||
],
|
||||
"lengths": [
|
||||
"10-20",
|
||||
"50-100",
|
||||
],
|
||||
"maxSplit": "0",
|
||||
"packets": "tlshello",
|
||||
},
|
||||
"type": "fragment",
|
||||
},
|
||||
],
|
||||
"udp": [],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
|
||||
{
|
||||
"tcp": [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"tcp": [
|
||||
{
|
||||
"type": "fragment",
|
||||
"settings": {
|
||||
"packets": "tlshello",
|
||||
"lengths": ["10-20", "50-100"],
|
||||
"delays": ["5-10", "0"],
|
||||
"maxSplit": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -359,6 +359,39 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' });
|
||||
});
|
||||
|
||||
it('loopback omits sniffing when disabled', () => {
|
||||
const form = rawOutboundToFormValues({
|
||||
protocol: 'loopback',
|
||||
settings: { inboundTag: 'tagged-inbound' },
|
||||
});
|
||||
if (form.protocol === 'loopback') {
|
||||
expect(form.settings.sniffing.enabled).toBe(false);
|
||||
}
|
||||
const back = formValuesToWirePayload(form);
|
||||
expect(back.settings).not.toHaveProperty('sniffing');
|
||||
});
|
||||
|
||||
it('loopback round-trips sniffing when enabled', () => {
|
||||
const wire = {
|
||||
protocol: 'loopback',
|
||||
settings: {
|
||||
inboundTag: 'tagged-inbound',
|
||||
sniffing: { enabled: true, destOverride: ['tls', 'http'], routeOnly: true },
|
||||
},
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
if (form.protocol === 'loopback') {
|
||||
expect(form.settings.sniffing.enabled).toBe(true);
|
||||
expect(form.settings.sniffing.destOverride).toEqual(['tls', 'http']);
|
||||
expect(form.settings.sniffing.routeOnly).toBe(true);
|
||||
}
|
||||
const back = formValuesToWirePayload(form);
|
||||
const sniffing = (back.settings as Record<string, unknown>).sniffing as Record<string, unknown>;
|
||||
expect(sniffing.enabled).toBe(true);
|
||||
expect(sniffing.destOverride).toEqual(['tls', 'http']);
|
||||
expect(sniffing.routeOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('unknown protocol falls back to vless without throwing', () => {
|
||||
const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
|
||||
expect(form.protocol).toBe('vless');
|
||||
|
||||
Reference in New Issue
Block a user