feat(inbound): Advanced XHTTP and external TLS proxy settings (#4491)

*  Introduce extended XHTTP and external proxy settings

*  Add custom SNI for proxy

*  Add previous changes into React version of app

* fix(sub): isolate per-proxy tlsSettings during external-proxy iteration

cloneMap (Clash) is shallow and `newStream := stream` (JSON) is an alias,
so tlsSettings was shared across iterations. The new applyExternalProxyTLSToStream
mutates it, leaking one proxy's serverName/fingerprint/alpn into the next
(only overwritten when the next proxy explicitly sets the same field).

Add cloneStreamForExternalProxy: shallow clones the top-level stream plus
deep clones tlsSettings and tlsSettings.settings. Regression test locks
in that proxy B does not inherit proxy A's fingerprint/alpn when B leaves
them unset.
This commit is contained in:
Maksim Alekseev
2026-05-24 22:54:26 +03:00
committed by GitHub
parent cfe1b25ca0
commit 1f90d2a6ee
9 changed files with 553 additions and 224 deletions
+170
View File
@@ -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,105 @@ 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",
"sni": "tls.example.com",
"fingerprint": "chrome",
"alpn": []any{"h3", "h2"},
}
applyExternalProxyTLSParams(ep, params, "tls")
if params["sni"] != "tls.example.com" {
t.Fatalf("sni = %q, want tls.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_FallsBackToDestSNI(t *testing.T) {
params := map[string]string{"security": "tls"}
ep := map[string]any{"dest": "proxy.example.com"}
applyExternalProxyTLSParams(ep, params, "tls")
if params["sni"] != "proxy.example.com" {
t.Fatalf("sni = %q, want proxy.example.com", params["sni"])
}
}
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
stream := map[string]any{
"security": "tls",
"tlsSettings": map[string]any{},
}
proxies := []map[string]any{
{"dest": "a.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
{"dest": "b.example.com"},
}
results := make([]map[string]any, 0, len(proxies))
for _, ep := range proxies {
working := cloneStreamForExternalProxy(stream)
applyExternalProxyTLSToStream(ep, working, "tls")
ts := working["tlsSettings"].(map[string]any)
snapshot := map[string]any{
"serverName": ts["serverName"],
"fingerprint": ts["fingerprint"],
"alpn": ts["alpn"],
}
results = append(results, snapshot)
}
if results[0]["serverName"] != "a.example.com" || results[0]["fingerprint"] != "chrome" {
t.Fatalf("proxy A snapshot = %v", results[0])
}
if results[1]["serverName"] != "b.example.com" {
t.Fatalf("proxy B serverName = %v, want b.example.com", results[1]["serverName"])
}
if results[1]["fingerprint"] != nil {
t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])
}
if results[1]["alpn"] != nil {
t.Fatalf("proxy B should inherit no alpn, got %v (leaked from A)", results[1]["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)