精简: 去掉 LDAP(目录登录+用户同步): util/ldap 包、同步 job、登录分支、全部 ldap* 设置
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bind that succeeds with an unusable search returns (empty, nil); the only
|
||||
// guard was `err != nil`, so that answer detached the entire inbound.
|
||||
func TestAutoDeleteAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
previous int64
|
||||
fetched int
|
||||
want bool
|
||||
}{
|
||||
{"empty fetch on a fresh job is refused", 0, 0, false},
|
||||
{"empty fetch after a healthy sync is refused", 500, 0, false},
|
||||
{"full fetch on a fresh job is allowed", 0, 500, true},
|
||||
{"steady fetch is allowed", 500, 500, true},
|
||||
{"growth is allowed", 500, 900, true},
|
||||
{"shrink above the retention floor is allowed", 500, 250, true},
|
||||
{"shrink below the retention floor is refused", 500, 249, false},
|
||||
{"collapse to a single user is refused", 500, 1, false},
|
||||
{"single user with no history is allowed", 0, 1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
j.lastFlagCount.Store(c.previous)
|
||||
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
|
||||
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
|
||||
c.previous, c.fetched, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||
|
||||
// Share of the previous successful fetch a new one must still return to be
|
||||
// trusted: a sudden collapse means a broken directory far more often than churn.
|
||||
const ldapAutoDeleteMinRetainPercent = 50
|
||||
|
||||
type LdapSyncJob struct {
|
||||
settingService service.SettingService
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
lastFlagCount atomic.Int64
|
||||
}
|
||||
|
||||
// --- Helper functions for mustGet ---
|
||||
func mustGetString(fn func() (string, error)) string {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetInt(fn func() (int, error)) int {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetBool(fn func() (bool, error)) bool {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetStringOr(fn func() (string, error), fallback string) string {
|
||||
v, err := fn()
|
||||
if err != nil || v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func NewLdapSyncJob() *LdapSyncJob {
|
||||
return new(LdapSyncJob)
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) Run() {
|
||||
logger.Info("LDAP sync job started")
|
||||
|
||||
enabled, err := j.settingService.GetLdapEnable()
|
||||
if err != nil || !enabled {
|
||||
logger.Warning("LDAP disabled or failed to fetch flag")
|
||||
return
|
||||
}
|
||||
|
||||
// --- LDAP fetch ---
|
||||
cfg := ldaputil.Config{
|
||||
Host: mustGetString(j.settingService.GetLdapHost),
|
||||
Port: mustGetInt(j.settingService.GetLdapPort),
|
||||
UseTLS: mustGetBool(j.settingService.GetLdapUseTLS),
|
||||
InsecureSkipVerify: mustGetBool(j.settingService.GetLdapInsecureSkipVerify),
|
||||
BindDN: mustGetString(j.settingService.GetLdapBindDN),
|
||||
Password: mustGetString(j.settingService.GetLdapPassword),
|
||||
BaseDN: mustGetString(j.settingService.GetLdapBaseDN),
|
||||
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
|
||||
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
|
||||
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
|
||||
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
|
||||
}
|
||||
|
||||
flags, err := ldaputil.FetchVlessFlags(cfg)
|
||||
if err != nil {
|
||||
logger.Warning("LDAP fetch failed:", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("Fetched %d LDAP flags", len(flags))
|
||||
|
||||
// --- Load all inbounds and all clients once ---
|
||||
inboundTags := splitCsv(mustGetString(j.settingService.GetLdapInboundTags))
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get inbounds:", err)
|
||||
return
|
||||
}
|
||||
|
||||
allClients := map[string]*model.Client{} // email -> client
|
||||
inboundMap := map[string]*model.Inbound{} // tag -> inbound
|
||||
for _, ib := range inbounds {
|
||||
inboundMap[ib.Tag] = ib
|
||||
clients, _ := j.inboundService.GetClients(ib)
|
||||
for i := range clients {
|
||||
allClients[clients[i].Email] = &clients[i]
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare batch operations ---
|
||||
autoCreate := mustGetBool(j.settingService.GetLdapAutoCreate)
|
||||
defGB := mustGetInt(j.settingService.GetLdapDefaultTotalGB)
|
||||
defExpiryDays := mustGetInt(j.settingService.GetLdapDefaultExpiryDays)
|
||||
defLimitIP := mustGetInt(j.settingService.GetLdapDefaultLimitIP)
|
||||
|
||||
resolvedInboundIds := make([]int, 0, len(inboundTags))
|
||||
resolvedTags := make([]string, 0, len(inboundTags))
|
||||
for _, tag := range inboundTags {
|
||||
ib := inboundMap[tag]
|
||||
if ib == nil {
|
||||
logger.Warningf("LDAP inbound tag %s does not match any inbound", tag)
|
||||
continue
|
||||
}
|
||||
resolvedInboundIds = append(resolvedInboundIds, ib.Id)
|
||||
resolvedTags = append(resolvedTags, tag)
|
||||
}
|
||||
|
||||
clientsToCreate := []model.Client{}
|
||||
clientsToEnable := map[string][]string{} // tag -> []email
|
||||
clientsToDisable := map[string][]string{} // tag -> []email
|
||||
|
||||
for email, allowed := range flags {
|
||||
existing := allClients[email]
|
||||
if existing == nil {
|
||||
if allowed && autoCreate {
|
||||
clientsToCreate = append(clientsToCreate, j.buildClient(email, defGB, defExpiryDays, defLimitIP))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, tag := range resolvedTags {
|
||||
if allowed && !existing.Enable {
|
||||
clientsToEnable[tag] = append(clientsToEnable[tag], email)
|
||||
} else if !allowed && existing.Enable {
|
||||
clientsToDisable[tag] = append(clientsToDisable[tag], email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
|
||||
|
||||
// --- Execute enable/disable batch ---
|
||||
for tag, emails := range clientsToEnable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, true)
|
||||
}
|
||||
for tag, emails := range clientsToDisable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, false)
|
||||
}
|
||||
|
||||
// --- Auto delete clients not in LDAP ---
|
||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
|
||||
ldapEmailSet := map[string]struct{}{}
|
||||
for e := range flags {
|
||||
ldapEmailSet[e] = struct{}{}
|
||||
}
|
||||
for _, tag := range inboundTags {
|
||||
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
|
||||
}
|
||||
}
|
||||
j.lastFlagCount.Store(int64(len(flags)))
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
|
||||
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
|
||||
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
|
||||
if fetched == 0 {
|
||||
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
|
||||
return false
|
||||
}
|
||||
previous := j.lastFlagCount.Load()
|
||||
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
|
||||
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
|
||||
fetched, previous, ldapAutoDeleteMinRetainPercent)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truthyValuesOrDefault(s string) []string {
|
||||
if vals := splitCsv(s); len(vals) > 0 {
|
||||
return vals
|
||||
}
|
||||
return DefaultTruthyValues
|
||||
}
|
||||
|
||||
func splitCsv(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildClient creates a new client for auto-create; ClientService.Create fills per-protocol credentials
|
||||
func (j *LdapSyncJob) buildClient(email string, defGB, defExpiryDays, defLimitIP int) model.Client {
|
||||
c := model.Client{
|
||||
Email: email,
|
||||
Enable: true,
|
||||
LimitIP: defLimitIP,
|
||||
TotalGB: int64(defGB) * 1024 * 1024 * 1024,
|
||||
}
|
||||
if defExpiryDays > 0 {
|
||||
c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// createClients adds each new LDAP client once, attached to every configured inbound
|
||||
func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, tags []string) {
|
||||
if len(newClients) == 0 || len(inboundIds) == 0 {
|
||||
return
|
||||
}
|
||||
tagList := strings.Join(tags, ",")
|
||||
created := 0
|
||||
restartNeeded := false
|
||||
for _, c := range newClients {
|
||||
nr, err := j.clientService.Create(&j.inboundService, &service.ClientCreatePayload{Client: c, InboundIds: inboundIds})
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to add client %s for tags %s: %v", c.Email, tagList, err)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
if created == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
restartNeeded := false
|
||||
changed := 0
|
||||
for _, email := range emails {
|
||||
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
|
||||
if err != nil {
|
||||
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
changed++
|
||||
}
|
||||
if needRestart {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
|
||||
}
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
||||
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get inbounds for deletion:", err)
|
||||
return
|
||||
}
|
||||
|
||||
batchSize := 50 // clients in 1 batch
|
||||
restartNeeded := false
|
||||
|
||||
for _, ib := range inbounds {
|
||||
if ib.Tag != inboundTag {
|
||||
continue
|
||||
}
|
||||
clients, err := j.inboundService.GetClients(ib)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to get clients for inbound %s: %v", ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect clients for deletion
|
||||
toDelete := []model.Client{}
|
||||
for _, c := range clients {
|
||||
if _, ok := ldapEmails[c.Email]; !ok {
|
||||
toDelete = append(toDelete, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < len(toDelete); i += batchSize {
|
||||
end := min(i+batchSize, len(toDelete))
|
||||
batch := toDelete[i:end]
|
||||
|
||||
for _, c := range batch {
|
||||
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
||||
c.Email, ib.Id, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
||||
c.Email, ib.Id, ib.Tag)
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
logger.Info("Xray restart scheduled after batch deletion")
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func initLdapJobDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
func TestBuildClient_ConvertsDefaultTotalGBToBytes(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
c := j.buildClient("user@example.com", 10, 0, 0)
|
||||
if want := int64(10) * 1024 * 1024 * 1024; c.TotalGB != want {
|
||||
t.Errorf("TotalGB = %d, want %d", c.TotalGB, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapCreateClients_AttachesToAllConfiguredInbounds(t *testing.T) {
|
||||
initLdapJobDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
tags := []string{"in-1080-tcp", "in-1081-tcp", "in-1082-tcp"}
|
||||
protocols := []model.Protocol{model.VLESS, model.Trojan, model.VLESS}
|
||||
inboundIds := make([]int, 0, len(tags))
|
||||
for i, tag := range tags {
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: 42080 + i,
|
||||
Protocol: protocols[i],
|
||||
Settings: `{"clients": []}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
inboundIds = append(inboundIds, ib.Id)
|
||||
}
|
||||
|
||||
j := NewLdapSyncJob()
|
||||
const email = "user@example.com"
|
||||
j.createClients([]model.Client{j.buildClient(email, 0, 0, 0)}, inboundIds, tags)
|
||||
|
||||
rec := &model.ClientRecord{}
|
||||
if err := db.Where("email = ?", email).First(rec).Error; err != nil {
|
||||
t.Fatalf("client record for %s not created: %v", email, err)
|
||||
}
|
||||
if rec.SubID == "" {
|
||||
t.Error("created LDAP client must carry a subId")
|
||||
}
|
||||
|
||||
clientSvc := &service.ClientService{}
|
||||
for i, id := range inboundIds {
|
||||
clients, err := clientSvc.ListForInbound(nil, id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(%s): %v", tags[i], err)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].Email != email {
|
||||
t.Fatalf("inbound %s must carry exactly the LDAP client, got %d clients", tags[i], len(clients))
|
||||
}
|
||||
if clients[0].SubID != rec.SubID {
|
||||
t.Errorf("inbound %s client subId = %q, want the shared %q", tags[i], clients[0].SubID, rec.SubID)
|
||||
}
|
||||
}
|
||||
|
||||
trojanClients, err := clientSvc.ListForInbound(nil, inboundIds[1])
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(trojan): %v", err)
|
||||
}
|
||||
if trojanClients[0].Password == "" {
|
||||
t.Error("trojan inbound client must get a generated password")
|
||||
}
|
||||
vlessClients, err := clientSvc.ListForInbound(nil, inboundIds[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(vless): %v", err)
|
||||
}
|
||||
if vlessClients[0].ID == "" {
|
||||
t.Error("vless inbound client must get a generated uuid")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user