mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-31 23:47:14 +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 { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
@@ -68,7 +69,9 @@ function asPath(name: NamePath): (string | number)[] {
|
|||||||
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'fragment':
|
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':
|
case 'sudoku':
|
||||||
return {
|
return {
|
||||||
password: '', ascii: '', customTable: '', customTables: [],
|
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> {
|
function defaultUdpMaskSettings(type: string): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'salamander':
|
case 'salamander':
|
||||||
@@ -137,6 +166,29 @@ function defaultUdpHop(): Record<string, unknown> {
|
|||||||
|
|
||||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||||
const base = asPath(name);
|
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';
|
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
||||||
// Wireguard carries no user-selectable transport (always a UDP listener/
|
// Wireguard carries no user-selectable transport (always a UDP listener/
|
||||||
// dialer), so only the UDP mask section applies — TCP masks would never
|
// 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"
|
placeholder="tlshello or n-m, e.g. 1-3"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<FragmentRangeList
|
||||||
label="Length"
|
listName={[fieldName, 'settings', 'lengths']}
|
||||||
name={[fieldName, 'settings', 'length']}
|
label="Lengths"
|
||||||
rules={[{ validator: validateFragmentLength }]}
|
placeholder="e.g. 100-200"
|
||||||
>
|
minItems={1}
|
||||||
<Input placeholder="e.g. 100-200" />
|
validator={validateFragmentLength}
|
||||||
</Form.Item>
|
/>
|
||||||
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
|
<FragmentRangeList
|
||||||
<Input />
|
listName={[fieldName, 'settings', 'delays']}
|
||||||
</Form.Item>
|
label="Delays"
|
||||||
|
placeholder="e.g. 10-20 or 0"
|
||||||
|
validator={validateFragmentDelayEntry}
|
||||||
|
/>
|
||||||
<Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
|
<Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</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'));
|
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> {
|
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
if (str.length === 0) {
|
if (str.length === 0) {
|
||||||
@@ -336,6 +388,61 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
|||||||
return Promise.resolve();
|
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
|
// randRange bytes must sit in 0-255 — xray rejects the whole config with
|
||||||
// "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
|
// "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
|
||||||
// xray reorders them).
|
// xray reorders them).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DnsOutboundFormSettings,
|
DnsOutboundFormSettings,
|
||||||
@@ -13,7 +14,6 @@ import type {
|
|||||||
OutboundFormSettings,
|
OutboundFormSettings,
|
||||||
OutboundFormValues,
|
OutboundFormValues,
|
||||||
OutboundStreamFormValues,
|
OutboundStreamFormValues,
|
||||||
ReverseSniffingForm,
|
|
||||||
ShadowsocksOutboundFormSettings,
|
ShadowsocksOutboundFormSettings,
|
||||||
TrojanOutboundFormSettings,
|
TrojanOutboundFormSettings,
|
||||||
VlessOutboundFormSettings,
|
VlessOutboundFormSettings,
|
||||||
@@ -55,21 +55,28 @@ function asPort(value: unknown, fallback: number): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REVERSE_SNIFFING_DEFAULT: ReverseSniffingForm = {
|
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
|
||||||
|
|
||||||
|
const SNIFFING_DEFAULT: Sniffing = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
destOverride: [...SNIFFING_DEST_VALUES],
|
||||||
metadataOnly: false,
|
metadataOnly: false,
|
||||||
routeOnly: false,
|
routeOnly: false,
|
||||||
ipsExcluded: [],
|
ipsExcluded: [],
|
||||||
domainsExcluded: [],
|
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 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 {
|
return {
|
||||||
enabled: asBool(r.enabled),
|
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),
|
metadataOnly: asBool(r.metadataOnly),
|
||||||
routeOnly: asBool(r.routeOnly),
|
routeOnly: asBool(r.routeOnly),
|
||||||
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
|
ipsExcluded: asArray(r.ipsExcluded).map((x) => asString(x)),
|
||||||
@@ -112,8 +119,8 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
|||||||
const reverse = asObject(raw.reverse);
|
const reverse = asObject(raw.reverse);
|
||||||
const reverseTag = asString(reverse.tag);
|
const reverseTag = asString(reverse.tag);
|
||||||
const reverseSniffing = reverseTag
|
const reverseSniffing = reverseTag
|
||||||
? reverseSniffingFromWire(reverse.sniffing)
|
? sniffingFromWire(reverse.sniffing)
|
||||||
: REVERSE_SNIFFING_DEFAULT;
|
: SNIFFING_DEFAULT;
|
||||||
const savedSeed = asArray(raw.testseed);
|
const savedSeed = asArray(raw.testseed);
|
||||||
const testseed = savedSeed.length === 4
|
const testseed = savedSeed.length === 4
|
||||||
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||||
@@ -324,7 +331,10 @@ function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
|
function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
|
||||||
return { inboundTag: asString(raw.inboundTag) };
|
return {
|
||||||
|
inboundTag: asString(raw.inboundTag),
|
||||||
|
sniffing: sniffingFromWire(raw.sniffing),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function muxFromWire(raw: unknown): MuxForm {
|
function muxFromWire(raw: unknown): MuxForm {
|
||||||
@@ -417,7 +427,7 @@ function vmessToWire(s: VmessOutboundFormSettings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reverseSniffingToWire(s: ReverseSniffingForm) {
|
function sniffingToWire(s: Sniffing) {
|
||||||
return {
|
return {
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
destOverride: s.destOverride,
|
destOverride: s.destOverride,
|
||||||
@@ -437,8 +447,8 @@ function vlessToWire(s: VlessOutboundFormSettings) {
|
|||||||
encryption: s.encryption || 'none',
|
encryption: s.encryption || 'none',
|
||||||
};
|
};
|
||||||
if (s.reverseTag) {
|
if (s.reverseTag) {
|
||||||
const sn = reverseSniffingToWire(s.reverseSniffing);
|
const sn = sniffingToWire(s.reverseSniffing);
|
||||||
const defaultSn = reverseSniffingToWire(REVERSE_SNIFFING_DEFAULT);
|
const defaultSn = sniffingToWire(SNIFFING_DEFAULT);
|
||||||
result.reverse = {
|
result.reverse = {
|
||||||
tag: s.reverseTag,
|
tag: s.reverseTag,
|
||||||
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
|
sniffing: JSON.stringify(sn) === JSON.stringify(defaultSn) ? {} : sn,
|
||||||
@@ -563,7 +573,13 @@ function dnsToWire(s: DnsOutboundFormSettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopbackToWire(s: LoopbackOutboundFormSettings) {
|
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().
|
// 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
|
// 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.
|
// always empty. Offer it only then; `listen`/`custom` work for local inbounds.
|
||||||
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
|
||||||
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
|
|
||||||
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
||||||
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
||||||
const isSSWith2022 = isSS2022({
|
const isSSWith2022 = isSS2022({
|
||||||
@@ -977,7 +976,7 @@ export default function InboundFormModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
|
const sniffingTab = <SniffingTab />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,67 +1,16 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
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 { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
return (
|
return (
|
||||||
<>
|
<SniffingFields
|
||||||
<Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
|
name={['sniffing']}
|
||||||
<Switch />
|
form={form}
|
||||||
</Form.Item>
|
enableLabel={t('enable')}
|
||||||
|
/>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||||
|
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||||
import { JsonEditor } from '@/components/form';
|
import { JsonEditor } from '@/components/form';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
OutboundFormBaseSchema,
|
OutboundFormBaseSchema,
|
||||||
type OutboundFormValues,
|
type OutboundFormValues,
|
||||||
} from '@/schemas/forms/outbound-form';
|
} from '@/schemas/forms/outbound-form';
|
||||||
import { SNIFFING_OPTION } from '@/schemas/primitives';
|
|
||||||
import {
|
import {
|
||||||
canEnableReality,
|
canEnableReality,
|
||||||
canEnableStream,
|
canEnableStream,
|
||||||
@@ -412,70 +411,12 @@ export default function OutboundFormModal({
|
|||||||
{() => {
|
{() => {
|
||||||
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
|
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
|
||||||
if (!reverseTag) return null;
|
if (!reverseTag) return null;
|
||||||
const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SniffingFields
|
||||||
<Form.Item
|
name={['settings', 'reverseSniffing']}
|
||||||
label={t('pages.xray.outboundForm.reverseSniffing')}
|
form={form}
|
||||||
name={['settings', 'reverseSniffing', 'enabled']}
|
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Form, Input } from 'antd';
|
import { Form, Input } from 'antd';
|
||||||
|
|
||||||
|
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||||
|
|
||||||
export default function LoopbackFields() {
|
export default function LoopbackFields() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
<>
|
||||||
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
|
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
||||||
</Form.Item>
|
<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 { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||||
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
|
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
|
export const InboundStreamFormSchema = NetworkSettingsSchema
|
||||||
.and(SecuritySettingsSchema)
|
.and(SecuritySettingsSchema)
|
||||||
.and(StreamExtrasSchema);
|
.and(StreamExtrasSchema);
|
||||||
@@ -43,9 +30,6 @@ export const InboundDbFieldsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
|
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({
|
export const InboundFormBaseSchema = z.object({
|
||||||
remark: z.string().default(''),
|
remark: z.string().default(''),
|
||||||
enable: z.boolean().default(true),
|
enable: z.boolean().default(true),
|
||||||
@@ -73,10 +57,6 @@ export const InboundFormSchema = InboundFormBaseSchema
|
|||||||
.and(InboundSettingsSchema);
|
.and(InboundSettingsSchema);
|
||||||
export type InboundFormValues = z.infer<typeof InboundFormSchema>;
|
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({
|
export const FallbackRowSchema = z.object({
|
||||||
rowKey: z.string(),
|
rowKey: z.string(),
|
||||||
childId: z.number().int().nullable(),
|
childId: z.number().int().nullable(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
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 { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
|
||||||
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
|
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
|
||||||
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
|
||||||
@@ -15,28 +15,6 @@ import {
|
|||||||
WireguardDomainStrategySchema,
|
WireguardDomainStrategySchema,
|
||||||
} from '@/schemas/protocols/outbound';
|
} 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({
|
export const VmessOutboundFormSettingsSchema = z.object({
|
||||||
address: z.string().default(''),
|
address: z.string().default(''),
|
||||||
port: PortSchema.default(443),
|
port: PortSchema.default(443),
|
||||||
@@ -45,20 +23,18 @@ export const VmessOutboundFormSettingsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
|
export type VmessOutboundFormSettings = z.infer<typeof VmessOutboundFormSettingsSchema>;
|
||||||
|
|
||||||
// Reverse-sniffing is only emitted when reverseTag is non-empty. Defaults
|
// Reverse sniffing (VLESS) and loopback sniffing share the canonical
|
||||||
// match legacy ReverseSniffing constructor.
|
// SniffingSchema — the same definition the inbound Sniffing tab uses — so
|
||||||
export const ReverseSniffingFormSchema = z.object({
|
// there is one source of truth for an xray SniffingConfig across the panel.
|
||||||
enabled: z.boolean().default(false),
|
const DEFAULT_SNIFFING: Sniffing = {
|
||||||
destOverride: z.array(z.string()).default(['http', 'tls', 'quic', 'fakedns']),
|
enabled: false,
|
||||||
metadataOnly: z.boolean().default(false),
|
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||||
routeOnly: z.boolean().default(false),
|
metadataOnly: false,
|
||||||
ipsExcluded: z.array(z.string()).default([]),
|
routeOnly: false,
|
||||||
domainsExcluded: z.array(z.string()).default([]),
|
ipsExcluded: [],
|
||||||
});
|
domainsExcluded: [],
|
||||||
export type ReverseSniffingForm = z.infer<typeof ReverseSniffingFormSchema>;
|
};
|
||||||
|
|
||||||
// VLESS outbound: flat connect target + auth + Vision-specific knobs +
|
|
||||||
// reverse-sniffing slice. testpre/testseed live behind canEnableVisionSeed.
|
|
||||||
export const VlessOutboundFormSettingsSchema = z.object({
|
export const VlessOutboundFormSettingsSchema = z.object({
|
||||||
address: z.string().default(''),
|
address: z.string().default(''),
|
||||||
port: PortSchema.default(443),
|
port: PortSchema.default(443),
|
||||||
@@ -66,14 +42,7 @@ export const VlessOutboundFormSettingsSchema = z.object({
|
|||||||
flow: z.string().default(''),
|
flow: z.string().default(''),
|
||||||
encryption: z.string().min(1).default('none'),
|
encryption: z.string().min(1).default('none'),
|
||||||
reverseTag: z.string().default(''),
|
reverseTag: z.string().default(''),
|
||||||
reverseSniffing: ReverseSniffingFormSchema.default({
|
reverseSniffing: SniffingSchema.default(DEFAULT_SNIFFING),
|
||||||
enabled: false,
|
|
||||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
|
||||||
metadataOnly: false,
|
|
||||||
routeOnly: false,
|
|
||||||
ipsExcluded: [],
|
|
||||||
domainsExcluded: [],
|
|
||||||
}),
|
|
||||||
testpre: z.number().int().min(0).default(0),
|
testpre: z.number().int().min(0).default(0),
|
||||||
testseed: z.array(z.number().int().positive()).default([]),
|
testseed: z.array(z.number().int().positive()).default([]),
|
||||||
});
|
});
|
||||||
@@ -205,26 +174,29 @@ export const DnsOutboundFormSettingsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type DnsOutboundFormSettings = z.infer<typeof DnsOutboundFormSettingsSchema>;
|
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({
|
export const LoopbackOutboundFormSettingsSchema = z.object({
|
||||||
inboundTag: z.string().default(''),
|
inboundTag: z.string().default(''),
|
||||||
|
sniffing: SniffingSchema.default(DEFAULT_SNIFFING),
|
||||||
});
|
});
|
||||||
export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
|
export type LoopbackOutboundFormSettings = z.infer<typeof LoopbackOutboundFormSettingsSchema>;
|
||||||
|
|
||||||
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
|
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
|
||||||
// inbound side: each branch is { protocol: literal, settings: <flat> }.
|
// inbound side: each branch is { protocol: literal, settings: <flat> }.
|
||||||
export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
|
export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
|
||||||
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('vless'), settings: VlessOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('dns'), settings: DnsOutboundFormSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
|
z.object({ protocol: z.literal('loopback'), settings: LoopbackOutboundFormSettingsSchema }),
|
||||||
]);
|
]);
|
||||||
export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
|
export type OutboundFormSettings = z.infer<typeof OutboundFormSettingsSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ export const VlessClientSchema = z.object({
|
|||||||
subId: z.string().default(''),
|
subId: z.string().default(''),
|
||||||
comment: z.string().default(''),
|
comment: z.string().default(''),
|
||||||
reset: z.number().int().min(0).default(0),
|
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
|
reverse: z
|
||||||
.object({
|
.object({
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
@@ -42,9 +39,6 @@ export const VlessInboundSettingsSchema = z.object({
|
|||||||
decryption: z.string().min(1).default('none'),
|
decryption: z.string().min(1).default('none'),
|
||||||
encryption: z.string().min(1).default('none'),
|
encryption: z.string().min(1).default('none'),
|
||||||
fallbacks: z.array(VlessFallbackSchema).default([]),
|
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(),
|
testseed: z.array(z.number().int().positive()).length(4).optional(),
|
||||||
});
|
});
|
||||||
export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;
|
export type VlessInboundSettings = z.infer<typeof VlessInboundSettingsSchema>;
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export * from './vless';
|
|||||||
export * from './vmess';
|
export * from './vmess';
|
||||||
export * from './wireguard';
|
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', [
|
export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
|
||||||
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundSettingsSchema }),
|
z.object({ protocol: z.literal('vmess'), settings: VmessOutboundSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('vless'), settings: VlessOutboundSettingsSchema }),
|
z.object({ protocol: z.literal('vless'), settings: VlessOutboundSettingsSchema }),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Loopback outbound reinjects traffic back into a named inbound for chained
|
import { SniffingSchema } from '@/schemas/primitives';
|
||||||
// routing. The single `inboundTag` field references an inbound tag by name.
|
|
||||||
export const LoopbackOutboundSettingsSchema = z.object({
|
export const LoopbackOutboundSettingsSchema = z.object({
|
||||||
inboundTag: z.string().optional(),
|
inboundTag: z.string().optional(),
|
||||||
|
sniffing: SniffingSchema.optional(),
|
||||||
});
|
});
|
||||||
export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;
|
export type LoopbackOutboundSettings = z.infer<typeof LoopbackOutboundSettingsSchema>;
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { PortSchema } from '@/schemas/primitives';
|
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({
|
export const TrojanOutboundServerSchema = z.object({
|
||||||
address: z.string().min(1),
|
address: z.string().min(1),
|
||||||
port: PortSchema,
|
port: PortSchema,
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { z } from 'zod';
|
|||||||
import { PortSchema } from '@/schemas/primitives';
|
import { PortSchema } from '@/schemas/primitives';
|
||||||
import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
|
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({
|
export const VmessOutboundUserSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
security: VmessSecuritySchema.default('auto'),
|
security: VmessSecuritySchema.default('auto'),
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const WireguardDomainStrategySchema = z.enum([
|
|||||||
]);
|
]);
|
||||||
export type WireguardDomainStrategy = z.infer<typeof WireguardDomainStrategySchema>;
|
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({
|
export const WireguardOutboundPeerSchema = z.object({
|
||||||
publicKey: z.string().min(1),
|
publicKey: z.string().min(1),
|
||||||
preSharedKey: z.string().optional(),
|
preSharedKey: z.string().optional(),
|
||||||
@@ -20,9 +18,6 @@ export const WireguardOutboundPeerSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type WireguardOutboundPeer = z.infer<typeof WireguardOutboundPeerSchema>;
|
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({
|
export const WireguardOutboundSettingsSchema = z.object({
|
||||||
mtu: z.number().int().min(1).optional(),
|
mtu: z.number().int().min(1).optional(),
|
||||||
secretKey: z.string().min(1),
|
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`] = `
|
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
|
||||||
{
|
{
|
||||||
"tcp": [
|
"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' });
|
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', () => {
|
it('unknown protocol falls back to vless without throwing', () => {
|
||||||
const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
|
const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
|
||||||
expect(form.protocol).toBe('vless');
|
expect(form.protocol).toBe('vless');
|
||||||
|
|||||||
Reference in New Issue
Block a user