mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-01 18:56:05 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user