fix(sub): honor per-inbound share address strategy in subscription output (#5208)

Subscriptions resolved a node-managed inbound's address to the node's
panel address unconditionally, so an inbound bound to a specific public
IP advertised an endpoint clients could not reach. The shareAddrStrategy
field added in #5162 only applied to panel share/QR links by design.

resolveInboundAddress now follows the same order as the panel's link
builder: 'listen' prefers a routable bind, 'custom' prefers shareAddr,
and the default 'node' keeps the existing node-first behavior, so output
is unchanged for inbounds that never set the field. Applies to raw,
JSON, and Clash subscriptions, which all resolve through this path.
Help text in all locales updated to drop the 'subscriptions are not
affected' caveat.
This commit is contained in:
MHSanaei
2026-06-11 21:31:27 +02:00
parent 21143a6d72
commit cc65f37164
15 changed files with 105 additions and 24 deletions
+30 -11
View File
@@ -788,23 +788,42 @@ func (s *SubService) loadNodes() {
s.nodesByID = m
}
// 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)
// resolveInboundAddress picks the host an external client should connect to,
// honoring the inbound's share address strategy the same way the panel's
// share/QR link builder does (#5208):
// - "listen": an explicit, client-reachable bind Listen wins, backed by the
// node's address for node-managed inbounds;
// - "custom": the inbound's ShareAddr wins, then node, then listen;
// - "node" (default, and any unknown value): the node's address for
// node-managed inbounds, then a routable Listen — the pre-strategy order.
//
// 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. This subscription path intentionally ignores
// per-inbound share address settings because subscription URLs are panel-owned.
// Every chain ends at 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 still overrides everything
// upstream of this call.
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
var nodeAddr string
if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
return n.Address
if n, ok := s.nodesByID[*inbound.NodeID]; ok {
nodeAddr = n.Address
}
}
var listenAddr string
if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) {
return listen
listenAddr = listen
}
candidates := []string{nodeAddr, listenAddr}
switch inbound.ShareAddrStrategy {
case "listen":
candidates = []string{listenAddr, nodeAddr}
case "custom":
candidates = []string{strings.TrimSpace(inbound.ShareAddr), nodeAddr, listenAddr}
}
for _, c := range candidates {
if c != "" {
return c
}
}
return s.address
}
+62
View File
@@ -127,6 +127,68 @@ func TestResolveInboundAddress(t *testing.T) {
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
}
})
// Per-inbound share address strategy (#5208): subscriptions follow the
// same order as the panel's share/QR links.
t.Run("listen strategy prefers the bind over the node address", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "listen"}
if got := s.resolveInboundAddress(ib); got != "203.0.113.7" {
t.Fatalf("listen-strategy address = %q, want the bind 203.0.113.7", got)
}
})
t.Run("listen strategy falls back to node address on a wildcard bind", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0", ShareAddrStrategy: "listen"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("listen-strategy wildcard address = %q, want node7.example.com", got)
}
})
t.Run("custom strategy uses the share address", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "custom", ShareAddr: "edge.example.com"}
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
t.Fatalf("custom-strategy address = %q, want edge.example.com", got)
}
})
t.Run("custom strategy with empty share address falls back to node", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, ShareAddrStrategy: "custom"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("custom-strategy fallback address = %q, want node7.example.com", got)
}
})
t.Run("node strategy keeps the pre-strategy order", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "node"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
}
})
}
func TestUnmarshalStreamSettings(t *testing.T) {