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.
This commit is contained in:
MHSanaei
2026-06-22 14:39:15 +02:00
parent 4854f9c1b8
commit af941798c6
4 changed files with 163 additions and 18 deletions
+32 -9
View File
@@ -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
View File
@@ -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) {
@@ -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
View File
@@ -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]