diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts
index e6883ecf..3dd5852d 100644
--- a/frontend/src/lib/xray/outbound-form-adapter.ts
+++ b/frontend/src/lib/xray/outbound-form-adapter.ts
@@ -554,6 +554,22 @@ function loopbackToWire(s: LoopbackOutboundFormSettings) {
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
+// Strip UI-only fields the form layered into streamSettings (e.g. the
+// XHTTP modal's enableXmux toggle that controls section visibility but
+// has no meaning on the wire). xray-core would ignore unknown fields
+// anyway but the panel reads back its own emitted JSON, so we keep
+// the wire shape clean.
+function stripUiOnlyStreamFields(stream: unknown): Raw {
+ const next = { ...(stream as Raw) };
+ const xh = next.xhttpSettings;
+ if (xh && typeof xh === 'object') {
+ const cleaned = { ...(xh as Raw) };
+ delete cleaned.enableXmux;
+ next.xhttpSettings = cleaned;
+ }
+ return next;
+}
+
function muxAllowed(values: OutboundFormValues): boolean {
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
const flow = values.protocol === 'vless'
@@ -596,7 +612,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
// still emit just `sockopt` if that key is present (legacy behavior).
if (values.streamSettings) {
if (STREAM_PROTOCOLS.has(values.protocol)) {
- result.streamSettings = values.streamSettings;
+ result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
} else {
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
if (sockopt) result.streamSettings = { sockopt };
diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx
index b42ffca4..c8b74be8 100644
--- a/frontend/src/pages/xray/OutboundFormModal.tsx
+++ b/frontend/src/pages/xray/OutboundFormModal.tsx
@@ -1306,11 +1306,308 @@ export default function OutboundFormModal({
>
-
- XHTTP advanced fields (XMUX, sequence/session placement,
- padding obfs) are still being migrated — edit them via
- the JSON tab.
-
+
+
+
+
+ {/* Padding obfs sub-section: gated by a Switch.
+ When on, four extra knobs (key/header/placement/
+ method) tune how Xray injects random padding to
+ disguise the post body shape. */}
+
+
+
+
+ {() => {
+ const obfs = !!form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
+ ]);
+ if (!obfs) return null;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }}
+
+
+
+
+ {() => {
+ const mode = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'mode',
+ ]);
+ return (
+
+ );
+ }}
+
+
+
+ {/* Session + sequence + uplinkData placements:
+ three orthogonal slots Xray uses to thread
+ request metadata through the transport
+ (path / header / cookie / query). Key field
+ only matters when placement is not 'path'. */}
+
+
+
+
+ {() => {
+ const placement = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'sessionPlacement',
+ ]);
+ if (!placement || placement === 'path') return null;
+ return (
+
+
+
+ );
+ }}
+
+
+
+
+
+ {() => {
+ const placement = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'seqPlacement',
+ ]);
+ if (!placement || placement === 'path') return null;
+ return (
+
+
+
+ );
+ }}
+
+
+ {/* Mode-conditional sub-sections. */}
+
+ {() => {
+ const mode = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'mode',
+ ]);
+ if (mode !== 'packet-up') return null;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {() => {
+ const place = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
+ ]);
+ if (!place || place === 'body') return null;
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }}
+
+ >
+ );
+ }}
+
+
+ {() => {
+ const mode = form.getFieldValue([
+ 'streamSettings', 'xhttpSettings', 'mode',
+ ]);
+ if (mode !== 'stream-up' && mode !== 'stream-one') return null;
+ return (
+
+
+
+ );
+ }}
+
+
+ {/* 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/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts
index 56e38eca..7d2e79b8 100644
--- a/frontend/src/schemas/protocols/stream/xhttp.ts
+++ b/frontend/src/schemas/protocols/stream/xhttp.ts
@@ -12,6 +12,19 @@ export type XHttpMode = z.infer;
// server ignores them at runtime. Outbound has additional fields (uplinkChunk
// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
// belong on the outbound class instead, not modeled here.
+// 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'.
+export const XHttpXmuxSchema = z.object({
+ maxConcurrency: z.string().default('16-32'),
+ 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'),
+ hKeepAlivePeriod: z.number().int().min(0).default(0),
+});
+export type XHttpXmux = z.infer;
+
export const XHttpStreamSettingsSchema = z.object({
path: z.string().default('/'),
host: z.string().default(''),
@@ -35,5 +48,16 @@ export const XHttpStreamSettingsSchema = z.object({
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().default(''),
headers: WsHeaderMapSchema.default({}),
+ // Outbound-only fields. Server (inbound) listener ignores these. The
+ // panel embeds them in share-link `extra` blobs so the same xhttp
+ // config can roundtrip on both sides.
+ scMinPostsIntervalMs: z.string().default('30'),
+ uplinkChunkSize: z.number().int().min(0).default(0),
+ noGRPCHeader: z.boolean().default(false),
+ xmux: XHttpXmuxSchema.optional(),
+ // UI-only toggle controlling whether the XMUX sub-form is expanded.
+ // Never present on the wire — outbound modal strips it via the
+ // form-to-wire adapter.
+ enableXmux: z.boolean().default(false),
});
export type XHttpStreamSettings = z.infer;