fix(clients): re-enable only quota-disabled clients on a traffic reset

Every traffic reset re-enabled its clients unconditionally, in the
client record and in the traffic row a master treats as the client's
state. A master forwards an inbound's scheduled reset and "reset all"
to its nodes through the per-client reset calls, so a client disabled
by hand on a node inbound came back on at the next scheduled reset.

Each reset now decides, before it zeroes the counters, which of its
clients the quota switched off, counting cross-panel usage, and
re-enables only those; a client disabled below its quota keeps its
record and traffic row off. The reset API docs say so.
This commit is contained in:
MHSanaei
2026-11-01 08:31:27 +01:00
parent 67820dc20d
commit 7f64a67ba5
10 changed files with 249 additions and 60 deletions
+103 -1
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"testing"
"time"
@@ -48,7 +49,7 @@ func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
mkTraffic(t, ib.Id, "alice@x", 10, 20, 30, 0, false)
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
@@ -231,3 +232,104 @@ func TestDelDepletedIgnoresPaddedUsageRow(t *testing.T) {
t.Fatalf("alice was deleted through a look-alike usage row: %v", err)
}
}
// trafficResets is every way to reset a client's traffic; a master forwards its
// scheduled and "reset all" resets to a node through the first two.
var trafficResets = []struct {
name string
run func(ib *model.Inbound, email string) error
}{
{"ResetTrafficByEmail", func(_ *model.Inbound, email string) error {
_, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, email)
return err
}},
{"BulkResetTraffic", func(_ *model.Inbound, email string) error {
_, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{email})
return err
}},
{"ResetAllClientTraffics", func(ib *model.Inbound, _ string) error {
_, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id)
return err
}},
{"ResetAllTraffics", func(*model.Inbound, string) error {
_, err := (&ClientService{}).ResetAllTraffics()
return err
}},
{"ResetClientTraffic", func(ib *model.Inbound, email string) error {
_, err := (&InboundService{}).ResetClientTraffic(ib.Id, email)
return err
}},
{"ResetClientTrafficByEmail", func(_ *model.Inbound, email string) error {
return (&InboundService{}).ResetClientTrafficByEmail(email)
}},
}
func TestTrafficReset_LeavesOperatorDisabledClientOff(t *testing.T) {
const email = "banned@x"
for _, total := range []int64{0, 1000} {
for _, reset := range trafficResets {
t.Run(fmt.Sprintf("%s/quota=%d", reset.name, total), func(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
// Disabled with usage below quota: nothing but a human did that.
ib := seedLocalDisabledClient(t, svc, 52020, "", email, total, 0, 10, 10)
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
assertEnableEverywhere(t, svc, &InboundService{}, ib.Id, email, false)
if tr := trafficOf(t, email); tr.Up != 0 || tr.Down != 0 {
t.Fatalf("traffic after reset up=%d down=%d, want 0/0", tr.Up, tr.Down)
}
})
}
}
}
func TestTrafficReset_RestoresQuotaDisabledClient(t *testing.T) {
const email = "depleted@x"
for _, reset := range trafficResets {
t.Run(reset.name, func(t *testing.T) {
setupBulkDB(t)
ib := seedLocalDisabledClient(t, &ClientService{}, 52021, "", email, 1000, 0, 600, 500)
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
if tr := trafficOf(t, email); !tr.Enable || tr.Up != 0 || tr.Down != 0 {
t.Fatalf("traffic after reset enable=%v up=%d down=%d, want true 0/0", tr.Enable, tr.Up, tr.Down)
}
})
}
}
// A node cuts a client on the usage its master pushed while the node's own
// share is still below the quota; a reset must still count that as depleted.
func TestTrafficReset_RestoresClientDisabledByGlobalUsage(t *testing.T) {
const email = "shared@x"
for i, reset := range trafficResets {
t.Run(reset.name, func(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib := seedLocalDisabledClient(t, svc, 52022, "", email, 1000, 0, 100, 100)
if err := inboundSvc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: email, Up: 800, Down: 700}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if err := reset.run(ib, email); err != nil {
t.Fatalf("%s: %v", reset.name, err)
}
if !trafficOf(t, email).Enable {
t.Fatal("client_traffics.enable = false: the pushed usage was not counted as depleted")
}
// Only the ClientService per-client resets restore the record and settings.
if i < 2 {
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
}
})
}
}
+9
View File
@@ -160,6 +160,15 @@ func quotaDisabledEmails(tx *gorm.DB, within []string) ([]string, error) {
if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil {
return nil, err
}
// Cross-panel usage depletes too: a client cut off on pushed usage was the quota's doing.
rows := make([]xray.ClientTraffic, len(cycles))
for i, c := range cycles {
rows[i] = xray.ClientTraffic{Email: c.Email, Up: c.Up, Down: c.Down, Total: c.Total}
}
overlayGlobalTrafficValues(tx, rows)
for i := range cycles {
cycles[i].Up, cycles[i].Down = rows[i].Up, rows[i].Down
}
var in map[string]bool
if within != nil {
in = make(map[string]bool, len(within))
@@ -137,12 +137,12 @@ func depleteClientTraffic(t *testing.T, inboundId int, email string) {
t.Helper()
db := database.GetDB()
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
Updates(map[string]any{"enable": false, "up": 1, "down": 1})
Updates(map[string]any{"enable": false, "up": 1, "down": 1, "total": 2})
if res.Error != nil {
t.Fatalf("deplete traffic: %v", res.Error)
}
if res.RowsAffected == 0 {
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil {
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1, Total: 2}).Error; err != nil {
t.Fatalf("create depleted traffic: %v", err)
}
}
+92 -27
View File
@@ -1,6 +1,7 @@
package service
import (
"slices"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -24,6 +25,11 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
if err != nil {
return false, err
}
// Decided before the resets below zero the counters the rule reads.
quotaDisabled, err := quotaDisabledEmails(database.GetDB(), []string{email})
if err != nil {
return false, err
}
needRestart := false
if len(inboundIds) == 0 {
@@ -44,9 +50,9 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
needRestart = nr
}
// Enable only once the counters are zero: a still-depleted client enabled
// first is switched off again by the next traffic tick.
if !rec.Enable {
// Enable only a client the quota switched off, once the counters are zero: a
// hand-disabled one stays off, and a still-depleted one would be switched off again.
if !rec.Enable && len(quotaDisabled) > 0 {
updated := rec.ToClient()
updated.Enable = true
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
@@ -73,22 +79,25 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
if err != nil {
return 0, err
}
affected := 0
var affected int64
var restore []string
var resetNodes []int
err = submitTrafficWrite(func() error {
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
var err error
if restore, err = quotaDisabledEmails(tx, cleanEmails); err != nil {
return err
}
keepOff, err := handDisabledEmails(tx, cleanEmails)
if err != nil {
return err
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
return err
}
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
res := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", batch).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if res.Error != nil {
return res.Error
}
affected += int(res.RowsAffected)
if affected, err = zeroClientTraffics(tx, cleanEmails, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
return err
@@ -110,7 +119,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
inboundSvc.deliverNodeResetsNow(resetNodes)
// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
// client first lets the next traffic tick switch it off again.
for _, e := range cleanEmails {
for _, e := range restore {
rec := recordsByEmail[e]
if rec == nil || rec.Enable {
continue
@@ -121,7 +130,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
}
}
return affected, nil
return int(affected), nil
}
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) {
@@ -180,17 +189,17 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil {
return qErr
}
keepOff, qErr := handDisabledEmails(tx, resetEmails)
if qErr != nil {
return qErr
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
return err
}
result := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", resetEmails).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if result.Error != nil {
return result.Error
if _, err := zeroClientTraffics(tx, resetEmails, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
@@ -213,7 +222,7 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
result := tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
@@ -234,13 +243,13 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil {
return qErr
}
res := tx.Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if res.Error != nil {
return res.Error
keepOff, qErr := handDisabledEmails(tx, nil)
if qErr != nil {
return qErr
}
if affected, qErr = zeroClientTraffics(tx, nil, keepOff); qErr != nil {
return qErr
}
affected = res.RowsAffected
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
return err
}
@@ -266,3 +275,59 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
}
return affected > 0 || needRestart, nil
}
// handDisabledEmails lists, among emails (every client when nil), the clients switched off by
// hand: a disabled record whose quota is not spent. Decide it before a reset zeroes the counters.
func handDisabledEmails(tx *gorm.DB, emails []string) ([]string, error) {
var disabled []string
q := func() *gorm.DB { return tx.Model(&model.ClientRecord{}).Where("enable = ?", false) }
if emails == nil {
if err := q().Pluck("email", &disabled).Error; err != nil {
return nil, err
}
} else {
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []string
if err := q().Where("email IN ?", batch).Pluck("email", &page).Error; err != nil {
return nil, err
}
disabled = append(disabled, page...)
}
}
if len(disabled) == 0 {
return nil, nil
}
quota, err := quotaDisabledEmails(tx, disabled)
if err != nil {
return nil, err
}
return slices.DeleteFunc(disabled, func(e string) bool { return slices.Contains(quota, e) }), nil
}
// zeroClientTraffics zeroes the counters of emails (every row when nil) and lifts their traffic
// gate, except for keepOff: a master pushes that flag to its nodes as the client's state.
func zeroClientTraffics(tx *gorm.DB, emails, keepOff []string) (int64, error) {
var affected int64
reset := map[string]any{"enable": true, "up": 0, "down": 0}
if emails == nil {
res := tx.Model(&xray.ClientTraffic{}).Where("1 = 1").Updates(reset)
if res.Error != nil {
return 0, res.Error
}
affected = res.RowsAffected
} else {
for _, batch := range chunkStrings(emails, sqlInChunk) {
res := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Updates(reset)
if res.Error != nil {
return 0, res.Error
}
affected += res.RowsAffected
}
}
for _, batch := range chunkStrings(keepOff, sqlInChunk) {
if err := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", false).Error; err != nil {
return 0, err
}
}
return affected, nil
}
@@ -103,12 +103,12 @@ func TestResetTrafficByEmail_LeavesRecordEnableTrue(t *testing.T) {
inboundSvc := &InboundService{}
email := "r-attached@x"
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false}
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false, TotalGB: 30}
ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c}))
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, email, 10, 20, 0, 0, false)
mkTraffic(t, ib.Id, email, 10, 20, 30, 0, false)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err)
@@ -142,6 +142,7 @@ func TestResetTrafficByEmail_NoInbound_LeavesRecordEnableTrue(t *testing.T) {
t.Fatalf("create record: %v", err)
}
forceRecordDisabled(t, svc, email)
mkTraffic(t, 0, email, 10, 20, 30, 0, false)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
t.Fatalf("ResetTrafficByEmail: %v", err)
+14 -6
View File
@@ -680,12 +680,14 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err
}
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
keepOff, err := handDisabledEmails(tx, []string{clientEmail})
if err != nil {
return err
}
if err := tx.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
if _, err := zeroClientTraffics(tx, []string{clientEmail}, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
return err
}
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
@@ -723,8 +725,14 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
if err != nil {
return false, nil, err
}
// A reset lifts the quota gate only: a client switched off by hand stays off.
keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
if err != nil {
return false, nil, err
}
handDisabled := len(keepOff) > 0
if !traffic.Enable {
if !traffic.Enable && !handDisabled {
inbound, err := s.GetInbound(id)
if err != nil {
return false, nil, err
@@ -766,7 +774,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
traffic.Up = 0
traffic.Down = 0
traffic.Enable = true
traffic.Enable = !handDisabled
db := database.GetDB()
now := time.Now().UnixMilli()