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 <17428017+STRENCH0@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Grigoriy
2026-07-03 02:12:32 +03:00
committed by GitHub
parent dbdecda03f
commit f90e4a6962
12 changed files with 267 additions and 46 deletions
+45 -22
View File
@@ -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
@@ -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)
}
}