diff --git a/frontend/src/lib/xray/forms/SniffingFields.tsx b/frontend/src/lib/xray/forms/SniffingFields.tsx
new file mode 100644
index 000000000..139b2a761
--- /dev/null
+++ b/frontend/src/lib/xray/forms/SniffingFields.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Select, Switch } from 'antd';
+import type { FormInstance } from 'antd/es/form';
+
+import { SNIFFING_OPTION } from '@/schemas/primitives';
+
+const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
+
+export interface SniffingFieldsProps {
+ // Base path to the sniffing object in the form, e.g. ['sniffing'] (inbound),
+ // ['settings', 'reverseSniffing'] (VLESS reverse), ['settings', 'sniffing']
+ // (loopback). All sub-fields hang off this path.
+ name: (string | number)[];
+ form: FormInstance;
+ // Label for the enable toggle — Enable / Reverse Sniffing / Sniffing differ
+ // per host.
+ enableLabel: string;
+}
+
+// Shared sniffing form fragment used everywhere the panel edits an xray
+// SniffingConfig: the inbound Sniffing tab, VLESS reverse sniffing, and the
+// loopback outbound. Renders the enable toggle plus the destOverride /
+// metadataOnly / routeOnly / excluded fields when enabled.
+export default function SniffingFields({ name, form, enableLabel }: SniffingFieldsProps) {
+ const { t } = useTranslation();
+ const enabled = Form.useWatch([...name, 'enabled'], form) ?? false;
+
+ return (
+ <>
+
+
+
+
+ {enabled && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
index 3eb48d86c..b14ebcd56 100644
--- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
+++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
@@ -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 {
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 {
}
}
+// 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): { next: Record; changed: boolean } {
+ const out: Record = { ...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 {
switch (type) {
case 'salamander':
@@ -137,6 +166,29 @@ function defaultUdpHop(): Record {
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;
+ if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
+ const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record);
+ 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"
/>
-
-
-
-
-
-
+
+
@@ -321,9 +376,6 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
}
-// Walks a deep object path safely. Used inside shouldUpdate which gets
-// the whole form values blob; we need to compare a deep field across
-// prev/curr without crashing on missing intermediates.
function validateFragmentLength(_rule: unknown, value: unknown): Promise {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
if (str.length === 0) {
@@ -336,6 +388,61 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise {
return Promise.resolve();
}
+// A delay segment is a millisecond value or range; 0 is allowed (no delay),
+// but an empty row would serialize as "" and break xray's Int32Range parse,
+// so require a value and let the user remove the row instead.
+function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise {
+ const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+ if (str.length === 0) {
+ return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay"));
+ }
+ if (!/^\d+(?:-\d+)?$/.test(str)) {
+ return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
+ }
+ return Promise.resolve();
+}
+
+// Per-segment range list for a fragment mask's `lengths`/`delays` (xray-core
+// #6334): an editable list of dash-range strings. xray applies entry N to
+// fragment segment N, clamping to the last entry. `minItems` keeps at least
+// one length row so the config never collapses to an empty (rejected) list.
+function FragmentRangeList({
+ listName, label, placeholder, validator, minItems = 0,
+}: {
+ listName: (string | number)[];
+ label: string;
+ placeholder: string;
+ validator?: (rule: unknown, value: unknown) => Promise;
+ minItems?: number;
+}) {
+ return (
+
+ {(fields, { add, remove }) => (
+ <>
+
+ } onClick={() => add('')} />
+
+ {fields.map((field, idx) => (
+
+ minItems
+ ? remove(field.name)} />
+ : null}
+ />
+
+ ))}
+ >
+ )}
+
+ );
+}
+
// 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).
diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts
index 4caf2d52f..ca501716e 100644
--- a/frontend/src/lib/xray/outbound-form-adapter.ts
+++ b/frontend/src/lib/xray/outbound-form-adapter.ts
@@ -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().
diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx
index 9600cbe77..779adbd77 100644
--- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx
+++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx
@@ -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({
);
- const sniffingTab = ;
+ const sniffingTab = ;
return (
<>
diff --git a/frontend/src/pages/inbounds/form/SniffingTab.tsx b/frontend/src/pages/inbounds/form/SniffingTab.tsx
index dea594527..ad94fb957 100644
--- a/frontend/src/pages/inbounds/form/SniffingTab.tsx
+++ b/frontend/src/pages/inbounds/form/SniffingTab.tsx
@@ -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 (
- <>
-
-
-
-
- {sniffingEnabled && (
- <>
-
-
- {Object.entries(SNIFFING_OPTION).map(([key, value]) => (
- {key}
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
+
);
}
diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
index 801a534ea..4df48d0c1 100644
--- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
+++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
@@ -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 (
- <>
-
-
-
- {sniff.enabled && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- >
+
);
}}
diff --git a/frontend/src/pages/xray/outbounds/protocols/loopback.tsx b/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
index d75e693cf..561460756 100644
--- a/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/loopback.tsx
@@ -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 (
-
-
-
+ <>
+
+
+
+
+
+ >
);
}
diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts
index ae10581bc..a729c2fbc 100644
--- a/frontend/src/schemas/forms/inbound-form.ts
+++ b/frontend/src/schemas/forms/inbound-form.ts
@@ -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() 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;
-// 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;
-// 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(),
diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts
index 4bff024d3..109ebab2b 100644
--- a/frontend/src/schemas/forms/outbound-form.ts
+++ b/frontend/src/schemas/forms/outbound-form.ts
@@ -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() 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;
-// 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;
+// 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;
+// 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;
// Discriminated union on `protocol`. Same tagged-wrapper pattern as the
// inbound side: each branch is { protocol: literal, settings: }.
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;
diff --git a/frontend/src/schemas/protocols/inbound/vless.ts b/frontend/src/schemas/protocols/inbound/vless.ts
index 666bbafd4..4b67eb751 100644
--- a/frontend/src/schemas/protocols/inbound/vless.ts
+++ b/frontend/src/schemas/protocols/inbound/vless.ts
@@ -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;
diff --git a/frontend/src/schemas/protocols/outbound/index.ts b/frontend/src/schemas/protocols/outbound/index.ts
index 0dd3af201..8087a109b 100644
--- a/frontend/src/schemas/protocols/outbound/index.ts
+++ b/frontend/src/schemas/protocols/outbound/index.ts
@@ -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 }),
diff --git a/frontend/src/schemas/protocols/outbound/loopback.ts b/frontend/src/schemas/protocols/outbound/loopback.ts
index f7079df8f..6bde38493 100644
--- a/frontend/src/schemas/protocols/outbound/loopback.ts
+++ b/frontend/src/schemas/protocols/outbound/loopback.ts
@@ -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;
diff --git a/frontend/src/schemas/protocols/outbound/trojan.ts b/frontend/src/schemas/protocols/outbound/trojan.ts
index 37a10be57..309c1b44d 100644
--- a/frontend/src/schemas/protocols/outbound/trojan.ts
+++ b/frontend/src/schemas/protocols/outbound/trojan.ts
@@ -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,
diff --git a/frontend/src/schemas/protocols/outbound/vmess.ts b/frontend/src/schemas/protocols/outbound/vmess.ts
index 6c82e939e..b40a1834f 100644
--- a/frontend/src/schemas/protocols/outbound/vmess.ts
+++ b/frontend/src/schemas/protocols/outbound/vmess.ts
@@ -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'),
diff --git a/frontend/src/schemas/protocols/outbound/wireguard.ts b/frontend/src/schemas/protocols/outbound/wireguard.ts
index 6080b1a1e..2207908d3 100644
--- a/frontend/src/schemas/protocols/outbound/wireguard.ts
+++ b/frontend/src/schemas/protocols/outbound/wireguard.ts
@@ -9,8 +9,6 @@ export const WireguardDomainStrategySchema = z.enum([
]);
export type WireguardDomainStrategy = z.infer;
-// 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;
-// 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),
diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap
index 893adff63..d962518f9 100644
--- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap
+++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap
@@ -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": [
diff --git a/frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json b/frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json
new file mode 100644
index 000000000..7e7827084
--- /dev/null
+++ b/frontend/src/test/golden/fixtures/finalmask/tcp-fragment-segments.json
@@ -0,0 +1,13 @@
+{
+ "tcp": [
+ {
+ "type": "fragment",
+ "settings": {
+ "packets": "tlshello",
+ "lengths": ["10-20", "50-100"],
+ "delays": ["5-10", "0"],
+ "maxSplit": "0"
+ }
+ }
+ ]
+}
diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts
index 10d17cf41..f28a54611 100644
--- a/frontend/src/test/outbound-form-adapter.test.ts
+++ b/frontend/src/test/outbound-form-adapter.test.ts
@@ -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).sniffing as Record;
+ 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');