Files
idsync/internal/ldaplib/client.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

136 lines
3.3 KiB
Go

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
}