mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-22 23:32:19 +07:00
fix(traffic): prevent phantom quota consumption from stale node data (#5412)
Three related bugs caused inflated traffic counters and spurious quota hits on multi-node setups, most visibly when a client email was renamed while a node was offline or its PostgreSQL deadlocked. **Fix 1 — phantom quota (root cause)** `setRemoteTrafficLocked` new-row path: when master had no `client_traffics` row for an email that a node reported, it seeded the row with `Up: cs.Up` — importing the node's full accumulated counter as if it were fresh quota usage. If the node retained stale data from a previously-deleted account (e.g. a failed deletion during an outage), the ghost 50 GB appeared on the new client immediately and triggered `disableInvalidClients` the same tick. Fixed by seeding at `Up: 0`; the current node value still becomes the baseline so only future increments count. **Fix 2 — PostgreSQL deadlock** `addClientTraffic` did a read-modify-write via `tx.Save(slice)`, issuing UPDATEs in slice order. Two concurrent goroutines locking the same rows in opposite order deadlock on PostgreSQL (SQLite avoids this with file-level serialisation). Replaced with atomic per-email `UPDATE SET up=up+?, down=down+?` statements. Also preserves the delayed-start ExpiryTime conversion that `adjustTraffics` computes in-memory but the old Save path persisted to the DB. **Fix 3 & 4 — stale `inbound_id` filters** `autoRenewClients` used `WHERE inbound_id NOT IN (node inbounds)` to skip node clients, but `client_traffics.inbound_id` is set once on INSERT and never refreshed. Replaced with an email-based subquery through `client_inbounds` (the authoritative source). Also added a safe type assertion for `settings["clients"].([]any)` that previously panicked on nil. **Fix 5 — stale `inbound_id` in reset** `resetAllClientTrafficsLocked` used `WHERE inbound_id = ?` to find which emails to reset; same staleness problem. Replaced with the `client_inbounds` join for email lookup; the `inbounds.last_traffic_reset_time` update still correctly uses the inbound ID directly on the `inbounds` table. Tests updated to reflect the new seeding-at-zero semantics and a new `TestGhostData_NoPhantomTraffic` test reproduces the exact 50 GB phantom scenario.
This commit is contained in:
@@ -121,22 +121,29 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
whereText := "inbound_id "
|
||||
if id == -1 {
|
||||
whereText += " > ?"
|
||||
} else {
|
||||
whereText += " = ?"
|
||||
}
|
||||
|
||||
// client_traffics.inbound_id is stale: it reflects the inbound the row was
|
||||
// first inserted under and is never refreshed. Use the client_inbounds join
|
||||
// as the authoritative source for which emails belong to a given inbound.
|
||||
var resetEmails []string
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where(whereText, id).
|
||||
Pluck("email", &resetEmails).Error; err != nil {
|
||||
return err
|
||||
if id == -1 {
|
||||
if err := tx.Model(xray.ClientTraffic{}).Pluck("email", &resetEmails).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Table("client_inbounds ci").
|
||||
Select("c.email").
|
||||
Joins("JOIN clients c ON c.id = ci.client_id").
|
||||
Where("ci.inbound_id = ?", id).
|
||||
Pluck("c.email", &resetEmails).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(resetEmails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where(whereText, id).
|
||||
Where("email IN ?", resetEmails).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
||||
|
||||
if result.Error != nil {
|
||||
|
||||
Reference in New Issue
Block a user