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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-07 19:26:40 +02:00
parent 3b64a62137
commit 42b2ebc00b
6 changed files with 482 additions and 156 deletions

View File

@@ -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=<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.
//
// 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=<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)
}

View File

@@ -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=<range> flat, for sing-box-family clients
// - extra=<url-encoded-json> 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;
}

View File

@@ -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: {

View File

@@ -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="Keep Alive Period">
<a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
<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="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 -->

View File

@@ -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,11 +110,6 @@
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>

View File

@@ -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