diff --git a/sub/subService.go b/sub/subService.go index a4d38485..bf9c029c 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -250,6 +250,21 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { obj["host"] = searchHost(headers) } obj["mode"], _ = xhttp["mode"].(string) + // 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. + 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 + } + } + } } security, _ := stream["security"].(string) obj["tls"] = security @@ -408,6 +423,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) if security == "tls" { @@ -604,6 +620,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) if security == "tls" { @@ -803,6 +820,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) @@ -1057,6 +1075,59 @@ 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. +// +// 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= — 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) { + if xhttp == nil { + return + } + + 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 + } + } + } + + if len(extra) > 0 { + if b, err := json.Marshal(extra); err == nil { + params["extra"] = string(b) + } + } +} + func searchHost(headers any) string { data, _ := headers.(map[string]any) for k, v := range data { diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index d0060fb4..1cef368b 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1317,6 +1317,60 @@ 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); + } + const extra = {}; + if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { + extra.xPaddingBytes = xhttp.xPaddingBytes; + } + if (xhttp.xPaddingObfsMode === true) { + extra.xPaddingObfsMode = true; + ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => { + if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) { + extra[k] = xhttp[k]; + } + }); + } + if (Object.keys(extra).length > 0) { + 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) { + 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]; + } + }); + } + } + get clients() { switch (this.protocol) { case Protocols.VMESS: return this.settings.vmesses; @@ -1530,6 +1584,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); } if (tls === 'tls') { @@ -1594,6 +1649,7 @@ class Inbound extends XrayCommonClass { 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); break; } @@ -1694,6 +1750,7 @@ class Inbound extends XrayCommonClass { 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); break; } @@ -1770,6 +1827,7 @@ class Inbound extends XrayCommonClass { 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); break; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index 2a288c49..97602815 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -930,7 +930,13 @@ class Outbound extends CommonClass { } else if (network === 'httpupgrade') { stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host); } else if (network === 'xhttp') { - stream.xhttp = new xHTTPStreamSettings(json.path, json.host, json.mode); + // xHTTPStreamSettings positional args are (path, host, headers, ..., mode); + // passing `json.mode` as the 3rd argument used to land in the `headers` + // slot, dropping the mode on the floor. Build the object and set mode + // explicitly to avoid that. + const xh = new xHTTPStreamSettings(json.path, json.host); + if (json.mode) xh.mode = json.mode; + stream.xhttp = xh; } if (json.tls && json.tls == 'tls') { @@ -972,7 +978,25 @@ class Outbound extends CommonClass { } else if (type === 'httpupgrade') { stream.httpupgrade = new HttpUpgradeStreamSettings(path, host); } else if (type === 'xhttp') { - stream.xhttp = new xHTTPStreamSettings(path, host, mode); + // Same positional bug as in the VMess-JSON branch above: + // passing `mode` as the 3rd positional arg put it into the + // `headers` slot. Build explicitly instead. + const xh = new xHTTPStreamSettings(path, host); + if (mode) xh.mode = mode; + const xpb = url.searchParams.get('x_padding_bytes'); + if (xpb) xh.xPaddingBytes = xpb; + const extraRaw = url.searchParams.get('extra'); + if (extraRaw) { + try { + const extra = JSON.parse(extraRaw); + if (typeof extra.xPaddingBytes === 'string' && extra.xPaddingBytes) xh.xPaddingBytes = extra.xPaddingBytes; + if (extra.xPaddingObfsMode === true) xh.xPaddingObfsMode = true; + ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => { + if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k]; + }); + } catch (_) { /* ignore malformed extra */ } + } + stream.xhttp = xh; } if (security == 'tls') {