|
@@ -14,6 +14,7 @@ import (
|
|
|
"slices"
|
|
"slices"
|
|
|
"strconv"
|
|
"strconv"
|
|
|
"strings"
|
|
"strings"
|
|
|
|
|
+ "sync"
|
|
|
"time"
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
@@ -122,10 +123,8 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|
|
return nodes, nil
|
|
return nodes, nil
|
|
|
}
|
|
}
|
|
|
inboundsByNode := make(map[int][]int, len(nodes))
|
|
inboundsByNode := make(map[int][]int, len(nodes))
|
|
|
- nodeByInbound := make(map[int]int, len(inboundRows))
|
|
|
|
|
for _, row := range inboundRows {
|
|
for _, row := range inboundRows {
|
|
|
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
|
|
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
|
|
|
- nodeByInbound[row.Id] = row.NodeID
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
type clientCountRow struct {
|
|
type clientCountRow struct {
|
|
@@ -150,60 +149,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- now := time.Now().UnixMilli()
|
|
|
|
|
- type trafficRow struct {
|
|
|
|
|
- InboundID int `gorm:"column:inbound_id"`
|
|
|
|
|
- Email string
|
|
|
|
|
- Enable bool
|
|
|
|
|
- Total int64
|
|
|
|
|
- Up int64
|
|
|
|
|
- Down int64
|
|
|
|
|
- ExpiryTime int64 `gorm:"column:expiry_time"`
|
|
|
|
|
- }
|
|
|
|
|
- var trafficRows []trafficRow
|
|
|
|
|
- inboundIDs := make([]int, 0, len(nodeByInbound))
|
|
|
|
|
- for id := range nodeByInbound {
|
|
|
|
|
- inboundIDs = append(inboundIDs, id)
|
|
|
|
|
- }
|
|
|
|
|
- // Chunk the IN clause to avoid "too many SQL variables" on SQLite
|
|
|
|
|
- // when there are many node-owned inbounds (common with many nodes).
|
|
|
|
|
- // sqliteMaxVars is defined in this package (inbound.go).
|
|
|
|
|
- for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
|
|
|
|
|
- var page []trafficRow
|
|
|
|
|
- if err := db.Table("client_traffics").
|
|
|
|
|
- Select("inbound_id, email, enable, total, up, down, expiry_time").
|
|
|
|
|
- Where("inbound_id IN ?", batch).
|
|
|
|
|
- Scan(&page).Error; err == nil {
|
|
|
|
|
- trafficRows = append(trafficRows, page...)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
depletedByNode := make(map[int]int)
|
|
depletedByNode := make(map[int]int)
|
|
|
- if len(trafficRows) > 0 {
|
|
|
|
|
- for _, row := range trafficRows {
|
|
|
|
|
- nodeID, ok := nodeByInbound[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 {
|
|
|
|
|
- depletedByNode[nodeID]++
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ disabledByNode := make(map[int]int)
|
|
|
|
|
+ activeByNode := make(map[int]int)
|
|
|
|
|
+ statuses, _ := s.nodeClientStatuses()
|
|
|
|
|
+ seen := make(map[int]map[int]struct{}, len(nodes))
|
|
|
|
|
+ for _, st := range statuses {
|
|
|
|
|
+ clientsSeen := seen[st.NodeID]
|
|
|
|
|
+ if clientsSeen == nil {
|
|
|
|
|
+ clientsSeen = make(map[int]struct{})
|
|
|
|
|
+ seen[st.NodeID] = clientsSeen
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, dup := clientsSeen[st.ClientID]; dup {
|
|
|
|
|
+ // A client attached to several inbounds of one node counts once,
|
|
|
|
|
+ // matching the distinct ClientCount above.
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ clientsSeen[st.ClientID] = struct{}{}
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case st.Depleted:
|
|
|
|
|
+ depletedByNode[st.NodeID]++
|
|
|
|
|
+ case st.Disabled:
|
|
|
|
|
+ disabledByNode[st.NodeID]++
|
|
|
|
|
+ default:
|
|
|
|
|
+ activeByNode[st.NodeID]++
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
onlineByGuid := s.onlineEmailsByGuid()
|
|
onlineByGuid := s.onlineEmailsByGuid()
|
|
|
|
|
+ selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
|
|
|
|
+ ambiguous := ambiguousNodeGuids(nodes, selfGuid)
|
|
|
for _, n := range nodes {
|
|
for _, n := range nodes {
|
|
|
n.InboundCount = len(inboundsByNode[n.Id])
|
|
n.InboundCount = len(inboundsByNode[n.Id])
|
|
|
n.DepletedCount = depletedByNode[n.Id]
|
|
n.DepletedCount = depletedByNode[n.Id]
|
|
|
|
|
+ n.DisabledCount = disabledByNode[n.Id]
|
|
|
|
|
+ n.ActiveCount = activeByNode[n.Id]
|
|
|
// Online is attributed to the node that physically hosts the client
|
|
// 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
|
|
// (by GUID): a client on a sub-node counts under the sub-node, not
|
|
|
// the intermediate node it syncs through (#4983).
|
|
// the intermediate node it syncs through (#4983).
|
|
|
- n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
|
|
|
|
|
|
|
+ n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n, ambiguous)])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return nodes, nil
|
|
return nodes, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// nodeClientStatus is one node-hosted client's classification, carrying enough
|
|
|
|
|
+// identity for callers to bucket it by node id or by attribution GUID.
|
|
|
|
|
+type nodeClientStatus struct {
|
|
|
|
|
+ InboundID int
|
|
|
|
|
+ NodeID int
|
|
|
|
|
+ ClientID int
|
|
|
|
|
+ Depleted bool
|
|
|
|
|
+ Disabled bool
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// nodeClientStatuses classifies every client attached to a node-hosted inbound as
|
|
|
|
|
+// depleted / disabled / active, matching client_traffics by EMAIL rather than by
|
|
|
|
|
+// inbound_id. client_traffics.inbound_id goes stale after an inbound is
|
|
|
|
|
+// delete+recreated, so filtering by it silently drops most rows; the
|
|
|
|
|
+// client_inbounds -> clients join is the reliable client set and the email join
|
|
|
|
|
+// pulls each client's live counters. Precedence matches the inbound page:
|
|
|
|
|
+// depleted (expired/exhausted) wins over disabled.
|
|
|
|
|
+func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
|
|
|
|
|
+ type row struct {
|
|
|
|
|
+ InboundID int `gorm:"column:inbound_id"`
|
|
|
|
|
+ NodeID int `gorm:"column:node_id"`
|
|
|
|
|
+ ClientID int `gorm:"column:client_id"`
|
|
|
|
|
+ Enable bool `gorm:"column:enable"`
|
|
|
|
|
+ Total int64 `gorm:"column:total"`
|
|
|
|
|
+ Up int64 `gorm:"column:up"`
|
|
|
|
|
+ Down int64 `gorm:"column:down"`
|
|
|
|
|
+ ExpiryTime int64 `gorm:"column:expiry_time"`
|
|
|
|
|
+ }
|
|
|
|
|
+ var rows []row
|
|
|
|
|
+ if err := database.GetDB().Table("inbounds").
|
|
|
|
|
+ Select("inbounds.id AS inbound_id, inbounds.node_id AS node_id, clients.id AS client_id, " +
|
|
|
|
|
+ "clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
|
|
|
|
|
+ Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
|
|
|
|
|
+ Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
|
|
|
|
+ Joins("LEFT JOIN client_traffics ct ON ct.email = clients.email").
|
|
|
|
|
+ Where("inbounds.node_id IS NOT NULL").
|
|
|
|
|
+ Scan(&rows).Error; err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ now := time.Now().UnixMilli()
|
|
|
|
|
+ out := make([]nodeClientStatus, 0, len(rows))
|
|
|
|
|
+ for _, r := range rows {
|
|
|
|
|
+ st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID}
|
|
|
|
|
+ expired := r.ExpiryTime > 0 && r.ExpiryTime <= now
|
|
|
|
|
+ exhausted := r.Total > 0 && r.Up+r.Down >= r.Total
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case expired || exhausted:
|
|
|
|
|
+ st.Depleted = true
|
|
|
|
|
+ case !r.Enable:
|
|
|
|
|
+ st.Disabled = true
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, st)
|
|
|
|
|
+ }
|
|
|
|
|
+ return out, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
|
|
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
|
|
|
svc := InboundService{}
|
|
svc := InboundService{}
|
|
|
byGuid := svc.GetOnlineClientsByGuid()
|
|
byGuid := svc.GetOnlineClientsByGuid()
|
|
@@ -218,14 +262,69 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
|
|
|
return out
|
|
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 its GUID is ambiguous. ambiguous comes from
|
|
|
|
|
+// ambiguousNodeGuids.
|
|
|
|
|
+func effectiveNodeGuid(n *model.Node, ambiguous map[string]struct{}) string {
|
|
|
|
|
+ if n.Guid == "" {
|
|
|
|
|
+ return synthNodeGuid(n.Id)
|
|
|
|
|
+ }
|
|
|
|
|
+ if n.Id > 0 {
|
|
|
|
|
+ if _, bad := ambiguous[n.Guid]; bad {
|
|
|
|
|
+ return synthNodeGuid(n.Id)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return n.Guid
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ambiguousNodeGuids returns the panelGuids a node must not be attributed under
|
|
|
|
|
+// directly, because doing so would merge two distinct identities: a GUID
|
|
|
|
|
+// reported by more than one of this master's direct nodes (cloned node servers
|
|
|
|
|
+// ship the same panelGuid in their copied settings), or a GUID equal to the
|
|
|
|
|
+// master's own panelGuid (a node cloned from the master). A node holding such a
|
|
|
|
|
+// GUID falls back to its node-unique synthNodeGuid. Transitive sub-nodes (Id 0)
|
|
|
|
|
+// carry distinct descendant GUIDs by construction and are excluded.
|
|
|
|
|
+func ambiguousNodeGuids(nodes []*model.Node, selfGuid string) map[string]struct{} {
|
|
|
|
|
+ counts := make(map[string]int, len(nodes))
|
|
|
|
|
+ for _, n := range nodes {
|
|
|
|
|
+ if n.Id > 0 && n.Guid != "" {
|
|
|
|
|
+ counts[n.Guid]++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ambiguous := make(map[string]struct{})
|
|
|
|
|
+ for guid, c := range counts {
|
|
|
|
|
+ if c > 1 {
|
|
|
|
|
+ ambiguous[guid] = struct{}{}
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- return synthNodeGuid(n.Id)
|
|
|
|
|
|
|
+ if selfGuid != "" {
|
|
|
|
|
+ if _, ok := counts[selfGuid]; ok {
|
|
|
|
|
+ ambiguous[selfGuid] = struct{}{}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ambiguous
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// effectiveNodeKey returns one node's attribution key without a preloaded node
|
|
|
|
|
+// list — its panelGuid when that GUID uniquely identifies it among the master's
|
|
|
|
|
+// nodes and differs from the master's own, otherwise its node-unique
|
|
|
|
|
+// synthNodeGuid. Same rule as effectiveNodeGuid + ambiguousNodeGuids, for the
|
|
|
|
|
+// write paths that handle a single node (online tree, IP attribution).
|
|
|
|
|
+func effectiveNodeKey(node *model.Node) string {
|
|
|
|
|
+ if node == nil {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ if node.Guid == "" {
|
|
|
|
|
+ return synthNodeGuid(node.Id)
|
|
|
|
|
+ }
|
|
|
|
|
+ var sameGuid int64
|
|
|
|
|
+ database.GetDB().Model(&model.Node{}).Where("guid = ?", node.Guid).Count(&sameGuid)
|
|
|
|
|
+ masterGuid, _ := (&SettingService{}).GetPanelGuid()
|
|
|
|
|
+ if sameGuid > 1 || node.Guid == masterGuid {
|
|
|
|
|
+ return synthNodeGuid(node.Id)
|
|
|
|
|
+ }
|
|
|
|
|
+ return node.Guid
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (s *NodeService) GetById(id int) (*model.Node, error) {
|
|
func (s *NodeService) GetById(id int) (*model.Node, error) {
|
|
@@ -456,7 +555,9 @@ func (s *NodeService) Delete(id int) error {
|
|
|
return common.NewError(fmt.Sprintf("cannot delete node: %d inbound(s) still attached to it; detach or delete them first", attached))
|
|
return common.NewError(fmt.Sprintf("cannot delete node: %d inbound(s) still attached to it; detach or delete them first", attached))
|
|
|
}
|
|
}
|
|
|
// Capture the node's guid before deleting the row so we can drop its per-node
|
|
// Capture the node's guid before deleting the row so we can drop its per-node
|
|
|
- // IP attribution (NodeClientIp is keyed by guid, not node id).
|
|
|
|
|
|
|
+ // IP attribution. NodeClientIp is keyed by the node's attribution key, which
|
|
|
|
|
+ // is its guid normally but its node-unique key for a cloned/ambiguous-guid
|
|
|
|
|
+ // node (see effectiveNodeKey) — so we purge both below.
|
|
|
var guid string
|
|
var guid string
|
|
|
var n model.Node
|
|
var n model.Node
|
|
|
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
|
|
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
|
|
@@ -470,10 +571,12 @@ func (s *NodeService) Delete(id int) error {
|
|
|
if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
|
if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
|
|
return err
|
|
return err
|
|
|
}
|
|
}
|
|
|
|
|
+ guids := []string{synthNodeGuid(id)}
|
|
|
if guid != "" {
|
|
if guid != "" {
|
|
|
- if err := tx.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
|
|
|
|
|
- return err
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ guids = append(guids, guid)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := tx.Where("node_guid IN ?", guids).Delete(&model.NodeClientIp{}).Error; err != nil {
|
|
|
|
|
+ return err
|
|
|
}
|
|
}
|
|
|
return tx.Where("id = ?", id).Delete(&model.Node{}).Error
|
|
return tx.Where("id = ?", id).Delete(&model.Node{}).Error
|
|
|
}); err != nil {
|
|
}); err != nil {
|
|
@@ -593,6 +696,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
|
|
// failed probe) reports none, so the stable identity survives blips.
|
|
// failed probe) reports none, so the stable identity survives blips.
|
|
|
if p.Guid != "" {
|
|
if p.Guid != "" {
|
|
|
updates["guid"] = p.Guid
|
|
updates["guid"] = p.Guid
|
|
|
|
|
+ s.warnOnDuplicateGuid(id, p.Guid)
|
|
|
}
|
|
}
|
|
|
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
|
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
|
|
return err
|
|
return err
|
|
@@ -607,6 +711,30 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// warnedDupGuid remembers the (nodeID -> guid) pairs already warned about so a
|
|
|
|
|
+// cloned-server collision is logged once, not every heartbeat.
|
|
|
|
|
+var warnedDupGuid sync.Map
|
|
|
|
|
+
|
|
|
|
|
+// warnOnDuplicateGuid logs once when a node reports a panelGuid already held by
|
|
|
|
|
+// another node or by the master itself (the cloned-server footgun). Attribution
|
|
|
|
|
+// still works — it falls back to node-unique keys — but the operator should
|
|
|
|
|
+// regenerate the duplicate panelGuid to restore real identity and per-node IP
|
|
|
|
|
+// attribution. Re-arms if the collision later clears.
|
|
|
|
|
+func (s *NodeService) warnOnDuplicateGuid(id int, guid string) {
|
|
|
|
|
+ var clash int64
|
|
|
|
|
+ database.GetDB().Model(&model.Node{}).Where("guid = ? AND id <> ?", guid, id).Count(&clash)
|
|
|
|
|
+ masterGuid, _ := (&SettingService{}).GetPanelGuid()
|
|
|
|
|
+ if clash == 0 && guid != masterGuid {
|
|
|
|
|
+ warnedDupGuid.Delete(id)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if prev, ok := warnedDupGuid.Load(id); ok && prev == guid {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ warnedDupGuid.Store(id, guid)
|
|
|
|
|
+ logger.Warningf("node %d reports panelGuid %s already used by another node or the master (cloned server?) — regenerate it on that node so online and IP attribution stay per-node", id, guid)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (s *NodeService) MarkNodeDirty(id int) error {
|
|
func (s *NodeService) MarkNodeDirty(id int) error {
|
|
|
if id <= 0 {
|
|
if id <= 0 {
|
|
|
return nil
|
|
return nil
|