diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 8264ea89d..74d757fa6 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/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 carries inside @@ -157,6 +160,16 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { if (streamSettings) { healStreamNetworkKey(streamSettings as unknown as Record); synthesizeTlsCertUseFile(streamSettings as unknown as Record); + const streamRecord = streamSettings as unknown as Record; + const xh = streamRecord.xhttpSettings; + if (xh && typeof xh === 'object' && !Array.isArray(xh)) { + const xhttp = xh as Record; + const xmux = xhttp.xmux; + if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) { + xhttp.enableXmux = true; + xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record) }; + } + } } const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index 2d91338f3..446754c6e 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/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): Record { + 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 { const out: Record = { ...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']); diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx index eb6ba700a..c7251cb61 100644 --- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx +++ b/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 }) { const { t } = useTranslation(); @@ -11,6 +14,15 @@ export default function XhttpForm({ form }: { form: FormInstance 0; + if (hasValues) return; + form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS }); + } + return ( <> @@ -213,6 +225,65 @@ export default function XhttpForm({ form }: { form: FormInstance + {/* 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. */} + + + + + {() => { + if (!form.getFieldValue([ + 'streamSettings', 'xhttpSettings', 'enableXmux', + ])) return null; + return ( + <> + + + + + + + + + + + + + + + + + + + + ); + }} + ); } diff --git a/frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap b/frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap index 53facfe20..13c7b6ea6 100644 --- a/frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap +++ b/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", ] `; diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index 772ed9c40..10d17cf41 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/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).xhttpSettings as Record; expect(xhttp).not.toHaveProperty('enableXmux'); - expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' }); + const xmux = xhttp.xmux as Record; + // 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', () => { diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index 33e53144f..ee6cb6c18 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/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; + 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; + 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; + expect(xmux).not.toHaveProperty('maxConcurrency'); + expect(xmux.maxConnections).toBe('8'); + }); }); describe('normalizeSockoptForWire', () => {