fix(clients): keep a hand-disabled client off through auto-renew

autoRenewClients re-enabled every renewing client whose traffic row was
off, as if only the panel's quota and expiry check cleared that flag.
The operator's disable routes (the client editor and bulk disable)
clear it too, so a client disabled by hand came back on with a fresh
period and zeroed counters as soon as its Auto Renew period ended.

A renewal now restores only a client the lifecycle job switched off:
its quota is spent, counting cross-panel usage, or its record was
stamped at or after its deadline, which the job does and a disable of a
live client cannot.
This commit is contained in:
MHSanaei
2026-11-01 08:23:48 +01:00
parent 30f17ef5a3
commit 67820dc20d
3 changed files with 130 additions and 6 deletions
@@ -102,3 +102,93 @@ func TestAutoRenewClients_MultiInbound(t *testing.T) {
}
}
}
// Every state is reached through the calls that produce it on a live panel: both
// operator paths clear client_traffics.enable as well, which a hand-seeded row hides.
func TestAutoRenewClients_RestoresOnlyWhatTheLifecycleJobDisabled(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
ib, _, err := inboundSvc.AddInbound(&model.Inbound{
UserId: 1, Enable: true, Port: 30301, Protocol: model.VLESS, Tag: "in-30301",
Settings: `{"clients":[],"decryption":"none"}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
})
if err != nil {
t.Fatalf("AddInbound: %v", err)
}
// A whole second, as autoRenewClients compares against those, and far enough
// away for the operator's calls below to land before it.
deadline := (time.Now().Unix() + 3) * 1000
wantEnable := map[string]bool{
"bulk-banned@x": false,
"edit-banned@x": false,
"quota-spent@x": true,
"time-up@x": true,
}
for email := range wantEnable {
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: email, Enable: true, Reset: 30, ExpiryTime: deadline, TotalGB: 100},
InboundIds: []int{ib.Id},
}); err != nil {
t.Fatalf("Create %s: %v", email, err)
}
}
record := func(email string) *model.ClientRecord {
t.Helper()
rec, err := clientSvc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail %s: %v", email, err)
}
return rec
}
if _, _, err := clientSvc.BulkSetEnable(inboundSvc, []string{"bulk-banned@x"}, false); err != nil {
t.Fatalf("BulkSetEnable: %v", err)
}
rec := record("edit-banned@x")
edited := rec.ToClient()
edited.Enable = false
if _, err := clientSvc.Update(inboundSvc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update: %v", err)
}
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "quota-spent@x", Up: 60, Down: 60}}); err != nil {
t.Fatalf("AddTraffic: %v", err)
}
if time.Now().UnixMilli() >= deadline {
t.Fatal("the operator's calls landed after the deadline; the setup is too slow")
}
time.Sleep(time.Until(time.UnixMilli(deadline)))
// A tick this close to the deadline disables what the next one renews.
if _, _, _, err := inboundSvc.disableInvalidClients(database.GetDB(), newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
}
for email := range wantEnable {
if record(email).Enable {
t.Fatalf("%s is still enabled before the renewal", email)
}
}
time.Sleep(time.Until(time.UnixMilli(deadline + 1000)))
if _, _, err := inboundSvc.AddTraffic(nil, nil); err != nil {
t.Fatalf("AddTraffic: %v", err)
}
for email, want := range wantEnable {
rec := record(email)
if rec.ExpiryTime <= deadline {
t.Errorf("%s was not renewed: expiry = %d", email, rec.ExpiryTime)
}
if rec.Enable != want {
t.Errorf("%s enable after the renewal = %v, want %v", email, rec.Enable, want)
}
var traffic xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil {
t.Fatalf("read client_traffics %s: %v", email, err)
}
if traffic.Enable != want {
t.Errorf("%s client_traffics.enable after the renewal = %v, want %v", email, traffic.Enable, want)
}
}
}
+29
View File
@@ -288,3 +288,32 @@ func (s *InboundService) DepletedClientEmails(emails []string) (map[string]struc
}
return depleted, nil
}
// lifecycleDisabledEmails picks, out of switched-off stats rows, the ones that
// disableInvalidClients cleared; an operator's disable clears the flag as well.
func lifecycleDisabledEmails(tx *gorm.DB, rows []xray.ClientTraffic) (map[string]struct{}, error) {
emails := make([]string, 0, len(rows))
for i := range rows {
emails = append(emails, rows[i].Email)
}
records, err := clientRecordsByEmail(tx, emails)
if err != nil {
return nil, err
}
// Cross-panel usage depletes too (depletedClientsCond); the overlay raises
// the counters in place, so callers hand it copies.
overlayGlobalTrafficValues(tx, rows)
disabled := make(map[string]struct{}, len(rows))
for i := range rows {
r := &rows[i]
depleted := r.Total > 0 && r.Up+r.Down >= r.Total
// The job stamps the record once the deadline has passed (whole seconds
// is all a stamp pushed to a node keeps); an operator's disable came earlier.
record := records[r.Email]
expired := record != nil && r.ExpiryTime > 0 && record.UpdatedAt >= r.ExpiryTime/1000*1000
if depleted || expired {
disabled[r.Email] = struct{}{}
}
}
return disabled, nil
}
+11 -6
View File
@@ -462,13 +462,18 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
// instead of a linear scan of every expired row (O(clients × expired) per
// inbound, quadratic at scale). Pointers keep the in-place mutation below.
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
// Keep the pre-renewal quota state: the shared pointer becomes enabled while
// processing the first inbound, while an already-enabled row paired with
// disabled settings represents an operator-disabled client we must preserve.
trafficWasEnabled := make(map[string]bool, len(traffics))
// Judged on the pre-renewal rows, as the shared pointer is enabled while processing
// the first inbound: only what the lifecycle job switched off comes back.
var switchedOff []xray.ClientTraffic
for i := range traffics {
trafficByEmail[traffics[i].Email] = traffics[i]
trafficWasEnabled[traffics[i].Email] = traffics[i].Enable
if !traffics[i].Enable {
switchedOff = append(switchedOff, *traffics[i])
}
}
lifecycleDisabled, err := lifecycleDisabledEmails(tx, switchedOff)
if err != nil {
return false, 0, err
}
renewedEmails := make([]string, 0, len(traffics))
for inbound_index := range inbounds {
@@ -508,7 +513,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
traffic.Up = 0
renewedEmails = append(renewedEmails, email)
}
if !trafficWasEnabled[email] {
if _, restore := lifecycleDisabled[email]; restore {
traffic.Enable = true
c["enable"] = true
key := inboundClientKey{inboundID: inbounds[inbound_index].Id, email: email}