mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 17:24:57 +03:00
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user