diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 550a3a738..194404475 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -387,7 +387,7 @@ export interface RawOutboundRow { mux?: unknown; } -export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); +const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); function hydrateStreamForm(stream: Raw): OutboundStreamFormValues { const next = { ...stream }; diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index 4dee44196..34c5fa1c0 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/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(); @@ -57,13 +57,9 @@ function int32RangeUpper(v: unknown): number { } // 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. +// and maxConcurrency. A positive maxConnections is an explicit opt-in to +// connection-pool mode — honor it and drop the leftover maxConcurrency +// default that load-time hydration backfills onto older saved configs. function resolveXmuxExclusivity(xmux: Record): Record { if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) { const out = { ...xmux }; diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx index 297158308..24be0d930 100644 --- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx +++ b/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) { return async (value: unknown): Promise => { @@ -36,7 +35,21 @@ 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 }); + } + + 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 +308,14 @@ export default function XhttpForm() { diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index 8b9d68dd5..99b0e5d33 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/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(() => { diff --git a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx b/frontend/src/pages/xray/outbounds/transport/xhttp.tsx index 90f4f17de..a62655265 100644 --- a/frontend/src/pages/xray/outbounds/transport/xhttp.tsx +++ b/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,20 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) { const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined; const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` }); + 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 ( <> @@ -283,12 +298,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) { diff --git a/frontend/src/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts index 8c8f38602..7d61e4544 100644 --- a/frontend/src/schemas/protocols/stream/xhttp.ts +++ b/frontend/src/schemas/protocols/stream/xhttp.ts @@ -15,9 +15,14 @@ export type XHttpMode = z.infer; // 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), so the bare schema +// default keeps only one of them non-zero — a non-zero maxConnections +// default resurrected on load made every re-save silently delete the +// user's maxConcurrency. 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 +30,15 @@ export const XHttpXmuxSchema = z.object({ }); export type XHttpXmux = z.infer; +// Seed for freshly enabling XMUX on a config that had no xmux block: +// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback +// rather than the concurrency strategy. +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. diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 99750d8d6..360e82431 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -353,3 +353,35 @@ describe('legacy xhttp session keys on edit (#5621)', () => { expect(out.sessionKey).toBeUndefined(); }); }); + +describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => { + const xmuxRow: RawInboundRow = { + ...vlessRow, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { + path: '/xh', + mode: 'auto', + xmux: { maxConcurrency: '1-2' }, + }, + }, + }; + + it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => { + const values = rawInboundToFormValues(xmuxRow); + const xhttp = (values.streamSettings as unknown as Record>).xhttpSettings; + expect(xhttp.enableXmux).toBe(true); + const xmux = xhttp.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + expect(xmux.maxConnections).toBe(0); + }); + + it('formValuesToWirePayload keeps maxConcurrency on an unedited re-save', () => { + const values = rawInboundToFormValues(xmuxRow); + const payload = formValuesToWirePayload(values); + const stream = JSON.parse(payload.streamSettings) as Record>; + const xmux = stream.xhttpSettings.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + }); +}); diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index 09aae41c4..29c5d27ec 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => { const wireXhttp = (back.streamSettings as Record).xhttpSettings as Record; expect(wireXhttp).not.toHaveProperty('xmux'); }); + + it('keeps a saved maxConcurrency through a load/re-save round-trip', () => { + const wire = { + ...xmuxWire, + streamSettings: { + ...xmuxWire.streamSettings, + xhttpSettings: { + ...xmuxWire.streamSettings.xhttpSettings, + xmux: { maxConcurrency: '1-2' }, + }, + }, + }; + const form = rawOutboundToFormValues(wire); + const xhttp = (form.streamSettings as Record).xhttpSettings as Record; + const xmux = xhttp.xmux as Record; + expect(xmux.maxConcurrency).toBe('1-2'); + expect(xmux.maxConnections).toBe(0); + + const back = formValuesToWirePayload(form); + const wireXhttp = (back.streamSettings as Record).xhttpSettings as Record; + const wireXmux = wireXhttp.xmux as Record; + expect(wireXmux.maxConcurrency).toBe('1-2'); + }); }); describe('outbound-form-adapter: full optional-block round-trip', () => { diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index 16e9f88bb..9c70f1bea 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/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,25 @@ 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('bare schema defaults keep only one exclusive xmux strategy non-zero', () => { + expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0); + expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32'); + }); + + it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing 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; expect(xmux.maxConnections).toBe(6); - expect(xmux).not.toHaveProperty('maxConcurrency'); + expect(xmux.maxConcurrency).toBe(''); }); });