From 67820dc20da32402486b9a4cae3a0ba9cc151039 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:23:48 +0100 Subject: [PATCH] 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. --- .../web/service/inbound_autorenew_test.go | 90 +++++++++++++++++++ internal/web/service/inbound_disable.go | 29 ++++++ internal/web/service/inbound_traffic.go | 17 ++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/internal/web/service/inbound_autorenew_test.go b/internal/web/service/inbound_autorenew_test.go index 54a7b6840..4961cab45 100644 --- a/internal/web/service/inbound_autorenew_test.go +++ b/internal/web/service/inbound_autorenew_test.go @@ -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) + } + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index c9cbd4330..46cdbba51 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -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 +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 6722b0e20..595c58e43 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -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}