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/lldapgql" "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, "只计算并打印物化计划,不写入 LLDAP") applyAll = flag.Bool("apply", false, "直接执行同步(快照+物化,默认只做 dry-run)") force = flag.Bool("force", false, "忽略删除数量阈值等安全护栏") snapshot = flag.Bool("snapshot", false, "只执行一轮快照(飞书→镜像+离职流程)后退出") genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出") hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出") checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接") ) flag.Parse() if *genSecret { fmt.Println(mustGenSecret()) return } if *hashPass != "" { fmt.Println(mustHashPassword(*hashPass)) return } // 配置自检:把解析后的有效值打出来(凭据脱敏)。 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) } // 读通道:LLDAP 的 LDAP(连接/搜索)。 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) } // 写通道:LLDAP 的 GraphQL。 gqlClient := lldapgql.New(cfg.Lldap.BaseURL, log) 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, gqlClient, db, log, syncer.SnapshotOptions{ WebhookURL: cfg.Feishu.WebhookURL, ConfirmBaseURL: cfg.Server.ConfirmBaseURL, ReNotifyInterval: 24 * time.Hour, TokenTTL: 72 * time.Hour, }) // 命令行模式:跑一轮后退出,便于放进 crontab 或手工验证。 if *snapshot { runSnapshotOnce(runner, log) return } 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.Schedule.Enable { scheduleAuto(cfg, runner, log) } else { log.Info("定时任务未启用(schedule.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.Printf("飞书 离职卡片 webhook : %s\n", orDefault(cfg.Feishu.WebhookURL, "(未配置)")) 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("LLDAP 写 base-url : %s\n", cfg.Lldap.BaseURL) fmt.Printf("LLDAP 写 admin-user : %s\n", cfg.Lldap.AdminUser) fmt.Printf("LLDAP 写 admin-pass : %s\n", mask(cfg.Lldap.AdminPass)) fmt.Println() fmt.Printf("用户 OU : %s\n", cfg.UserOUDN()) fmt.Printf("部门组 OU : %s\n", cfg.GroupRootDN()) fmt.Printf("离职确认端点对外 URL : %s\n", orDefault(cfg.Server.ConfirmBaseURL, "(未配置,离职卡片将无法提供确认链接)")) fmt.Println() fmt.Printf("定时任务 : enable=%v snapshot=%q sync=%q sync-dry-run=%v\n", cfg.Schedule.Enable, cfg.Schedule.Snapshot, cfg.Schedule.Sync, cfg.Schedule.SyncDryRun) fmt.Printf("删除阈值 : max=%d percent=%.0f%%\n", cfg.Guard.MaxDeactivate, cfg.Guard.MaxDeactivatePercent*100) } func orDefault(v, def string) string { if v == "" { return def } return v } // runSnapshotOnce 只执行一轮快照并退出。 func runSnapshotOnce(runner *syncer.Runner, log *slog.Logger) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() res, err := runner.RunSnapshot(ctx) if err != nil { fatal("快照失败", err) } log.Info("快照完成", "users", res.Users, "departments", res.Departments, "skipped", res.Skipped, "leave_candidates", res.LeaveCandidates, "pending_confirm", res.PendingConfirm, "reinstated", res.Reinstated, "cards_sent", res.CardsSent) for _, w := range res.Warnings { log.Warn("快照告警", "detail", w) } } // 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() // 先快照(飞书→镜像),再物化(镜像→LLDAP)。 if _, err := runner.RunSnapshot(ctx); err != nil { fatal("快照失败", err) } 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, "deleted", res.Deleted, "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 } // scheduleAuto 注册两套定时任务:快照低频、物化略高频。输出到 runner 的互斥锁 // 保证两者绝不同时执行。 func scheduleAuto(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) { c := cron.New( cron.WithSeconds(), cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)), ) if _, err := c.AddFunc(cfg.Schedule.Snapshot, func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() log.Info("定时快照开始") if res, err := runner.RunSnapshot(ctx); err != nil { log.Error("定时快照失败", "err", err) } else { log.Info("定时快照完成", "users", res.Users, "pending_confirm", res.PendingConfirm, "cards_sent", res.CardsSent) } }); err != nil { log.Error("注册定时快照失败", "schedule", cfg.Schedule.Snapshot, "err", err) } if _, err := c.AddFunc(cfg.Schedule.Sync, func() { dry := cfg.Schedule.SyncDryRun 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, "deleted", res.Deleted, "added", res.Added, "removed", res.Removed, "failed", res.Failed) }); err != nil { log.Error("注册定时物化失败", "schedule", cfg.Schedule.Sync, "err", err) } c.Start() log.Info("定时任务已启用", "snapshot", cfg.Schedule.Snapshot, "sync", cfg.Schedule.Sync, "sync_dry_run", cfg.Schedule.SyncDryRun) } func printPlanTo(p *syncer.Plan) { fmt.Println("===== 物化计划 =====") s := p.Stats fmt.Printf("新建用户 %d · 更新用户 %d · 删除用户 %d\n", s.CreateUser, s.UpdateUser, s.DeleteUser) fmt.Printf("新建分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n", s.CreateGroup, s.AddMember, s.RemoveMember, s.Unchanged) if len(p.Ops) > 0 { fmt.Println("\n----- 操作明细 -----") for _, op := range p.Ops { fmt.Printf(" [%-15s] %-22s %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 mustGenSecret() string { s, err := server.GenerateSecret() if err != nil { fatal("生成密钥失败", err) } return s } func mustHashPassword(plain string) string { h, err := server.HashPassword(plain) if err != nil { fatal("生成哈希失败", err) } return h } func fatal(msg string, err error) { fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) os.Exit(1) }