Преглед на файлове

refactor(xhttp): split fields by direction, expand outbound coverage

Audit panel xhttp config against xray-core's runtime paths and split
fields per direction so each side carries only what it actually uses:

- Bidirectional (must match): host, path, mode, all xPadding*,
  session*/seq*, uplinkData*/Key, scMaxEachPostBytes
- Server-only (inbound): noSSEHeader, scMaxBufferedPosts,
  scStreamUpServerSecs, serverMaxHeaderBytes
- Client-only (outbound): uplinkHTTPMethod, uplinkChunkSize,
  noGRPCHeader, scMinPostsIntervalMs, xmux

The inbound previously held client-only fields and the outbound was
missing every must-match field beyond host/path/mode — meaning a
panel-built outbound couldn't connect to an inbound with a custom
xPaddingKey/sessionKey/etc.

Headers stay on the inbound for URL-share purposes only; xray's
listener ignores them at runtime, but they travel through the share
link's `extra` blob so the client picks them up.

Renames the URL helpers (applyXhttpPadding* -> applyXhttpExtra*) since
the blob now carries more than padding, and folds path/host/mode into
the helper so each link generator's xhttp branch is one line.

Adds two enforcement points for xray's "uplinkHTTPMethod=GET only in
packet-up" rule: the GET option is disabled when mode != packet-up,
and a watcher on the outbound modal auto-clears GET when the user
switches modes.

Hides the XMUX block behind an `enableXmux` switch on the outbound
form (mirrors the QUIC Params toggle) so the section doesn't clutter
the form by default; fromJson auto-flips it on for outbounds with
saved xmux config.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
MHSanaei преди 1 ден
родител
ревизия
42b2ebc00b
променени са 6 файла, в които са добавени 481 реда и са изтрити 155 реда
  1. 95 48
      sub/subService.go
  2. 136 77
      web/assets/js/model/inbound.js
  3. 105 1
      web/assets/js/model/outbound.js
  4. 129 15
      web/html/form/outbound.html
  5. 5 14
      web/html/form/stream/stream_xhttp.html
  6. 11 0
      web/html/modals/xray_outbound_modal.html

+ 95 - 48
sub/subService.go

@@ -582,28 +582,18 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
 		applyPathAndHostParams(httpupgrade, params)
 	case "xhttp":
 		xhttp, _ := stream["xhttpSettings"].(map[string]any)
-		applyPathAndHostParams(xhttp, params)
-		params["mode"], _ = xhttp["mode"].(string)
-		applyXhttpPaddingParams(xhttp, params)
+		applyXhttpExtraParams(xhttp, params)
 	}
 }
 
-func applyXhttpPaddingObj(xhttp map[string]any, obj map[string]any) {
-	// VMess base64 JSON supports arbitrary keys; copy the padding
-	// settings through so clients can match the server's xhttp
-	// xPaddingBytes range and, when the admin opted into obfs
-	// mode, the custom key / header / placement / method.
+// applyXhttpExtraObj copies the bidirectional xhttp settings into the
+// VMess base64 JSON link object. VMess supports arbitrary keys, so we
+// flatten the SplitHTTPConfig "extra" fields directly onto obj.
+func applyXhttpExtraObj(xhttp map[string]any, obj map[string]any) {
 	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
 		obj["x_padding_bytes"] = xpb
 	}
-	if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
-		obj["xPaddingObfsMode"] = true
-		for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
-			if v, ok := xhttp[field].(string); ok && len(v) > 0 {
-				obj[field] = v
-			}
-		}
-	}
+	maps.Copy(obj, buildXhttpExtra(xhttp))
 }
 
 func applyVmessNetworkParams(stream map[string]any, network string, obj map[string]any) {
@@ -639,8 +629,10 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
 	case "xhttp":
 		xhttp, _ := stream["xhttpSettings"].(map[string]any)
 		applyPathAndHostObj(xhttp, obj)
-		obj["mode"], _ = xhttp["mode"].(string)
-		applyXhttpPaddingObj(xhttp, obj)
+		if mode, ok := xhttp["mode"].(string); ok {
+			obj["mode"] = mode
+		}
+		applyXhttpExtraObj(xhttp, obj)
 	}
 }
 
@@ -928,45 +920,33 @@ func searchKey(data any, key string) (any, bool) {
 	return nil, false
 }
 
-// applyXhttpPaddingParams copies the xPadding* fields from an xhttpSettings
-// map into the URL query params of a vless:// / trojan:// / ss:// link.
+// buildXhttpExtra walks an xhttpSettings map and returns the JSON blob
+// that goes into the URL's `extra` param (or, for VMess, the link
+// object). Carries ONLY the bidirectional fields from xray-core's
+// SplitHTTPConfig — i.e. the ones the server enforces and the client
+// must match. Strictly one-sided fields are excluded:
 //
-// Before this helper existed, only path / host / mode were propagated,
-// so a server configured with a non-default xPaddingBytes (e.g. 80-600)
-// or with xPaddingObfsMode=true + custom xPaddingKey / xPaddingHeader
-// would silently diverge from the client: the client kept defaults,
-// hit the server, and was rejected by its padding validation
-// ("invalid padding" in the inbound log) — the client-visible symptom
-// was "xhttp doesn't connect" on OpenWRT / sing-box.
-//
-// Two encodings are written so every popular client can read at least one:
-//
-//   - x_padding_bytes=<range>  — flat param, understood by sing-box and its
-//     derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
-//   - extra=<url-encoded-json> — full xhttp settings blob, which is how
-//     xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
-//     obfs-mode key / header / placement / method.
+//   - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
+//     serverMaxHeaderBytes) — client wouldn't read them, so emitting
+//     them just bloats the URL.
+//   - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
+//     noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the
+//     inbound config doesn't have them; the client configures them
+//     locally.
 //
-// Anything that doesn't map to a non-empty value is skipped, so simple
-// inbounds (no custom padding) produce exactly the same URL as before.
-func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
+// Truthy-only guards keep default inbounds emitting the same compact URL
+// they did before this helper grew.
+func buildXhttpExtra(xhttp map[string]any) map[string]any {
 	if xhttp == nil {
-		return
-	}
-
-	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
-		params["x_padding_bytes"] = xpb
+		return nil
 	}
-
 	extra := map[string]any{}
+
 	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
 		extra["xPaddingBytes"] = xpb
 	}
 	if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
 		extra["xPaddingObfsMode"] = true
-		// The obfs-mode-only fields: only populate the ones the admin
-		// actually set, so xray-core falls back to its own defaults for
-		// the rest instead of seeing spurious empty strings.
 		for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
 			if v, ok := xhttp[field].(string); ok && len(v) > 0 {
 				extra[field] = v
@@ -974,7 +954,74 @@ func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
 		}
 	}
 
-	if len(extra) > 0 {
+	stringFields := []string{
+		"sessionPlacement", "sessionKey",
+		"seqPlacement", "seqKey",
+		"uplinkDataPlacement", "uplinkDataKey",
+		"scMaxEachPostBytes",
+	}
+	for _, field := range stringFields {
+		if v, ok := xhttp[field].(string); ok && len(v) > 0 {
+			extra[field] = v
+		}
+	}
+
+	// Headers — emitted as the {name: value} map upstream's struct
+	// expects. The server runtime ignores this field, but the client
+	// (consuming the share link) honors it. Drop any "host" entry —
+	// host already wins as a top-level URL param.
+	if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
+		out := map[string]any{}
+		for k, v := range rawHeaders {
+			if strings.EqualFold(k, "host") {
+				continue
+			}
+			out[k] = v
+		}
+		if len(out) > 0 {
+			extra["headers"] = out
+		}
+	}
+
+	if len(extra) == 0 {
+		return nil
+	}
+	return extra
+}
+
+// applyXhttpExtraParams emits the full xhttp config into the URL query
+// params of a vless:// / trojan:// / ss:// link. Sets path/host/mode at
+// top level (xray's Build() always lets these win over `extra`) and packs
+// everything else into a JSON `extra` param. Also writes the flat
+// `x_padding_bytes` param sing-box-family clients understand.
+//
+// Without this, the admin's custom xPaddingBytes / sessionKey / etc. never
+// reach the client and handshakes are silently rejected with
+// `invalid padding (...) length: 0` — the client-visible symptom is
+// "xhttp doesn't connect" on OpenWRT / sing-box.
+//
+// Two encodings are written so every popular client can read at least one:
+//
+//   - x_padding_bytes=<range>  — flat param, understood by sing-box and its
+//     derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
+//   - extra=<url-encoded-json> — full xhttp settings blob, which is how
+//     xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
+//     bidirectional fields beyond path/host/mode.
+func applyXhttpExtraParams(xhttp map[string]any, params map[string]string) {
+	if xhttp == nil {
+		return
+	}
+	applyPathAndHostParams(xhttp, params)
+	if mode, ok := xhttp["mode"].(string); ok {
+		params["mode"] = mode
+	}
+
+	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
+		params["x_padding_bytes"] = xpb
+	}
+
+	extra := buildXhttpExtra(xhttp)
+	if extra != nil {
 		if b, err := json.Marshal(extra); err == nil {
 			params["extra"] = string(b)
 		}

+ 136 - 77
web/assets/js/model/inbound.js

@@ -472,54 +472,67 @@ class HTTPUpgradeStreamSettings extends XrayCommonClass {
     }
 }
 
+// Mirrors the inbound (server-side) view of Xray-core's SplitHTTPConfig
+// (infra/conf/transport_internet.go). Only fields the server actually
+// reads at runtime, plus the bidirectional fields the server enforces,
+// live here. Client-only fields (uplinkHTTPMethod, uplinkChunkSize,
+// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) belong on
+// the outbound class instead.
+//
+// `headers` is technically client-only at runtime (xray's listener
+// doesn't read it) but we keep it here so the admin can set request
+// headers that get embedded into the share link's `extra` blob — the
+// client picks them up from there.
 class xHTTPStreamSettings extends XrayCommonClass {
     constructor(
+        // Bidirectional — must match between client and server
         path = '/',
         host = '',
-        headers = [],
-        scMaxBufferedPosts = 30,
-        scMaxEachPostBytes = "1000000",
-        scStreamUpServerSecs = "20-80",
-        noSSEHeader = false,
-        xPaddingBytes = "100-1000",
         mode = MODE_OPTION.AUTO,
+        xPaddingBytes = "100-1000",
         xPaddingObfsMode = false,
         xPaddingKey = '',
         xPaddingHeader = '',
         xPaddingPlacement = '',
         xPaddingMethod = '',
-        uplinkHTTPMethod = '',
         sessionPlacement = '',
         sessionKey = '',
         seqPlacement = '',
         seqKey = '',
         uplinkDataPlacement = '',
         uplinkDataKey = '',
-        uplinkChunkSize = 0,
+        scMaxEachPostBytes = "1000000",
+        // Server-side only
+        noSSEHeader = false,
+        scMaxBufferedPosts = 30,
+        scStreamUpServerSecs = "20-80",
+        serverMaxHeaderBytes = 0,
+        // URL-share only — embedded in the link's `extra` blob so clients
+        // pick them up; xray's listener ignores them at runtime.
+        headers = [],
     ) {
         super();
         this.path = path;
         this.host = host;
-        this.headers = headers;
-        this.scMaxBufferedPosts = scMaxBufferedPosts;
-        this.scMaxEachPostBytes = scMaxEachPostBytes;
-        this.scStreamUpServerSecs = scStreamUpServerSecs;
-        this.noSSEHeader = noSSEHeader;
-        this.xPaddingBytes = xPaddingBytes;
         this.mode = mode;
+        this.xPaddingBytes = xPaddingBytes;
         this.xPaddingObfsMode = xPaddingObfsMode;
         this.xPaddingKey = xPaddingKey;
         this.xPaddingHeader = xPaddingHeader;
         this.xPaddingPlacement = xPaddingPlacement;
         this.xPaddingMethod = xPaddingMethod;
-        this.uplinkHTTPMethod = uplinkHTTPMethod;
         this.sessionPlacement = sessionPlacement;
         this.sessionKey = sessionKey;
         this.seqPlacement = seqPlacement;
         this.seqKey = seqKey;
         this.uplinkDataPlacement = uplinkDataPlacement;
         this.uplinkDataKey = uplinkDataKey;
-        this.uplinkChunkSize = uplinkChunkSize;
+        this.scMaxEachPostBytes = scMaxEachPostBytes;
+        this.noSSEHeader = noSSEHeader;
+        this.scMaxBufferedPosts = scMaxBufferedPosts;
+        this.scStreamUpServerSecs = scStreamUpServerSecs;
+        this.serverMaxHeaderBytes = serverMaxHeaderBytes;
+        this.headers = headers;
     }
 
     addHeader(name, value) {
@@ -534,26 +547,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
         return new xHTTPStreamSettings(
             json.path,
             json.host,
-            XrayCommonClass.toHeaders(json.headers),
-            json.scMaxBufferedPosts,
-            json.scMaxEachPostBytes,
-            json.scStreamUpServerSecs,
-            json.noSSEHeader,
-            json.xPaddingBytes,
             json.mode,
+            json.xPaddingBytes,
             json.xPaddingObfsMode,
             json.xPaddingKey,
             json.xPaddingHeader,
             json.xPaddingPlacement,
             json.xPaddingMethod,
-            json.uplinkHTTPMethod,
             json.sessionPlacement,
             json.sessionKey,
             json.seqPlacement,
             json.seqKey,
             json.uplinkDataPlacement,
             json.uplinkDataKey,
-            json.uplinkChunkSize,
+            json.scMaxEachPostBytes,
+            json.noSSEHeader,
+            json.scMaxBufferedPosts,
+            json.scStreamUpServerSecs,
+            json.serverMaxHeaderBytes,
+            XrayCommonClass.toHeaders(json.headers),
         );
     }
 
@@ -561,26 +573,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
         return {
             path: this.path,
             host: this.host,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-            scMaxBufferedPosts: this.scMaxBufferedPosts,
-            scMaxEachPostBytes: this.scMaxEachPostBytes,
-            scStreamUpServerSecs: this.scStreamUpServerSecs,
-            noSSEHeader: this.noSSEHeader,
-            xPaddingBytes: this.xPaddingBytes,
             mode: this.mode,
+            xPaddingBytes: this.xPaddingBytes,
             xPaddingObfsMode: this.xPaddingObfsMode,
             xPaddingKey: this.xPaddingKey,
             xPaddingHeader: this.xPaddingHeader,
             xPaddingPlacement: this.xPaddingPlacement,
             xPaddingMethod: this.xPaddingMethod,
-            uplinkHTTPMethod: this.uplinkHTTPMethod,
             sessionPlacement: this.sessionPlacement,
             sessionKey: this.sessionKey,
             seqPlacement: this.seqPlacement,
             seqKey: this.seqKey,
             uplinkDataPlacement: this.uplinkDataPlacement,
             uplinkDataKey: this.uplinkDataKey,
-            uplinkChunkSize: this.uplinkChunkSize,
+            scMaxEachPostBytes: this.scMaxEachPostBytes,
+            noSSEHeader: this.noSSEHeader,
+            scMaxBufferedPosts: this.scMaxBufferedPosts,
+            scStreamUpServerSecs: this.scStreamUpServerSecs,
+            serverMaxHeaderBytes: this.serverMaxHeaderBytes,
+            headers: XrayCommonClass.toV2Headers(this.headers, false),
         };
     }
 }
@@ -1523,26 +1534,39 @@ class Inbound extends XrayCommonClass {
         return this.clientStats;
     }
 
-    // Copy the xPadding* settings into the query-string of a vless/trojan/ss
-    // link. Without this, the admin's custom xPaddingBytes range and (in
-    // obfs mode) the custom xPaddingKey / xPaddingHeader / placement /
-    // method never reach the client — the client keeps xray / sing-box's
-    // internal defaults and the server rejects every handshake with
-    // `invalid padding (...) length: 0`.
+    // Looks for a "host"-named entry in xhttp.headers and returns its value,
+    // or '' if not found. Used as a fallback when xhttp.host is empty so the
+    // share URL still carries a usable Host hint.
+    static xhttpHostFallback(xhttp) {
+        if (!xhttp || !Array.isArray(xhttp.headers)) return '';
+        for (const h of xhttp.headers) {
+            if (h && typeof h.name === 'string' && h.name.toLowerCase() === 'host') {
+                return h.value || '';
+            }
+        }
+        return '';
+    }
+
+    // Build the JSON blob that goes into the URL's `extra` param (or, for
+    // VMess, into the base64-encoded link object). Carries ONLY the
+    // bidirectional fields from xray-core's SplitHTTPConfig — i.e. the
+    // ones the server enforces and the client must match. Strictly
+    // one-sided fields are excluded:
     //
-    // Two encodings are emitted so each client family can pick at least
-    // one up:
-    //   - x_padding_bytes=<range>       flat, for sing-box-family clients
-    //   - extra=<url-encoded-json>       full blob, for xray-core clients
+    //   - server-only (noSSEHeader, scMaxBufferedPosts,
+    //     scStreamUpServerSecs, serverMaxHeaderBytes) — client wouldn't
+    //     read them, so emitting them just bloats the URL.
+    //   - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
+    //     noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) —
+    //     not on the inbound class at all; the client configures them
+    //     locally.
     //
-    // Fields are only included when they actually have a value, so a
-    // default inbound yields the same URL it did before this helper.
-    static applyXhttpPaddingToParams(xhttp, params) {
-        if (!xhttp) return;
-        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
-            params.set("x_padding_bytes", xhttp.xPaddingBytes);
-        }
+    // Truthy-only guards keep default inbounds emitting the same compact
+    // URL they did before this helper grew.
+    static buildXhttpExtra(xhttp) {
+        if (!xhttp) return null;
         const extra = {};
+
         if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
             extra.xPaddingBytes = xhttp.xPaddingBytes;
         }
@@ -1554,26 +1578,73 @@ class Inbound extends XrayCommonClass {
                 }
             });
         }
-        if (Object.keys(extra).length > 0) {
-            params.set("extra", JSON.stringify(extra));
+
+        const stringFields = [
+            "sessionPlacement", "sessionKey",
+            "seqPlacement", "seqKey",
+            "uplinkDataPlacement", "uplinkDataKey",
+            "scMaxEachPostBytes",
+        ];
+        for (const k of stringFields) {
+            const v = xhttp[k];
+            if (typeof v === 'string' && v.length > 0) extra[k] = v;
+        }
+
+        // Headers — emitted as the {name: value} map upstream's struct
+        // expects. The server runtime ignores this field, but the client
+        // (consuming the share link) honors it.
+        if (Array.isArray(xhttp.headers) && xhttp.headers.length > 0) {
+            const headersMap = {};
+            for (const h of xhttp.headers) {
+                if (h && h.name && h.name.toLowerCase() !== 'host') {
+                    headersMap[h.name] = h.value || '';
+                }
+            }
+            if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
         }
+
+        return Object.keys(extra).length > 0 ? extra : null;
+    }
+
+    // Inject the inbound-side xhttp config into URL query params for
+    // vless/trojan/ss links. Sets path/host/mode at top level (xray's
+    // Build() always lets these win over `extra`) and packs the
+    // bidirectional fields into a JSON `extra` param. Also writes the
+    // flat `x_padding_bytes` param sing-box-family clients understand.
+    //
+    // Without this, the admin's custom xPaddingBytes / sessionKey / etc.
+    // never reach the client and handshakes are silently rejected with
+    // `invalid padding (...) length: 0`.
+    static applyXhttpExtraToParams(xhttp, params) {
+        if (!xhttp) return;
+        params.set("path", xhttp.path);
+        const host = xhttp.host?.length > 0 ? xhttp.host : Inbound.xhttpHostFallback(xhttp);
+        params.set("host", host);
+        params.set("mode", xhttp.mode);
+
+        // Flat fallback for sing-box-family clients that don't read `extra`.
+        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+            params.set("x_padding_bytes", xhttp.xPaddingBytes);
+        }
+
+        const extra = Inbound.buildXhttpExtra(xhttp);
+        if (extra) params.set("extra", JSON.stringify(extra));
     }
 
     // VMess variant: VMess links are a base64-encoded JSON object, so we
-    // copy the padding fields directly into the JSON instead of building
-    // a query string.
-    static applyXhttpPaddingToObj(xhttp, obj) {
+    // copy the same bidirectional fields directly into the JSON instead
+    // of building a query string. (The base VMess link generator already
+    // sets net/type/path/host, so we only contribute the SplitHTTPConfig
+    // extra side here.)
+    static applyXhttpExtraToObj(xhttp, obj) {
         if (!xhttp || !obj) return;
         if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
             obj.x_padding_bytes = xhttp.xPaddingBytes;
         }
-        if (xhttp.xPaddingObfsMode === true) {
-            obj.xPaddingObfsMode = true;
-            ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
-                if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
-                    obj[k] = xhttp[k];
-                }
-            });
+        const extra = Inbound.buildXhttpExtra(xhttp);
+        if (!extra) return;
+        for (const [k, v] of Object.entries(extra)) {
+            obj[k] = v;
         }
     }
 
@@ -1839,7 +1910,7 @@ class Inbound extends XrayCommonClass {
             obj.path = xhttp.path;
             obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
             obj.type = xhttp.mode;
-            Inbound.applyXhttpPaddingToObj(xhttp, obj);
+            Inbound.applyXhttpExtraToObj(xhttp, obj);
         }
 
         Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
@@ -1904,11 +1975,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 
@@ -2009,11 +2076,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 
@@ -2090,11 +2153,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 

+ 105 - 1
web/assets/js/model/outbound.js

@@ -402,11 +402,35 @@ class HttpUpgradeStreamSettings extends CommonClass {
     }
 }
 
+// Mirrors the outbound (client-side) view of Xray-core's SplitHTTPConfig
+// (infra/conf/transport_internet.go). Only fields the client actually
+// reads at runtime, plus the bidirectional fields the client must match
+// against the server, live here. Server-only fields (noSSEHeader,
+// scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes) belong
+// on the inbound class instead.
 class xHTTPStreamSettings extends CommonClass {
     constructor(
+        // Bidirectional — must match the inbound side
         path = '/',
         host = '',
         mode = '',
+        xPaddingBytes = "100-1000",
+        xPaddingObfsMode = false,
+        xPaddingKey = '',
+        xPaddingHeader = '',
+        xPaddingPlacement = '',
+        xPaddingMethod = '',
+        sessionPlacement = '',
+        sessionKey = '',
+        seqPlacement = '',
+        seqKey = '',
+        uplinkDataPlacement = '',
+        uplinkDataKey = '',
+        scMaxEachPostBytes = "1000000",
+        // Client-side only
+        headers = [],
+        uplinkHTTPMethod = '',
+        uplinkChunkSize = 0,
         noGRPCHeader = false,
         scMinPostsIntervalMs = "30",
         xmux = {
@@ -417,32 +441,112 @@ class xHTTPStreamSettings extends CommonClass {
             hMaxReusableSecs: "1800-3000",
             hKeepAlivePeriod: 0,
         },
+        // UI-only toggle — controls whether the XMUX block is expanded in
+        // the form (mirrors the QUIC Params switch in stream_finalmask).
+        // Never serialized; toJson() only emits the xmux block itself.
+        enableXmux = false,
     ) {
         super();
         this.path = path;
         this.host = host;
         this.mode = mode;
+        this.xPaddingBytes = xPaddingBytes;
+        this.xPaddingObfsMode = xPaddingObfsMode;
+        this.xPaddingKey = xPaddingKey;
+        this.xPaddingHeader = xPaddingHeader;
+        this.xPaddingPlacement = xPaddingPlacement;
+        this.xPaddingMethod = xPaddingMethod;
+        this.sessionPlacement = sessionPlacement;
+        this.sessionKey = sessionKey;
+        this.seqPlacement = seqPlacement;
+        this.seqKey = seqKey;
+        this.uplinkDataPlacement = uplinkDataPlacement;
+        this.uplinkDataKey = uplinkDataKey;
+        this.scMaxEachPostBytes = scMaxEachPostBytes;
+        this.headers = headers;
+        this.uplinkHTTPMethod = uplinkHTTPMethod;
+        this.uplinkChunkSize = uplinkChunkSize;
         this.noGRPCHeader = noGRPCHeader;
         this.scMinPostsIntervalMs = scMinPostsIntervalMs;
         this.xmux = xmux;
+        this.enableXmux = enableXmux;
+    }
+
+    addHeader(name, value) {
+        this.headers.push({ name: name, value: value });
+    }
+
+    removeHeader(index) {
+        this.headers.splice(index, 1);
     }
 
     static fromJson(json = {}) {
+        const headersInput = json.headers;
+        let headers = [];
+        if (Array.isArray(headersInput)) {
+            headers = headersInput;
+        } else if (headersInput && typeof headersInput === 'object') {
+            // Upstream uses a {name: value} map; convert to the panel's [{name, value}] form.
+            headers = Object.entries(headersInput).map(([name, value]) => ({ name, value }));
+        }
         return new xHTTPStreamSettings(
             json.path,
             json.host,
             json.mode,
+            json.xPaddingBytes,
+            json.xPaddingObfsMode,
+            json.xPaddingKey,
+            json.xPaddingHeader,
+            json.xPaddingPlacement,
+            json.xPaddingMethod,
+            json.sessionPlacement,
+            json.sessionKey,
+            json.seqPlacement,
+            json.seqKey,
+            json.uplinkDataPlacement,
+            json.uplinkDataKey,
+            json.scMaxEachPostBytes,
+            headers,
+            json.uplinkHTTPMethod,
+            json.uplinkChunkSize,
             json.noGRPCHeader,
             json.scMinPostsIntervalMs,
-            json.xmux
+            json.xmux,
+            // Auto-toggle the XMUX switch on when an existing outbound has
+            // the xmux key saved, so users editing such configs see their
+            // values immediately.
+            json.xmux !== undefined,
         );
     }
 
     toJson() {
+        // Upstream expects headers as a {name: value} map, not a list of entries.
+        const headersMap = {};
+        if (Array.isArray(this.headers)) {
+            for (const h of this.headers) {
+                if (h && h.name) headersMap[h.name] = h.value || '';
+            }
+        }
         return {
             path: this.path,
             host: this.host,
             mode: this.mode,
+            xPaddingBytes: this.xPaddingBytes,
+            xPaddingObfsMode: this.xPaddingObfsMode,
+            xPaddingKey: this.xPaddingKey,
+            xPaddingHeader: this.xPaddingHeader,
+            xPaddingPlacement: this.xPaddingPlacement,
+            xPaddingMethod: this.xPaddingMethod,
+            sessionPlacement: this.sessionPlacement,
+            sessionKey: this.sessionKey,
+            seqPlacement: this.seqPlacement,
+            seqKey: this.seqKey,
+            uplinkDataPlacement: this.uplinkDataPlacement,
+            uplinkDataKey: this.uplinkDataKey,
+            scMaxEachPostBytes: this.scMaxEachPostBytes,
+            headers: headersMap,
+            uplinkHTTPMethod: this.uplinkHTTPMethod,
+            uplinkChunkSize: this.uplinkChunkSize,
             noGRPCHeader: this.noGRPCHeader,
             scMinPostsIntervalMs: this.scMinPostsIntervalMs,
             xmux: {

+ 129 - 15
web/html/form/outbound.html

@@ -566,36 +566,150 @@
           <a-form-item label='{{ i18n "path" }}'>
             <a-input v-model.trim="outbound.stream.xhttp.path"></a-input>
           </a-form-item>
+          <a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
+            <a-button icon="plus" size="small" @click="outbound.stream.xhttp.addHeader('', '')"></a-button>
+          </a-form-item>
+          <a-form-item :wrapper-col="{span:24}">
+            <a-input-group compact v-for="(header, index) in outbound.stream.xhttp.headers">
+              <a-input :style="{ width: '50%' }" v-model.trim="header.name"
+                placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
+                <template slot="addonBefore">[[ index+1 ]]</template>
+              </a-input>
+              <a-input :style="{ width: '50%' }" v-model.trim="header.value"
+                placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
+                <a-button icon="minus" slot="addonAfter" size="small"
+                  @click="outbound.stream.xhttp.removeHeader(index)"></a-button>
+              </a-input>
+            </a-input-group>
+          </a-form-item>
           <a-form-item label="Mode">
             <a-select v-model="outbound.stream.xhttp.mode" :dropdown-class-name="themeSwitcher.currentTheme">
               <a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
             </a-select>
           </a-form-item>
-          <a-form-item label="No gRPC Header"
-            v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
-            <a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
+          <a-form-item label="Max Upload Size (Byte)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
+            <a-input v-model.trim="outbound.stream.xhttp.scMaxEachPostBytes"></a-input>
           </a-form-item>
           <a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
             <a-input v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input>
           </a-form-item>
-          <a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
-            <a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
+          <a-form-item label="Padding Bytes">
+            <a-input v-model.trim="outbound.stream.xhttp.xPaddingBytes"></a-input>
           </a-form-item>
-          <a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
-            <a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
+          <a-form-item label="Padding Obfs Mode">
+            <a-switch v-model="outbound.stream.xhttp.xPaddingObfsMode"></a-switch>
           </a-form-item>
-          <a-form-item label="Max Reuse Times">
-            <a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
+          <template v-if="outbound.stream.xhttp.xPaddingObfsMode">
+            <a-form-item label="Padding Key">
+              <a-input v-model.trim="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding"></a-input>
+            </a-form-item>
+            <a-form-item label="Padding Header">
+              <a-input v-model.trim="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding"></a-input>
+            </a-form-item>
+            <a-form-item label="Padding Placement">
+              <a-select v-model="outbound.stream.xhttp.xPaddingPlacement"
+                :dropdown-class-name="themeSwitcher.currentTheme">
+                <a-select-option value>Default (queryInHeader)</a-select-option>
+                <a-select-option value="queryInHeader">queryInHeader</a-select-option>
+                <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Padding Method">
+              <a-select v-model="outbound.stream.xhttp.xPaddingMethod"
+                :dropdown-class-name="themeSwitcher.currentTheme">
+                <a-select-option value>Default (repeat-x)</a-select-option>
+                <a-select-option value="repeat-x">repeat-x</a-select-option>
+                <a-select-option value="tokenish">tokenish</a-select-option>
+              </a-select>
+            </a-form-item>
+          </template>
+          <a-form-item label="Uplink HTTP Method">
+            <a-select v-model="outbound.stream.xhttp.uplinkHTTPMethod"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (POST)</a-select-option>
+              <a-select-option value="POST">POST</a-select-option>
+              <a-select-option value="PUT">PUT</a-select-option>
+              <a-select-option value="GET" :disabled="outbound.stream.xhttp.mode !== 'packet-up'">
+                GET (packet-up only)
+              </a-select-option>
+            </a-select>
           </a-form-item>
-          <a-form-item label="Max Request Times">
-            <a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
+          <a-form-item label="Session Placement">
+            <a-select v-model="outbound.stream.xhttp.sessionPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (path)</a-select-option>
+              <a-select-option value="path">path</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
           </a-form-item>
-          <a-form-item label="Max Reusable Secs">
-            <a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
+          <a-form-item label="Session Key"
+            v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'">
+            <a-input v-model.trim="outbound.stream.xhttp.sessionKey" placeholder="x_session"></a-input>
+          </a-form-item>
+          <a-form-item label="Sequence Placement">
+            <a-select v-model="outbound.stream.xhttp.seqPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (path)</a-select-option>
+              <a-select-option value="path">path</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Sequence Key"
+            v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'">
+            <a-input v-model.trim="outbound.stream.xhttp.seqKey" placeholder="x_seq"></a-input>
+          </a-form-item>
+          <a-form-item label="Uplink Data Placement" v-if="outbound.stream.xhttp.mode === 'packet-up'">
+            <a-select v-model="outbound.stream.xhttp.uplinkDataPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (body)</a-select-option>
+              <a-select-option value="body">body</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Uplink Data Key"
+            v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
+            <a-input v-model.trim="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
+          </a-form-item>
+          <a-form-item label="Uplink Chunk Size"
+            v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
+            <a-input-number v-model.number="outbound.stream.xhttp.uplinkChunkSize" :min="0"
+              placeholder="0 (unlimited)"></a-input-number>
+          </a-form-item>
+          <a-form-item label="No gRPC Header"
+            v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
+            <a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
           </a-form-item>
-          <a-form-item label="Keep Alive Period">
-            <a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
+          <a-form-item label="XMUX">
+            <a-switch v-model="outbound.stream.xhttp.enableXmux"></a-switch>
           </a-form-item>
+          <template v-if="outbound.stream.xhttp.enableXmux">
+            <a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
+              <a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
+              <a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Reuse Times">
+              <a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Request Times">
+              <a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Reusable Secs">
+              <a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
+            </a-form-item>
+            <a-form-item label="Keep Alive Period">
+              <a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
+            </a-form-item>
+          </template>
         </template>
 
         <!-- hysteria -->

+ 5 - 14
web/html/form/stream/stream_xhttp.html

@@ -37,6 +37,10 @@
   <a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'">
     <a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
   </a-form-item>
+  <a-form-item label="Server Max Header Bytes">
+    <a-input-number v-model.number="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
+      placeholder="0 (default)"></a-input-number>
+  </a-form-item>
   <a-form-item label="Padding Bytes">
     <a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
   </a-form-item>
@@ -67,14 +71,6 @@
       </a-select>
     </a-form-item>
   </template>
-  <a-form-item label="Uplink HTTP Method">
-    <a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod" :dropdown-class-name="themeSwitcher.currentTheme">
-      <a-select-option value>Default (POST)</a-select-option>
-      <a-select-option value="POST">POST</a-select-option>
-      <a-select-option value="PUT">PUT</a-select-option>
-      <a-select-option value="GET">GET (packet-up only)</a-select-option>
-    </a-select>
-  </a-form-item>
   <a-form-item label="Session Placement">
     <a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
       <a-select-option value>Default (path)</a-select-option>
@@ -114,13 +110,8 @@
     v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
     <a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
   </a-form-item>
-  <a-form-item label="Uplink Chunk Size"
-    v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
-    <a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize" :min="0"
-      placeholder="0 (unlimited)"></a-input-number>
-  </a-form-item>
   <a-form-item label="No SSE Header">
     <a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
   </a-form-item>
 </a-form>
-{{end}}
+{{end}}

+ 11 - 0
web/html/modals/xray_outbound_modal.html

@@ -104,6 +104,17 @@
                 return outModal.outbound;
             },
         },
+        watch: {
+            // xray-core's SplitHTTPConfig.Build() rejects "GET" as
+            // uplinkHTTPMethod outside packet-up mode. Clear the field
+            // instead of carrying an invalid combination through.
+            "outModal.outbound.stream.xhttp.mode"(newMode) {
+                const xhttp = outModal.outbound.stream && outModal.outbound.stream.xhttp;
+                if (xhttp && xhttp.uplinkHTTPMethod === "GET" && newMode !== "packet-up") {
+                    xhttp.uplinkHTTPMethod = "";
+                }
+            },
+        },
         methods: {
             streamNetworkChange() {
                 if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound