diff --git a/sub/subService.go b/sub/subService.go index 12d9bfb5..33605ad6 100644 --- a/sub/subService.go +++ b/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. +// - 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. // -// Two encodings are written so every popular client can read at least one: -// -// - x_padding_bytes= — flat param, understood by sing-box and its -// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …). -// - extra= — full xhttp settings blob, which is how -// xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the -// obfs-mode key / header / placement / method. -// -// 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 + return nil } - - if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { - params["x_padding_bytes"] = xpb - } - 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= — flat param, understood by sing-box and its +// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …). +// - extra= — 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) } diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 7fcc9ed5..a7c34f07 100644 --- a/web/assets/js/model/inbound.js +++ b/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`. - // - // Two encodings are emitted so each client family can pick at least - // one up: - // - x_padding_bytes= flat, for sing-box-family clients - // - extra= full blob, for xray-core clients - // - // 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); + // 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: + // + // - 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. + // + // 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; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index bc4725c2..fe4b3d85 100644 --- a/web/assets/js/model/outbound.js +++ b/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: { diff --git a/web/html/form/outbound.html b/web/html/form/outbound.html index 53f8fae4..b171a099 100644 --- a/web/html/form/outbound.html +++ b/web/html/form/outbound.html @@ -566,36 +566,150 @@ + + + + + + + + + + + + + [[ key ]] - - + + - - + + - - + + - - + + + + Default (POST) + POST + PUT + + GET (packet-up only) + + - - + + + Default (path) + path + header + cookie + query + - - + + - - + + + Default (path) + path + header + cookie + query + + + + + + + Default (body) + body + header + cookie + query + + + + + + + + + + + + + + + diff --git a/web/html/form/stream/stream_xhttp.html b/web/html/form/stream/stream_xhttp.html index 342a46f1..c275026c 100644 --- a/web/html/form/stream/stream_xhttp.html +++ b/web/html/form/stream/stream_xhttp.html @@ -37,6 +37,10 @@ + + + @@ -67,14 +71,6 @@ - - - Default (POST) - POST - PUT - GET (packet-up only) - - Default (path) @@ -114,13 +110,8 @@ v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"> - - - -{{end}} \ No newline at end of file +{{end}} diff --git a/web/html/modals/xray_outbound_modal.html b/web/html/modals/xray_outbound_modal.html index eb536be3..16b6417a 100644 --- a/web/html/modals/xray_outbound_modal.html +++ b/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