From 4e423fa452cd82533d5fbc45fae735a7c16a0811 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Wed, 9 Sep 2026 00:37:31 +0200 Subject: [PATCH] fix(sub): drop Reality parameters when a host forces plain TLS A Host row may set Security to tls on an inbound whose own stream is Reality. The emitted link then carried security=tls next to pbk, sid, spx and the Reality dest as sni: the endpoint no longer performs a Reality handshake, so those describe a server the client will never reach, and clients that honour them fail to connect. Only the security key was rewritten at emit time, and the existing strip covered alpn/sni/fp/pcs for forceTls=none alone. Clear the Reality-only parameters before the endpoint's own TLS overrides are applied, so a host that supplies its own sni or fingerprint still wins. Closes #6424 --- internal/sub/endpoint.go | 14 ++++++++++++++ internal/sub/host_sub_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index cfef3a665..07772414b 100644 --- a/internal/sub/endpoint.go +++ b/internal/sub/endpoint.go @@ -69,6 +69,19 @@ func applyEndpointTLSObj(e ShareEndpoint, obj map[string]any, security string) { } } +// dropBaseRealityParams removes the parameters that only mean something on a +// reality link once a host forces the endpoint to plain TLS or no TLS. +func dropBaseRealityParams(params map[string]string, baseSecurity, securityToApply string) { + if baseSecurity != "reality" || securityToApply == "reality" { + return + } + // sni and fp name the master's reality dest, not this endpoint's own + // certificate; the host's values are re-applied right after this. + for _, k := range []string{"pbk", "sid", "spx", "pqv", "sni", "fp"} { + delete(params, k) + } +} + // buildEndpointLinks renders one URL-param link per endpoint (vless/trojan/ss). // securityToApply mirrors the legacy externalProxy loop: "same" keeps the base // security, otherwise the endpoint's forceTls wins; "none" strips TLS hint @@ -87,6 +100,7 @@ func (s *SubService) buildEndpointLinks( securityToApply = e.ForceTls } nextParams := cloneStringMap(params) + dropBaseRealityParams(nextParams, baseSecurity, securityToApply) applyEndpointTLSParams(e, nextParams, securityToApply) applyEndpointRealityParams(e, nextParams, securityToApply) applyEndpointHostPath(e, nextParams) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index a57b85ef5..d62846ddf 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -418,3 +418,27 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) { t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml) } } + +// A host that forces plain TLS over a Reality inbound must not leave the +// Reality identity behind: pbk/sid/spx and the Reality dest sni describe a +// handshake the endpoint no longer performs. +func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) { + seedSubDB(t) + reality := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["master-dest.example.com"],"publicKey":"MASTERPBK","shortIds":["ab12"],"fingerprint":"chrome"}}` + ib := seedSubInbound(t, "s1", "reality-in", 4461, 1, reality) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "edge.example.com", Port: 443, Security: "tls"}) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + joined := strings.Join(links, "\n") + if !strings.Contains(joined, "security=tls") { + t.Fatalf("host forces tls, link must say so: %s", joined) + } + for _, leaked := range []string{"pbk=", "sid=", "spx=", "sni=master-dest.example.com"} { + if strings.Contains(joined, leaked) { + t.Fatalf("reality parameter %q survived a tls host override: %s", leaked, joined) + } + } +}