From 3168c87c6767614de6bddd3c150901a0acc4e6ea Mon Sep 17 00:00:00 2001 From: Farhan Zare Date: Fri, 2 Oct 2026 18:32:35 -0400 Subject: [PATCH] fix(sub): keep serverNames out of a reality host's JSON client config (#6691) * fix(sub): keep serverNames out of a reality host's JSON client config A host with an SNI on a REALITY inbound set both serverName and the server-side serverNames list on the per-host stream. Both the JSON and Clash renderers have already reduced the stream to its client form by then, so serverNames was never read, and the JSON subscription shipped it in the proxy outbound. xray-core refuses to start that config ("non-empty serverNames, please use serverName instead"), which breaks every JSON-subscription client on the inbound. Set serverName only. Fixes #6690 * docs(sub): keep the host reality SNI comments within two lines Same two-line comment cap the #6694 review applied. --- internal/sub/host_sub.go | 5 ++-- internal/sub/host_sub_test.go | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 39dc5cd74..6dabec503 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -162,14 +162,13 @@ func applyHostStreamOverrides(ep map[string]any, stream map[string]any) { } } } - // Reality SNI override (host only): JSON realityData reads serverNames and - // clash reads serverName, so set both forms. + // Reality SNI override (host only): the stream is already in client form, and xray + // refuses a reality client carrying the server-side serverNames list (#6690). if isHostEndpoint(ep) { if sec, _ := stream["security"].(string); sec == "reality" { if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil { if sni, ok := externalProxySNI(ep); ok { rs["serverName"] = sni - rs["serverNames"] = []any{sni} } } } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index b159af6ff..55f2b7c4e 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -480,6 +480,61 @@ func TestSub_HostRealitySniOverride(t *testing.T) { } } +// A reality host's SNI reaches JSON and Clash as serverName only: xray refuses a +// reality client that also carries serverNames (#6690). +func TestSub_HostRealitySniJSONAndClash(t *testing.T) { + seedSubDB(t) + realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}` + ib := seedSubInbound(t, "s1", "rlj", 4491, 1, realityStream) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "RLJ", Address: "rl.cdn.com", Port: 8443, + Security: "reality", Sni: "host.reality.com", + }) + + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var doc map[string]any + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("a single-config subscription should be one JSON object: %v\n%s", err, out) + } + reality := proxyRealitySettings(t, doc) + if got := reality["serverName"]; got != "host.reality.com" { + t.Fatalf("json serverName = %v, want the host's SNI host.reality.com", got) + } + if names, leaked := reality["serverNames"]; leaked { + t.Fatalf("server-side serverNames %v leaked into the json reality client:\n%s", names, out) + } + + yaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + if !strings.Contains(yaml, "servername: host.reality.com") { + t.Fatalf("clash proxy should carry the host's SNI:\n%s", yaml) + } +} + +func proxyRealitySettings(t *testing.T, doc map[string]any) map[string]any { + t.Helper() + outbounds, _ := doc["outbounds"].([]any) + for _, ob := range outbounds { + outbound, _ := ob.(map[string]any) + if outbound["tag"] != "proxy" { + continue + } + stream, _ := outbound["streamSettings"].(map[string]any) + reality, ok := stream["realitySettings"].(map[string]any) + if !ok { + t.Fatalf("proxy outbound has no realitySettings: %v", outbound) + } + return reality + } + t.Fatalf("no proxy outbound in %v", doc) + return nil +} + // #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is // absent from GetClash but present in the raw GetSubs output. func TestSub_ExcludeFromSubTypes(t *testing.T) {