Files
3x-ui/internal/web/service/node_tree.go
T
MHSanaei 98c9ba1f91 fix(nodes): extend duplicate-GUID hardening to master collisions, IP attribution, and a heartbeat warning
Builds on the node-vs-node fix: a node's GUID is now also treated as ambiguous when it equals the master's own panelGuid (a node cloned from the master), so the master's local clients and that node can't merge. Centralized as ambiguousNodeGuids(nodes, selfGuid) + effectiveNodeKey(node).

Applied the same node-unique fallback to the GUID-keyed IP attribution that #4983 added but the prior commit left collapsing: MergeClientIpsByGuid remaps a cloned node's own subtree to its node-unique key, nodeGuidNameMap resolves names by that key, and node deletion purges both keys. Added a throttled heartbeat warning so the operator is told to regenerate a duplicate panelGuid. Tests cover master-collision, effectiveNodeKey, and the IP remap.
2026-06-22 15:08:02 +02:00

239 lines
7.0 KiB
Go

package service
import (
"context"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
// LocalDescendants returns this panel's read-only summaries of the nodes it
// directly manages, so a parent panel can surface them as transitive sub-nodes
// (#4983). Only nodes with a known GUID are included — a stable identity is
// required to attribute them one hop up. Not recursive: each panel reports its
// own direct nodes, and a master walks one level via each direct node's
// endpoint, which covers the Node1 -> Node2 -> Node3 case.
func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
selfGuid, _ := (&SettingService{}).GetPanelGuid()
db := database.GetDB()
var nodes []*model.Node
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
return nil, err
}
out := make([]model.NodeSummary, 0, len(nodes))
for _, n := range nodes {
if n.Guid == "" {
continue
}
out = append(out, model.NodeSummary{
Guid: n.Guid,
ParentGuid: selfGuid,
Name: n.Name,
Address: n.Address,
Scheme: n.Scheme,
Port: n.Port,
Status: n.Status,
LastHeartbeat: n.LastHeartbeat,
LatencyMs: n.LatencyMs,
PanelVersion: n.PanelVersion,
XrayVersion: n.XrayVersion,
XrayState: n.XrayState,
XrayError: n.XrayError,
})
}
return out, nil
}
var (
nodeDescendantsMu sync.RWMutex
nodeDescendantsCache = map[int][]model.NodeSummary{}
)
// RefreshDescendants pulls a direct node's published sub-node summaries and
// caches them keyed by node id. Best-effort: a fetch error keeps the last good
// set (the node may be briefly unreachable). Called from the heartbeat job.
func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
if n == nil {
return
}
mgr := runtime.GetManager()
if mgr == nil {
return
}
rt, err := mgr.RemoteFor(n)
if err != nil {
return
}
summaries, err := rt.GetDescendants(ctx)
if err != nil {
return
}
nodeDescendantsMu.Lock()
if len(summaries) == 0 {
delete(nodeDescendantsCache, n.Id)
} else {
nodeDescendantsCache[n.Id] = summaries
}
nodeDescendantsMu.Unlock()
}
// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
func (s *NodeService) ClearDescendants(nodeID int) {
nodeDescendantsMu.Lock()
delete(nodeDescendantsCache, nodeID)
nodeDescendantsMu.Unlock()
}
func cachedDescendants() []model.NodeSummary {
nodeDescendantsMu.RLock()
defer nodeDescendantsMu.RUnlock()
out := make([]model.NodeSummary, 0)
for _, list := range nodeDescendantsCache {
out = append(out, list...)
}
return out
}
// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
// from them, with per-GUID counts so each node shows only the inbounds/online
// it physically hosts (#4983). Direct nodes carry the master's own GUID as
// ParentGuid; a transitive node carries its parent node's GUID. Transitive
// nodes are read-only projections (Id == 0). Used by the Nodes page and the
// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
nodes, err := s.GetAll()
if err != nil {
return nodes, err
}
selfGuid, _ := (&SettingService{}).GetPanelGuid()
directGuids := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
n.ParentGuid = selfGuid
if n.Guid != "" {
directGuids[n.Guid] = struct{}{}
}
}
seen := make(map[string]struct{})
var transitive []*model.Node
for _, sum := range cachedDescendants() {
if sum.Guid == "" {
continue
}
if _, ok := directGuids[sum.Guid]; ok {
continue // already shown as a direct node
}
if _, ok := seen[sum.Guid]; ok {
continue
}
seen[sum.Guid] = struct{}{}
transitive = append(transitive, &model.Node{
Guid: sum.Guid,
ParentGuid: sum.ParentGuid,
Name: sum.Name,
Address: sum.Address,
Scheme: sum.Scheme,
Port: sum.Port,
Status: sum.Status,
LastHeartbeat: sum.LastHeartbeat,
LatencyMs: sum.LatencyMs,
PanelVersion: sum.PanelVersion,
XrayVersion: sum.XrayVersion,
XrayState: sum.XrayState,
XrayError: sum.XrayError,
Transitive: true,
})
}
if len(transitive) == 0 {
return nodes, nil
}
all := make([]*model.Node, 0, len(nodes)+len(transitive))
all = append(all, nodes...)
all = append(all, transitive...)
s.recountByGuid(all, selfGuid)
return all, nil
}
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
// node shows only its own inbounds and each transitive node shows its own
// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
// this only changes behaviour once a transitive node exists.
func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
db := database.GetDB()
type ibRow struct {
Id int
NodeID *int `gorm:"column:node_id"`
OriginNodeGuid string `gorm:"column:origin_node_guid"`
}
var ibRows []ibRow
if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
return
}
ambiguous := ambiguousNodeGuids(nodes, selfGuid)
effByInbound := make(map[int]string, len(ibRows))
inboundCountByGuid := make(map[string]int)
ids := make([]int, 0, len(ibRows))
for _, r := range ibRows {
guid := r.OriginNodeGuid
if guid == "" {
if r.NodeID != nil {
guid = synthNodeGuid(*r.NodeID)
} else {
guid = selfGuid
}
} else if r.NodeID != nil {
// Origin still holds an ambiguous GUID (cloned server / master-shared,
// not yet re-attributed): bucket under the hosting node's unique id so
// the clones don't merge.
if _, bad := ambiguous[guid]; bad {
guid = synthNodeGuid(*r.NodeID)
}
}
effByInbound[r.Id] = guid
inboundCountByGuid[guid]++
ids = append(ids, r.Id)
}
now := time.Now().UnixMilli()
depletedByGuid := make(map[string]int)
if len(ids) > 0 {
type tRow struct {
InboundID int `gorm:"column:inbound_id"`
Enable bool
Total int64
Up int64
Down int64
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var tRows []tRow
if err := db.Table("client_traffics").
Select("inbound_id, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
for _, row := range tRows {
guid, ok := effByInbound[row.InboundID]
if !ok {
continue
}
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
if expired || exhausted || !row.Enable {
depletedByGuid[guid]++
}
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
guid := effectiveNodeGuid(n, ambiguous)
n.InboundCount = inboundCountByGuid[guid]
n.OnlineCount = len(onlineByGuid[guid])
n.DepletedCount = depletedByGuid[guid]
}
}