diff --git a/Dockerfile b/Dockerfile index 53d3092..8047db4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,9 @@ FROM node:20-alpine AS frontend-builder WORKDIR /app +# 限制 Node 内存,避免 2 核小机器 docker build 时 OOM/高负载 // Logan.Li +ENV NODE_OPTIONS="--max-old-space-size=2048" + # 使用淘宝镜像加速 RUN npm config set registry https://registry.npmmirror.com @@ -73,6 +76,7 @@ FROM golang:1.24-alpine AS agent-builder ARG VERSION ARG BUILD_TIME +ARG TARGETARCH WORKDIR /agent @@ -84,9 +88,10 @@ RUN go mod download COPY agent/ ./ -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-linux-amd64 . && \ - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-linux-arm64 . && \ - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" -o bin/aiops-agent-darwin-arm64 . +# 仅编译目标架构,arm64/darwin 通过下载 API 提供,降低构建 CPU 占用 // Logan.Li +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ + -ldflags="-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \ + -o bin/aiops-agent-linux-${TARGETARCH} . # ==================== Stage 4: Runtime ==================== FROM alpine:3.20 @@ -121,8 +126,8 @@ COPY --from=backend-builder /app/aiops . # 创建运行时目录 RUN mkdir -p /app/agent/bin /app/agent/deploy -# 复制 agent 二进制(U4: 仅 linux-amd64 内置, arm64/darwin 通过下载 API 提供)// Logan.Li -COPY --from=agent-builder /agent/bin/aiops-agent-linux-amd64 /app/agent/bin/ +# 复制 agent 二进制(仅目标架构, 其他架构通过下载 API 提供)// Logan.Li +COPY --from=agent-builder /agent/bin/aiops-agent-linux-${TARGETARCH} /app/agent/bin/aiops-agent-linux-${TARGETARCH} # 复制 agent 安装脚本 COPY agent/deploy/install.sh /app/agent/deploy/ diff --git a/README.md b/README.md index abbf707..fe02872 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - WebSocket (Gorilla) / PTY 终端 (creack/pty) - 自研轻量 Kubernetes HTTP 客户端(channel.k8s.io WebSocket 协议) - go:embed 嵌入前端静态资源(单二进制 / 单容器部署) +- **LDAP / Active Directory** 统一身份认证(登录、用户列表、批量导入) - Agent CLI 引擎(`pkg/agentcli/`):ReAct + Evolver + 双协议 LLM(OpenAI / Anthropic) ### Agent @@ -63,15 +64,31 @@ curl http://localhost:8080/version # 查看运行版本 ### Docker Compose 部署 ```bash -./run.sh docker up # 构建并启动 (MySQL + Redis + AIOps) -./run.sh docker up v1.5.0 # 指定版本构建 -./run.sh docker push v1.5.0 # 构建并推送到 lghost/aiops +# 日常推荐:镜像已存在时,跳过构建直接启动(低负载) +./run.sh docker fast -# 访问(前后端同一端口) -# http://localhost:8080 -# http://localhost:8080/version +# 代码有变更:增量构建 + 启动 +./run.sh docker up +./run.sh docker up v1.5.0 # 指定版本构建 + +# 仅升级应用容器,不重建 MySQL/Redis +./run.sh docker upgrade + +# 依赖或缓存异常时:无缓存全量重建 +./run.sh docker up fresh + +./run.sh docker push v1.5.0 # 构建并推送到 lghost/aiops +./run.sh docker down # 停止容器 +./run.sh docker logs # 查看日志 ``` +访问地址(前后端同一端口): + +- http://localhost:8080 +- http://localhost:8080/version(健康检查推荐,无 DB 依赖) + +> **小机器提示(2 核 / 8GB)**:`docker up` 会触发前端 `npm run build`(临时 `node` 进程 CPU 可达 100%+),属构建期正常现象;AIOps 运行时仅为 Go 二进制,稳态 CPU 通常 < 1%。日常用 `docker fast` 可避免重复编译。 + ### Kubernetes 部署 ```bash @@ -112,6 +129,30 @@ curl http://localhost:8080/version # 查看运行版本 | ops01 | 123456 | 运维 | | dev01 | 123456 | 开发 | +> 生产环境请修改默认密码与 `JWT_SECRET`。 + +## LDAP / Active Directory + +系统设置 → LDAP 支持 **OpenLDAP** 与 **Active Directory** 双目录: + +| 能力 | OpenLDAP | Active Directory | +|------|----------|------------------| +| 连接 / 登录认证 | ✅ | ✅ | +| 用户列表 / 批量导入 | ✅ | ✅ | +| OU / 容器浏览 | ✅ | ✅(含 `CN=Users` 等容器) | +| 目录内创建用户 | ✅ | ✅(需 LDAPS + 服务账号权限) | + +**AD 推荐配置示例**: + +| 配置项 | 推荐值 | +|--------|--------| +| 目录类型 | Active Directory | +| Base DN | `DC=company,DC=com` | +| Admin DN | `CN=svc-aiops,CN=Users,DC=company,DC=com` | +| 搜索过滤器 | 留空(使用内置兼容过滤器)或 `(sAMAccountName=%s)` | +| 用户名字段 | `sAMAccountName` | +| LDAPS | 创建 AD 用户时必须启用 | + ## 项目结构 ``` @@ -144,7 +185,7 @@ aiops/ ├── deploy/kubernetes/ # K8s 部署清单 (devops 命名空间) ├── docker-compose.yml # MySQL + Redis + AIOps embed(3 容器) ├── docker-compose.k8s.yml # 单应用容器(连接外部 DB) -├── Dockerfile # embed 单容器镜像(推荐) +├── Dockerfile # embed 单容器镜像(推荐) ├── run.sh # 统一入口脚本 └── aiops # 独立二进制产物(gitignore,项目根目录) ``` @@ -161,7 +202,7 @@ aiops/ | 自动化运维 | 定时任务、脚本库、**远程执行**(Go-native SSH,执行/上传) | | AIOps 智能 | AI 助手、告警分析、容量预测、**Agent CLI**(ReAct + WebSocket 实时回传 + 终端审计回放) | | 资源申请 | 云服务器/应用部署申请审批 | -| 系统管理 | 用户/角色、操作审计、终端会话审计、凭证配置(7 种类型)、大模型设置(OpenAI + Anthropic 双协议) | +| 系统管理 | 用户/角色、**LDAP / AD 统一认证**、操作审计、终端会话审计、凭证配置(7 种类型)、大模型设置(OpenAI + Anthropic 双协议) | ### UI 与体验 @@ -169,6 +210,16 @@ aiops/ - **7 套主题**:跟随系统、深海蓝、明亮白 + One Dark / Dracula / Tokyo Night / Catppuccin - **国际化**:i18next 框架,已覆盖 dashboard / server / alert / approval / audit / deploy / k8s 等模块 +## 常见问题 + +| 现象 | 原因 | 处理 | +|------|------|------| +| `docker up` 时 `node` CPU 100%+ | Docker 构建前端(`npm run build`) | 正常,构建结束后自动消失;日常用 `docker fast` | +| 构建后 load 仍偏高 | 宿主机其他服务(网关/监控 Agent) | `top` 排查非 AIOps 进程 | +| MySQL 内存占用高 | 默认 buffer pool 较大 | Compose 已限制 `innodb-buffer-pool-size=256M` | +| Mac 二进制无法在 Linux 运行 | 平台不匹配 | 在 Linux 构建或使用 Docker 镜像 | +| LDAP 登录失败 | 过滤器/字段映射与目录类型不匹配 | 系统设置选择正确目录类型并配置 `user_mapping` | + ## 文档 详细开发文档见 [docs/README.md](./docs/README.md)。 diff --git a/backend/controller/ldap.go b/backend/controller/ldap.go index 280631f..2aaef7c 100644 --- a/backend/controller/ldap.go +++ b/backend/controller/ldap.go @@ -32,6 +32,17 @@ type LDAPAuthInfo struct { Email string } +var ldapUserAttrs = []string{ + "uid", "cn", "displayName", "mail", "userPrincipalName", "sAMAccountName", + "telephoneNumber", "mobile", "title", "department", +} + +var ldapUserAttrSets = [][]string{ + {"uid", "cn", "displayName", "mail", "telephoneNumber", "mobile", "title", "department"}, + {"sAMAccountName", "userPrincipalName", "displayName", "cn", "mail", "telephoneNumber", "mobile", "title", "department"}, + ldapUserAttrs, +} + // dialLDAP 连接LDAP服务器 func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) { if cfg.LDAPHost == "" { @@ -50,6 +61,136 @@ func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) { return ldap.DialURL(uri, ldap.DialWithDialer(dialer)) } +// ldapUserFilter 构造兼容 OpenLDAP 与 AD 的用户搜索过滤器 // Logan.Li +func ldapUserFilter(cfg *model.LDAPConfig, username string) string { + escaped := ldap.EscapeFilter(username) + if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" { + sf := strings.TrimSpace(*cfg.SearchFilter) + if strings.Contains(sf, "%s") { + return strings.ReplaceAll(sf, "%s", escaped) + } + return fmt.Sprintf("(&%s(|(uid=%s)(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s)))", sf, escaped, escaped, escaped, escaped) + } + return fmt.Sprintf("(|(&(objectClass=inetOrgPerson)(uid=%s))(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))))", escaped, escaped, escaped, escaped) +} + +// ldapADUserFilter 生成 AD 兜底过滤器,参考 opsx-monitor 的 sAMAccountName/userPrincipalName/cn 兼容策略 // Logan.Li +func ldapADUserFilter(username string) string { + escaped := ldap.EscapeFilter(username) + return fmt.Sprintf("(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))", escaped, escaped, escaped) +} + +// ldapUserListFilters 返回目录用户列表过滤器,先尊重配置,再补 OpenLDAP/AD 兜底 // Logan.Li +func ldapUserListFilters(cfg *model.LDAPConfig) []string { + filters := []string{} + if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" { + sf := strings.TrimSpace(*cfg.SearchFilter) + if strings.Contains(sf, "%s") { + filters = append(filters, strings.ReplaceAll(sf, "%s", "*")) + } else { + filters = append(filters, sf) + } + } + filters = append(filters, + "(objectClass=inetOrgPerson)", + "(&(objectClass=user)(|(sAMAccountName=*)(userPrincipalName=*)))", + ) + return filters +} + +// ldapEntryAttr 按属性名回退读取,兼容 OpenLDAP 与 AD 常见字段 // Logan.Li +func ldapEntryAttr(entry *ldap.Entry, names ...string) string { + for _, name := range names { + if v := entry.GetAttributeValue(name); v != "" { + return v + } + } + return "" +} + +// ldapUserMapping 读取用户字段映射,同时提供 AD/OpenLDAP 默认回退 // Logan.Li +func ldapUserMapping(cfg *model.LDAPConfig) map[string]string { + userMapping := ldapDefaultUserMapping(cfg) + if cfg.UserMapping != nil && *cfg.UserMapping != "" { + _ = json.Unmarshal([]byte(*cfg.UserMapping), &userMapping) + } + return userMapping +} + +// ldapIdentityFromEntry 将目录条目转换为平台统一用户信息 // Logan.Li +func ldapIdentityFromEntry(cfg *model.LDAPConfig, entry *ldap.Entry, fallbackUsername string) *LDAPAuthInfo { + mapping := ldapUserMapping(cfg) + mappedUsername := ldapEntryAttr(entry, mapping["username"]) + mappedDisplay := ldapEntryAttr(entry, mapping["display_name"]) + mappedEmail := ldapEntryAttr(entry, mapping["email"]) + info := &LDAPAuthInfo{ + Username: ldapFirstNonEmpty(mappedUsername, ldapEntryAttr(entry, "uid", "sAMAccountName", "userPrincipalName", "cn"), fallbackUsername), + DisplayName: ldapFirstNonEmpty(mappedDisplay, ldapEntryAttr(entry, "displayName", "cn"), fallbackUsername), + Email: ldapFirstNonEmpty(mappedEmail, ldapEntryAttr(entry, "mail", "userPrincipalName")), + } + return info +} + +func ldapFirstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// ldapIsInvalidAttributeErr 判断目录不支持某些属性时的错误,用于 OpenLDAP/AD 属性集降级 // Logan.Li +func ldapIsInvalidAttributeErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "undefined attribute") || + strings.Contains(msg, "invalid attribute") || + strings.Contains(msg, "attribute type undefined") || + strings.Contains(msg, "no such attribute") +} + +// ldapSearchUsers 使用多组属性搜索,避免 OpenLDAP 与 AD 互不支持属性时整体失败 // Logan.Li +func ldapSearchUsers(conn *ldap.Conn, baseDN, filter string, sizeLimit int) (*ldap.SearchResult, error) { + var lastErr error + for _, attrs := range ldapUserAttrSets { + searchReq := ldap.NewSearchRequest( + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + sizeLimit, 10, false, + filter, + attrs, + nil, + ) + sr, err := conn.Search(searchReq) + if err == nil { + return sr, nil + } + lastErr = err + if ldapIsInvalidAttributeErr(err) { + continue + } + return nil, err + } + return nil, lastErr +} + +// ldapSearchFirstUser 查找单个用户,过滤器兼容 OpenLDAP 与 Active Directory // Logan.Li +func ldapSearchFirstUser(conn *ldap.Conn, cfg *model.LDAPConfig, username string) (*ldap.Entry, error) { + for _, filter := range []string{ldapUserFilter(cfg, username), ldapADUserFilter(username)} { + sr, err := ldapSearchUsers(conn, cfg.BaseDN, filter, 1) + if err != nil { + return nil, err + } + if len(sr.Entries) > 0 { + return sr.Entries[0], nil + } + } + return nil, fmt.Errorf("LDAP用户不存在") +} + // tryLDAPAuth 尝试LDAP认证,返回用户信息 func tryLDAPAuth(db *gorm.DB, username, password string) (*LDAPAuthInfo, error) { var cfg model.LDAPConfig @@ -71,69 +212,18 @@ func tryLDAPAuth(db *gorm.DB, username, password string) (*LDAPAuthInfo, error) return nil, fmt.Errorf("LDAP管理员绑定失败: %w", err) } - // Build search filter - userFilter := "" - baseFilter := "(objectClass=inetOrgPerson)" - if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - sf := *cfg.SearchFilter - if strings.Contains(sf, "%s") { - userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(username)) - } else { - baseFilter = sf - userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username)) - } - } else { - userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username)) - } - - // Search user - searchReq := ldap.NewSearchRequest( - cfg.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 1, 10, false, - userFilter, - []string{"uid", "cn", "mail", "telephoneNumber"}, - nil, - ) - sr, err := conn.Search(searchReq) - if err != nil || len(sr.Entries) == 0 { + entry, err := ldapSearchFirstUser(conn, &cfg, username) + if err != nil { return nil, fmt.Errorf("LDAP用户不存在") } - entry := sr.Entries[0] userDN := entry.DN - // Verify password by binding as user + // 使用用户 DN 重新绑定以校验密码,兼容 OpenLDAP 与 AD // Logan.Li if err := conn.Bind(userDN, password); err != nil { return nil, fmt.Errorf("LDAP密码验证失败: %w", err) } - // Extract user info using mapping - userMapping := map[string]string{ - "username": "uid", - "email": "mail", - "display_name": "cn", - } - if cfg.UserMapping != nil && *cfg.UserMapping != "" { - json.Unmarshal([]byte(*cfg.UserMapping), &userMapping) - } - - getAttr := func(key string) string { - if v := entry.GetAttributeValue(key); v != "" { - return v - } - return "" - } - - info := &LDAPAuthInfo{ - Username: getAttr(userMapping["username"]), - DisplayName: getAttr(userMapping["display_name"]), - Email: getAttr(userMapping["email"]), - } - if info.Username == "" { - info.Username = username - } - - return info, nil + return ldapIdentityFromEntry(&cfg, entry, username), nil } // GetLDAPConfig 获取LDAP配置 @@ -181,17 +271,29 @@ func (h *LDAPHandler) UpdateLDAPConfig(c *gin.Context) { } return nil } + hasKey := func(key string) bool { + _, ok := input[key] + return ok + } userMappingStr := "" if um, ok := input["user_mapping"]; ok { - if bytes, err := json.Marshal(um); err == nil { + if um == nil || um == "" { + userMappingStr = "{}" + } else if bytes, err := json.Marshal(um); err == nil { userMappingStr = string(bytes) } } + serverType := getStr("server_type") + if serverType == "" { + serverType = ldapServerOpenLDAP + } + if len(configs) == 0 { // Create cfg := model.LDAPConfig{ + ServerType: serverType, LDAPHost: getStr("ldap_host"), AdminDN: getStr("admin_dn"), BaseDN: getStr("base_dn"), @@ -216,6 +318,13 @@ func (h *LDAPHandler) UpdateLDAPConfig(c *gin.Context) { // Update cfg := configs[0] updates := map[string]interface{}{} + if hasKey("server_type") { + st := getStr("server_type") + if st == "" { + st = ldapServerOpenLDAP + } + updates["server_type"] = st + } if v := getStr("ldap_host"); v != "" { updates["ldap_host"] = v } @@ -225,8 +334,9 @@ func (h *LDAPHandler) UpdateLDAPConfig(c *gin.Context) { if v := getStr("admin_dn"); v != "" { updates["admin_dn"] = v } - if v := getStr("search_filter"); v != "" { - updates["search_filter"] = v + if hasKey("search_filter") { + v := strings.TrimSpace(getStr("search_filter")) + updates["search_filter"] = &v } if userMappingStr != "" { updates["user_mapping"] = userMappingStr @@ -282,36 +392,17 @@ func (h *LDAPHandler) TestLDAPConnection(c *gin.Context) { // If test_user provided, search and verify if req.TestUser != "" && req.TestPassword != "" { - userFilter := "" - baseFilter := "(objectClass=inetOrgPerson)" - if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - sf := *cfg.SearchFilter - if strings.Contains(sf, "%s") { - userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(req.TestUser)) - } else { - baseFilter = sf - userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser)) - } - } else { - userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser)) - } - - sr, err := conn.Search(ldap.NewSearchRequest( - cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 1, 10, false, userFilter, - []string{"uid", "cn", "mail"}, - nil, - )) - if err != nil || len(sr.Entries) == 0 { + entry, err := ldapSearchFirstUser(conn, &cfg, req.TestUser) + if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("用户 %s 不存在", req.TestUser)}) return } - userDN := sr.Entries[0].DN + userDN := entry.DN if err := conn.Bind(userDN, req.TestPassword); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("密码验证失败: %s", err)}) return } - c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"success": true, "message": fmt.Sprintf("认证成功!用户DN: %s", userDN)}}) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"success": true, "message": fmt.Sprintf("认证成功!用户DN: %s", userDN), "user_info": ldapIdentityFromEntry(&cfg, entry, req.TestUser)}}) return } diff --git a/backend/controller/ldap_ad.go b/backend/controller/ldap_ad.go new file mode 100644 index 0000000..16922b2 --- /dev/null +++ b/backend/controller/ldap_ad.go @@ -0,0 +1,154 @@ +package controller + +import ( + "fmt" + "strings" + "unicode/utf16" + + "ezops/model" + + "github.com/go-ldap/ldap/v3" +) + +const ( + ldapServerOpenLDAP = "openldap" + ldapServerAD = "ad" +) + +// ldapIsAD 判断当前配置是否为 Active Directory // Logan.Li +func ldapIsAD(cfg *model.LDAPConfig) bool { + if cfg == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(cfg.ServerType), ldapServerAD) +} + +// ldapDefaultUserMapping 按目录类型返回默认字段映射 // Logan.Li +func ldapDefaultUserMapping(cfg *model.LDAPConfig) map[string]string { + if ldapIsAD(cfg) { + return map[string]string{ + "username": "sAMAccountName", + "email": "mail", + "display_name": "displayName", + } + } + return map[string]string{ + "username": "uid", + "email": "mail", + "display_name": "cn", + } +} + +// ldapEncodeADPassword 将明文密码编码为 AD unicodePwd(UTF-16LE 带引号) // Logan.Li +func ldapEncodeADPassword(password string) []byte { + quoted := `"` + password + `"` + runes := utf16.Encode([]rune(quoted)) + buf := make([]byte, len(runes)*2) + for i, r := range runes { + buf[i*2] = byte(r) + buf[i*2+1] = byte(r >> 8) + } + return buf +} + +// ldapBuildUserDN 按目录类型构造用户 DN // Logan.Li +func ldapBuildUserDN(cfg *model.LDAPConfig, username, displayName, parentDN string) (string, error) { + parentDN = strings.TrimSpace(parentDN) + if parentDN == "" { + return "", fmt.Errorf("父级 DN 不能为空") + } + if ldapIsAD(cfg) { + cn := strings.TrimSpace(displayName) + if cn == "" { + cn = username + } + return fmt.Sprintf("CN=%s,%s", ldap.EscapeDN(cn), parentDN), nil + } + if strings.TrimSpace(username) == "" { + return "", fmt.Errorf("用户名不能为空") + } + return fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(username), parentDN), nil +} + +// ldapOUListFilters 返回 OU/容器列表过滤器,兼容 OpenLDAP 与 AD // Logan.Li +func ldapOUListFilters(cfg *model.LDAPConfig) []string { + if ldapIsAD(cfg) { + return []string{ + "(|(objectClass=organizationalUnit)(objectClass=container))", + } + } + return []string{"(objectClass=organizationalUnit)"} +} + +// ldapOUEntryName 读取 OU/容器显示名 // Logan.Li +func ldapOUEntryName(entry *ldap.Entry) string { + if v := entry.GetAttributeValue("ou"); v != "" { + return v + } + return ldapEntryAttr(entry, "cn", "name") +} + +// ldapAddOpenLDAPUser 在 OpenLDAP 中创建 inetOrgPerson 用户 // Logan.Li +func ldapAddOpenLDAPUser(conn *ldap.Conn, cfg *model.LDAPConfig, userDN, username, displayName, email, password string) error { + mapping := ldapUserMapping(cfg) + uidAttr := mapping["username"] + if uidAttr == "" { + uidAttr = "uid" + } + mailAttr := mapping["email"] + if mailAttr == "" { + mailAttr = "mail" + } + cn := displayName + if cn == "" { + cn = username + } + + addReq := ldap.NewAddRequest(userDN, nil) + addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"}) + addReq.Attribute(uidAttr, []string{username}) + addReq.Attribute("cn", []string{cn}) + addReq.Attribute("sn", []string{cn}) + if email != "" { + addReq.Attribute(mailAttr, []string{email}) + } + addReq.Attribute("userPassword", []string{password}) + return conn.Add(addReq) +} + +// ldapAddADUser 在 Active Directory 中创建并启用用户账号 // Logan.Li +func ldapAddADUser(conn *ldap.Conn, userDN, username, displayName, email, password string) error { + cn := strings.TrimSpace(displayName) + if cn == "" { + cn = username + } + sn := cn + if idx := strings.LastIndex(cn, " "); idx > 0 { + sn = cn[idx+1:] + } + + addReq := ldap.NewAddRequest(userDN, nil) + addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + addReq.Attribute("cn", []string{cn}) + addReq.Attribute("sAMAccountName", []string{username}) + addReq.Attribute("sn", []string{sn}) + if email != "" { + addReq.Attribute("mail", []string{email}) + } + // 先以禁用状态创建,设置密码后再启用 // Logan.Li + addReq.Attribute("userAccountControl", []string{"514"}) + if err := conn.Add(addReq); err != nil { + return err + } + + modPwd := ldap.NewModifyRequest(userDN, nil) + modPwd.Replace("unicodePwd", []string{string(ldapEncodeADPassword(password))}) + if err := conn.Modify(modPwd); err != nil { + _ = conn.Del(ldap.NewDelRequest(userDN, nil)) + return fmt.Errorf("设置 AD 密码失败(需 LDAPS 连接): %w", err) + } + + modEnable := ldap.NewModifyRequest(userDN, nil) + modEnable.Replace("userAccountControl", []string{"512"}) + return conn.Modify(modEnable) +} \ No newline at end of file diff --git a/backend/controller/ldap_ou.go b/backend/controller/ldap_ou.go index 438f59a..04bc1b6 100644 --- a/backend/controller/ldap_ou.go +++ b/backend/controller/ldap_ou.go @@ -3,7 +3,6 @@ package controller import ( "fmt" "net/http" - "time" "ezops/model" @@ -20,48 +19,48 @@ func (h *LDAPHandler) ListLDAPOUs(c *gin.Context) { return } cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) return } defer conn.Close() - conn.SetTimeout(5 * time.Second) if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) return } - searchReq := ldap.NewSearchRequest( - cfg.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - "(objectClass=organizationalUnit)", - []string{"ou", "description"}, - nil, - ) - sr, err := conn.Search(searchReq) - if err != nil { - c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) - return - } - type OU struct { DN string `json:"dn"` Name string `json:"name"` Description string `json:"description"` } var ous []OU - for _, e := range sr.Entries { - ous = append(ous, OU{ - DN: e.DN, - Name: e.GetAttributeValue("ou"), - Description: e.GetAttributeValue("description"), - }) + seenDN := map[string]bool{} + for _, filter := range ldapOUListFilters(&cfg) { + searchReq := ldap.NewSearchRequest( + cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{"ou", "cn", "name", "description"}, + nil, + ) + sr, err := conn.Search(searchReq) + if err != nil { + continue + } + for _, e := range sr.Entries { + if seenDN[e.DN] { + continue + } + seenDN[e.DN] = true + ous = append(ous, OU{ + DN: e.DN, + Name: ldapOUEntryName(e), + Description: e.GetAttributeValue("description"), + }) + } } c.JSON(http.StatusOK, model.Response{Code: 200, Data: ous}) } @@ -85,23 +84,18 @@ func (h *LDAPHandler) CreateLDAPOU(c *gin.Context) { return } cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } if req.ParentDN == "" { req.ParentDN = cfg.BaseDN } ouDN := fmt.Sprintf("ou=%s,%s", ldap.EscapeFilter(req.OUName), req.ParentDN) - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) return } defer conn.Close() - conn.SetTimeout(5 * time.Second) if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) return diff --git a/backend/controller/ldap_users.go b/backend/controller/ldap_users.go index 1a6e605..c41c92f 100644 --- a/backend/controller/ldap_users.go +++ b/backend/controller/ldap_users.go @@ -1,11 +1,8 @@ package controller import ( - "encoding/json" "fmt" "net/http" - "strings" - "time" "ezops/model" @@ -24,18 +21,18 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { var cfg model.LDAPConfig if err := h.db.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusOK, []interface{}{}) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) return } conn, err := dialLDAP(&cfg) if err != nil { - c.JSON(http.StatusOK, []interface{}{}) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) return } defer conn.Close() if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { - c.JSON(http.StatusOK, []interface{}{}) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: []interface{}{}}) return } @@ -44,30 +41,10 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { searchBase = req.OuDn } - baseFilter := "(objectClass=inetOrgPerson)" - if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - sf := *cfg.SearchFilter - if strings.Contains(sf, "%s") { - baseFilter = strings.ReplaceAll(sf, "%s", "*") - } else { - baseFilter = sf - } - } - - sr, err := conn.Search(ldap.NewSearchRequest( - searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 200, 0, false, baseFilter, - []string{"uid", "cn", "mail", "dn", "telephoneNumber", "title", "department"}, - nil, - )) - if err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - type LdapUser struct { DN string `json:"dn"` UID string `json:"uid"` + Username string `json:"username"` CN string `json:"cn"` Mail string `json:"mail"` Phone string `json:"phone"` @@ -75,16 +52,32 @@ func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) { Department string `json:"department"` } var users []LdapUser - for _, e := range sr.Entries { - users = append(users, LdapUser{ - DN: e.DN, - UID: e.GetAttributeValue("uid"), - CN: e.GetAttributeValue("cn"), - Mail: e.GetAttributeValue("mail"), - Phone: e.GetAttributeValue("telephoneNumber"), - Title: e.GetAttributeValue("title"), - Department: e.GetAttributeValue("department"), - }) + seenDNs := map[string]bool{} + for _, filter := range ldapUserListFilters(&cfg) { + sr, err := ldapSearchUsers(conn, searchBase, filter, 500) + if err != nil { + continue + } + for _, e := range sr.Entries { + if seenDNs[e.DN] { + continue + } + info := ldapIdentityFromEntry(&cfg, e, "") + if info.Username == "" { + continue + } + seenDNs[e.DN] = true + users = append(users, LdapUser{ + DN: e.DN, + UID: info.Username, + Username: info.Username, + CN: info.DisplayName, + Mail: info.Email, + Phone: ldapEntryAttr(e, "telephoneNumber", "mobile"), + Title: ldapEntryAttr(e, "title"), + Department: ldapEntryAttr(e, "department"), + }) + } } c.JSON(http.StatusOK, model.Response{Code: 200, Data: users}) } @@ -195,54 +188,40 @@ func (h *LDAPHandler) CreateLDAPUser(c *gin.Context) { return } cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } if req.OUDN == "" { req.OUDN = cfg.BaseDN } - userDN := fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(req.Username), req.OUDN) - - // Parse user mapping - mapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"} - if cfg.UserMapping != nil { - _ = json.Unmarshal([]byte(*cfg.UserMapping), &mapping) + userDN, err := ldapBuildUserDN(&cfg, req.Username, req.DisplayName, req.OUDN) + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()}) + return + } + if ldapIsAD(&cfg) && (cfg.UseLDAPS == nil || !*cfg.UseLDAPS) { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Active Directory 创建用户需启用 LDAPS"}) + return } - uidAttr := mapping["username"] - if uidAttr == "" { - uidAttr = "uid" - } - mailAttr := mapping["email"] - if mailAttr == "" { - mailAttr = "mail" - } - - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) return } defer conn.Close() - conn.SetTimeout(5 * time.Second) if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) return } - addReq := ldap.NewAddRequest(userDN, nil) - addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"}) - addReq.Attribute(uidAttr, []string{req.Username}) - addReq.Attribute("cn", []string{req.DisplayName}) - addReq.Attribute("sn", []string{req.DisplayName}) - if req.Email != "" { - addReq.Attribute(mailAttr, []string{req.Email}) - } - addReq.Attribute("userPassword", []string{req.Password}) - if err := conn.Add(addReq); err != nil { - c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建用户失败: " + err.Error()}) - return + if ldapIsAD(&cfg) { + if err := ldapAddADUser(conn, userDN, req.Username, req.DisplayName, req.Email, req.Password); err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建 AD 用户失败: " + err.Error()}) + return + } + } else { + if err := ldapAddOpenLDAPUser(conn, &cfg, userDN, req.Username, req.DisplayName, req.Email, req.Password); err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建用户失败: " + err.Error()}) + return + } } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "用户创建成功", Data: gin.H{"dn": userDN}}) @@ -265,18 +244,13 @@ func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) { return } cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) return } defer conn.Close() - conn.SetTimeout(5 * time.Second) if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) return @@ -312,28 +286,23 @@ func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) { return } cfg := configs[0] - port := 389 - if cfg.LDAPPort != nil { - port = *cfg.LDAPPort - } - conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port)) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()}) return } defer conn.Close() - conn.SetTimeout(5 * time.Second) if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()}) return } - // 先查当前值,做 diff + // 先查当前值,做 diff(AD 同时读取 displayName) // Logan.Li sr, err := conn.Search(ldap.NewSearchRequest( req.DN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, "(objectClass=*)", - []string{"cn", "mail", "telephoneNumber", "title"}, + []string{"cn", "displayName", "mail", "telephoneNumber", "title"}, nil, )) if err != nil || len(sr.Entries) == 0 { @@ -343,10 +312,13 @@ func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) { entry := sr.Entries[0] modReq := ldap.NewModifyRequest(req.DN, nil) - // cn - oldCN := entry.GetAttributeValue("cn") + // cn / displayName(AD 优先 displayName) // Logan.Li + oldCN := ldapEntryAttr(entry, "displayName", "cn") if req.CN != "" && req.CN != oldCN { modReq.Replace("cn", []string{req.CN}) + if ldapIsAD(&cfg) { + modReq.Replace("displayName", []string{req.CN}) + } modReq.Replace("sn", []string{req.CN}) // sn 同步更新 } // mail diff --git a/backend/model/user.go b/backend/model/user.go index 3f0ebb6..821cd32 100644 --- a/backend/model/user.go +++ b/backend/model/user.go @@ -196,6 +196,7 @@ type ChangePasswordRequest struct { // LDAPConfig LDAP配置 type LDAPConfig struct { ID int `gorm:"primarykey" json:"id"` + ServerType string `gorm:"size:20;default:openldap" json:"server_type"` // openldap | ad // Logan.Li LDAPHost string `gorm:"size:200;not null" json:"ldap_host"` LDAPPort *int `json:"ldap_port"` UseLDAPS *bool `json:"use_ldaps"` diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 377aa50..22421c7 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -231,4 +231,11 @@ func applySchemaPatches(db *gorm.DB) { } ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles") ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles") + + // LDAP 目录类型列(openldap | ad) // Logan.Li + if m.HasTable(&model.LDAPConfig{}) && !m.HasColumn(&model.LDAPConfig{}, "server_type") { + if err := m.AddColumn(&model.LDAPConfig{}, "ServerType"); err != nil { + log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err) + } + } } diff --git a/docker-compose.yml b/docker-compose.yml index 8923242..3d7f19b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + --innodb-buffer-pool-size=256M + --performance-schema=OFF healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"] interval: 10s diff --git a/frontend/src/components/system/SystemSettingsTab.tsx b/frontend/src/components/system/SystemSettingsTab.tsx index 3742a40..e12fbca 100644 --- a/frontend/src/components/system/SystemSettingsTab.tsx +++ b/frontend/src/components/system/SystemSettingsTab.tsx @@ -24,7 +24,10 @@ const SETTINGS_TABS = [ ] as const; type SettingsSubTab = (typeof SETTINGS_TABS)[number]['id']; +export type LdapServerType = 'openldap' | 'ad'; + export interface LdapFormData { + server_type: LdapServerType; ldap_host: string; ldap_port: number; use_ldaps: boolean; @@ -32,6 +35,11 @@ export interface LdapFormData { admin_password: string; base_dn: string; search_filter: string; + user_mapping: { + username: string; + email: string; + display_name: string; + }; } interface SystemSettingsTabProps { @@ -325,6 +333,32 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) {
+
+ + +
setLdapForm({ ...ldapForm, base_dn: e.target.value })} className="dg-input" - placeholder="ou=users,dc=example,dc=com" + placeholder={ldapForm.server_type === 'ad' ? 'DC=example,DC=com' : 'ou=users,dc=example,dc=com'} />
@@ -359,7 +393,7 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) { value={ldapForm.admin_dn} onChange={(e) => setLdapForm({ ...ldapForm, admin_dn: e.target.value })} className="dg-input" - placeholder="cn=admin,dc=example,dc=com" + placeholder={ldapForm.server_type === 'ad' ? 'CN=admin,CN=Users,DC=example,DC=com' : 'cn=admin,dc=example,dc=com'} />
@@ -378,7 +412,43 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) { value={ldapForm.search_filter} onChange={(e) => setLdapForm({ ...ldapForm, search_filter: e.target.value })} className="dg-input" - placeholder="(uid=%s)" + placeholder={ldapForm.server_type === 'ad' ? '(sAMAccountName=%s)' : '(uid=%s)'} + /> +
+
+ + setLdapForm({ + ...ldapForm, + user_mapping: { ...ldapForm.user_mapping, username: e.target.value }, + })} + className="dg-input" + placeholder={ldapForm.server_type === 'ad' ? 'sAMAccountName' : 'uid'} + /> +
+
+ + setLdapForm({ + ...ldapForm, + user_mapping: { ...ldapForm.user_mapping, email: e.target.value }, + })} + className="dg-input" + placeholder="mail" + /> +
+
+ + setLdapForm({ + ...ldapForm, + user_mapping: { ...ldapForm.user_mapping, display_name: e.target.value }, + })} + className="dg-input" + placeholder={ldapForm.server_type === 'ad' ? 'displayName' : 'cn'} />
@@ -388,6 +458,9 @@ export function SystemSettingsTab(props: SystemSettingsTabProps) { className="data-[state=checked]:bg-blue-600" /> 启用 LDAPS(SSL 加密) + {ldapForm.server_type === 'ad' && ( + AD 创建用户必须启用 LDAPS + )}
diff --git a/frontend/src/components/system/useLdapManagement.ts b/frontend/src/components/system/useLdapManagement.ts index ff9a7d5..b8ce431 100644 --- a/frontend/src/components/system/useLdapManagement.ts +++ b/frontend/src/components/system/useLdapManagement.ts @@ -30,10 +30,13 @@ export function useLdapManagement(onUsersChanged: () => Promise | void) { // ===== LDAP 配置 ===== const [ldapConfig, setLdapConfig] = useState(null); - const [ldapForm, setLdapForm] = useState({ + const defaultLdapForm = (): LdapFormData => ({ + server_type: 'openldap', ldap_host: '', ldap_port: 389, use_ldaps: false, admin_dn: '', admin_password: '', base_dn: '', search_filter: '', + user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' }, }); + const [ldapForm, setLdapForm] = useState(defaultLdapForm()); const [ldapSaving, setLdapSaving] = useState(false); const [ldapTesting, setLdapTesting] = useState(false); const [ldapTestResult, setLdapTestResult] = useState(null); @@ -68,7 +71,26 @@ export function useLdapManagement(onUsersChanged: () => Promise | void) { const res = await ldapApi.getConfig(); if (res && res.ldap_host) { setLdapConfig(res); + const serverType = (res.server_type === 'ad' ? 'ad' : 'openldap') as LdapFormData['server_type']; + let mapping = { username: 'uid', email: 'mail', display_name: 'cn' }; + if (serverType === 'ad') { + mapping = { username: 'sAMAccountName', email: 'mail', display_name: 'displayName' }; + } + if (res.user_mapping) { + let um = res.user_mapping; + if (typeof um === 'string') { + try { um = JSON.parse(um); } catch { um = null; } + } + if (um && typeof um === 'object') { + mapping = { + username: um.username || mapping.username, + email: um.email || mapping.email, + display_name: um.display_name || mapping.display_name, + }; + } + } setLdapForm({ + server_type: serverType, ldap_host: res.ldap_host || '', ldap_port: res.ldap_port || 389, use_ldaps: res.use_ldaps || false, @@ -76,6 +98,7 @@ export function useLdapManagement(onUsersChanged: () => Promise | void) { admin_password: '', base_dn: res.base_dn || '', search_filter: res.search_filter || '', + user_mapping: mapping, }); } } catch { @@ -87,7 +110,10 @@ export function useLdapManagement(onUsersChanged: () => Promise | void) { const handleLdapSave = async () => { setLdapSaving(true); try { - await ldapApi.updateConfig(ldapForm); + await ldapApi.updateConfig({ + ...ldapForm, + user_mapping: ldapForm.user_mapping, + }); await loadLdapConfig(); } catch (error) { console.error('Failed to save LDAP config:', error); @@ -101,7 +127,10 @@ export function useLdapManagement(onUsersChanged: () => Promise | void) { setLdapTesting(true); setLdapTestResult(null); try { - await ldapApi.updateConfig(ldapForm); + await ldapApi.updateConfig({ + ...ldapForm, + user_mapping: ldapForm.user_mapping, + }); const res = await ldapApi.testConnection(); setLdapTestResult(res?.message || (res?.success ? '连接成功' : '连接失败')); } catch (error) { diff --git a/run.sh b/run.sh index 1847095..fb5549d 100755 --- a/run.sh +++ b/run.sh @@ -754,20 +754,20 @@ cmd_docker() { case "$action" in up|upgrade|update) banner "========== Docker Compose 启动/升级 ($BUILD_VERSION) ==========" - clean_all_artifacts + # Docker 在容器内构建,无需清理宿主机产物;每次 prune 缓存会导致下次全量编译 // Logan.Li if [ "$fresh_build" -eq 1 ]; then info "全量重建镜像 (--no-cache)..." - $DOCKER_COMPOSE build --no-cache + $DOCKER_COMPOSE build --no-cache aiops $DOCKER_COMPOSE up -d --force-recreate --remove-orphans + elif [ "$action" = "upgrade" ] || [ "$action" = "update" ]; then + info "升级应用:仅重建 aiops 容器..." + $DOCKER_COMPOSE build aiops + $DOCKER_COMPOSE up -d --force-recreate --no-deps aiops else - info "增量构建 + 强制重建容器..." - $DOCKER_COMPOSE up -d --build --force-recreate --remove-orphans + info "增量构建 + 启动容器(保留 BuildKit 缓存)..." + $DOCKER_COMPOSE up -d --build --remove-orphans fi echo "" - info "清理历史构建缓存..." - docker builder prune -f 2>/dev/null || true - docker image prune -f 2>/dev/null || true - echo "" info "等待服务就绪..." sleep 5 $DOCKER_COMPOSE ps @@ -818,12 +818,20 @@ cmd_docker() { echo " $(df -h / | awk 'NR==2{print $3, "used /", $2, "total ("$5")"}')" success "清理完成" ;; + fast|start) + banner "========== Docker Compose 快速启动 (不构建镜像) ==========" + $DOCKER_COMPOSE up -d --remove-orphans + sleep 3 + $DOCKER_COMPOSE ps + info "访问地址: http://localhost:8080" + ;; fresh) cmd_docker up fresh ;; help|--help|-h) echo "Docker Compose 部署命令 (embed 单容器 + MySQL + Redis):" echo " ./run.sh docker up 增量构建 + 启动" + echo " ./run.sh docker fast 跳过构建,直接启动(低负载)" echo " ./run.sh docker up v1.0.1 指定版本构建" echo " ./run.sh docker up fresh 无缓存全量重建" echo " ./run.sh docker push v1.0.1 构建并推送到 $DOCKERHUB_IMAGE"