package ldaplib import ( "crypto/tls" "fmt" "log/slog" "net" "time" "github.com/go-ldap/ldap/v3" "idsync/internal/config" ) // Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低, // 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。 // 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。 type Client struct { cfg config.Ldap log *slog.Logger tlsConf *tls.Config dialOpts []ldap.DialOpt // group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。 group GroupSchema } // 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)) } schema := GroupSchema{ ObjectClass: cfg.GroupObjectClass, MemberAttr: cfg.GroupMemberAttr, } if schema.ObjectClass == "" { schema.ObjectClass = "groupOfUniqueNames" } if schema.MemberAttr == "" { schema.MemberAttr = "uniqueMember" } return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil } // Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。 type Conn struct { conn *ldap.Conn cfg config.Ldap group GroupSchema } // 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, group: c.group}, 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 }