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)