Ver Fonte

fix(panel): use the hosting node address for WireGuard client configs (#5679)

* fix(panel): use the hosting node address for WireGuard client configs

The clients page rendered a node-managed WireGuard inbound's config with the
master panel's host in Endpoint instead of the hosting node's address, so the
copied/QR config pointed at the wrong server. The subscription path already
resolves this via resolveInboundAddress; the UI generator did not.

Expose the share-host resolution inputs (node address, listen, share-address
strategy/address) on InboundOption and route buildWireguardClientConfig through
the same canonical resolver the inbounds-page share links use, extracted as
resolveShareHost. This also brings local inbounds with a shareable listen or a
listen/custom share strategy into parity with the subscription Endpoint; the
common listen=0.0.0.0 case still falls back to the panel host.

* fix(frontend): keep a raw fallback host and refresh node-fed inbound options

Code review of the WireGuard node-endpoint change surfaced two gaps.
resolveShareHost normalized its last-resort fallbackHostname, so a panel
reached via a hostname the share-host grammar rejects (underscore label,
trailing-dot FQDN) emitted a broken 'Endpoint = :51820'; the fallback now
stays verbatim when normalization empties it. Node mutations only
invalidated the nodes query, leaving the staleTime-Infinity inbound
options cache serving an edited node address until the sync job
broadcast (never, for disabled/offline nodes); they now invalidate the
options key too.

Also folds the ShareHostFields projections into direct structural passes,
elides the default node shareAddrStrategy so omitempty drops it, and
replaces the nullable node-address scan with COALESCE.

---------

Co-authored-by: STRENCH0 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Grigoriy há 18 horas atrás
pai
commit
f90e4a6962

+ 17 - 0
frontend/public/openapi.json

@@ -1828,6 +1828,13 @@
             "example": 1,
             "type": "integer"
           },
+          "listen": {
+            "type": "string"
+          },
+          "nodeAddress": {
+            "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
+            "type": "string"
+          },
           "nodeId": {
             "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
             "nullable": true,
@@ -1845,6 +1852,12 @@
             "example": "VLESS-443",
             "type": "string"
           },
+          "shareAddr": {
+            "type": "string"
+          },
+          "shareAddrStrategy": {
+            "type": "string"
+          },
           "ssMethod": {
             "type": "string"
           },
@@ -2783,10 +2796,14 @@
                   "obj": [
                     {
                       "id": 1,
+                      "listen": "",
+                      "nodeAddress": "",
                       "nodeId": null,
                       "port": 443,
                       "protocol": "vless",
                       "remark": "VLESS-443",
+                      "shareAddr": "",
+                      "shareAddrStrategy": "",
                       "ssMethod": "",
                       "tag": "in-443-tcp",
                       "tlsFlowCapable": true,

+ 4 - 1
frontend/src/api/queries/useNodeMutations.ts

@@ -24,7 +24,10 @@ export interface RemoteInboundOption {
 
 export function useNodeMutations() {
   const queryClient = useQueryClient();
-  const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
+    queryClient.invalidateQueries({ queryKey: keys.inbounds.options() });
+  };
 
   const createMut = useMutation({
     mutationFn: (payload: Partial<NodeRecord>) =>

+ 4 - 0
frontend/src/generated/examples.ts

@@ -400,10 +400,14 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   "InboundOption": {
     "id": 1,
+    "listen": "",
+    "nodeAddress": "",
     "nodeId": null,
     "port": 443,
     "protocol": "vless",
     "remark": "VLESS-443",
+    "shareAddr": "",
+    "shareAddrStrategy": "",
     "ssMethod": "",
     "tag": "in-443-tcp",
     "tlsFlowCapable": true,

+ 13 - 0
frontend/src/generated/schemas.ts

@@ -1802,6 +1802,13 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 1,
         "type": "integer"
       },
+      "listen": {
+        "type": "string"
+      },
+      "nodeAddress": {
+        "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
+        "type": "string"
+      },
       "nodeId": {
         "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
         "nullable": true,
@@ -1819,6 +1826,12 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "VLESS-443",
         "type": "string"
       },
+      "shareAddr": {
+        "type": "string"
+      },
+      "shareAddrStrategy": {
+        "type": "string"
+      },
       "ssMethod": {
         "type": "string"
       },

+ 4 - 0
frontend/src/generated/types.ts

@@ -394,10 +394,14 @@ export interface InboundFallback {
 
 export interface InboundOption {
   id: number;
+  listen?: string;
+  nodeAddress?: string;
   nodeId?: number | null;
   port: number;
   protocol: string;
   remark: string;
+  shareAddr?: string;
+  shareAddrStrategy?: string;
   ssMethod: string;
   tag: string;
   tlsFlowCapable: boolean;

+ 4 - 0
frontend/src/generated/zod.ts

@@ -421,10 +421,14 @@ export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
 
 export const InboundOptionSchema = z.object({
   id: z.number().int(),
+  listen: z.string().optional(),
+  nodeAddress: z.string().optional(),
   nodeId: z.number().int().nullable().optional(),
   port: z.number().int(),
   protocol: z.string(),
   remark: z.string(),
+  shareAddr: z.string().optional(),
+  shareAddrStrategy: z.string().optional(),
   ssMethod: z.string(),
   tag: z.string(),
   tlsFlowCapable: z.boolean(),

+ 39 - 19
frontend/src/lib/xray/inbound-link.ts

@@ -967,33 +967,44 @@ function isShareableHost(host: string): boolean {
   return true;
 }
 
-function shareableListen(inbound: Inbound): string {
-  const listen = inbound.listen.trim();
-  return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen)
-    ? normalizeShareHost(listen)
+function shareableListenFrom(listen: string): string {
+  const trimmed = listen.trim();
+  return trimmed.length > 0 && !isUnixSocketListen(trimmed) && isShareableHost(trimmed)
+    ? normalizeShareHost(trimmed)
     : '';
 }
 
 type ShareAddrStrategy = 'node' | 'listen' | 'custom';
 
-function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy {
-  const strategy = inbound.shareAddrStrategy;
-  return strategy === 'listen' || strategy === 'custom'
-    ? strategy
-    : 'node';
+function normalizeShareAddrStrategy(strategy: string | undefined): ShareAddrStrategy {
+  return strategy === 'listen' || strategy === 'custom' ? strategy : 'node';
 }
 
-// Orchestrators.
-// resolveAddr picks the host that goes into share/QR links. The default
-// `node` strategy keeps the previous node-address-first behavior for
-// node-managed inbounds; other strategies let a row prefer its listen address
-// or a custom endpoint.
-export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
+// ShareHostFields is the subset of an inbound resolveShareHost needs, so callers
+// holding only a lightweight projection (e.g. the clients page InboundOption)
+// can pick the same host as the full-inbound share/QR path.
+export interface ShareHostFields {
+  listen?: string;
+  shareAddr?: string;
+  shareAddrStrategy?: string;
+}
+
+// resolveShareHost picks the host that goes into share/QR links, the browser-side
+// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
+// address (empty for this panel's own inbounds); fallbackHostname is the
+// already-resolved panel/public host used as the last resort — kept verbatim when
+// it fails normalization (e.g. an underscore intranet hostname) so the last
+// resort never degrades to an empty host.
+export function resolveShareHost(
+  fields: ShareHostFields,
+  hostOverride: string,
+  fallbackHostname: string,
+): string {
   const nodeAddr = normalizeShareHost(hostOverride);
-  const listenAddr = shareableListen(inbound);
-  const customAddr = normalizeShareHost(inbound.shareAddr ?? '');
-  const fallbackAddr = normalizeShareHost(fallbackHostname);
-  switch (shareAddrStrategy(inbound)) {
+  const listenAddr = shareableListenFrom(fields.listen ?? '');
+  const customAddr = normalizeShareHost(fields.shareAddr ?? '');
+  const fallbackAddr = normalizeShareHost(fallbackHostname) || fallbackHostname.trim();
+  switch (normalizeShareAddrStrategy(fields.shareAddrStrategy)) {
     case 'listen':
       return listenAddr || nodeAddr || fallbackAddr;
     case 'custom':
@@ -1003,6 +1014,15 @@ export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHost
   }
 }
 
+// Orchestrators.
+// resolveAddr picks the host that goes into share/QR links. The default
+// `node` strategy keeps the previous node-address-first behavior for
+// node-managed inbounds; other strategies let a row prefer its listen address
+// or a custom endpoint.
+export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
+  return resolveShareHost(inbound, hostOverride, fallbackHostname);
+}
+
 // A loopback browser host means the panel was reached through a tunnel (e.g.
 // SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
 function isLoopbackHost(host: string): boolean {

+ 2 - 2
frontend/src/pages/clients/wireguardConfig.ts

@@ -1,5 +1,5 @@
 import { formatInboundLabel } from '@/lib/inbounds/label';
-import { preferPublicHost } from '@/lib/xray/inbound-link';
+import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 
 export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
@@ -22,7 +22,7 @@ export function buildWireguardClientConfig(
   host = window.location.hostname,
   publicHost = '',
 ): string {
-  const endpointHost = preferPublicHost(host, publicHost);
+  const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
   const address = client.allowedIPs || '10.0.0.2/32';
   const endpoint = `${endpointHost}:${inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';

+ 7 - 0
frontend/src/schemas/client.ts

@@ -54,6 +54,13 @@ export const InboundOptionSchema = z.object({
   wgDns: z.string().optional(),
   // Hosting node id; absent/null for this panel's own inbounds (#4997).
   nodeId: z.number().nullable().optional(),
+  // Share-host resolution inputs, mirroring the backend resolveInboundAddress so
+  // the clients page picks the same WireGuard endpoint host as the subscription:
+  // the hosting node address, the inbound listen, and its share-address strategy.
+  nodeAddress: z.string().optional(),
+  listen: z.string().optional(),
+  shareAddr: z.string().optional(),
+  shareAddrStrategy: z.string().optional(),
 }).loose();
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);

+ 37 - 0
frontend/src/test/wireguard-client-config.test.ts

@@ -52,4 +52,41 @@ describe('buildWireguardClientConfig', () => {
     const cfg = buildWireguardClientConfig({ ...client, preSharedKey: undefined }, inbound, 'example.com', '');
     expect(cfg).not.toContain('PresharedKey');
   });
+
+  it('uses the hosting node address as the endpoint host for node-managed inbounds', () => {
+    const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: 'node.example.net' }, 'master.example.com', '');
+    expect(cfg).toContain('Endpoint = node.example.net:51820');
+    expect(cfg).not.toContain('master.example.com');
+  });
+
+  it('falls back to the panel host when the node address is blank', () => {
+    const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: '   ' }, 'master.example.com', '');
+    expect(cfg).toContain('Endpoint = master.example.com:51820');
+  });
+
+  it('honors the custom share-address strategy over the node address', () => {
+    const cfg = buildWireguardClientConfig(
+      client,
+      { ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'custom', shareAddr: 'vpn.example.com' },
+      'master.example.com',
+      '',
+    );
+    expect(cfg).toContain('Endpoint = vpn.example.com:51820');
+  });
+
+  it('honors the listen share-address strategy over the node address', () => {
+    const cfg = buildWireguardClientConfig(
+      client,
+      { ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'listen', listen: '198.51.100.7' },
+      'master.example.com',
+      '',
+    );
+    expect(cfg).toContain('Endpoint = 198.51.100.7:51820');
+  });
+
+  it('keeps a panel hostname that fails share-host normalization instead of emitting an empty endpoint', () => {
+    const cfg = buildWireguardClientConfig(client, { ...inbound, listen: '0.0.0.0' }, 'wg_gw.corp.lan', '');
+    expect(cfg).toContain('Endpoint = wg_gw.corp.lan:51820');
+    expect(cfg).not.toContain('Endpoint = :51820');
+  });
 });

+ 45 - 22
internal/web/service/inbound.go

@@ -305,24 +305,39 @@ type InboundOption struct {
 	// Hosting node; nil for this panel's own inbounds. Lets the clients
 	// page map a node filter onto inbound IDs (#4997).
 	NodeId *int `json:"nodeId,omitempty"`
+	// Share-host resolution inputs, mirroring the subscription's
+	// resolveInboundAddress so the clients page renders a node-managed WireGuard
+	// Endpoint that points at the node, not the master panel. NodeAddress is the
+	// hosting node's externally reachable address (empty for this panel's own
+	// inbounds); Listen and ShareAddrStrategy/ShareAddr feed the same
+	// node→listen→custom fallback the share/QR links already use.
+	NodeAddress       string `json:"nodeAddress,omitempty"`
+	Listen            string `json:"listen,omitempty"`
+	ShareAddr         string `json:"shareAddr,omitempty"`
+	ShareAddrStrategy string `json:"shareAddrStrategy,omitempty"`
 }
 
 func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
 	db := database.GetDB()
 	var rows []struct {
-		Id             int    `gorm:"column:id"`
-		Remark         string `gorm:"column:remark"`
-		Tag            string `gorm:"column:tag"`
-		Protocol       string `gorm:"column:protocol"`
-		Port           int    `gorm:"column:port"`
-		StreamSettings string `gorm:"column:stream_settings"`
-		Settings       string `gorm:"column:settings"`
-		NodeId         *int   `gorm:"column:node_id"`
+		Id                int    `gorm:"column:id"`
+		Remark            string `gorm:"column:remark"`
+		Tag               string `gorm:"column:tag"`
+		Protocol          string `gorm:"column:protocol"`
+		Port              int    `gorm:"column:port"`
+		StreamSettings    string `gorm:"column:stream_settings"`
+		Settings          string `gorm:"column:settings"`
+		Listen            string `gorm:"column:listen"`
+		ShareAddr         string `gorm:"column:share_addr"`
+		ShareAddrStrategy string `gorm:"column:share_addr_strategy"`
+		NodeId            *int   `gorm:"column:node_id"`
+		NodeAddress       string `gorm:"column:node_address"`
 	}
 	err := db.Table("inbounds").
-		Select("id, remark, tag, protocol, port, stream_settings, settings, node_id").
-		Where("user_id = ?", userId).
-		Order("id ASC").
+		Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address").
+		Joins("LEFT JOIN nodes ON nodes.id = inbounds.node_id").
+		Where("inbounds.user_id = ?", userId).
+		Order("inbounds.id ASC").
 		Scan(&rows).Error
 	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
 		return nil, err
@@ -330,18 +345,26 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	out := make([]InboundOption, 0, len(rows))
 	for _, r := range rows {
 		wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
+		shareAddrStrategy := r.ShareAddrStrategy
+		if shareAddrStrategy == "node" {
+			shareAddrStrategy = ""
+		}
 		out = append(out, InboundOption{
-			Id:             r.Id,
-			Remark:         r.Remark,
-			Tag:            r.Tag,
-			Protocol:       r.Protocol,
-			Port:           r.Port,
-			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
-			SsMethod:       inboundShadowsocksMethod(r.Protocol, r.Settings),
-			WgPublicKey:    wgPublicKey,
-			WgMtu:          wgMtu,
-			WgDns:          wgDns,
-			NodeId:         r.NodeId,
+			Id:                r.Id,
+			Remark:            r.Remark,
+			Tag:               r.Tag,
+			Protocol:          r.Protocol,
+			Port:              r.Port,
+			TlsFlowCapable:    inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
+			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
+			WgPublicKey:       wgPublicKey,
+			WgMtu:             wgMtu,
+			WgDns:             wgDns,
+			NodeId:            r.NodeId,
+			NodeAddress:       r.NodeAddress,
+			Listen:            r.Listen,
+			ShareAddr:         r.ShareAddr,
+			ShareAddrStrategy: shareAddrStrategy,
 		})
 	}
 	return out, nil

+ 89 - 0
internal/web/service/inbound_options_node_address_test.go

@@ -0,0 +1,89 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestGetInboundOptions_NodeAddress verifies that a node-managed inbound carries
+// its hosting node's externally reachable address, while this panel's own
+// inbounds report an empty NodeAddress. The clients page uses it as the
+// WireGuard endpoint host so a copied config points at the node, not the master.
+func TestGetInboundOptions_NodeAddress(t *testing.T) {
+	setupConflictDB(t)
+
+	node := &model.Node{Name: "de-fra-1", Address: "node.example.net", Port: 2053, Enable: true}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	nodeInbound := &model.Inbound{
+		UserId:   1,
+		Tag:      "in-51820-udp",
+		Enable:   true,
+		Listen:   "0.0.0.0",
+		Port:     51820,
+		Protocol: model.WireGuard,
+		Settings: `{"clients":[],"secretKey":"QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0="}`,
+		NodeID:   &node.Id,
+	}
+	localInbound := &model.Inbound{
+		UserId:            1,
+		Tag:               "in-443-tcp",
+		Enable:            true,
+		Listen:            "0.0.0.0",
+		Port:              443,
+		Protocol:          model.VLESS,
+		StreamSettings:    `{"network":"tcp"}`,
+		Settings:          `{"clients":[]}`,
+		ShareAddrStrategy: "custom",
+		ShareAddr:         "vpn.example.com",
+	}
+	if err := database.GetDB().Create(nodeInbound).Error; err != nil {
+		t.Fatalf("create node inbound: %v", err)
+	}
+	if err := database.GetDB().Create(localInbound).Error; err != nil {
+		t.Fatalf("create local inbound: %v", err)
+	}
+
+	svc := &InboundService{}
+	options, err := svc.GetInboundOptions(1)
+	if err != nil {
+		t.Fatalf("GetInboundOptions: %v", err)
+	}
+
+	byID := make(map[int]InboundOption, len(options))
+	for _, o := range options {
+		byID[o.Id] = o
+	}
+
+	got, ok := byID[nodeInbound.Id]
+	if !ok {
+		t.Fatalf("node inbound %d missing from options", nodeInbound.Id)
+	}
+	if got.NodeAddress != "node.example.net" {
+		t.Fatalf("node inbound NodeAddress = %q, want node.example.net", got.NodeAddress)
+	}
+	if got.Listen != "0.0.0.0" {
+		t.Fatalf("node inbound Listen = %q, want 0.0.0.0", got.Listen)
+	}
+	if got.ShareAddrStrategy != "" {
+		t.Fatalf("node inbound ShareAddrStrategy = %q, want empty (the default node strategy is elided so omitempty drops it)", got.ShareAddrStrategy)
+	}
+
+	local, ok := byID[localInbound.Id]
+	if !ok {
+		t.Fatalf("local inbound %d missing from options", localInbound.Id)
+	}
+	if local.NodeAddress != "" {
+		t.Fatalf("local inbound NodeAddress = %q, want empty", local.NodeAddress)
+	}
+	if local.ShareAddrStrategy != "custom" {
+		t.Fatalf("local inbound ShareAddrStrategy = %q, want custom", local.ShareAddrStrategy)
+	}
+	if local.ShareAddr != "vpn.example.com" {
+		t.Fatalf("local inbound ShareAddr = %q, want vpn.example.com", local.ShareAddr)
+	}
+}