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) }