飞书通讯录 ↔ 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/)不入库。
249 lines
8.8 KiB
Go
249 lines
8.8 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
// Config 是 idsync 的完整配置。
|
||
type Config struct {
|
||
System System `mapstructure:"system"`
|
||
Server Server `mapstructure:"server"`
|
||
Feishu Feishu `mapstructure:"feishu"`
|
||
Ldap Ldap `mapstructure:"ldap"`
|
||
Database Database `mapstructure:"database"`
|
||
Sync Sync `mapstructure:"sync"`
|
||
}
|
||
|
||
type System struct {
|
||
// Mode: debug 或 release,影响 gin 与日志详细度
|
||
Mode string `mapstructure:"mode"`
|
||
// DataDir 用于存放 sqlite 台账与日志
|
||
DataDir string `mapstructure:"data-dir"`
|
||
}
|
||
|
||
type Server struct {
|
||
// Listen 形如 0.0.0.0:8899
|
||
Listen string `mapstructure:"listen"`
|
||
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
|
||
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
|
||
AdminUser string `mapstructure:"admin-user"`
|
||
AdminPassword string `mapstructure:"admin-password"`
|
||
AdminPasswordHash string `mapstructure:"admin-password-hash"`
|
||
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。
|
||
SessionSecret string `mapstructure:"session-secret"`
|
||
// SecureCookie 在 HTTPS 前置反代后应设为 true
|
||
SecureCookie bool `mapstructure:"secure-cookie"`
|
||
}
|
||
|
||
type Feishu struct {
|
||
AppID string `mapstructure:"app-id"`
|
||
AppSecret string `mapstructure:"app-secret"`
|
||
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com
|
||
BaseURL string `mapstructure:"base-url"`
|
||
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门
|
||
DeptList []string `mapstructure:"dept-list"`
|
||
// QPS 限制调用飞书的速率,默认 5
|
||
QPS int `mapstructure:"qps"`
|
||
// TimeoutSec 单次 API 调用超时,默认 30
|
||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||
}
|
||
|
||
type Ldap struct {
|
||
URL string `mapstructure:"url"`
|
||
BaseDN string `mapstructure:"base-dn"`
|
||
AdminDN string `mapstructure:"admin-dn"`
|
||
AdminPass string `mapstructure:"admin-pass"`
|
||
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树
|
||
UserOU string `mapstructure:"user-ou"`
|
||
// InactiveOU 离职用户迁往的 OU,应位于 Authelia base_dn 之外
|
||
InactiveOU string `mapstructure:"inactive-ou"`
|
||
// GroupRootOU 部门树的根 OU 名称(仅名称,不含 ou=)
|
||
GroupRootName string `mapstructure:"group-root-name"`
|
||
// GroupObjectClass 部门分组使用的 objectClass。
|
||
// 必须与 Authelia 的 groups_filter 一致,否则分组对 Authelia 不可见,
|
||
// Remote-Groups 会是空的,基于组的 ACL 全部失效。
|
||
// 常见取值:groupOfUniqueNames(配 uniqueMember)或 groupOfNames(配 member)。
|
||
GroupObjectClass string `mapstructure:"group-object-class"`
|
||
// GroupMemberAttr 分组成员属性名。留空则按 GroupObjectClass 推导。
|
||
GroupMemberAttr string `mapstructure:"group-member-attr"`
|
||
// TimeoutSec 连接与操作超时,默认 15
|
||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||
// StartTLS 对 ldap:// 启用 StartTLS
|
||
StartTLS bool `mapstructure:"start-tls"`
|
||
// InsecureSkipVerify 仅用于自签证书的测试环境
|
||
InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"`
|
||
// InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成
|
||
InitialPassword string `mapstructure:"initial-password"`
|
||
}
|
||
|
||
type Database struct {
|
||
// Source 是 sqlite 文件路径
|
||
Source string `mapstructure:"source"`
|
||
}
|
||
|
||
type Sync struct {
|
||
// Enable 是否启用定时同步
|
||
Enable bool `mapstructure:"enable"`
|
||
// Schedule 秒级 cron,例如 "0 40 0 * * *"
|
||
Schedule string `mapstructure:"schedule"`
|
||
// DefaultDryRun 为 true 时,定时任务只生成计划不执行
|
||
DefaultDryRun bool `mapstructure:"default-dry-run"`
|
||
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制
|
||
MaxDeactivate int `mapstructure:"max-deactivate"`
|
||
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制
|
||
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
|
||
}
|
||
|
||
// Load 读取配置文件并应用默认值与校验。
|
||
func Load(path string) (*Config, error) {
|
||
v := viper.New()
|
||
setDefaults(v)
|
||
|
||
if path != "" {
|
||
v.SetConfigFile(path)
|
||
} else {
|
||
v.SetConfigName("config")
|
||
v.SetConfigType("yaml")
|
||
v.AddConfigPath(".")
|
||
v.AddConfigPath("/etc/idsync")
|
||
}
|
||
|
||
if err := v.ReadInConfig(); err != nil {
|
||
return nil, fmt.Errorf("读取配置失败: %w", err)
|
||
}
|
||
|
||
cfg := &Config{}
|
||
if err := v.Unmarshal(cfg); err != nil {
|
||
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||
}
|
||
|
||
if err := cfg.normalize(); err != nil {
|
||
return nil, err
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
func setDefaults(v *viper.Viper) {
|
||
v.SetDefault("system.mode", "release")
|
||
v.SetDefault("system.data-dir", "data")
|
||
|
||
v.SetDefault("server.listen", "0.0.0.0:8899")
|
||
v.SetDefault("server.admin-user", "admin")
|
||
v.SetDefault("server.secure-cookie", false)
|
||
|
||
v.SetDefault("feishu.qps", 5)
|
||
v.SetDefault("feishu.timeout-sec", 30)
|
||
|
||
v.SetDefault("ldap.url", "ldap://localhost:389")
|
||
v.SetDefault("ldap.user-ou", "ou=people")
|
||
v.SetDefault("ldap.inactive-ou", "ou=Inactive")
|
||
v.SetDefault("ldap.group-root-name", "feishuroot")
|
||
v.SetDefault("ldap.group-object-class", "groupOfUniqueNames")
|
||
v.SetDefault("ldap.group-member-attr", "")
|
||
v.SetDefault("ldap.timeout-sec", 15)
|
||
v.SetDefault("ldap.start-tls", false)
|
||
v.SetDefault("ldap.insecure-skip-verify", false)
|
||
|
||
v.SetDefault("database.source", "data/idsync.db")
|
||
|
||
v.SetDefault("sync.enable", false)
|
||
v.SetDefault("sync.schedule", "0 40 0 * * *")
|
||
v.SetDefault("sync.default-dry-run", true)
|
||
v.SetDefault("sync.max-deactivate", 0)
|
||
v.SetDefault("sync.max-deactivate-percent", 0.2)
|
||
}
|
||
|
||
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
|
||
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
|
||
func (c *Config) NormalizeForTest() error { return c.normalize() }
|
||
|
||
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。
|
||
func (c *Config) normalize() error {
|
||
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
|
||
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
|
||
}
|
||
if c.Ldap.BaseDN == "" {
|
||
return fmt.Errorf("ldap.base-dn 不能为空")
|
||
}
|
||
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" {
|
||
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空")
|
||
}
|
||
if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
|
||
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
|
||
}
|
||
if c.Sync.Enable && c.Sync.Schedule == "" {
|
||
return fmt.Errorf("sync.schedule 不能为空")
|
||
}
|
||
|
||
if err := c.Ldap.normalizeGroupSchema(); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。
|
||
//
|
||
// 这两项必须与 Authelia 的 groups_filter 匹配:objectClass 不匹配则 Authelia
|
||
// 完全看不到我们建的分组;成员属性不匹配则 membership 查询返回空。二者都不会
|
||
// 报错,只会表现为「分组 ACL 静默失效」,所以这里主动校验而不是默默容忍。
|
||
func (l *Ldap) normalizeGroupSchema() error {
|
||
oc := strings.TrimSpace(l.GroupObjectClass)
|
||
if oc == "" {
|
||
oc = "groupOfUniqueNames"
|
||
}
|
||
l.GroupObjectClass = oc
|
||
|
||
attr := strings.TrimSpace(l.GroupMemberAttr)
|
||
if attr == "" {
|
||
// 按 objectClass 推导出配套的成员属性
|
||
switch strings.ToLower(oc) {
|
||
case "groupofnames":
|
||
attr = "member"
|
||
default:
|
||
attr = "uniqueMember"
|
||
}
|
||
}
|
||
l.GroupMemberAttr = attr
|
||
|
||
// 校验搭配是否正确:groupOfNames 只允许 member,groupOfUniqueNames 只允许 uniqueMember。
|
||
// 写错会产生 LDAP objectClassViolation,但报错信息晦涩,提前拦下更友好。
|
||
switch strings.ToLower(oc) {
|
||
case "groupofnames":
|
||
if !strings.EqualFold(attr, "member") {
|
||
return fmt.Errorf("ldap.group-object-class 为 groupOfNames 时,ldap.group-member-attr 必须为 member(当前 %q)", attr)
|
||
}
|
||
case "groupofuniquenames":
|
||
if !strings.EqualFold(attr, "uniqueMember") {
|
||
return fmt.Errorf("ldap.group-object-class 为 groupOfUniqueNames 时,ldap.group-member-attr 必须为 uniqueMember(当前 %q)", attr)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UserOUDN 返回在职用户的完整 OU DN。
|
||
func (c *Config) UserOUDN() string {
|
||
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN)
|
||
}
|
||
|
||
// InactiveOUDN 返回离职用户的完整 OU DN。
|
||
func (c *Config) InactiveOUDN() string {
|
||
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
|
||
}
|
||
|
||
// GroupRootDN 返回部门树根 OU 的完整 DN。
|
||
func (c *Config) GroupRootDN() string {
|
||
name := strings.TrimSpace(c.Ldap.GroupRootName)
|
||
if name == "" {
|
||
name = "feishuroot"
|
||
}
|
||
return "ou=" + name + "," + c.Ldap.BaseDN
|
||
}
|
||
|
||
// joinDN 把相对 RDN 与后缀拼成完整 DN,容忍两端多余逗号。
|
||
func joinDN(rdn, suffix string) string {
|
||
return strings.TrimSuffix(strings.TrimSpace(rdn), ",") + "," + strings.TrimSpace(suffix)
|
||
}
|