Files
idsync/internal/config/config.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

249 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}