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.
This commit is contained in:
MHSanaei
2026-11-01 00:44:05 +01:00
parent c92c7718bf
commit bebe1b911d
3 changed files with 108 additions and 0 deletions
+21
View File
@@ -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
}
+66
View File
@@ -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])
}
}
}
+21
View File
@@ -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
}