diff --git a/internal/web/job/ldap_sync_job.go b/internal/web/job/ldap_sync_job.go index 65e47e645..56f93cc3c 100644 --- a/internal/web/job/ldap_sync_job.go +++ b/internal/web/job/ldap_sync_job.go @@ -1,6 +1,7 @@ package job import ( + "slices" "strings" "sync/atomic" "time" @@ -253,9 +254,29 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, logger.Infof("LDAP auto-create: %d clients for %s", created, tagList) } +// withoutDepleted drops the clients the panel switched off itself: the directory +// flag grants an account, it does not waive a spent quota or a passed expiry. +func (j *LdapSyncJob) withoutDepleted(emails []string) []string { + if len(emails) == 0 { + return nil + } + depleted, err := j.inboundService.DepletedClientEmails(emails) + if err != nil { + logger.Warning("LDAP enable skipped, failed to look up depleted clients:", err) + return nil + } + return slices.DeleteFunc(emails, func(email string) bool { + _, hit := depleted[email] + return hit + }) +} + // batchSetEnable takes the bulk path: per-user calls held each inbound's lock through // its node push, so users sharing a hung node inbound queued one push timeout apiece. func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) { + if enable { + emails = j.withoutDepleted(emails) + } if len(emails) == 0 { return } diff --git a/internal/web/job/ldap_sync_job_test.go b/internal/web/job/ldap_sync_job_test.go index bdc5a77b1..ab20c3097 100644 --- a/internal/web/job/ldap_sync_job_test.go +++ b/internal/web/job/ldap_sync_job_test.go @@ -1,13 +1,17 @@ package job import ( + "encoding/json" + "fmt" "path/filepath" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func initLdapJobDB(t *testing.T) { @@ -128,3 +132,65 @@ func TestLdapCreateClients_FlagsRestartWhenEveryClientPartlyApplies(t *testing.T t.Fatal("a partly-applied LDAP batch left Xray unflagged for restart") } } + +// ldapDisabledClientsInbound seeds one local inbound holding the given clients +// switched off everywhere, each with its own client_traffics row. +func ldapDisabledClientsInbound(t *testing.T, rows []xray.ClientTraffic) *model.Inbound { + t.Helper() + initLdapJobDB(t) + db := database.GetDB() + clients := make([]model.Client, 0, len(rows)) + for i, row := range rows { + clients = append(clients, model.Client{ + Email: row.Email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), + Enable: false, TotalGB: row.Total, ExpiryTime: row.ExpiryTime, + }) + } + settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"}) + ib := &model.Inbound{ + UserId: 1, Enable: true, Port: 47300, Protocol: model.VLESS, + Tag: "ldap-local-in", Settings: string(settings), StreamSettings: `{"network":"tcp"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + for i, c := range clients { + rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: false, TotalGB: c.TotalGB, ExpiryTime: c.ExpiryTime} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client record: %v", err) + } + // gorm default:true drops an explicit false on Create (#6478). + if err := db.Model(&rec).Update("enable", false).Error; err != nil { + t.Fatalf("disable client record: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("link client: %v", err) + } + traffic := rows[i] + traffic.InboundId = ib.Id + if err := db.Create(&traffic).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + } + return ib +} + +// The directory flag stays true after an account expires or spends its quota, so +// enabling on the flag alone undid the traffic job's disable on every sync. +func TestLdapEnableLeavesDepletedClientsOff(t *testing.T) { + ib := ldapDisabledClientsInbound(t, []xray.ClientTraffic{ + {Email: "expired@ldap", ExpiryTime: time.Now().Add(-time.Minute).UnixMilli()}, + {Email: "spent@ldap", Total: 100, Up: 60, Down: 40}, + {Email: "paused@ldap", Total: 100, Up: 1, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}, + {Email: "unlimited@ldap", Up: 1 << 40}, + }) + + NewLdapSyncJob().batchSetEnable([]string{"expired@ldap", "spent@ldap", "paused@ldap", "unlimited@ldap"}, true) + + want := map[string]bool{"expired@ldap": false, "spent@ldap": false, "paused@ldap": true, "unlimited@ldap": true} + for email, enabled := range inboundClientEnables(t, ib.Id) { + if enabled != want[email] { + t.Errorf("client %s enabled = %v after the LDAP enable, want %v", email, enabled, want[email]) + } + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index 45e2632f1..c9cbd4330 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -267,3 +268,23 @@ func trafficDisabledEmails(db *gorm.DB, emails []string) (map[string]struct{}, e } return disabled, nil } + +// DepletedClientEmails reports which emails disableInvalidClients keeps off (quota +// spent or expiry passed), so an unattended enable does not hand them back. +func (s *InboundService) DepletedClientEmails(emails []string) (map[string]struct{}, error) { + db := database.GetDB() + cond, condArgs := depletedCond(db) + depleted := make(map[string]struct{}) + for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqlInChunk) { + var page []string + if err := db.Model(xray.ClientTraffic{}). + Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...). + Pluck("email", &page).Error; err != nil { + return nil, err + } + for _, e := range page { + depleted[e] = struct{}{} + } + } + return depleted, nil +}