Преглед на файлове

fix(xhttp): stop XMUX Max Concurrency from reverting on save

XHttpXmuxSchema defaulted maxConnections to 6 (added for xray-core's
anti-RKN behavior), so toggling XMUX on - or simply loading any
previously-saved inbound/outbound - silently populated maxConnections
even when the user only ever touched maxConcurrency. The save-time
mutual-exclusivity rule then saw both fields non-zero and discarded
maxConcurrency, and the deleted value came back as the '16-32' schema
default on the next load, making the field look like it never saved.

Revert the schema default to 0 and instead seed the anti-RKN
maxConnections=6 default only where XMUX is freshly enabled
(XMUX_FRESH_DEFAULTS), with maxConcurrency left blank so the two never
start out conflicting. Also make maxConcurrency/maxConnections clear
each other live in both the inbound and outbound XMUX forms, so
whichever field the user actually edits is the one that gets saved.

Addresses #5864

Co-authored-by: Sanaei <[email protected]>
claude[bot] преди 21 часа
родител
ревизия
133ab83a25

+ 1 - 1
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -46,7 +46,7 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
 // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
 // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
 // mutual-exclusivity check on the `.To` (upper) side.
-function int32RangeUpper(v: unknown): number {
+export function int32RangeUpper(v: unknown): number {
   if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
   if (typeof v !== 'string') return 0;
   const trimmed = v.trim();

+ 24 - 4
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -4,10 +4,9 @@ import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
-import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { XHTTP_SESSION_ID_TABLES, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
-
-const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
+import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
 
 function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
   return async (value: unknown): Promise<true | string> => {
@@ -36,7 +35,26 @@ export default function XhttpForm() {
     const existing = getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
-    setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
+    setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
+  }
+
+  // maxConcurrency and maxConnections are mutually exclusive on the wire
+  // (xray-core rejects a config that sets both) — clear the other field
+  // as soon as the user picks one, so the value they just typed is what
+  // actually gets saved instead of silently losing to whichever field
+  // still holds a leftover default (#5864).
+  function onXmuxMaxConcurrencyChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConnections')) > 0) {
+      setValue('streamSettings.xhttpSettings.xmux.maxConnections', 0);
+    }
+  }
+
+  function onXmuxMaxConnectionsChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConcurrency')) > 0) {
+      setValue('streamSettings.xhttpSettings.xmux.maxConcurrency', '');
+    }
   }
 
   return (
@@ -295,12 +313,14 @@ export default function XhttpForm() {
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={onXmuxMaxConcurrencyChange}
           >
             <Input placeholder="16-32" />
           </FormField>
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={onXmuxMaxConnectionsChange}
           >
             <Input placeholder="0" />
           </FormField>

+ 2 - 2
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -17,11 +17,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { JsonEditor } from '@/components/form';
 import { Wireguard } from '@/utils';
 import {
-  XMUX_DEFAULTS,
   formValuesToWirePayload,
   rawOutboundToFormValues,
 } from '@/lib/xray/outbound-form-adapter';
 import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
+import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 import {
   OutboundFormBaseSchema,
   type OutboundFormValues,
@@ -255,7 +255,7 @@ export default function OutboundFormModal({
     const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
-    methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
+    methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
   }
 
   const duplicateTag = useMemo(() => {

+ 23 - 1
frontend/src/pages/xray/outbounds/transport/xhttp.tsx

@@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
 import { HeaderMapEditor } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
+import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
 import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
 
 import { MODE_OPTIONS } from '../outbound-form-constants';
@@ -28,7 +29,7 @@ const XH = 'streamSettings.xhttpSettings';
 
 export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const { t } = useTranslation();
-  const { control } = useFormContext();
+  const { control, getValues, setValue } = useFormContext();
   const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
   const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
   const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
@@ -37,6 +38,25 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
   const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
 
+  // maxConcurrency and maxConnections are mutually exclusive on the wire
+  // (xray-core rejects a config that sets both) — clear the other field
+  // as soon as the user picks one, so the value they just typed is what
+  // actually gets saved instead of silently losing to whichever field
+  // still holds a leftover default (#5864).
+  function onXmuxMaxConcurrencyChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) {
+      setValue(`${XH}.xmux.maxConnections`, 0);
+    }
+  }
+
+  function onXmuxMaxConnectionsChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) {
+      setValue(`${XH}.xmux.maxConcurrency`, '');
+    }
+  }
+
   return (
     <>
       <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
@@ -283,12 +303,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={onXmuxMaxConcurrencyChange}
           >
             <Input placeholder="16-32" />
           </FormField>
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={onXmuxMaxConnectionsChange}
           >
             <Input placeholder="0" />
           </FormField>

+ 24 - 1
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -15,9 +15,20 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
 // XMUX is the connection-multiplexing layer xHTTP uses to fan out
 // parallel requests over a small pool of upstream connections. Fields
 // are strings because they accept dash-range values like '16-32'.
+// maxConcurrency and maxConnections are mutually exclusive strategies —
+// xray-core rejects a config that sets both (see resolveXmuxExclusivity
+// in stream-wire-normalize.ts) — so the schema default only ever makes
+// ONE of them non-zero. Baking a non-zero maxConnections in here too
+// (previously done to mirror xray-core v26.6.27's own maxConnections=6
+// anti-RKN fallback) made every load of a previously-saved config that
+// only ever set maxConcurrency silently regain a "conflicting" second
+// value, which then got the user's real maxConcurrency deleted on the
+// very next save (#5864). XMUX_FRESH_DEFAULTS below is the one place
+// that intentionally seeds the anti-RKN default, for a config that never
+// had an xmux block before.
 export const XHttpXmuxSchema = z.object({
   maxConcurrency: z.string().default('16-32'),
-  maxConnections: z.union([z.string(), z.number()]).default(6),
+  maxConnections: z.union([z.string(), z.number()]).default(0),
   cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
   hMaxRequestTimes: z.string().default('600-900'),
   hMaxReusableSecs: z.string().default('1800-3000'),
@@ -25,6 +36,18 @@ export const XHttpXmuxSchema = z.object({
 });
 export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
 
+// Seed values for freshly enabling XMUX on a config that had no xmux
+// block at all. Mirrors xray-core v26.6.27's own maxConnections=6
+// anti-RKN fallback instead of the schema's baseline maxConcurrency
+// default, so turning XMUX on without touching either field matches what
+// xray-core already does out of the box — without leaving both fields
+// non-zero simultaneously.
+export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
+  ...XHttpXmuxSchema.parse({}),
+  maxConcurrency: '',
+  maxConnections: 6,
+};
+
 // Predefined sessionIDTable names xray-core accepts as a shorthand for a
 // charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
 // charset string is also accepted.

+ 37 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -353,3 +353,40 @@ describe('legacy xhttp session keys on edit (#5621)', () => {
     expect(out.sessionKey).toBeUndefined();
   });
 });
+
+// Regression (#5864): an inbound saved before xmux.maxConnections got a
+// non-zero schema default only ever persisted maxConcurrency. Loading it
+// must not resurrect a phantom maxConnections that then fights
+// maxConcurrency and deletes it again on the very next save, even if the
+// user never touches the XMUX section at all.
+describe('xhttp xmux maxConnections stays off when only maxConcurrency was saved (#5864)', () => {
+  const xmuxRow: RawInboundRow = {
+    ...vlessRow,
+    streamSettings: {
+      network: 'xhttp',
+      security: 'none',
+      xhttpSettings: {
+        path: '/xh',
+        mode: 'auto',
+        xmux: { maxConcurrency: '1-2' },
+      },
+    },
+  };
+
+  it('rawInboundToFormValues does not default maxConnections to a non-zero value', () => {
+    const values = rawInboundToFormValues(xmuxRow);
+    const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
+    expect(xhttp.enableXmux).toBe(true);
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+    expect(xmux.maxConnections).toBe(0);
+  });
+
+  it('formValuesToWirePayload preserves maxConcurrency on an unedited re-save', () => {
+    const values = rawInboundToFormValues(xmuxRow);
+    const payload = formValuesToWirePayload(values);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
+    const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+  });
+});

+ 27 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -521,6 +521,33 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
     const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
     expect(wireXhttp).not.toHaveProperty('xmux');
   });
+
+  // Regression (#5864): a config that only ever saved maxConcurrency (no
+  // maxConnections key at all) must not regain a phantom non-zero
+  // maxConnections on load — that would fight the real maxConcurrency and
+  // delete it again on the very next save, with no XMUX field touched.
+  it('does not resurrect a non-zero maxConnections when only maxConcurrency was saved', () => {
+    const wire = {
+      ...xmuxWire,
+      streamSettings: {
+        ...xmuxWire.streamSettings,
+        xhttpSettings: {
+          ...xmuxWire.streamSettings.xhttpSettings,
+          xmux: { maxConcurrency: '1-2' },
+        },
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+    expect(xmux.maxConnections).toBe(0);
+
+    const back = formValuesToWirePayload(form);
+    const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
+    const wireXmux = wireXhttp.xmux as Record<string, unknown>;
+    expect(wireXmux.maxConcurrency).toBe('1-2');
+  });
 });
 
 describe('outbound-form-adapter: full optional-block round-trip', () => {

+ 15 - 5
frontend/src/test/stream-wire-normalize.test.ts

@@ -12,7 +12,7 @@ import {
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
-import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 
 describe('validateRealityTarget', () => {
   it('accepts host:port and bare port', () => {
@@ -153,19 +153,29 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(xmux.maxConnections).toBe('8');
   });
 
-  it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => {
-    expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6);
+  it('does not bake a non-zero maxConnections into the bare schema default (#5864)', () => {
+    // A bare parse must only ever make ONE of the exclusive fields non-zero,
+    // otherwise every load of a previously-saved config that only set
+    // maxConcurrency would regain a phantom conflicting maxConnections and
+    // lose maxConcurrency again on the very next save.
+    expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0);
+    expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
+  });
+
+  it('XMUX_FRESH_DEFAULTS seeds xray-core\'s anti-RKN maxConnections=6 default without an opposing maxConcurrency', () => {
+    expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
+    expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
 
     const out = normalizeXhttpForWire({
       path: '/app',
       mode: 'stream-one',
       enableXmux: true,
-      xmux: XHttpXmuxSchema.parse({}),
+      xmux: XMUX_FRESH_DEFAULTS,
     }, 'outbound');
 
     const xmux = out.xmux as Record<string, unknown>;
     expect(xmux.maxConnections).toBe(6);
-    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConcurrency).toBe('');
   });
 });