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:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+364
View File
@@ -0,0 +1,364 @@
package syncer
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
"idsync/internal/store"
)
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
type Runner struct {
cfg *config.Config
feishu *feishu.Client
ldap *ldaplib.Client
store *store.Store
log *slog.Logger
// guard 是执行前的安全阈值。
guard Guard
}
// NewRunner 构造编排器。
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
return &Runner{
cfg: cfg,
feishu: f,
ldap: l,
store: s,
log: log,
guard: Guard{
MaxDeactivate: cfg.Sync.MaxDeactivate,
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
},
}
}
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
// 因此预览内容与真正会发生的事情一致。
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
plan, _, err := r.plan(ctx)
return plan, err
}
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
run, err := r.store.StartRun(modeOf(dryRun), trigger)
if err != nil {
return nil, nil, fmt.Errorf("创建同步记录失败: %w", err)
}
plan, desired, err := r.prepare(ctx, run, force)
if err != nil {
return plan, nil, err
}
if dryRun {
fillRunFromPlan(run, plan)
run.Status = "success"
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err)
}
return plan, nil, nil
}
res := r.apply(ctx, plan)
fillRunFromResult(run, plan, res)
// 只有全部成功才回写台账:部分失败时保持台账不变,
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
if res.Failed == 0 {
if err := r.persist(plan, desired); err != nil {
run.Status = "failed"
run.ErrorMsg = "回写台账失败: " + err.Error()
_ = r.store.FinishRun(run)
return plan, res, fmt.Errorf("回写台账失败: %w", err)
}
} else {
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed)
}
run.Status = "success"
if res.Failed > 0 {
run.Status = "failed"
run.ErrorMsg = strings.Join(res.Errors, "; ")
}
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err)
}
return plan, res, nil
}
// prepare 计算计划并做安全检查。
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) {
plan, desired, err := r.plan(ctx)
if err != nil {
run.Status = "failed"
run.ErrorMsg = err.Error()
_ = r.store.FinishRun(run)
return nil, nil, err
}
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
guard := r.guard
if force {
guard.AllowBulk = true
}
if err := guard.Check(plan); err != nil {
run.Status = "failed"
run.ErrorMsg = err.Error()
_ = r.store.FinishRun(run)
return plan, desired, err
}
return plan, desired, nil
}
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
if err != nil {
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
}
r.log.Info("已读取飞书数据",
"departments", len(snap.Departments), "users", len(snap.Users),
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
var skipWarnings []string
for _, u := range snap.SkippedNoEmail {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
feishu.DisplayName(u), u.UnionID))
}
for _, d := range snap.SkippedDuplicate {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
d.UID, strings.Join(d.Names, "、")))
}
desired := mapping.Build(snap, mapping.BuildOptions{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
})
conn, err := r.ldap.Dial()
if err != nil {
return nil, nil, err
}
defer conn.Close()
active, err := conn.ListUsers(r.cfg.UserOUDN())
if err != nil {
return nil, nil, err
}
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
if err != nil {
return nil, nil, err
}
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil {
return nil, nil, err
}
accounts, err := r.store.AccountsByUID()
if err != nil {
return nil, nil, err
}
groupLedger, err := r.store.GroupsByDeptID()
if err != nil {
return nil, nil, err
}
ledgerAccounts := make(map[string]string, len(accounts))
for uid, a := range accounts {
ledgerAccounts[uid] = a.State
}
ledgerGroups := make(map[string]string, len(groupLedger))
for deptID, g := range groupLedger {
ledgerGroups[deptID] = g.DN
}
plan := ComputePlan(&Input{
Desired: desired,
Active: active,
Inactive: inactive,
Groups: groups,
LedgerAccounts: ledgerAccounts,
LedgerGroups: ledgerGroups,
AdminDN: r.cfg.Ldap.AdminDN,
GroupRootDN: r.cfg.GroupRootDN(),
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
})
plan.Warnings = append(skipWarnings, plan.Warnings...)
// 对“候选停用”的用户,向飞书确认是否真的离职。
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
if err := r.confirmResigned(ctx, plan, snap); err != nil {
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
}
return plan, desired, nil
}
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
var uids []string
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
accounts, err := r.store.AccountsByUID()
if err != nil {
return err
}
for i := range plan.Ops {
if plan.Ops[i].Kind != OpDeactivate {
continue
}
acc, ok := accounts[plan.Ops[i].Target]
if !ok || acc.UnionID == "" {
continue
}
uids = append(uids, acc.UnionID)
}
if len(uids) == 0 {
return nil
}
resigned, err := r.feishu.CheckResigned(ctx, uids)
if err != nil {
return err
}
confirmed := make(map[string]bool, len(resigned))
for _, rr := range resigned {
confirmed[rr.UnionID] = true
}
keep := make([]Op, 0, len(plan.Ops))
for _, op := range plan.Ops {
if op.Kind != OpDeactivate {
keep = append(keep, op)
continue
}
acc := accounts[op.Target]
if acc != nil && confirmed[acc.UnionID] {
keep = append(keep, op)
continue
}
// 未能确认离职:不动作,并明确告知。
plan.Stats.Deactivate--
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
op.Target, r.cfg.InactiveOUDN()))
}
plan.Ops = keep
return nil
}
// apply 执行计划。
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
exec := NewExecutor(r.ldap, ExecConfig{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
GroupRootName: r.cfg.Ldap.GroupRootName,
AdminDN: r.cfg.Ldap.AdminDN,
InitialPassword: r.cfg.Ldap.InitialPassword,
}, r.log)
return exec.Apply(ctx, plan)
}
// persist 把本次同步的期望状态回写台账。
//
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
now := time.Now()
// 分组:凡在本次期望范围内的都登记。
for _, g := range desired.Groups {
if err := r.store.UpsertGroup(&store.Group{
DeptID: g.DeptID,
DN: g.DN,
Name: g.Name,
ParentID: g.ParentID,
State: "active",
LastSyncedAt: now,
}); err != nil {
return err
}
}
// 用户:在职的登记为 active。
for _, u := range desired.Users {
if err := r.store.UpsertAccount(&store.Account{
UID: u.UID,
DN: u.DN,
Email: u.Mail,
Name: u.DisplayName,
State: "active",
AttrsHash: AttrsHash(u),
DeptIDs: strings.Join(u.DeptIDs, ","),
LastSyncedAt: now,
}); err != nil {
return err
}
}
// 停用的账号:更新状态与,DN 指向离职 OU。
for _, op := range plan.Ops {
if op.Kind == OpDeactivate {
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil {
return err
}
}
}
return nil
}
// modeOf 把 dry-run 标志转成记录用的模式名。
func modeOf(dryRun bool) string {
if dryRun {
return "dry-run"
}
return "apply"
}
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup +
plan.Stats.Reactivate + plan.Stats.Deactivate
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
run.Added = plan.Stats.AddMember
run.Removed = plan.Stats.RemoveMember
run.Skipped = plan.Stats.Unchanged
}
// fillRunFromResult 用实际执行结果填充同步记录。
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
run.Created = res.Created
run.Updated = res.Updated
run.Moved = res.Moved
run.Added = res.Added
run.Removed = res.Removed
run.Failed = res.Failed
run.Skipped = plan.Stats.Unchanged
}