Explorar o código

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.
MHSanaei hai 16 horas
pai
achega
cc65f37164

+ 30 - 11
internal/sub/service.go

@@ -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 - 0
internal/sub/service_test.go

@@ -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) {

+ 1 - 1
internal/web/translation/ar-EG.json

@@ -591,7 +591,7 @@
         "getNewSeed": "احصل على Seed جديد",
         "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0.",
         "shareAddrStrategy": "استراتيجية عنوان المشاركة",
-        "shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR. لا تتأثر روابط الاشتراك.",
+        "shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR ومخرجات الاشتراك.",
         "shareAddr": "عنوان مشاركة مخصص",
         "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/en-US.json

@@ -592,7 +592,7 @@
         "getNewSeed": "Get New Seed",
         "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case.",
         "shareAddrStrategy": "Share address strategy",
-        "shareAddrStrategyHelp": "Controls which address is written into exported share links and QR codes. Subscription links are not affected.",
+        "shareAddrStrategyHelp": "Controls which address is written into exported share links, QR codes, and subscription output.",
         "shareAddr": "Custom share address",
         "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/es-ES.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Obtener nuevo Seed",
         "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock) para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0.",
         "shareAddrStrategy": "Estrategia de dirección para compartir",
-        "shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados y códigos QR. Los enlaces de suscripción no se ven afectados.",
+        "shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados, códigos QR y la salida de suscripción.",
         "shareAddr": "Dirección compartida personalizada",
         "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/fa-IR.json

@@ -591,7 +591,7 @@
         "getNewSeed": "دریافت Seed جدید",
         "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock) تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید.",
         "shareAddrStrategy": "راهبرد آدرس اشتراک‌گذاری",
-        "shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی و کدهای QR نوشته شود. لینک‌های اشتراک تحت تأثیر قرار نمی‌گیرند.",
+        "shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی، کدهای QR و خروجی اشتراک نوشته شود.",
         "shareAddr": "آدرس اشتراک‌گذاری سفارشی",
         "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/id-ID.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Dapatkan Seed baru",
         "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock) untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0.",
         "shareAddrStrategy": "Strategi alamat berbagi",
-        "shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor dan kode QR. Tautan langganan tidak terpengaruh.",
+        "shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor, kode QR, dan keluaran langganan.",
         "shareAddr": "Alamat berbagi kustom",
         "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/ja-JP.json

@@ -591,7 +591,7 @@
         "getNewSeed": "新しい Seed を取得",
         "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。",
         "shareAddrStrategy": "共有アドレス戦略",
-        "shareAddrStrategyHelp": "エクスポートされる共有リンクとQRコードに書き込むアドレスを制御します。サブスクリプションリンクには影響しません。",
+        "shareAddrStrategyHelp": "エクスポートされる共有リンク、QRコード、サブスクリプション出力に書き込むアドレスを制御します。",
         "shareAddr": "カスタム共有アドレス",
         "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/pt-BR.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Obter novo Seed",
         "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock) para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0.",
         "shareAddrStrategy": "Estratégia de endereço de compartilhamento",
-        "shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados e nos códigos QR. Links de assinatura não são afetados.",
+        "shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados, códigos QR e na saída de assinatura.",
         "shareAddr": "Endereço de compartilhamento personalizado",
         "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/ru-RU.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Получить новый Seed",
         "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0.",
         "shareAddrStrategy": "Стратегия адреса для ссылок",
-        "shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки и QR-коды. Ссылки подписки не затрагиваются.",
+        "shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки, QR-коды и выдачу подписки.",
         "shareAddr": "Пользовательский адрес для ссылок",
         "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/tr-TR.json

@@ -592,7 +592,7 @@
         "getNewSeed": "Yeni Seed Al",
         "listenHelp": "TCP portu yerine bir Unix soket yolu da girebilirsiniz (örn. /run/xray/in.sock) — bu durumda Port'u 0 olarak ayarlayın.",
         "shareAddrStrategy": "Paylaşım adresi stratejisi",
-        "shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına ve QR kodlarına hangi adresin yazılacağını belirler. Abonelik bağlantıları etkilenmez.",
+        "shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.",
         "shareAddr": "Özel paylaşım adresi",
         "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/uk-UA.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Отримати новий Seed",
         "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0.",
         "shareAddrStrategy": "Стратегія адреси поширення",
-        "shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення та QR-коди. Посилання підписки не змінюються.",
+        "shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення, QR-коди та вивід підписки.",
         "shareAddr": "Користувацька адреса поширення",
         "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/vi-VN.json

@@ -591,7 +591,7 @@
         "getNewSeed": "Lấy Seed mới",
         "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock) để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0.",
         "shareAddrStrategy": "Chiến lược địa chỉ chia sẻ",
-        "shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất và mã QR. Liên kết đăng ký không bị ảnh hưởng.",
+        "shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất, mã QR và nội dung đăng ký.",
         "shareAddr": "Địa chỉ chia sẻ tùy chỉnh",
         "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/zh-CN.json

@@ -591,7 +591,7 @@
         "getNewSeed": "获取新 Seed",
         "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。",
         "shareAddrStrategy": "分享地址策略",
-        "shareAddrStrategyHelp": "控制导出分享链接和二维码时写入哪个地址,不影响订阅链接。",
+        "shareAddrStrategyHelp": "控制导出分享链接、二维码和订阅输出时写入哪个地址。",
         "shareAddr": "自定义分享地址",
         "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
         "shareAddrStrategyOptions": {

+ 1 - 1
internal/web/translation/zh-TW.json

@@ -591,7 +591,7 @@
         "getNewSeed": "取得新 Seed",
         "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。",
         "shareAddrStrategy": "分享地址策略",
-        "shareAddrStrategyHelp": "控制匯出分享連結和 QR Code 時寫入哪個地址,不影響訂閱連結。",
+        "shareAddrStrategyHelp": "控制匯出分享連結、QR Code 和訂閱輸出時寫入哪個地址。",
         "shareAddr": "自訂分享地址",
         "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
         "shareAddrStrategyOptions": {