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/)不入库。
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/server"
|
||||
"idsync/internal/store"
|
||||
"idsync/internal/syncer"
|
||||
)
|
||||
|
||||
// version 由构建参数注入。
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
cfgPath = flag.String("config", "config.yml", "配置文件路径")
|
||||
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP")
|
||||
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)")
|
||||
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏")
|
||||
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
|
||||
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
|
||||
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *genSecret {
|
||||
s, err := server.GenerateSecret()
|
||||
if err != nil {
|
||||
fatal("生成密钥失败", err)
|
||||
}
|
||||
fmt.Println(s)
|
||||
return
|
||||
}
|
||||
if *hashPass != "" {
|
||||
h, err := server.HashPassword(*hashPass)
|
||||
if err != nil {
|
||||
fatal("生成哈希失败", err)
|
||||
}
|
||||
fmt.Println(h)
|
||||
return
|
||||
}
|
||||
|
||||
// 配置自检:把解析后的有效值打出来(凭据脱敏)。
|
||||
// 用于在真正连接目录前确认关键项,尤其是分组形态——
|
||||
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
|
||||
if *checkCfg {
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
fatal("配置加载失败", err)
|
||||
}
|
||||
printEffectiveConfig(cfg)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
fatal("配置加载失败", err)
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
if cfg.System.Mode == "debug" {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
if err := os.MkdirAll(cfg.System.DataDir, 0o755); err != nil {
|
||||
fatal("创建数据目录失败", err)
|
||||
}
|
||||
|
||||
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
|
||||
if err != nil {
|
||||
fatal("初始化 LDAP 客户端失败", err)
|
||||
}
|
||||
if err := ldapClient.Ping(); err != nil {
|
||||
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
|
||||
} else {
|
||||
log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
feishuClient, err := feishu.New(feishu.Options{
|
||||
AppID: cfg.Feishu.AppID,
|
||||
AppSecret: cfg.Feishu.AppSecret,
|
||||
BaseURL: cfg.Feishu.BaseURL,
|
||||
QPS: cfg.Feishu.QPS,
|
||||
Timeout: time.Duration(cfg.Feishu.TimeoutSec) * time.Second,
|
||||
}, log)
|
||||
if err != nil {
|
||||
fatal("初始化飞书客户端失败", err)
|
||||
}
|
||||
|
||||
db, err := store.Open(cfg.Database.Source)
|
||||
if err != nil {
|
||||
fatal("打开台账数据库失败", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log)
|
||||
|
||||
// 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。
|
||||
if *dryRun || *applyAll {
|
||||
runOnce(cfg, runner, *applyAll, *force, log)
|
||||
return
|
||||
}
|
||||
|
||||
// 服务模式
|
||||
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
|
||||
if err != nil {
|
||||
fatal("初始化服务失败", err)
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("管理界面已启动", "addr", cfg.Server.Listen, "version", version)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fatal("HTTP 服务异常退出", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Sync.Enable {
|
||||
scheduleSync(cfg, runner, log)
|
||||
} else {
|
||||
log.Info("定时同步未启用(sync.enable=false)")
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Info("正在关闭服务…")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||
log.Error("关闭 HTTP 服务时出错", "err", err)
|
||||
}
|
||||
log.Info("已退出")
|
||||
}
|
||||
|
||||
// printEffectiveConfig 输出解析后的关键配置,凭据一律脱敏。
|
||||
func printEffectiveConfig(cfg *config.Config) {
|
||||
mask := func(s string) string {
|
||||
if s == "" {
|
||||
return "(空)"
|
||||
}
|
||||
if len(s) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return s[:4] + "****" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
fmt.Println("===== 有效配置 =====")
|
||||
fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
|
||||
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
|
||||
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
|
||||
fmt.Println()
|
||||
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL)
|
||||
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN)
|
||||
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN)
|
||||
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
|
||||
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS)
|
||||
fmt.Println()
|
||||
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN())
|
||||
fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN())
|
||||
fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN())
|
||||
fmt.Println()
|
||||
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass)
|
||||
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr)
|
||||
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n",
|
||||
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass)
|
||||
fmt.Println()
|
||||
fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)"))
|
||||
fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n",
|
||||
cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun)
|
||||
fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n",
|
||||
cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100)
|
||||
|
||||
// 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。
|
||||
userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN()
|
||||
if inactiveDN == userDN {
|
||||
fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效")
|
||||
} else if len(inactiveDN) > len(userDN) &&
|
||||
inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
|
||||
fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户")
|
||||
} else {
|
||||
fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)")
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// runOnce 执行一次同步并打印计划,然后退出。
|
||||
func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
plan, res, err := runner.Run(ctx, !apply, force, "manual")
|
||||
if plan != nil {
|
||||
printPlanTo(plan)
|
||||
}
|
||||
if err != nil {
|
||||
fatal("同步失败", err)
|
||||
}
|
||||
|
||||
if !apply {
|
||||
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("同步完成",
|
||||
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
|
||||
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
|
||||
for _, e := range res.Errors {
|
||||
log.Error("操作失败详情", "detail", e)
|
||||
}
|
||||
}
|
||||
|
||||
// buildServer 组装 HTTP 服务,并在缺少密钥/哈希时生成安全的替代值。
|
||||
func buildServer(
|
||||
cfg *config.Config,
|
||||
runner *syncer.Runner,
|
||||
db *store.Store,
|
||||
ldapClient *ldaplib.Client,
|
||||
feishuClient *feishu.Client,
|
||||
log *slog.Logger,
|
||||
) (*server.Server, error) {
|
||||
secret := cfg.Server.SessionSecret
|
||||
if secret == "" {
|
||||
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
|
||||
generated, err := server.GenerateSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成会话密钥失败: %w", err)
|
||||
}
|
||||
secret = generated
|
||||
log.Warn("未配置 server.session-secret,已生成临时密钥,重启后需重新登录",
|
||||
"hint", "把它写入配置可保持会话", "session_secret", secret)
|
||||
}
|
||||
|
||||
var hashBytes []byte
|
||||
if cfg.Server.AdminPasswordHash != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(cfg.Server.AdminPasswordHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server.admin-password-hash 不是合法的 base64: %w", err)
|
||||
}
|
||||
hashBytes = b
|
||||
}
|
||||
|
||||
auth, err := server.NewAuth(
|
||||
cfg.Server.AdminUser, hashBytes, cfg.Server.AdminPassword,
|
||||
[]byte(secret), cfg.Server.SecureCookie,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return server.New(&server.Deps{
|
||||
Cfg: cfg,
|
||||
Auth: auth,
|
||||
Store: db,
|
||||
Runner: runner,
|
||||
LDAP: ldapClient,
|
||||
FeishuTest: func(ctx context.Context) error {
|
||||
return feishuClient.Test(ctx)
|
||||
},
|
||||
Log: log,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// scheduleSync 注册定时同步。
|
||||
//
|
||||
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量
|
||||
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
|
||||
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
|
||||
c := cron.New(
|
||||
cron.WithSeconds(),
|
||||
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
|
||||
)
|
||||
|
||||
_, err := c.AddFunc(cfg.Sync.Schedule, func() {
|
||||
dry := cfg.Sync.DefaultDryRun
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
log.Info("定时同步开始", "dry_run", dry)
|
||||
plan, res, err := runner.Run(ctx, dry, false, "schedule")
|
||||
if err != nil {
|
||||
log.Error("定时同步失败", "err", err)
|
||||
return
|
||||
}
|
||||
if dry {
|
||||
planned := 0
|
||||
if plan != nil {
|
||||
planned = len(plan.Ops)
|
||||
}
|
||||
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned)
|
||||
return
|
||||
}
|
||||
log.Info("定时同步完成",
|
||||
"created", res.Created, "updated", res.Updated,
|
||||
"moved", res.Moved, "failed", res.Failed)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Start()
|
||||
log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun)
|
||||
}
|
||||
|
||||
func printPlanTo(p *syncer.Plan) {
|
||||
fmt.Println("===== 同步计划 =====")
|
||||
s := p.Stats
|
||||
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n",
|
||||
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate)
|
||||
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
|
||||
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
|
||||
|
||||
if len(p.Ops) > 0 {
|
||||
fmt.Println("\n----- 操作明细 -----")
|
||||
for _, op := range p.Ops {
|
||||
fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail)
|
||||
}
|
||||
}
|
||||
if len(p.Warnings) > 0 {
|
||||
fmt.Println("\n----- 需要注意 -----")
|
||||
for _, w := range p.Warnings {
|
||||
fmt.Printf(" ! %s\n", w)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func fatal(msg string, err error) {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user