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

360 lines
11 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 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)
}