From a40d85ce53c0ce35fb329ae6058ac6870a45162e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 2 Jun 2026 22:01:43 +0200 Subject: [PATCH] fix(sub): advertise routable inbound Listen in subscription links resolveInboundAddress stopped using the inbound's bind Listen in 3.2.5/3.2.6, so a per-inbound Address/IP no longer appeared in generated subscription/share links - they always used the host the subscriber reached the panel on. The frontend QR path still honored Listen, so the panel and the subscription disagreed (issue #4798). Restore advertising Listen when it is a routable host (real IP or hostname), reusing isRoutableHost and excluding unix-domain sockets. Loopback/wildcard binds still fall back to the subscriber host, keeping the earlier loopback-leak fix intact. Precedence is now node address > routable Listen > subscriber host; External Proxy still overrides everything. Closes #4798 --- sub/subService.go | 15 +++++++++++---- sub/subService_test.go | 25 ++++++++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index 5bcd88e7..23616931 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -713,16 +713,23 @@ func (s *SubService) loadNodes() { s.nodesByID = m } -// resolveInboundAddress returns the node's address for node-managed inbounds, -// otherwise the subscriber's host (s.address). The inbound's bind Listen is -// deliberately ignored: it's a server-side address, not a client-reachable -// host, so operators advertise a specific endpoint via External Proxy instead. +// resolveInboundAddress picks the host an external client should connect to: +// 1. node-managed inbound -> the node's address +// 2. an explicit, client-reachable bind Listen -> that Listen +// 3. otherwise the subscriber's request host (s.address) +// A loopback/wildcard bind or a unix-domain-socket listen is a server-side +// detail and is never advertised; External Proxy remains the way to advertise +// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and +// the subscription agree. func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { if inbound.NodeID != nil && s.nodesByID != nil { if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" { return n.Address } } + if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) { + return listen + } return s.address } diff --git a/sub/subService_test.go b/sub/subService_test.go index 21286327..0497746b 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -64,15 +64,26 @@ func TestIsRoutableHost(t *testing.T) { func TestResolveInboundAddress(t *testing.T) { const reqHost = "sub.example.com" - // A subscriber reaches the panel through reqHost; the inbound's own - // bind Listen IP (loopback, private, or even a public secondary IP) is - // a server-side detail and must never become the link's connect host. - t.Run("bind listen IP must not leak into the link host", func(t *testing.T) { + // A routable bind Listen (a real IP or hostname the operator set as the + // inbound's advertised endpoint) becomes the link's connect host. + t.Run("routable listen is advertised as the link host", func(t *testing.T) { s := &SubService{address: reqHost} - for _, listen := range []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "1.2.3.4", "0.0.0.0", "::", "::0", ""} { + for _, listen := range []string{"1.2.3.4", "10.0.0.5", "192.168.1.10", "203.0.113.7", "vpn.example.com"} { + ib := &model.Inbound{Listen: listen} + if got := s.resolveInboundAddress(ib); got != listen { + t.Fatalf("listen %q: address = %q, want %q (advertised listen)", listen, got, listen) + } + } + }) + + // A loopback/wildcard bind or a unix-domain-socket listen is a + // server-side detail and must never leak into the link host. + t.Run("non-routable listen falls back to subscriber host", func(t *testing.T) { + s := &SubService{address: reqHost} + for _, listen := range []string{"", "0.0.0.0", "::", "::0", "127.0.0.1", "::1", "@fallback", "/run/x.sock"} { ib := &model.Inbound{Listen: listen} if got := s.resolveInboundAddress(ib); got != reqHost { - t.Fatalf("listen %q: address = %q, want %q (subscriber host, not bind IP)", listen, got, reqHost) + t.Fatalf("listen %q: address = %q, want %q (subscriber host, not bind detail)", listen, got, reqHost) } } }) @@ -92,7 +103,7 @@ func TestResolveInboundAddress(t *testing.T) { t.Run("node id with no known node falls back to subscriber host", func(t *testing.T) { id := 9 s := &SubService{address: reqHost, nodesByID: map[int]*model.Node{}} - ib := &model.Inbound{NodeID: &id, Listen: "10.0.0.1"} + ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0"} if got := s.resolveInboundAddress(ib); got != reqHost { t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost) }