Bläddra i källkod

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
MHSanaei 1 dag sedan
förälder
incheckning
852b53db79

+ 63 - 0
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 (
+    <>
+      <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>
+        </>
+      )}
+    </>
+  );
+}

+ 121 - 14
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<string, unknown> {
   switch (type) {
     case 'fragment':
-      return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
+      // `lengths`/`delays` are per-segment range arrays (xray-core #6334);
+      // a single length entry reproduces the legacy single-range behavior.
+      return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
     case 'sudoku':
       return {
         password: '', ascii: '', customTable: '', customTables: [],
@@ -81,6 +84,32 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
   }
 }
 
+// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
+// with `lengths`/`delays` arrays (the singular keys remain in core only as a
+// fallback). Lift any legacy singular value into a one-element array so the
+// list UI shows it, and drop the singular key so we never emit both.
+function migrateFragmentSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
+  const out: Record<string, unknown> = { ...settings };
+  let changed = false;
+  if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
+    out.lengths = [out.length];
+    changed = true;
+  }
+  if ('length' in out) {
+    delete out.length;
+    changed = true;
+  }
+  if (!Array.isArray(out.delays) && typeof out.delay === 'string' && out.delay.trim() !== '') {
+    out.delays = [out.delay];
+    changed = true;
+  }
+  if ('delay' in out) {
+    delete out.delay;
+    changed = true;
+  }
+  return { next: out, changed };
+}
+
 function defaultUdpMaskSettings(type: string): Record<string, unknown> {
   switch (type) {
     case 'salamander':
@@ -137,6 +166,29 @@ function defaultUdpHop(): Record<string, unknown> {
 
 export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
+
+  // Migrate legacy single-range fragment masks to the per-segment arrays once
+  // on mount so configs saved before #6334 render in the list UI.
+  const migratedRef = useRef(false);
+  useEffect(() => {
+    if (migratedRef.current) return;
+    migratedRef.current = true;
+    const tcp = form.getFieldValue([...base, 'tcp']);
+    if (!Array.isArray(tcp)) return;
+    let anyChanged = false;
+    const next = tcp.map((mask) => {
+      if (!mask || typeof mask !== 'object') return mask;
+      const m = mask as Record<string, unknown>;
+      if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
+      const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
+      if (!changed) return mask;
+      anyChanged = true;
+      return { ...m, settings: migrated };
+    });
+    if (anyChanged) form.setFieldValue([...base, 'tcp'], next);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
   // Wireguard carries no user-selectable transport (always a UDP listener/
   // dialer), so only the UDP mask section applies — TCP masks would never
@@ -261,16 +313,19 @@ function TcpMaskItem({
                     placeholder="tlshello or n-m, e.g. 1-3"
                   />
                 </Form.Item>
-                <Form.Item
-                  label="Length"
-                  name={[fieldName, 'settings', 'length']}
-                  rules={[{ validator: validateFragmentLength }]}
-                >
-                  <Input placeholder="e.g. 100-200" />
-                </Form.Item>
-                <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
-                  <Input />
-                </Form.Item>
+                <FragmentRangeList
+                  listName={[fieldName, 'settings', 'lengths']}
+                  label="Lengths"
+                  placeholder="e.g. 100-200"
+                  minItems={1}
+                  validator={validateFragmentLength}
+                />
+                <FragmentRangeList
+                  listName={[fieldName, 'settings', 'delays']}
+                  label="Delays"
+                  placeholder="e.g. 10-20 or 0"
+                  validator={validateFragmentDelayEntry}
+                />
                 <Form.Item label="Max Split" name={[fieldName, 'settings', 'maxSplit']}>
                   <Input />
                 </Form.Item>
@@ -321,9 +376,6 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
   return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
 }
 
-// Walks a deep object path safely. Used inside shouldUpdate which gets
-// the whole form values blob; we need to compare a deep field across
-// prev/curr without crashing on missing intermediates.
 function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
   if (str.length === 0) {
@@ -336,6 +388,61 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
   return Promise.resolve();
 }
 
+// A delay segment is a millisecond value or range; 0 is allowed (no delay),
+// but an empty row would serialize as "" and break xray's Int32Range parse,
+// so require a value and let the user remove the row instead.
+function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<void> {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  if (str.length === 0) {
+    return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay"));
+  }
+  if (!/^\d+(?:-\d+)?$/.test(str)) {
+    return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
+  }
+  return Promise.resolve();
+}
+
+// Per-segment range list for a fragment mask's `lengths`/`delays` (xray-core
+// #6334): an editable list of dash-range strings. xray applies entry N to
+// fragment segment N, clamping to the last entry. `minItems` keeps at least
+// one length row so the config never collapses to an empty (rejected) list.
+function FragmentRangeList({
+  listName, label, placeholder, validator, minItems = 0,
+}: {
+  listName: (string | number)[];
+  label: string;
+  placeholder: string;
+  validator?: (rule: unknown, value: unknown) => Promise<void>;
+  minItems?: number;
+}) {
+  return (
+    <Form.List name={listName}>
+      {(fields, { add, remove }) => (
+        <>
+          <Form.Item label={label}>
+            <Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
+          </Form.Item>
+          {fields.map((field, idx) => (
+            <Form.Item
+              key={field.key}
+              label={`#${idx + 1}`}
+              name={field.name}
+              rules={validator ? [{ validator }] : undefined}
+            >
+              <Input
+                placeholder={placeholder}
+                addonAfter={fields.length > minItems
+                  ? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
+                  : null}
+              />
+            </Form.Item>
+          ))}
+        </>
+      )}
+    </Form.List>
+  );
+}
+
 // randRange bytes must sit in 0-255 — xray rejects the whole config with
 // "invalid randRange" otherwise (reversed ranges like "200-100" are fine,
 // xray reorders them).

+ 29 - 13
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().

+ 1 - 2
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({
     </div>
   );
 
-  const sniffingTab = <SniffingTab sniffingEnabled={sniffingEnabled} />;
+  const sniffingTab = <SniffingTab />;
 
   return (
     <>

+ 9 - 60
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 (
-    <>
-      <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')}
+    />
   );
 }

+ 6 - 65
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 (
-                            <>
-                              <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>

+ 15 - 3
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 (
-    <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')}
+      />
+    </>
   );
 }

+ 0 - 20
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<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(),

+ 27 - 55
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<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>;
 

+ 0 - 6
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<typeof VlessInboundSettingsSchema>;

+ 0 - 4
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 }),

+ 3 - 2
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<typeof LoopbackOutboundSettingsSchema>;

+ 0 - 3
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,

+ 0 - 3
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'),

+ 0 - 5
frontend/src/schemas/protocols/outbound/wireguard.ts

@@ -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),

+ 23 - 0
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": [

+ 13 - 0
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"
+      }
+    }
+  ]
+}

+ 33 - 0
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<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');