mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 19:55:04 +03:00
ce44d2e772
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.
264 lines
6.7 KiB
Go
264 lines
6.7 KiB
Go
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)
|
|
}
|