From 326d027f6cee36af68161cfc4d1ca5a44216b20d Mon Sep 17 00:00:00 2001 From: Maksim Alekseev Date: Fri, 22 May 2026 09:25:58 +0300 Subject: [PATCH] :sparkles: Introduce extended XHTTP and external proxy settings --- frontend/src/models/inbound.js | 93 ++++++--- frontend/src/models/outbound.js | 34 +++- .../src/pages/inbounds/InboundFormModal.vue | 59 ++++-- sub/subClashService.go | 18 +- sub/subJsonService.go | 7 +- sub/subService.go | 190 +++++++++++++++++- sub/subService_test.go | 121 +++++++++++ 7 files changed, 462 insertions(+), 60 deletions(-) diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index f333c628..da85732b 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -499,14 +499,13 @@ export 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. +// live here. Most client-only fields (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. +// `headers` and `uplinkHTTPMethod` are client-only at runtime (xray's +// listener doesn't read them) but we keep them here so the admin can set +// values that get embedded into the share link's `extra` blob. export class xHTTPStreamSettings extends XrayCommonClass { constructor( // Bidirectional — must match between client and server @@ -533,6 +532,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { serverMaxHeaderBytes = 0, // URL-share only — embedded in the link's `extra` blob so clients // pick them up; xray's listener ignores them at runtime. + uplinkHTTPMethod = '', headers = [], ) { super(); @@ -556,6 +556,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { this.scMaxBufferedPosts = scMaxBufferedPosts; this.scStreamUpServerSecs = scStreamUpServerSecs; this.serverMaxHeaderBytes = serverMaxHeaderBytes; + this.uplinkHTTPMethod = uplinkHTTPMethod; this.headers = headers; } @@ -589,6 +590,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { json.scMaxBufferedPosts, json.scStreamUpServerSecs, json.serverMaxHeaderBytes, + json.uplinkHTTPMethod, XrayCommonClass.toHeaders(json.headers), ); } @@ -615,6 +617,7 @@ export class xHTTPStreamSettings extends XrayCommonClass { scMaxBufferedPosts: this.scMaxBufferedPosts, scStreamUpServerSecs: this.scStreamUpServerSecs, serverMaxHeaderBytes: this.serverMaxHeaderBytes, + uplinkHTTPMethod: this.uplinkHTTPMethod, headers: XrayCommonClass.toV2Headers(this.headers, false), }; } @@ -1584,10 +1587,9 @@ export class Inbound extends XrayCommonClass { // - 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. + // - client-only values are included only when present on the inbound + // object. Imported/API-created configs can carry them there, and + // the share link is the only place clients can receive them. // // Truthy-only guards keep default inbounds emitting the same compact // URL they did before this helper grew. @@ -1607,21 +1609,35 @@ export class Inbound extends XrayCommonClass { }); } - if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) { - extra.mode = xhttp.mode; - } - const stringFields = [ + "uplinkHTTPMethod", "sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", - "scMaxEachPostBytes", + "scMaxEachPostBytes", "scMinPostsIntervalMs", ]; for (const k of stringFields) { const v = xhttp[k]; if (typeof v === 'string' && v.length > 0) extra[k] = v; } + const uplinkChunkSize = xhttp.uplinkChunkSize; + if ((typeof uplinkChunkSize === 'number' && uplinkChunkSize !== 0) || + (typeof uplinkChunkSize === 'string' && uplinkChunkSize.length > 0)) { + extra.uplinkChunkSize = uplinkChunkSize; + } + + if (xhttp.noGRPCHeader === true) { + extra.noGRPCHeader = true; + } + + for (const k of ["xmux", "downloadSettings"]) { + const v = xhttp[k]; + if (v && typeof v === 'object' && Object.keys(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. @@ -1680,6 +1696,27 @@ export class Inbound extends XrayCommonClass { } } + static externalProxyAlpn(value) { + if (Array.isArray(value)) return value.filter(Boolean).join(','); + return typeof value === 'string' ? value : ''; + } + + static applyExternalProxyTLSParams(externalProxy, params, security) { + if (!externalProxy || security !== 'tls') return; + if (externalProxy.dest?.length > 0) params.set("sni", externalProxy.dest); + if (externalProxy.fingerprint?.length > 0) params.set("fp", externalProxy.fingerprint); + const alpn = Inbound.externalProxyAlpn(externalProxy.alpn); + if (alpn.length > 0) params.set("alpn", alpn); + } + + static applyExternalProxyTLSObj(externalProxy, obj, security) { + if (!externalProxy || !obj || security !== 'tls') return; + if (externalProxy.dest?.length > 0) obj.sni = externalProxy.dest; + if (externalProxy.fingerprint?.length > 0) obj.fp = externalProxy.fingerprint; + const alpn = Inbound.externalProxyAlpn(externalProxy.alpn); + if (alpn.length > 0) obj.alpn = alpn; + } + static hasShareableFinalMaskValue(value) { if (value == null) { return false; @@ -1894,7 +1931,7 @@ export class Inbound extends XrayCommonClass { this.sniffing = new Sniffing(); } - genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security) { + genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security, externalProxy = null) { if (this.protocol !== Protocols.VMESS) { return ''; } @@ -1958,11 +1995,12 @@ export class Inbound extends XrayCommonClass { obj.alpn = this.stream.tls.alpn.join(','); } } + Inbound.applyExternalProxyTLSObj(externalProxy, obj, tls); return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2)); } - genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow) { + genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow, externalProxy = null) { const uuid = clientId; const type = this.stream.network; const security = forceTls == 'same' ? this.stream.security : forceTls; @@ -2028,6 +2066,7 @@ export class Inbound extends XrayCommonClass { params.set("flow", flow); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { @@ -2064,7 +2103,7 @@ export class Inbound extends XrayCommonClass { return url.toString(); } - genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword) { + genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) { let settings = this.settings; const type = this.stream.network; const security = forceTls == 'same' ? this.stream.security : forceTls; @@ -2126,6 +2165,7 @@ export class Inbound extends XrayCommonClass { params.set("sni", this.stream.tls.sni); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } @@ -2142,7 +2182,7 @@ export class Inbound extends XrayCommonClass { return url.toString(); } - genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword) { + genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) { const security = forceTls == 'same' ? this.stream.security : forceTls; const type = this.stream.network; const params = new Map(); @@ -2203,6 +2243,7 @@ export class Inbound extends XrayCommonClass { params.set("sni", this.stream.tls.sni); } } + Inbound.applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { @@ -2344,16 +2385,16 @@ export class Inbound extends XrayCommonClass { return links.join('\r\n'); } - genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) { + genLink(address = '', port = this.port, forceTls = 'same', remark = '', client, externalProxy = null) { switch (this.protocol) { case Protocols.VMESS: - return this.genVmessLink(address, port, forceTls, remark, client.id, client.security); + return this.genVmessLink(address, port, forceTls, remark, client.id, client.security, externalProxy); case Protocols.VLESS: - return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow); + return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow, externalProxy); case Protocols.SHADOWSOCKS: - return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : ''); + return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '', externalProxy); case Protocols.TROJAN: - return this.genTrojanLink(address, port, forceTls, remark, client.password); + return this.genTrojanLink(address, port, forceTls, remark, client.password, externalProxy); case Protocols.HYSTERIA: return this.genHysteriaLink(address, port, remark, client.auth.length > 0 ? client.auth : this.stream.hysteria.auth); default: return ''; @@ -2384,7 +2425,7 @@ export class Inbound extends XrayCommonClass { let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar); result.push({ remark: r, - link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client) + link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client, ep) }); }); } diff --git a/frontend/src/models/outbound.js b/frontend/src/models/outbound.js index c696bc07..79af2682 100644 --- a/frontend/src/models/outbound.js +++ b/frontend/src/models/outbound.js @@ -1407,10 +1407,24 @@ export class Outbound extends CommonClass { }); } // Bidirectional string fields carried in the extra block - const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"]; + const xFields = [ + "uplinkHTTPMethod", + "sessionPlacement", "sessionKey", + "seqPlacement", "seqKey", + "uplinkDataPlacement", "uplinkDataKey", + "scMaxEachPostBytes", "scMinPostsIntervalMs", + ]; xFields.forEach(k => { if (typeof json[k] === 'string' && json[k]) xh[k] = json[k]; }); + if (typeof json.uplinkChunkSize === 'number' && json.uplinkChunkSize !== 0) xh.uplinkChunkSize = json.uplinkChunkSize; + if (typeof json.uplinkChunkSize === 'string' && json.uplinkChunkSize) xh.uplinkChunkSize = json.uplinkChunkSize; + if (json.noGRPCHeader === true) xh.noGRPCHeader = true; + if (json.xmux && typeof json.xmux === 'object') { + xh.xmux = json.xmux; + xh.enableXmux = true; + } + if (json.downloadSettings && typeof json.downloadSettings === 'object') xh.downloadSettings = json.downloadSettings; // Headers — VMess extra emits them as a {name: value} map if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) { xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value })); @@ -1487,10 +1501,24 @@ export class Outbound extends CommonClass { }); if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode; // Bidirectional string fields carried inside the extra block - const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"]; + const xFields = [ + "uplinkHTTPMethod", + "sessionPlacement", "sessionKey", + "seqPlacement", "seqKey", + "uplinkDataPlacement", "uplinkDataKey", + "scMaxEachPostBytes", "scMinPostsIntervalMs", + ]; xFields.forEach(k => { if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k]; }); + if (typeof extra.uplinkChunkSize === 'number' && extra.uplinkChunkSize !== 0) xh.uplinkChunkSize = extra.uplinkChunkSize; + if (typeof extra.uplinkChunkSize === 'string' && extra.uplinkChunkSize) xh.uplinkChunkSize = extra.uplinkChunkSize; + if (extra.noGRPCHeader === true) xh.noGRPCHeader = true; + if (extra.xmux && typeof extra.xmux === 'object') { + xh.xmux = extra.xmux; + xh.enableXmux = true; + } + if (extra.downloadSettings && typeof extra.downloadSettings === 'object') xh.downloadSettings = extra.downloadSettings; // Headers — extra emits them as a {name: value} map if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) { xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value })); @@ -2354,4 +2382,4 @@ Outbound.HysteriaSettings = class extends CommonClass { version: this.version }; } -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 5a2782b7..65330586 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -101,6 +101,8 @@ const externalProxy = computed({ dest: window.location.hostname, port: inbound.value.port, remark: '', + fingerprint: '', + alpn: [], }]; } else { inbound.value.stream.externalProxy = []; @@ -1597,6 +1599,15 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); + + + Default (POST) + POST + PUT + GET (packet-up + only) + + @@ -1674,32 +1685,42 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); + @click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '', fingerprint: '', alpn: [] })"> - - - - {{ t('pages.inbounds.same') }} - {{ t('none') }} - TLS +
+ + + + {{ t('pages.inbounds.same') }} + {{ t('none') }} + TLS + + + + + + + + + + + + + Default + {{ fp }} - - - - - - - - - + + {{ alpn }} + + +
diff --git a/sub/subClashService.go b/sub/subClashService.go index 7b638dfe..21e98f54 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -122,7 +122,8 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client defaultDest = host } externalProxies, ok := stream["externalProxy"].([]any) - if !ok || len(externalProxies) == 0 { + hasExternalProxy := ok && len(externalProxies) > 0 + if !hasExternalProxy { externalProxies = []any{map[string]any{ "forceTls": "same", "dest": defaultDest, @@ -153,6 +154,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client delete(workingStream, "realitySettings") } } + security, _ := workingStream["security"].(string) + if hasExternalProxy { + applyExternalProxyTLSToStream(extPrxy, workingStream, security) + } proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string)) if len(proxy) > 0 { @@ -383,6 +388,17 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" { proxy["client-fingerprint"] = fingerprint } + if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok { + out := make([]string, 0, len(alpn)) + for _, item := range alpn { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + proxy["alpn"] = out + } + } } return true case "reality": diff --git a/sub/subJsonService.go b/sub/subJsonService.go index bbc0a381..7972a682 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -174,7 +174,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, } externalProxies, ok := stream["externalProxy"].([]any) - if !ok || len(externalProxies) == 0 { + hasExternalProxy := ok && len(externalProxies) > 0 + if !hasExternalProxy { externalProxies = []any{ map[string]any{ "forceTls": "same", @@ -204,6 +205,10 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, delete(newStream, "tlsSettings") } } + security, _ := newStream["security"].(string) + if hasExternalProxy { + applyExternalProxyTLSToStream(extPrxy, newStream, security) + } streamSettings, _ := json.MarshalIndent(newStream, "", " ") var newOutbounds []json_util.RawMessage diff --git a/sub/subService.go b/sub/subService.go index 077ab9a5..41ca3c2a 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -863,11 +863,131 @@ func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]a return newObj } +func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security string) { + if security != "tls" { + return + } + if dest, ok := ep["dest"].(string); ok && dest != "" { + obj["sni"] = dest + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + obj["fp"] = fp + } + if alpn, ok := externalProxyALPN(ep["alpn"]); ok { + obj["alpn"] = alpn + } +} + +func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) { + if security != "tls" { + return + } + if dest, ok := ep["dest"].(string); ok && dest != "" { + params["sni"] = dest + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + params["fp"] = fp + } + if alpn, ok := externalProxyALPN(ep["alpn"]); ok { + params["alpn"] = alpn + } +} + +func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) { + if security != "tls" { + return + } + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + if tlsSettings == nil { + tlsSettings = map[string]any{} + stream["tlsSettings"] = tlsSettings + } + if dest, ok := ep["dest"].(string); ok && dest != "" { + tlsSettings["serverName"] = dest + } + if fp, ok := ep["fingerprint"].(string); ok && fp != "" { + tlsSettings["fingerprint"] = fp + settings, _ := tlsSettings["settings"].(map[string]any) + if settings == nil { + settings = map[string]any{} + tlsSettings["settings"] = settings + } + settings["fingerprint"] = fp + } + if alpn, ok := externalProxyALPNList(ep["alpn"]); ok { + tlsSettings["alpn"] = alpn + } +} + +func externalProxyALPN(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, v != "" + case []string: + if len(v) == 0 { + return "", false + } + return strings.Join(v, ","), true + case []any: + alpn := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + alpn = append(alpn, s) + } + } + if len(alpn) == 0 { + return "", false + } + return strings.Join(alpn, ","), true + default: + return "", false + } +} + +func externalProxyALPNList(value any) ([]any, bool) { + switch v := value.(type) { + case string: + if v == "" { + return nil, false + } + parts := strings.Split(v, ",") + out := make([]any, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out, len(out) > 0 + case []string: + out := make([]any, 0, len(v)) + for _, item := range v { + if item != "" { + out = append(out, item) + } + } + return out, len(out) > 0 + case []any: + out := make([]any, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out, len(out) > 0 + default: + return nil, false + } +} + func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string { var links strings.Builder for index, externalProxy := range externalProxies { ep, _ := externalProxy.(map[string]any) newSecurity, _ := ep["forceTls"].(string) + securityToApply := baseObj["tls"].(string) + if newSecurity != "same" { + securityToApply = newSecurity + } newObj := cloneVmessShareObj(baseObj, newSecurity) newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) newObj["add"] = ep["dest"].(string) @@ -876,6 +996,7 @@ func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj if newSecurity != "same" { newObj["tls"] = newSecurity } + applyExternalProxyTLSObj(ep, newObj, securityToApply) if index > 0 { links.WriteString("\n") } @@ -931,11 +1052,14 @@ func (s *SubService) buildExternalProxyURLLinks( securityToApply = newSecurity } + nextParams := cloneStringMap(params) + applyExternalProxyTLSParams(ep, nextParams, securityToApply) + links = append( links, buildLinkWithParamsAndSecurity( makeLink(dest, port), - params, + nextParams, makeRemark(ep), securityToApply, newSecurity == "none", @@ -1066,10 +1190,9 @@ func searchKey(data any, key string) (any, bool) { // - 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. +// - client-only values are included only when present in the inbound +// JSON. Some deployments/imported configs carry them there, and the +// subscription link is the only place clients can receive them. // // Truthy-only guards keep default inbounds emitting the same compact URL // they did before this helper grew. @@ -1091,15 +1214,12 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { } } - if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 { - extra["mode"] = mode - } - stringFields := []string{ + "uplinkHTTPMethod", "sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", - "scMaxEachPostBytes", + "scMaxEachPostBytes", "scMinPostsIntervalMs", } for _, field := range stringFields { if v, ok := xhttp[field].(string); ok && len(v) > 0 { @@ -1107,6 +1227,24 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { } } + for _, field := range []string{"uplinkChunkSize"} { + if v, ok := nonZeroShareValue(xhttp[field]); ok { + extra[field] = v + } + } + + for _, field := range []string{"noGRPCHeader"} { + if v, ok := xhttp[field].(bool); ok && v { + extra[field] = v + } + } + + for _, field := range []string{"xmux", "downloadSettings"} { + if v, ok := nonEmptyShareObject(xhttp[field]); ok { + 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 — @@ -1130,6 +1268,38 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any { return extra } +func nonZeroShareValue(v any) (any, bool) { + switch value := v.(type) { + case string: + return value, value != "" + case int: + return value, value != 0 + case int32: + return value, value != 0 + case int64: + return value, value != 0 + case float32: + return value, value != 0 + case float64: + return value, value != 0 + default: + return nil, false + } +} + +func nonEmptyShareObject(v any) (any, bool) { + switch value := v.(type) { + case map[string]any: + return value, len(value) > 0 + case map[string]string: + return value, len(value) > 0 + case []any: + return value, len(value) > 0 + default: + return nil, false + } +} + // 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 diff --git a/sub/subService_test.go b/sub/subService_test.go index f83db7e3..b8a512e5 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -151,6 +151,77 @@ func TestSearchKey_OnScalar(t *testing.T) { } } +func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) { + extra := buildXhttpExtra(map[string]any{ + "path": "/xhttp", + "host": "example.com", + "mode": "packet-up", + "xPaddingBytes": "100-1000", + "uplinkHTTPMethod": "GET", + "uplinkChunkSize": float64(4096), + "noGRPCHeader": true, + "scMinPostsIntervalMs": "20-40", + "xmux": map[string]any{ + "maxConcurrency": "16-32", + "hMaxRequestTimes": "600-900", + "hMaxReusableSecs": "1800-3000", + "hKeepAlivePeriod": float64(15), + }, + "downloadSettings": map[string]any{ + "network": "xhttp", + }, + "headers": map[string]any{ + "Host": "ignored.example.com", + "X-Forwarded": "1", + "X-Test-Empty": "", + }, + }) + + if extra["path"] != nil || extra["host"] != nil { + t.Fatalf("path/host should stay top-level, got extra %#v", extra) + } + for _, key := range []string{ + "xPaddingBytes", + "uplinkHTTPMethod", + "uplinkChunkSize", + "noGRPCHeader", + "scMinPostsIntervalMs", + "xmux", + "downloadSettings", + } { + if _, ok := extra[key]; !ok { + t.Fatalf("extra missing %q: %#v", key, extra) + } + } + if _, ok := extra["mode"]; ok { + t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra) + } + + headers, ok := extra["headers"].(map[string]any) + if !ok { + t.Fatalf("headers = %#v, want map", extra["headers"]) + } + if _, ok := headers["Host"]; ok { + t.Fatalf("headers should not include Host: %#v", headers) + } + if headers["X-Forwarded"] != "1" { + t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"]) + } +} + +func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) { + extra := buildXhttpExtra(map[string]any{ + "uplinkHTTPMethod": "", + "uplinkChunkSize": float64(0), + "noGRPCHeader": false, + "xmux": map[string]any{}, + "downloadSettings": map[string]any{}, + }) + if extra != nil { + t.Fatalf("default-only xhttp extra = %#v, want nil", extra) + } +} + func TestCloneStringMap(t *testing.T) { src := map[string]string{"a": "1", "b": "2"} dst := cloneStringMap(src) @@ -369,6 +440,56 @@ func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) { } } +func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) { + params := map[string]string{ + "security": "tls", + "sni": "origin.example.com", + "fp": "firefox", + "alpn": "h2", + } + ep := map[string]any{ + "dest": "proxy.example.com", + "fingerprint": "chrome", + "alpn": []any{"h3", "h2"}, + } + + applyExternalProxyTLSParams(ep, params, "tls") + + if params["sni"] != "proxy.example.com" { + t.Fatalf("sni = %q, want proxy.example.com", params["sni"]) + } + if params["fp"] != "chrome" { + t.Fatalf("fp = %q, want chrome", params["fp"]) + } + if params["alpn"] != "h3,h2" { + t.Fatalf("alpn = %q, want h3,h2", params["alpn"]) + } +} + +func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) { + params := map[string]string{ + "security": "none", + "sni": "origin.example.com", + } + ep := map[string]any{ + "dest": "proxy.example.com", + "fingerprint": "chrome", + "alpn": []any{"h3"}, + } + + applyExternalProxyTLSParams(ep, params, "none") + + if params["sni"] != "origin.example.com" { + t.Fatalf("sni should not change for security=none, got %q", params["sni"]) + } + if _, ok := params["fp"]; ok { + t.Fatalf("fp should not be set for security=none, got %v", params) + } + if _, ok := params["alpn"]; ok { + t.Fatalf("alpn should not be set for security=none, got %v", params) + } +} + func TestExtractKcpShareFields_Defaults(t *testing.T) { stream := map[string]any{} got := extractKcpShareFields(stream)