mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 04:14:21 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user