Просмотр исходного кода

fix: prevent xhttp xmux maxConcurrency from being dropped on save

Both maxConcurrency ('16-32') and maxConnections (6) default to a
non-zero value the instant XMUX is toggled on, so they already
collide before either field is touched. The save-time mutual-
exclusivity check always favored maxConnections and silently
dropped maxConcurrency, even when the admin only ever edited that
one field - so an explicit "1-2" always reverted back to "16-32"
after saving and reopening the inbound/outbound.

Live-clear the untouched sibling field in the form the moment
either becomes a real value, so the two schema defaults never
collide unless the admin actually chose to set both explicitly.
Also stop a live-cleared empty maxConcurrency from leaking onto
the wire as a literal empty string.

Fixes #5864

Co-authored-by: Sanaei <[email protected]>
claude[bot] 20 часов назад
Родитель
Сommit
7c2e84e36f

+ 24 - 2
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();
@@ -73,6 +73,26 @@ function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, u
   return xmux;
 }
 
+export type XmuxEditableField = 'maxConcurrency' | 'maxConnections';
+
+// Both maxConcurrency ('16-32') and maxConnections (6) default to a
+// non-zero value the instant XMUX is toggled on (XHttpXmuxSchema), so they
+// already collide before either field is touched — resolveXmuxExclusivity
+// above always favors maxConnections and silently drops maxConcurrency,
+// even when the admin only ever meant to edit maxConcurrency (#5864).
+// Forms call this from each field's onChange: the moment one becomes a
+// real (>0) value, live-clear the sibling in the UI so the save-time
+// exclusivity check above has nothing left to collide with.
+export function xmuxSiblingOnEdit(
+  edited: XmuxEditableField,
+  value: unknown,
+): { field: XmuxEditableField; value: '' | 0 } | undefined {
+  if (int32RangeUpper(value) <= 0) return undefined;
+  return edited === 'maxConcurrency'
+    ? { field: 'maxConnections', value: 0 }
+    : { field: 'maxConcurrency', value: '' };
+}
+
 /** Validates REALITY inbound `target` / `dest` (must include a port). */
 export function validateRealityTarget(target: string): string | undefined {
   const trimmed = target.trim();
@@ -189,7 +209,9 @@ export function normalizeXhttpForWire(
   }
 
   if (isRecord(out.xmux)) {
-    out.xmux = resolveXmuxExclusivity(out.xmux);
+    const xmux = { ...out.xmux };
+    dropEmptyStrings(xmux, ['maxConcurrency']);
+    out.xmux = resolveXmuxExclusivity(xmux);
   }
 
   dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);

+ 9 - 0
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -6,6 +6,7 @@ import { HeaderMapEditor } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
+import { xmuxSiblingOnEdit, type XmuxEditableField } from '@/lib/xray/stream-wire-normalize';
 
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
@@ -39,6 +40,12 @@ export default function XhttpForm() {
     setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
   }
 
+  function onXmuxFieldChange(edited: XmuxEditableField, value: unknown) {
+    const next = xmuxSiblingOnEdit(edited, value);
+    if (!next) return;
+    setValue(`streamSettings.xhttpSettings.xmux.${next.field}`, next.value);
+  }
+
   return (
     <>
       <FormField name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
@@ -295,12 +302,14 @@ export default function XhttpForm() {
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={(v) => onXmuxFieldChange('maxConcurrency', v)}
           >
             <Input placeholder="16-32" />
           </FormField>
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={(v) => onXmuxFieldChange('maxConnections', v)}
           >
             <Input placeholder="0" />
           </FormField>

+ 10 - 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 { xmuxSiblingOnEdit, type XmuxEditableField } 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, 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,12 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
   const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
 
+  function onXmuxFieldChange(edited: XmuxEditableField, value: unknown) {
+    const next = xmuxSiblingOnEdit(edited, value);
+    if (!next) return;
+    setValue(`${XH}.xmux.${next.field}`, next.value);
+  }
+
   return (
     <>
       <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
@@ -283,12 +290,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={(v) => onXmuxFieldChange('maxConcurrency', v)}
           >
             <Input placeholder="16-32" />
           </FormField>
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={(v) => onXmuxFieldChange('maxConnections', v)}
           >
             <Input placeholder="0" />
           </FormField>

+ 63 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -8,6 +8,7 @@ import {
   normalizeStreamSettingsForWire,
   normalizeXhttpForWire,
   validateRealityTarget,
+  xmuxSiblingOnEdit,
 } from '@/lib/xray/stream-wire-normalize';
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
@@ -167,6 +168,68 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(xmux.maxConnections).toBe(6);
     expect(xmux).not.toHaveProperty('maxConcurrency');
   });
+
+  // #5864 repro: editing maxConcurrency alone used to always lose to the
+  // maxConnections=6 default because both defaulted non-zero at once. The
+  // form now live-clears the untouched sibling to 0 (see xmuxSiblingOnEdit
+  // below) before save, so the exclusivity check above has nothing to
+  // collide with and the explicit maxConcurrency edit survives.
+  it('keeps an explicit maxConcurrency edit once the sibling has been live-cleared to 0', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { ...XHttpXmuxSchema.parse({}), maxConcurrency: '1-2', maxConnections: 0 },
+    }, 'inbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+    expect(xmux.maxConnections).toBe(0);
+  });
+
+  it('drops maxConcurrency when it is left as an empty string, regardless of maxConnections', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { ...XHttpXmuxSchema.parse({}), maxConcurrency: '', maxConnections: '10' },
+    }, 'outbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConnections).toBe('10');
+  });
+});
+
+describe('xmuxSiblingOnEdit', () => {
+  it('is a no-op while the edited field is still empty/zero', () => {
+    expect(xmuxSiblingOnEdit('maxConcurrency', '')).toBeUndefined();
+    expect(xmuxSiblingOnEdit('maxConcurrency', '0-0')).toBeUndefined();
+    expect(xmuxSiblingOnEdit('maxConnections', 0)).toBeUndefined();
+    expect(xmuxSiblingOnEdit('maxConnections', '')).toBeUndefined();
+  });
+
+  it('clears maxConnections to 0 the instant maxConcurrency becomes a real value', () => {
+    expect(xmuxSiblingOnEdit('maxConcurrency', '1-2')).toEqual({
+      field: 'maxConnections',
+      value: 0,
+    });
+    expect(xmuxSiblingOnEdit('maxConcurrency', '5')).toEqual({
+      field: 'maxConnections',
+      value: 0,
+    });
+  });
+
+  it('clears maxConcurrency to "" the instant maxConnections becomes a real value', () => {
+    expect(xmuxSiblingOnEdit('maxConnections', '10')).toEqual({
+      field: 'maxConcurrency',
+      value: '',
+    });
+    expect(xmuxSiblingOnEdit('maxConnections', 4)).toEqual({
+      field: 'maxConcurrency',
+      value: '',
+    });
+  });
 });
 
 describe('normalizeSockoptForWire', () => {