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