- 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 永不移出、删除最后阶段
396 lines
12 KiB
Go
396 lines
12 KiB
Go
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)
|
||
} |