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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-06-12 12:31:13 +02:00
committed by GitHub
parent 63a6d40457
commit 0766e16684
6 changed files with 190 additions and 4 deletions
@@ -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
View File
@@ -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']);