From af941798c67ec65ed056db05c7052ccd9d30e4ec Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 22 Jun 2026 14:39:15 +0200 Subject: [PATCH] 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. --- internal/web/service/inbound_node.go | 41 +++++++-- internal/web/service/node.go | 44 ++++++++-- internal/web/service/node_shared_guid_test.go | 86 +++++++++++++++++++ internal/web/service/node_tree.go | 10 ++- 4 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 internal/web/service/node_shared_guid_test.go diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 34a437d2b..cc7653f35 100644 --- a/internal/web/service/inbound_node.go +++ b/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) } diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 3106cac13..33feccf21 100644 --- a/internal/web/service/node.go +++ b/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) { diff --git a/internal/web/service/node_shared_guid_test.go b/internal/web/service/node_shared_guid_test.go new file mode 100644 index 000000000..a264244b0 --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/node_tree.go b/internal/web/service/node_tree.go index f99452dd5..f8d168fb8 100644 --- a/internal/web/service/node_tree.go +++ b/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]