Files
caowangrenbo 126e52a6ed feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server
- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
2026-09-29 23:48:55 +08:00

396 lines
12 KiB
Go
Raw Permalink 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 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)
}