浏览代码

fix(nodes): keep cloned nodes (shared panelGuid) in separate attribution buckets

#4983 keys online/inbound attribution by panelGuid, assuming it is globally unique. Cloned node servers ship an identical panelGuid in their copied settings, so the master collapsed several physical nodes into one bucket: GetMergedNodeTrees merged their online sets under one key and every inbound on those nodes (same origin_node_guid) read that merged set, so the inbound page showed online cross-attributed and counts inflated.

Fall back to the node-unique synthNodeGuid(node.Id) whenever a node's panelGuid is shared by another of the master's direct nodes. Applied consistently at originGuidFor (origin_node_guid write), the online-tree key plus a self-key remap for nodes that report a GUID-keyed tree, effectiveNodeGuid, and recountByGuid's inbound bucketing. sharedNodeGuids computes the collision set. Online now works without node changes; making panelGuids unique restores real-GUID identity and also fixes GUID-keyed IP attribution.
MHSanaei 9 小时之前
父节点
当前提交
af941798c6

+ 32 - 9
internal/web/service/inbound_node.go

@@ -211,16 +211,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	// originGuidFor attributes a synced inbound to the panel that physically
 	// hosts it: inbounds the node forwards from its own sub-nodes already carry
 	// a non-empty OriginNodeGuid (kept as-is across hops); the node's own local
-	// inbounds report empty, so they are attributed to the node's own GUID. An
-	// empty result (old-build node with no GUID yet) leaves attribution to the
-	// node_id fallback downstream (#4983).
+	// inbounds report empty, so they are attributed to the node's own key. That
+	// key is the node's panelGuid, unless another of this master's nodes reports
+	// the same GUID (cloned server — the panelGuid ships in the copied settings),
+	// in which case it would collapse both nodes into one #4983 bucket, so fall
+	// back to the node-unique id. Old-build nodes with no GUID use the id too.
 	var nodeRow model.Node
 	db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
+	guidShared := false
+	if nodeRow.Guid != "" {
+		var sameGuid int64
+		db.Model(&model.Node{}).Where("guid = ?", nodeRow.Guid).Count(&sameGuid)
+		guidShared = sameGuid > 1
+	}
+	selfKey := nodeRow.Guid
+	if selfKey == "" || guidShared {
+		selfKey = synthNodeGuid(nodeID)
+	}
 	originGuidFor := func(snapIb *model.Inbound) string {
 		if snapIb.OriginNodeGuid != "" {
 			return snapIb.OriginNodeGuid
 		}
-		return nodeRow.Guid
+		return selfKey
 	}
 
 	var central []model.Inbound
@@ -810,14 +822,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 	if p != nil {
 		tree := snap.OnlineTree
-		if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
+		switch {
+		case len(tree) == 0 && len(snap.OnlineEmails) > 0:
 			// Old-build node (no GUID tree): key its flat online list under its
 			// own effective identity so attribution still works for that branch.
-			effectiveGuid := nodeRow.Guid
-			if effectiveGuid == "" {
-				effectiveGuid = synthNodeGuid(nodeID)
+			tree = map[string][]string{selfKey: snap.OnlineEmails}
+		case guidShared && len(tree) > 0:
+			// Newer cloned node: its own clients arrive keyed under the shared
+			// panelGuid. Remap just that entry to the node-unique key so the
+			// clones don't merge; descendant subtrees keep their distinct GUIDs.
+			if _, ok := tree[nodeRow.Guid]; ok {
+				remapped := make(map[string][]string, len(tree))
+				for g, emails := range tree {
+					if g == nodeRow.Guid {
+						g = selfKey
+					}
+					remapped[g] = emails
+				}
+				tree = remapped
 			}
-			tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
 		}
 		p.SetNodeOnlineTree(nodeID, tree)
 	}

+ 36 - 8
internal/web/service/node.go

@@ -192,13 +192,14 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 		}
 	}
 	onlineByGuid := s.onlineEmailsByGuid()
+	shared := sharedNodeGuids(nodes)
 	for _, n := range nodes {
 		n.InboundCount = len(inboundsByNode[n.Id])
 		n.DepletedCount = depletedByNode[n.Id]
 		// Online is attributed to the node that physically hosts the client
 		// (by GUID): a client on a sub-node counts under the sub-node, not
 		// the intermediate node it syncs through (#4983).
-		n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
+		n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n, shared)])
 	}
 
 	return nodes, nil
@@ -218,14 +219,41 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
 	return out
 }
 
-// effectiveNodeGuid is a node's stable online-attribution key: its reported
-// panelGuid, or a master-local synthetic id when the node is an old build that
-// hasn't reported one yet (#4983).
-func effectiveNodeGuid(n *model.Node) string {
-	if n.Guid != "" {
-		return n.Guid
+// effectiveNodeGuid is a node's stable online/inbound attribution key: its
+// reported panelGuid, or a master-local synthetic node-id fallback when the node
+// has no GUID yet (old build) or shares its GUID with another direct node. The
+// shared case is a cloned server — the panelGuid is copied with the disk image —
+// where an identical GUID would otherwise collapse two physical nodes into one
+// #4983 attribution bucket. shared comes from sharedNodeGuids.
+func effectiveNodeGuid(n *model.Node, shared map[string]struct{}) string {
+	if n.Guid == "" {
+		return synthNodeGuid(n.Id)
 	}
-	return synthNodeGuid(n.Id)
+	if n.Id > 0 {
+		if _, dup := shared[n.Guid]; dup {
+			return synthNodeGuid(n.Id)
+		}
+	}
+	return n.Guid
+}
+
+// sharedNodeGuids returns the panelGuids reported by more than one of this
+// master's own direct nodes (Id > 0). Transitive sub-nodes (Id 0) carry distinct
+// descendant GUIDs by construction and are excluded.
+func sharedNodeGuids(nodes []*model.Node) map[string]struct{} {
+	counts := make(map[string]int, len(nodes))
+	for _, n := range nodes {
+		if n.Id > 0 && n.Guid != "" {
+			counts[n.Guid]++
+		}
+	}
+	shared := make(map[string]struct{})
+	for guid, c := range counts {
+		if c > 1 {
+			shared[guid] = struct{}{}
+		}
+	}
+	return shared
 }
 
 func (s *NodeService) GetById(id int) (*model.Node, error) {

+ 86 - 0
internal/web/service/node_shared_guid_test.go

@@ -0,0 +1,86 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Cloned node servers ship an identical panelGuid in their copied settings.
+// effectiveNodeGuid must keep each physical node in its own attribution bucket
+// by falling back to the node-unique id when a GUID is shared, while leaving a
+// uniquely-identified node on its real GUID.
+func TestEffectiveNodeGuid_DisambiguatesSharedGuids(t *testing.T) {
+	nodes := []*model.Node{
+		{Id: 1, Guid: "dup"},
+		{Id: 2, Guid: "dup"},
+		{Id: 3, Guid: "uniq"},
+		{Id: 4, Guid: ""},
+		{Id: 0, Guid: "transitive"},
+	}
+	shared := sharedNodeGuids(nodes)
+
+	if _, ok := shared["dup"]; !ok {
+		t.Fatalf("dup must be flagged shared, got %v", shared)
+	}
+	if _, ok := shared["uniq"]; ok {
+		t.Fatalf("uniq must not be shared, got %v", shared)
+	}
+	if _, ok := shared["transitive"]; ok {
+		t.Fatalf("transitive (Id 0) must not count toward sharing, got %v", shared)
+	}
+
+	cases := map[*model.Node]string{
+		nodes[0]: "node:1",
+		nodes[1]: "node:2",
+		nodes[2]: "uniq",
+		nodes[3]: "node:4",
+		nodes[4]: "transitive",
+	}
+	for n, want := range cases {
+		if got := effectiveNodeGuid(n, shared); got != want {
+			t.Errorf("effectiveNodeGuid(Id=%d, Guid=%q) = %q, want %q", n.Id, n.Guid, got, want)
+		}
+	}
+}
+
+// recountByGuid must split per-node counts even when two direct nodes share a
+// GUID and their inbounds still carry that shared GUID as origin (pre-backfill).
+func TestRecountByGuid_SplitsClonedNodesWithSharedGuid(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	svc := NodeService{}
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+
+	n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	n3 := &model.Node{Id: 3, Name: "C", Address: "10.0.0.3", Port: 2053, ApiToken: "t", Guid: "uniq", Status: "online"}
+	for _, n := range []*model.Node{n1, n2, n3} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node %s: %v", n.Name, err)
+		}
+	}
+
+	id1, id2, id3 := 1, 2, 3
+	inbounds := []*model.Inbound{
+		{Tag: "a", Port: 1001, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id1, OriginNodeGuid: "dup"},
+		{Tag: "b", Port: 1002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id2, OriginNodeGuid: "dup"},
+		{Tag: "c", Port: 1003, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id3, OriginNodeGuid: "uniq"},
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	nodes := []*model.Node{n1, n2, n3}
+	svc.recountByGuid(nodes, selfGuid)
+
+	if n1.InboundCount != 1 || n2.InboundCount != 1 {
+		t.Errorf("cloned nodes must not share inbound counts: n1=%d n2=%d, want 1,1", n1.InboundCount, n2.InboundCount)
+	}
+	if n3.InboundCount != 1 {
+		t.Errorf("unique node InboundCount = %d, want 1", n3.InboundCount)
+	}
+}

+ 9 - 1
internal/web/service/node_tree.go

@@ -174,6 +174,7 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
 	if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
 		return
 	}
+	shared := sharedNodeGuids(nodes)
 	effByInbound := make(map[int]string, len(ibRows))
 	inboundCountByGuid := make(map[string]int)
 	ids := make([]int, 0, len(ibRows))
@@ -185,6 +186,13 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
 			} else {
 				guid = selfGuid
 			}
+		} else if r.NodeID != nil {
+			// Origin still holds a GUID two direct nodes share (cloned server,
+			// not yet re-attributed): bucket under the hosting node's unique id
+			// so the clones don't merge.
+			if _, dup := shared[guid]; dup {
+				guid = synthNodeGuid(*r.NodeID)
+			}
 		}
 		effByInbound[r.Id] = guid
 		inboundCountByGuid[guid]++
@@ -222,7 +230,7 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
 
 	onlineByGuid := s.onlineEmailsByGuid()
 	for _, n := range nodes {
-		guid := effectiveNodeGuid(n)
+		guid := effectiveNodeGuid(n, shared)
 		n.InboundCount = inboundCountByGuid[guid]
 		n.OnlineCount = len(onlineByGuid[guid])
 		n.DepletedCount = depletedByGuid[guid]