飞书通讯录 ↔ 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/)不入库。
327 lines
8.9 KiB
Go
327 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"idsync/internal/config"
|
|
"idsync/internal/ldaplib"
|
|
"idsync/internal/store"
|
|
"idsync/internal/syncer"
|
|
"idsync/internal/webui"
|
|
)
|
|
|
|
// Deps 是服务器依赖。
|
|
type Deps struct {
|
|
Cfg *config.Config
|
|
Auth *Auth
|
|
Store *store.Store
|
|
Runner *syncer.Runner
|
|
LDAP *ldaplib.Client
|
|
// FeishuTest 检查飞书连通性
|
|
FeishuTest func(ctx context.Context) error
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Server 装配路由。
|
|
type Server struct {
|
|
deps *Deps
|
|
mux *http.ServeMux
|
|
// syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。
|
|
syncMu chan struct{}
|
|
}
|
|
|
|
// New 构造服务器。
|
|
func New(d *Deps) *Server {
|
|
s := &Server{
|
|
deps: d,
|
|
mux: http.NewServeMux(),
|
|
syncMu: make(chan struct{}, 1),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// Handler 返回可挂载到 http.Server 的处理器。
|
|
func (s *Server) Handler() http.Handler { return s.mux }
|
|
|
|
func (s *Server) routes() {
|
|
// 静态资源与登录页不需要认证。
|
|
s.mux.HandleFunc("/", s.handleIndex)
|
|
s.mux.HandleFunc("/static/", webui.AssetHandler())
|
|
|
|
s.mux.HandleFunc("/api/login", s.handleLogin)
|
|
s.mux.HandleFunc("/api/logout", s.handleLogout)
|
|
|
|
// 以下接口需要登录。
|
|
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
|
|
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
|
|
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
|
|
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
|
|
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
|
|
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
|
|
}
|
|
|
|
// handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"})
|
|
return
|
|
}
|
|
webui.IndexHandler()(w, r)
|
|
}
|
|
|
|
// handleLogin 处理登录。
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
|
return
|
|
}
|
|
if err := s.deps.Auth.Login(w, r); err != nil {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleLogout 处理登出。
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
s.deps.Auth.Logout(w)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleStatus 返回概览信息:连通性、计数、上次同步。
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
|
|
cfg := s.deps.Cfg
|
|
|
|
ldapOK := true
|
|
ldapErr := ""
|
|
if err := s.deps.LDAP.Ping(); err != nil {
|
|
ldapOK = false
|
|
ldapErr = err.Error()
|
|
}
|
|
|
|
feishuOK := true
|
|
feishuErr := ""
|
|
if s.deps.FeishuTest != nil {
|
|
if err := s.deps.FeishuTest(ctx); err != nil {
|
|
feishuOK = false
|
|
feishuErr = err.Error()
|
|
}
|
|
}
|
|
|
|
accounts, err := s.deps.Store.AllAccounts()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
groups, err := s.deps.Store.AllGroups()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
active, inactive := 0, 0
|
|
for _, a := range accounts {
|
|
if a.State == "inactive" {
|
|
inactive++
|
|
} else {
|
|
active++
|
|
}
|
|
}
|
|
|
|
lastRun, err := s.deps.Store.LastRun()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ldap": map[string]any{
|
|
"ok": ldapOK, "error": ldapErr,
|
|
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN,
|
|
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
|
|
},
|
|
"feishu": map[string]any{
|
|
"ok": feishuOK, "error": feishuErr,
|
|
"appId": maskSecret(cfg.Feishu.AppID),
|
|
"deptList": cfg.Feishu.DeptList,
|
|
},
|
|
"counts": map[string]any{
|
|
"activeUsers": active, "inactiveUsers": inactive, "groups": len(groups),
|
|
},
|
|
"lastRun": lastRun,
|
|
"sync": map[string]any{
|
|
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule,
|
|
"defaultDryRun": cfg.Sync.DefaultDryRun,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。
|
|
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|
accounts, err := s.deps.Store.AllAccounts()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项,
|
|
// 这类问题在界面上一眼可见比事后翻日志有用得多。
|
|
drift := map[string]string{}
|
|
if conn, err := s.deps.LDAP.Dial(); err == nil {
|
|
defer conn.Close()
|
|
active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN())
|
|
inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN())
|
|
if errA == nil && errI == nil {
|
|
for _, a := range accounts {
|
|
switch a.State {
|
|
case "inactive":
|
|
if _, ok := inactive[a.UID]; !ok {
|
|
drift[a.UID] = "台账标记离职,但 LDAP 中不存在"
|
|
}
|
|
default:
|
|
if _, ok := active[a.UID]; !ok {
|
|
if _, inInactive := inactive[a.UID]; inInactive {
|
|
drift[a.UID] = "台账标记在职,实际位于离职 OU"
|
|
} else {
|
|
drift[a.UID] = "台账存在,但 LDAP 中不存在"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type row struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
State string `json:"state"`
|
|
DN string `json:"dn"`
|
|
DeptIDs string `json:"deptIds"`
|
|
LastSync string `json:"lastSyncedAt"`
|
|
Drift string `json:"drift,omitempty"`
|
|
}
|
|
|
|
rows := make([]row, 0, len(accounts))
|
|
for _, a := range accounts {
|
|
rows = append(rows, row{
|
|
UID: a.UID,
|
|
Name: a.Name,
|
|
Email: a.Email,
|
|
State: a.State,
|
|
DN: a.DN,
|
|
DeptIDs: a.DeptIDs,
|
|
LastSync: a.LastSyncedAt.Format(time.RFC3339),
|
|
Drift: drift[a.UID],
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
|
|
}
|
|
|
|
// handleGroups 返回受管分组清单。
|
|
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
|
groups, err := s.deps.Store.AllGroups()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
|
}
|
|
|
|
// handlePlan 计算并返回 dry-run 计划,不产生任何写入。
|
|
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
plan, err := s.deps.Runner.Preview(ctx)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, plan)
|
|
}
|
|
|
|
// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。
|
|
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DryRun *bool `json:"dryRun"`
|
|
Force bool `json:"force"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
}
|
|
|
|
// 默认 dry-run:忘记传参数时宁可什么都不做。
|
|
dryRun := true
|
|
if req.DryRun != nil {
|
|
dryRun = *req.DryRun
|
|
}
|
|
|
|
// 同一时刻只允许一次同步。
|
|
select {
|
|
case s.syncMu <- struct{}{}:
|
|
defer func() { <-s.syncMu }()
|
|
default:
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
|
defer cancel()
|
|
|
|
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(), "plan": plan,
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"dryRun": dryRun, "plan": plan, "result": res,
|
|
})
|
|
}
|
|
|
|
// handleRuns 返回同步历史。
|
|
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
|
runs, err := s.deps.Store.RecentRuns(100)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
|
}
|
|
|
|
// writeJSON 输出 JSON 响应。
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。
|
|
func maskSecret(s string) string {
|
|
if len(s) <= 8 {
|
|
if len(s) == 0 {
|
|
return "未配置"
|
|
}
|
|
return "****"
|
|
}
|
|
return s[:4] + "****" + s[len(s)-4:]
|
|
}
|