mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-14 07:10:58 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user