فهرست منبع

feat: implement inbound XMUX form fields (#5211)

* feat: implement inbound XMUX form fields

* fix: replace any cast to satisfy eslint

* test: update xhttp form snapshot for XMUX

* fix(inbound): persist xmux on save so the XMUX form actually round-trips

The inbound wire normalizer unconditionally deleted xhttpSettings.xmux,
so the new inbound XMUX form was stripped on save and never reached the
stored config — the subscription extra blob (buildXhttpExtra) could
never see it. Gate the deletion on the enableXmux toggle, mirroring the
outbound adapter, and add regression tests for both on/off cases.

* fix(xmux): enforce xray-core's maxConnections/maxConcurrency exclusivity

xray-core's XmuxConfig rejects a config that sets both maxConnections
and maxConcurrency. The panel pre-fills maxConcurrency ('16-32') whenever
XMUX is enabled, so an explicit maxConnections would always collide and
make xray refuse the config. Mirror core's semantics in the wire
normalizer: when maxConnections is set (>0, an explicit opt-in since it
defaults to 0), drop the leftover default maxConcurrency. Applies to both
inbound and outbound xhttp.

---------

Co-authored-by: Sanaei <[email protected]>
Rouzbeh† 1 روز پیش
والد
کامیت
0766e16684

+ 13 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -12,6 +12,9 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+
+const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
 // Plain-data adapter between the panel's stored inbound row shape and
 // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -157,6 +160,16 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
   if (streamSettings) {
     healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
     synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
+    const streamRecord = streamSettings as unknown as Record<string, unknown>;
+    const xh = streamRecord.xhttpSettings;
+    if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
+      const xhttp = xh as Record<string, unknown>;
+      const xmux = xhttp.xmux;
+      if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
+        xhttp.enableXmux = true;
+        xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
+      }
+    }
   }
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
 

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

@@ -41,6 +41,36 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
   return isRecord(headers) && Object.keys(headers).length > 0;
 }
 
+// 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 {
+  if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
+  if (typeof v !== 'string') return 0;
+  const trimmed = v.trim();
+  if (trimmed === '') return 0;
+  const parts = trimmed.split('-');
+  const n = Number(parts[parts.length - 1]);
+  return Number.isFinite(n) ? n : 0;
+}
+
+// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
+// and maxConcurrency ("maxConnections cannot be specified together with
+// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
+// XMUX is enabled, so any explicit maxConnections would otherwise always
+// collide and make xray refuse the config. maxConnections defaults to 0
+// (off), so a positive value is an explicit opt-in to connection-pool
+// mode — honor it and drop the leftover default maxConcurrency, matching
+// core's "one strategy at a time" semantics.
+function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
+  if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
+    const out = { ...xmux };
+    delete out.maxConcurrency;
+    return out;
+  }
+  return xmux;
+}
+
 /** Validates REALITY inbound `target` / `dest` (must include a port). */
 export function validateRealityTarget(target: string): string | undefined {
   const trimmed = target.trim();
@@ -115,15 +145,19 @@ export function normalizeXhttpForWire(
 ): Record<string, unknown> {
   const out: Record<string, unknown> = { ...raw };
   const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
-
+  const enableXmux = out.enableXmux === true;
   delete out.enableXmux;
 
   if (side === 'inbound') {
-    delete out.xmux;
+    if (!enableXmux) delete out.xmux;
     delete out.scMinPostsIntervalMs;
     delete out.uplinkChunkSize;
   }
 
+  if (isRecord(out.xmux)) {
+    out.xmux = resolveXmuxExclusivity(out.xmux);
+  }
+
   dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
   // Empty tuning fields mean "use xray-core's default" — never emit them.
   dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);

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

@@ -3,6 +3,9 @@ import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'ant
 
 import { HeaderMapEditor } from '@/components/form';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+
+const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
 export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
   const { t } = useTranslation();
@@ -11,6 +14,15 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
   const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
   const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
   const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
+
+  function onXmuxToggle(checked: boolean) {
+    if (!checked) return;
+    const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+    const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
+    if (hasValues) return;
+    form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+  }
+
   return (
     <>
       <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
@@ -213,6 +225,65 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
       >
         <Switch />
       </Form.Item>
+      {/* XMUX is the connection-multiplexing layer
+          xHTTP uses to fan out parallel requests over
+          a small pool of upstream connections. UI-only
+          toggle (enableXmux) hides the 6 nested knobs
+          when off. */}
+      <Form.Item
+        label="XMUX"
+        name={['streamSettings', 'xhttpSettings', 'enableXmux']}
+        valuePropName="checked"
+      >
+        <Switch onChange={onXmuxToggle} />
+      </Form.Item>
+      <Form.Item shouldUpdate noStyle>
+        {() => {
+          if (!form.getFieldValue([
+            'streamSettings', 'xhttpSettings', 'enableXmux',
+          ])) return null;
+          return (
+            <>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxConcurrency')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+              >
+                <Input placeholder="16-32" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxConnections')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+              >
+                <Input placeholder="0" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxReuseTimes')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
+              >
+                <Input />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxRequestTimes')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
+              >
+                <Input placeholder="600-900" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.maxReusableSecs')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
+              >
+                <Input placeholder="1800-3000" />
+              </Form.Item>
+              <Form.Item
+                label={t('pages.xray.outboundForm.keepAlivePeriod')}
+                name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
+              >
+                <InputNumber min={0} style={{ width: '100%' }} />
+              </Form.Item>
+            </>
+          );
+        }}
+      </Form.Item>
     </>
   );
 }

+ 1 - 0
frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap

@@ -130,5 +130,6 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
   "Session Placement",
   "Sequence Placement",
   "No SSE Header",
+  "XMUX",
 ]
 `;

+ 6 - 2
frontend/src/test/outbound-form-adapter.test.ts

@@ -399,11 +399,15 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
     });
   });
 
-  it('round-trips xmux on save and strips the UI-only enableXmux flag', () => {
+  it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
     const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
     const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
     expect(xhttp).not.toHaveProperty('enableXmux');
-    expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' });
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    // xray-core rejects maxConnections + maxConcurrency together; the
+    // explicit maxConnections wins and maxConcurrency is dropped.
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux).toMatchObject({ maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' });
   });
 
   it('drops xmux on save when the toggle is off', () => {

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

@@ -65,6 +65,69 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
     expect(out).not.toHaveProperty('scMaxEachPostBytes');
   });
+
+  it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { maxConcurrency: '16-32' },
+    }, 'inbound');
+
+    expect(out).not.toHaveProperty('enableXmux');
+    expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
+  });
+
+  it('drops inbound xmux when enableXmux is off', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: false,
+      xmux: { maxConcurrency: '16-32' },
+    }, 'inbound');
+
+    expect(out).not.toHaveProperty('enableXmux');
+    expect(out).not.toHaveProperty('xmux');
+  });
+
+  // xray-core rejects a config with both maxConnections and maxConcurrency.
+  it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'auto',
+      enableXmux: true,
+      xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
+    }, 'inbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConnections).toBe(4);
+    expect(xmux.hKeepAlivePeriod).toBe(30);
+  });
+
+  it('keeps maxConcurrency when maxConnections is 0/unset', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'stream-one',
+      xmux: { maxConcurrency: '16-32', maxConnections: 0 },
+    }, 'outbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('16-32');
+    expect(xmux.maxConnections).toBe(0);
+  });
+
+  it('applies xmux exclusivity on the outbound side too', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'stream-one',
+      xmux: { maxConcurrency: '16-32', maxConnections: '8' },
+    }, 'outbound');
+
+    const xmux = out.xmux as Record<string, unknown>;
+    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConnections).toBe('8');
+  });
 });
 
 describe('normalizeSockoptForWire', () => {