- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class / group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url + 两套 schedule + guard - ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password - config.example.yml 重写成 LLDAP 形态 - 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份, mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
122 lines
2.9 KiB
Go
122 lines
2.9 KiB
Go
package ldaplib
|
|
|
|
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
|
|
"idsync/internal/config"
|
|
)
|
|
|
|
// Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
|
|
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
|
|
type Client struct {
|
|
cfg config.Ldap
|
|
log *slog.Logger
|
|
tlsConf *tls.Config
|
|
dialOpts []ldap.DialOpt
|
|
}
|
|
|
|
// NewClient 构造 LDAP 读客户端并解析拨号参数。
|
|
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
|
if cfg.URL == "" {
|
|
return nil, fmt.Errorf("ldap.url 未配置")
|
|
}
|
|
|
|
dialOpts := []ldap.DialOpt{
|
|
ldap.DialWithDialer(&net.Dialer{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}),
|
|
}
|
|
|
|
var tlsConf *tls.Config
|
|
if cfg.InsecureSkipVerify || hasLDAPS(cfg.URL) || cfg.StartTLS {
|
|
tlsConf = &tls.Config{
|
|
ServerName: serverName(cfg.URL),
|
|
InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // 由配置显式开启,仅供自签证书环境
|
|
}
|
|
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
|
|
}
|
|
|
|
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
|
|
}
|
|
|
|
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
|
|
type Conn struct {
|
|
conn *ldap.Conn
|
|
cfg config.Ldap
|
|
}
|
|
|
|
// Close 释放连接。
|
|
func (c *Conn) Close() {
|
|
if c.conn != nil {
|
|
_ = c.conn.Close()
|
|
}
|
|
}
|
|
|
|
// Dial 建立并绑定一个新的 LDAP 连接。
|
|
func (c *Client) Dial() (*Conn, error) {
|
|
conn, err := ldap.DialURL(c.cfg.URL, c.dialOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("连接 LDAP 失败 (%s): %w", c.cfg.URL, err)
|
|
}
|
|
|
|
if c.cfg.StartTLS && !hasLDAPS(c.cfg.URL) {
|
|
if err := conn.StartTLS(c.tlsConf); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("StartTLS 失败: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("绑定 LDAP 失败 (%s): %w", c.cfg.AdminDN, err)
|
|
}
|
|
|
|
return &Conn{conn: conn, cfg: c.cfg}, nil
|
|
}
|
|
|
|
// Ping 验证连通性与凭据,用于健康检查。
|
|
func (c *Client) Ping() error {
|
|
conn, err := c.Dial()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
return nil
|
|
}
|
|
|
|
func hasLDAPS(url string) bool {
|
|
return len(url) >= 8 && url[:8] == "ldaps://"
|
|
}
|
|
|
|
// serverName 从 URL 中提取主机名,供 TLS SNI 与证书校验使用。
|
|
func serverName(rawURL string) string {
|
|
host := rawURL
|
|
for _, prefix := range []string{"ldaps://", "ldap://"} {
|
|
if len(host) >= len(prefix) && host[:len(prefix)] == prefix {
|
|
host = host[len(prefix):]
|
|
break
|
|
}
|
|
}
|
|
if i := lastIndexByte(host, '/'); i >= 0 {
|
|
host = host[:i]
|
|
}
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
return host
|
|
}
|
|
|
|
func lastIndexByte(s string, b byte) int {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
if s[i] == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
} |