Files
3x-ui/internal/web/service/inbound_traffic_global.go
T
Sanaei f8e9f2f087 fix(node): stop a departed master's frozen traffic from disabling clients (#6113)
client_global_traffics rows are keyed by (master_guid, email) and are only
ever overwritten by a push from that same master. A master that stops
pushing — decommissioned, reinstalled under a fresh GUID, or detached from
the node — therefore leaves its last snapshot behind permanently.

depletedClientsCond's cross-panel EXISTS branch matched any such row, so a
node kept comparing a client's quota against counters frozen weeks earlier.
Once they exceeded the quota the node disabled the client on every traffic
poll, and the node -> master enable merge latched that off on the master too,
where nothing sets it back. The reported symptom is exactly this: a client at
11 GB of a 24 GB quota, enabled on two nodes, disabled on the third, which
still held a 27-day-old row from a previous master reporting 30 GB.

Bound both the enforcement predicate and the display overlay to rows a master
refreshed within globalTrafficFreshWindow. Masters push every 30s, so a live
master is never affected; a master that is merely unreachable for a while
keeps enforcing for a full day before its numbers are set aside.

The one-way enable merge that makes such a disable permanent on the master is
deliberate (12d84c2a, #4917) and is left alone.
2026-07-27 14:21:56 +02:00

222 lines
6.9 KiB
Go

package service
import (
"strings"
"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/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// AcceptGlobalTraffic ingests a master panel's aggregated per-client usage
// into client_global_traffics, keyed by (masterGuid, email). The numbers are
// display/enforcement inputs only — client_traffics keeps this panel's
// local-only counters, so the pushing master's (and any other master's)
// delta accounting over our snapshot stays correct.
//
// Rows are overwritten, not max-merged: a reset on the master propagates here
// on its next push. Emails this panel doesn't host are dropped.
func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error {
masterGuid = strings.TrimSpace(masterGuid)
if masterGuid == "" {
return nil
}
emails := make([]string, 0, len(traffics))
byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
for _, t := range traffics {
if t == nil || t.Email == "" {
continue
}
if _, dup := byEmail[t.Email]; !dup {
emails = append(emails, t.Email)
}
byEmail[t.Email] = t
}
if len(emails) == 0 {
return nil
}
return submitTrafficWrite(func() error {
db := database.GetDB()
known := make([]string, 0, len(emails))
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []string
if err := db.Model(xray.ClientTraffic{}).
Where("email IN ?", batch).
Pluck("email", &page).Error; err != nil {
return err
}
known = append(known, page...)
}
if len(known) == 0 {
return nil
}
now := time.Now().UnixMilli()
rows := make([]model.ClientGlobalTraffic, 0, len(known))
for _, email := range known {
t := byEmail[email]
if t == nil {
continue
}
rows = append(rows, model.ClientGlobalTraffic{
MasterGuid: masterGuid,
Email: email,
Up: t.Up,
Down: t.Down,
UpdatedAt: now,
})
}
return db.Transaction(func(tx *gorm.DB) error {
for _, batch := range chunkGlobalRows(rows, 200) {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "master_guid"}, {Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"up", "down", "updated_at"}),
}).Create(&batch).Error; err != nil {
return err
}
}
return nil
})
})
}
func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.ClientGlobalTraffic {
if len(rows) == 0 {
return nil
}
out := make([][]model.ClientGlobalTraffic, 0, (len(rows)+size-1)/size)
for start := 0; start < len(rows); start += size {
end := min(start+size, len(rows))
out = append(out, rows[start:end])
}
return out
}
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
// value any master still pushing for that email reported. Read-path only —
// callers hand it rows about to be serialized for display; the stored counters
// are untouched. Rows older than globalTrafficFreshWindow are ignored: they
// come from a master that stopped pushing, and folding their frozen counters
// in would keep showing usage the client no longer has (#6113).
func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
if len(rows) == 0 {
return
}
freshSince := globalTrafficFreshSince()
// Cheap short-circuit for the common case (a panel no master pushes to).
var probe int64
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
return
}
emails := make([]string, 0, len(rows))
byEmail := make(map[string][]*xray.ClientTraffic, len(rows))
for _, r := range rows {
if r == nil || r.Email == "" {
continue
}
key := strings.ToLower(r.Email)
if _, ok := byEmail[key]; !ok {
emails = append(emails, r.Email)
}
byEmail[key] = append(byEmail[key], r)
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
var globals []model.ClientGlobalTraffic
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
logger.Warning("overlayGlobalTraffic:", err)
return
}
for i := range globals {
for _, r := range byEmail[strings.ToLower(globals[i].Email)] {
if globals[i].Up > r.Up {
r.Up = globals[i].Up
}
if globals[i].Down > r.Down {
r.Down = globals[i].Down
}
}
}
}
}
// overlayGlobalTrafficValues is overlayGlobalTraffic for value slices.
func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
if len(rows) == 0 {
return
}
ptrs := make([]*xray.ClientTraffic, 0, len(rows))
for i := range rows {
ptrs = append(ptrs, &rows[i])
}
overlayGlobalTraffic(db, ptrs)
}
// GetNodeClientTraffics returns this panel's aggregated traffic rows for the
// clients known to live on the given node (those with a delta baseline) —
// the payload for Remote.PushGlobalClientTraffics. The rows carry the global
// overlay so a mid-chain panel forwards the widest view it has seen, not just
// its own aggregate.
func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
db := database.GetDB()
var emails []string
if err := db.Model(&model.NodeClientTraffic{}).
Where("node_id = ?", nodeID).
Pluck("email", &emails).Error; err != nil {
return nil, err
}
if len(emails) == 0 {
return nil, nil
}
out := make([]*xray.ClientTraffic, 0, len(emails))
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
return nil, err
}
out = append(out, page...)
}
overlayGlobalTraffic(db, out)
return out, nil
}
// overlayInboundsClientStats applies the global overlay to every preloaded
// ClientStats row across the given inbounds. UI read paths only — never the
// full /panel/api/inbounds/list payload, which doubles as the traffic
// snapshot masters poll: overlaying that would leak pushed globals back into
// the masters' delta accounting.
func (s *InboundService) overlayInboundsClientStats(db *gorm.DB, inbounds []*model.Inbound) {
rows := make([]*xray.ClientTraffic, 0)
for _, ib := range inbounds {
for j := range ib.ClientStats {
rows = append(rows, &ib.ClientStats[j])
}
}
overlayGlobalTraffic(db, rows)
}
// clearGlobalTraffic drops every master's pushed rows for the given emails.
// Used by client deletion and traffic-reset flows: after a node-local reset
// the next master push restores the master's (authoritative) numbers, and
// after a master-side reset that push carries the reset values anyway.
func clearGlobalTraffic(tx *gorm.DB, emails ...string) error {
if len(emails) == 0 {
return nil
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
if err := tx.Where("email IN ?", batch).Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
return err
}
}
return nil
}