mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-20 03:26:08 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user