mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +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']);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,5 +130,6 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
|
||||
"Session Placement",
|
||||
"Sequence Placement",
|
||||
"No SSE Header",
|
||||
"XMUX",
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user