From ce44d2e772b54acde203acaad487b5714bd04dbf Mon Sep 17 00:00:00 2001 From: 25f2002654 Date: Mon, 2 Nov 2026 19:40:28 +0530 Subject: [PATCH] fix(ldap): keep trusted baseline after rejected fetch (#6936) The auto-delete retention guard refused a partial directory result for one tick, but still stored it as the next baseline, so the following tick detached clients that were still in LDAP. A refused fetch now leaves the baseline unchanged. Closes #6935. --- .../web/job/ldap_auto_delete_baseline_test.go | 263 ++++++++++++++++++ .../web/job/ldap_auto_delete_guard_test.go | 2 +- internal/web/job/ldap_sync_job.go | 24 +- 3 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 internal/web/job/ldap_auto_delete_baseline_test.go diff --git a/internal/web/job/ldap_auto_delete_baseline_test.go b/internal/web/job/ldap_auto_delete_baseline_test.go new file mode 100644 index 000000000..b20db06ea --- /dev/null +++ b/internal/web/job/ldap_auto_delete_baseline_test.go @@ -0,0 +1,263 @@ +package job + +import ( + "encoding/json" + "fmt" + "net" + "slices" + "strconv" + "sync" + "testing" + "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/xray" +) + +type ldapDir struct { + ln net.Listener + mu sync.Mutex + users []string +} + +func newLDAPDir(t *testing.T, users ...string) *ldapDir { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + d := &ldapDir{ln: ln, users: users} + go d.serve() + t.Cleanup(func() { _ = ln.Close() }) + return d +} + +func (d *ldapDir) port() int { return d.ln.Addr().(*net.TCPAddr).Port } + +func (d *ldapDir) setUsers(users []string) { + d.mu.Lock() + defer d.mu.Unlock() + d.users = users +} + +func (d *ldapDir) serve() { + for { + conn, err := d.ln.Accept() + if err != nil { + return + } + go d.handle(conn) + } +} + +func (d *ldapDir) handle(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil { + return + } + id, ok := ldapMessageID(buf[:n]) + if !ok { + return + } + d.mu.Lock() + users := append([]string(nil), d.users...) + d.mu.Unlock() + for _, user := range users { + if _, err := conn.Write(ldapSearchEntry(id, user)); err != nil { + return + } + } + _, _ = conn.Write(ldapSearchDone(id)) +} + +func berLength(n int) []byte { + if n < 0x80 { + return []byte{byte(n)} + } + var encoded []byte + for value := n; value > 0; value >>= 8 { + encoded = append([]byte{byte(value & 0xff)}, encoded...) + } + return append([]byte{byte(0x80 | len(encoded))}, encoded...) +} + +func berTLV(tag byte, content []byte) []byte { + out := []byte{tag} + out = append(out, berLength(len(content))...) + return append(out, content...) +} + +func berString(tag byte, value string) []byte { return berTLV(tag, []byte(value)) } + +func berInt(tag byte, value int) []byte { return berTLV(tag, []byte{byte(value)}) } + +func berSeq(items ...[]byte) []byte { + var content []byte + for _, item := range items { + content = append(content, item...) + } + return berTLV(0x30, content) +} + +func berConcat(parts ...[]byte) []byte { + var out []byte + for _, part := range parts { + out = append(out, part...) + } + return out +} + +func ldapMessageID(message []byte) (int, bool) { + if len(message) < 2 || message[0] != 0x30 { + return 0, false + } + index := 1 + if message[index]&0x80 != 0 { + index += 1 + int(message[index]&0x7f) + } else { + index++ + } + if index+2 > len(message) || message[index] != 0x02 { + return 0, false + } + index++ + length := int(message[index]) + index++ + if length == 0 || length > 4 || index+length > len(message) { + return 0, false + } + id := 0 + for _, value := range message[index : index+length] { + id = id<<8 | int(value) + } + return id, true +} + +func ldapSearchEntry(id int, mail string) []byte { + mailAttr := berSeq(berString(0x04, "mail"), berTLV(0x31, berString(0x04, mail))) + flagAttr := berSeq(berString(0x04, "vless_enabled"), berTLV(0x31, berString(0x04, "true"))) + entry := berTLV(0x64, berConcat(berString(0x04, "dc=example,dc=com"), berSeq(mailAttr, flagAttr))) + return berSeq(berInt(0x02, id), entry) +} + +func ldapSearchDone(id int) []byte { + done := berTLV(0x65, berConcat(berInt(0x0a, 0), berString(0x04, ""), berString(0x04, ""))) + return berSeq(berInt(0x02, id), done) +} + +func ldapSetSetting(t *testing.T, key, value string) { + t.Helper() + if err := database.GetDB().Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil { + t.Fatalf("clear setting %s: %v", key, err) + } + if err := database.GetDB().Create(&model.Setting{Key: key, Value: value}).Error; err != nil { + t.Fatalf("set %s=%s: %v", key, value, err) + } +} + +func ldapInboundEmails(t *testing.T, inboundID int) []string { + t.Helper() + var inbound model.Inbound + if err := database.GetDB().First(&inbound, inboundID).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + var parsed struct { + Clients []model.Client `json:"clients"` + } + if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil { + t.Fatalf("parse settings: %v", err) + } + emails := make([]string, 0, len(parsed.Clients)) + for _, client := range parsed.Clients { + emails = append(emails, client.Email) + } + return emails +} + +func assertLDAPInboundEmails(t *testing.T, inboundID int, want []string) { + t.Helper() + got := ldapInboundEmails(t, inboundID) + want = slices.Clone(want) + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("inbound clients = %v, want %v", got, want) + } +} + +func ldapBaselineFixture(t *testing.T) (*LdapSyncJob, *ldapDir, *model.Inbound, []string) { + t.Helper() + initLdapJobDB(t) + db := database.GetDB() + + const tag = "ldap-baseline-in" + all := make([]string, 0, 10) + clients := make([]model.Client, 0, 10) + for i := 0; i < 10; i++ { + email := fmt.Sprintf("u%d@ldap", i) + all = append(all, email) + clients = append(clients, model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Enable: true}) + } + settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"}) + inbound := &model.Inbound{ + UserId: 1, Enable: true, Port: 47300, Protocol: model.VLESS, + Tag: tag, Settings: string(settings), StreamSettings: `{"network":"tcp"}`, + } + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + for _, client := range clients { + record := model.ClientRecord{Email: client.Email, UUID: client.ID, Enable: true} + if err := db.Create(&record).Error; err != nil { + t.Fatalf("create client record: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil { + t.Fatalf("link client: %v", err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: client.Email, Enable: true}).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + } + + dir := newLDAPDir(t, all...) + ldapSetSetting(t, "ldapEnable", "true") + ldapSetSetting(t, "ldapHost", "127.0.0.1") + ldapSetSetting(t, "ldapPort", strconv.Itoa(dir.port())) + ldapSetSetting(t, "ldapInboundTags", tag) + ldapSetSetting(t, "ldapAutoDelete", "true") + + return NewLdapSyncJob(), dir, inbound, all +} + +func TestLdapAutoDeleteKeepsBaselineAfterRefusedFetch(t *testing.T) { + job, dir, inbound, all := ldapBaselineFixture(t) + + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) + + dir.setUsers(all[:4]) + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) + + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) +} + +func TestLdapAutoDeleteKeepsBaselineAfterEmptyFetch(t *testing.T) { + job, dir, inbound, all := ldapBaselineFixture(t) + + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) + + dir.setUsers(nil) + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) + + dir.setUsers(all[:4]) + job.Run() + assertLDAPInboundEmails(t, inbound.Id, all) +} diff --git a/internal/web/job/ldap_auto_delete_guard_test.go b/internal/web/job/ldap_auto_delete_guard_test.go index c4846a262..359501d54 100644 --- a/internal/web/job/ldap_auto_delete_guard_test.go +++ b/internal/web/job/ldap_auto_delete_guard_test.go @@ -24,7 +24,7 @@ func TestAutoDeleteAllowed(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { j := NewLdapSyncJob() - j.lastFlagCount.Store(c.previous) + j.lastTrustedFlagCount.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) diff --git a/internal/web/job/ldap_sync_job.go b/internal/web/job/ldap_sync_job.go index 56f93cc3c..6229a5ec8 100644 --- a/internal/web/job/ldap_sync_job.go +++ b/internal/web/job/ldap_sync_job.go @@ -19,11 +19,11 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"} const ldapAutoDeleteMinRetainPercent = 50 type LdapSyncJob struct { - settingService service.SettingService - inboundService service.InboundService - clientService service.ClientService - xrayService service.XrayService - lastFlagCount atomic.Int64 + settingService service.SettingService + inboundService service.InboundService + clientService service.ClientService + xrayService service.XrayService + lastTrustedFlagCount atomic.Int64 } // --- Helper functions for mustGet --- @@ -160,7 +160,10 @@ func (j *LdapSyncJob) Run() { // --- Auto delete clients not in LDAP --- autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete) - if autoDelete && j.autoDeleteSafeForFetch(len(flags)) { + if autoDelete { + if !j.autoDeleteSafeForFetch(len(flags)) { + return + } ldapEmailSet := map[string]struct{}{} for e := range flags { ldapEmailSet[e] = struct{}{} @@ -169,19 +172,18 @@ func (j *LdapSyncJob) Run() { j.deleteClientsNotInLDAP(tag, ldapEmailSet) } } - j.lastFlagCount.Store(int64(len(flags))) + j.lastTrustedFlagCount.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". +// An empty fetch can mean a renamed OU or lost read grant, not that every user is 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() + previous := j.lastTrustedFlagCount.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)", + logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous trusted sync saw %d (below %d%% retention)", fetched, previous, ldapAutoDeleteMinRetainPercent) return false }