From bebe1b911d460a22922457a1d2e701de61f53adc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:44:05 +0100 Subject: [PATCH] fix(ldap): keep expired and over-quota clients off on directory sync The LDAP sync enabled every disabled client whose directory flag was true, without asking why it was off. A client the traffic job had just disabled for a passed expiry or a spent quota therefore came back on every sync and connected again for the seconds until the next traffic pass, for as long as the directory listed it. The sync's enable step now drops the clients the panel's own depletion rule keeps off, and enables nobody in a run whose lookup fails. A client the operator renews is enabled at the next sync. --- internal/web/job/ldap_sync_job.go | 21 ++++++++ internal/web/job/ldap_sync_job_test.go | 66 +++++++++++++++++++++++++ internal/web/service/inbound_disable.go | 21 ++++++++ 3 files changed, 108 insertions(+) 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 +}