From 2b4e199a97e11f51968d95d52db5550c2a1b9ccc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 6 Jun 2026 02:13:39 +0200 Subject: [PATCH] fix(sub): don't project public inbounds through a fallback master A standalone inbound bound to a public/wildcard listen that still carried a stale inbound_fallbacks row had its share/subscription link rewritten with the master's port + Reality/TLS settings (keeping only its own transport), producing an unusable link that silently fails - the client connects but no traffic flows. The leak hit every backend link surface: subscription URL, JSON sub, Clash sub, and the panel Client Information link. Gate projectThroughFallbackMaster on reachability: only project a child that is not directly reachable on its own listen (loopback or a unix-domain socket). A public or wildcard inbound advertises its own port + security regardless of any fallback row. Legit loopback/socket fallback children still project as before. Closes #4987 --- sub/subService.go | 26 ++++++++++++++++++++++++++ sub/subService_test.go | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/sub/subService.go b/sub/subService.go index 11e77441..039691b9 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool { return ip != nil && ip.IsLoopback() } +// listenIsInternalOnly reports whether a bind address is reachable only from +// the same host — a loopback IP or a unix-domain socket. Such an inbound can't +// be dialed directly by a remote client, so when it is the child side of a +// fallback its share link must be projected through the master. A public or +// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises +// itself. +func listenIsInternalOnly(listen string) bool { + if listen == "" { + return false + } + if listen[0] == '@' || listen[0] == '/' { + return true + } + return isLoopbackHost(listen) +} + // GetSubs retrieves subscription links for a given subscription ID and host. func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) { s.PrepareForRequest(host) @@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) // Returns true when a projection happened; sub services call this before // generating links so a child VLESS-WS bound to 127.0.0.1 emits the // master's :443 + TLS state instead of its own loopback endpoint. +// +// Projection only applies to a child that is not directly reachable on its +// own listen (loopback or a unix-domain socket). An inbound on a public or +// wildcard listen is reachable on its own port, so it advertises its own +// port + security even when a stale fallback rule still names it as a child — +// otherwise its share link would leak the master's port and Reality/TLS +// settings (#4987). func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { if inbound == nil { return false } + if !listenIsInternalOnly(inbound.Listen) { + return false + } db := database.GetDB() var master *model.Inbound diff --git a/sub/subService_test.go b/sub/subService_test.go index a4d538c5..a9746b6a 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) { } } +func TestListenIsInternalOnly(t *testing.T) { + // Reachable only from the same host -> a fallback child here must be + // projected through its master. + internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"} + for _, v := range internalOnly { + if !listenIsInternalOnly(v) { + t.Fatalf("listenIsInternalOnly(%q) = false, want true", v) + } + } + // Directly reachable on its own port -> never projected, even if a stale + // fallback rule names it as a child (#4987). + reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"} + for _, v := range reachable { + if listenIsInternalOnly(v) { + t.Fatalf("listenIsInternalOnly(%q) = true, want false", v) + } + } +} + func TestResolveInboundAddress(t *testing.T) { const reqHost = "sub.example.com"