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.
This commit is contained in:
Sanaei
2026-07-27 14:21:56 +02:00
parent 5accd8a611
commit f8e9f2f087
4 changed files with 126 additions and 26 deletions

View File

@@ -429,8 +429,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
}
now := time.Now().Unix() * 1000
cond := depletedCond(db)
cond, condArgs := depletedCond(db)
candidateEmails := make([]string, 0, len(plan))
for email, entry := range plan {
if entry.applyExpiry || entry.applyTotal {
@@ -441,7 +440,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
for _, batch := range chunkStrings(candidateEmails, sqlInChunk) {
var rows []string
if err := db.Model(xray.ClientTraffic{}).
Where(cond+" AND enable = ? AND email IN ?", now, false, batch).
Where(cond+" AND enable = ? AND email IN ?", append(append([]any{}, condArgs...), false, batch)...).
Pluck("email", &rows).Error; err != nil {
return result, needRestart, err
}
@@ -503,7 +502,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
for _, batch := range chunkStrings(wasList, sqlInChunk) {
var rows []string
if err := db.Model(xray.ClientTraffic{}).
Where(cond+" AND email IN ?", now, batch).
Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...).
Pluck("email", &rows).Error; err != nil {
return result, needRestart, err
}

View File

@@ -2,6 +2,7 @@ package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -70,7 +71,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
// client over its local quota is still disabled.
if got := depletedCond(db); got != depletedClientsCondLocal {
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
t.Fatalf("empty globals must use the local-only predicate")
}
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
@@ -85,11 +86,84 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if got := depletedCond(db); got != depletedClientsCond {
if got, _ := depletedCond(db); got != depletedClientsCond {
t.Fatalf("with globals present the cross-panel predicate must be used")
}
}
// A master that stopped pushing leaves its last snapshot behind forever. Those
// frozen counters must not keep enforcing quota, or a client well inside its
// limit is disabled on every traffic poll with no way back (#6113).
func TestStaleGlobalTraffic_Ignored(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
staleAt := time.Now().Add(-globalTrafficFreshWindow - time.Hour).UnixMilli()
seedStaleGlobal := func(t *testing.T, guid, email string, up, down int64) {
t.Helper()
if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: email, Up: up, Down: down}}); err != nil {
t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
}
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("master_guid = ? AND email = ?", guid, email).
Update("updated_at", staleAt).Error; err != nil {
t.Fatalf("age row: %v", err)
}
}
t.Run("stale row alone neither enforces nor selects the cross-panel predicate", func(t *testing.T) {
// 200 of 1000 used locally, 1900 reported by a master gone for a day.
seedClientRow(t, "cap", 1, 100, 100, 1000)
seedStaleGlobal(t, "dead-master", "cap", 1000, 900)
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
t.Fatalf("only stale globals must fall back to the local-only predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
}
if got := readTraffic(t, db, "cap"); !got.Enable {
t.Error("client within its local quota must stay enabled")
}
})
t.Run("stale row does not inflate the displayed total", func(t *testing.T) {
rows := []*xray.ClientTraffic{{Email: "cap", Up: 100, Down: 100}}
overlayGlobalTraffic(db, rows)
if rows[0].Up != 100 || rows[0].Down != 100 {
t.Errorf("stale global must not overlay display counters, got up=%d down=%d", rows[0].Up, rows[0].Down)
}
})
t.Run("a live master still enforces alongside the stale row", func(t *testing.T) {
// This is the #6113 shape: one dead master frozen over quota, one live
// master well under it. Only the live one may decide.
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 1, Down: 1}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if got, _ := depletedCond(db); got != depletedClientsCond {
t.Fatalf("a fresh global row must select the cross-panel predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
}
// And once the live master reports real depletion, it takes effect.
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
}
})
}
func TestGlobalUsage_DisablesClient(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}

View File

@@ -49,46 +49,67 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
return needRestart, count, err
}
// globalTrafficFreshWindow bounds how long a pushed client_global_traffics row
// stays authoritative. Masters refresh their rows every nodeGlobalPushInterval
// (30s), so a row older than this belongs to a master that stopped pushing —
// decommissioned, reinstalled under a new GUID, or detached from this node.
// Such a row keeps its last-seen counters forever, and without this bound a
// long-dead master's numbers permanently trip the cross-panel quota check and
// disable clients that are nowhere near their limit (#6113). The window is far
// wider than any real push gap, so a master that is merely unreachable for a
// while keeps enforcing.
const globalTrafficFreshWindow = 24 * time.Hour
func globalTrafficFreshSince() int64 {
return time.Now().Add(-globalTrafficFreshWindow).UnixMilli()
}
// depletedClientsCond matches clients that exhausted their quota or expired.
// Besides the local counters it also trips on the cross-panel usage a master
// pushed into client_global_traffics — that's what lets a node cut a client
// whose combined usage exceeds the quota even though the local share doesn't
// (placeholders: now).
// whose combined usage exceeds the quota even though the local share doesn't.
// Only rows a master refreshed recently count (placeholders: now, freshSince).
const depletedClientsCond = `((total > 0 AND up + down >= total)
OR (expiry_time > 0 AND expiry_time <= ?)
OR (total > 0 AND EXISTS (
SELECT 1 FROM client_global_traffics g
WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
WHERE g.email = client_traffics.email
AND g.updated_at >= ?
AND g.up + g.down >= client_traffics.total
)))`
// depletedClientsCondLocal is depletedClientsCond without the cross-panel
// client_global_traffics check. The EXISTS branch is a correlated subquery that
// turns every traffic poll into a full client_traffics scan; on a panel no
// master pushes to (the common case) client_global_traffics is empty, so the
// branch can never match and is pure CPU cost (#5392).
// branch can never match and is pure CPU cost (#5392). Placeholders: now.
const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
OR (expiry_time > 0 AND expiry_time <= ?))`
// depletedCond returns the local-only predicate unless this panel actually
// holds global-traffic rows, in which case the cross-panel EXISTS check is
// needed to enforce combined quota. Both variants take the same single
// expiry_time placeholder, so callers pass identical args either way.
func depletedCond(tx *gorm.DB) string {
// depletedCond returns the predicate matching depleted clients together with
// the arguments it binds. The local-only variant is used unless this panel
// holds a global-traffic row a master still refreshes, in which case the
// cross-panel EXISTS check is needed to enforce combined quota.
func depletedCond(tx *gorm.DB) (string, []any) {
now := time.Now().UnixMilli()
freshSince := globalTrafficFreshSince()
var probe int64
if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
return depletedClientsCond
err := tx.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error
if err == nil && probe > 0 {
return depletedClientsCond, []any{now, freshSince}
}
return depletedClientsCondLocal
return depletedClientsCondLocal, []any{now}
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
cond := depletedCond(tx)
cond, condArgs := depletedCond(tx)
var depletedRows []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where(cond+" AND enable = ?", now, true).
Where(cond+" AND enable = ?", append(condArgs, true)...).
Find(&depletedRows).Error
if err != nil {
return false, 0, err
@@ -185,7 +206,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if len(depletedEmails) > 0 {
if err := tx.Model(&model.ClientRecord{}).
Where("email IN ?", depletedEmails).
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
logger.Warning("disableInvalidClients update clients.enable:", err)
}
}

View File

@@ -100,15 +100,21 @@ func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.Clien
}
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
// value any master pushed for that email. Read-path only — callers hand it
// rows about to be serialized for display; the stored counters are untouched.
// 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{}).Limit(1).Count(&probe).Error; err != nil || probe == 0 {
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
return
}
@@ -126,7 +132,7 @@ func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
var globals []model.ClientGlobalTraffic
if err := db.Where("email IN ?", batch).Find(&globals).Error; err != nil {
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
logger.Warning("overlayGlobalTraffic:", err)
return
}