package httpapi import ( "context" "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/dnsverification" "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) type dnsAccountVerifier interface { Verify(context.Context, string, string, map[string]string) (dnsverification.Report, error) } func (s *Server) managedDNSRepository(c *gin.Context) (repository.DNSAccountRepository, bool) { repo, ok := s.projectMembers.(repository.DNSAccountRepository) if !ok { s.writeError(c, http.StatusServiceUnavailable, "DNS 账户服务尚未就绪", nil) } return repo, ok } func (s *Server) writeDNSAccountError(c *gin.Context, err error) { switch { case errors.Is(err, repository.ErrProjectAccessDenied): s.writeError(c, http.StatusForbidden, "无权操作此 DNS 账户", nil) case errors.Is(err, repository.ErrDNSAccountConflict): s.writeError(c, http.StatusConflict, "DNS 账户已变更,请刷新后重试", nil) case errors.Is(err, repository.ErrDNSAccountBusy): s.writeError(c, http.StatusConflict, "DNS 账户仍有执行租约,请等待任务及挑战清理完成", nil) default: // 驱动、秘密仓库和服务商的错误均可能携带凭据,不能回显或写入日志。 s.writeError(c, http.StatusServiceUnavailable, "DNS 账户操作失败,请稍后重试", nil) } } func (s *Server) createManagedDNSAccount(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } var request struct { Name string `json:"name"` ProjectID string `json:"project_id"` Provider string `json:"provider"` Credentials map[string]string `json:"credentials"` } if err := bindJSON(c, &request, setupRequestLimit); err != nil { s.writeError(c, http.StatusBadRequest, "DNS 账户参数无效", nil) return } if !s.authorizeProjectPermissions(c, request.ProjectID, domain.PermissionDNSManage) { return } item := domain.DNSAccount{ ID: domain.NewID(), Name: strings.TrimSpace(request.Name), ProjectID: request.ProjectID, OwnerUserID: c.GetString(authenticatedActorKey), Provider: request.Provider, CredentialRef: "secret://dns/" + domain.NewID(), Status: domain.DNSAccountActive, VerificationState: domain.DNSAccountUnverified, Revision: 1, } if err := item.Validate(); err != nil { s.writeError(c, http.StatusBadRequest, "DNS 账户名称、项目或服务商无效", nil) return } if !s.storeManagedDNSCredential(c, item.Provider, item.CredentialRef, request.Credentials) { return } created, err := repo.CreateDNSAccount(c.Request.Context(), item.OwnerUserID, item) if err != nil { // 提交错误可能有歧义,不能猜测未提交并删除新秘密;后续引用回收统一处理。 s.writeDNSAccountError(c, err) return } c.JSON(http.StatusCreated, created) } func (s *Server) storeManagedDNSCredential(c *gin.Context, provider, reference string, credentials map[string]string) bool { descriptor, err := dnsprovider.GetDescriptor(provider) if err != nil || !descriptor.Available { s.writeError(c, http.StatusBadRequest, "DNS 服务商不可用", nil) return false } cleaned, err := dnsprovider.ValidateCredentials(provider, credentials) if err != nil { s.writeError(c, http.StatusBadRequest, "DNS 凭据字段无效或不完整", nil) return false } if s.secrets == nil { s.writeError(c, http.StatusServiceUnavailable, "凭据存储尚未就绪", nil) return false } if err := s.secrets.Put(c.Request.Context(), reference, cleaned); err != nil { s.writeError(c, http.StatusServiceUnavailable, "保存 DNS 凭据失败", nil) return false } return true } func (s *Server) getManagedDNSAccount(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) if err != nil { s.writeDNSAccountError(c, err) return } c.JSON(http.StatusOK, item) } func (s *Server) renameManagedDNSAccount(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } var request struct { Name string `json:"name"` Revision int64 `json:"revision"` } if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 { s.writeError(c, http.StatusBadRequest, "DNS 账户名称或版本无效", nil) return } // 先核对可见范围,避免借参数错误探测其他项目账户。 item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) if err != nil { s.writeDNSAccountError(c, err) return } item.Name, item.CredentialRef = strings.TrimSpace(request.Name), "secret://dns/validation" if err := item.Validate(); err != nil { s.writeError(c, http.StatusBadRequest, "DNS 账户名称无效", nil) return } updated, err := repo.RenameDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name) if err != nil { s.writeDNSAccountError(c, err) return } c.JSON(http.StatusOK, updated) } func (s *Server) setManagedDNSAccountStatus(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } var request struct { Enabled *bool `json:"enabled"` Revision int64 `json:"revision"` } if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Enabled == nil || request.Revision < 1 { s.writeError(c, http.StatusBadRequest, "DNS 账户状态或版本无效", nil) return } item, err := repo.SetDNSAccountEnabled(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, *request.Enabled) if err != nil { s.writeDNSAccountError(c, err) return } c.JSON(http.StatusOK, item) } func (s *Server) rotateManagedDNSAccountCredential(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } var request struct { Revision int64 `json:"revision"` Credentials map[string]string `json:"credentials"` } if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 { s.writeError(c, http.StatusBadRequest, "DNS 凭据或版本无效", nil) return } item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) if err != nil { s.writeDNSAccountError(c, err) return } if !s.authorizeProjectPermissions(c, item.ProjectID, domain.PermissionDNSManage) { return } if item.Revision != request.Revision { s.writeDNSAccountError(c, repository.ErrDNSAccountConflict) return } reference := "secret://dns/" + domain.NewID() if !s.storeManagedDNSCredential(c, item.Provider, reference, request.Credentials) { return } item, err = repo.RotateDNSAccountCredential(c.Request.Context(), c.GetString(authenticatedActorKey), item.ID, request.Revision, reference) if err != nil { s.writeDNSAccountError(c, err) return } // 不删除旧凭据,仍有运行任务或旧配置引用时须由后续租约回收流程处理。 c.JSON(http.StatusOK, item) } func (s *Server) verifyManagedDNSAccount(c *gin.Context) { repo, ok := s.managedDNSRepository(c) if !ok { return } var request struct { Revision int64 `json:"revision"` TestDomain string `json:"test_domain"` } if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 || !domain.IsValidName(request.TestDomain) { s.writeError(c, http.StatusBadRequest, "DNS 验证域名或版本无效", nil) return } actor := c.GetString(authenticatedActorKey) item, err := repo.GetDNSAccountForVerification(c.Request.Context(), actor, c.Param("id")) if err != nil { s.writeDNSAccountError(c, err) return } if item.Revision != request.Revision { s.writeDNSAccountError(c, repository.ErrDNSAccountConflict) return } credentials, err := s.readDNSCredentials(c.Request.Context(), item.CredentialRef) if err != nil { s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 凭据失败", nil) return } verifier := s.dnsAccountVerifier if verifier == nil { verifier = dnsprovider.NewTester(dnsprovider.NewLegoFactory()) } ctx, cancel := context.WithTimeout(c.Request.Context(), dnsprovider.PermissionVerificationTimeoutFor(item.Provider)) defer cancel() _, verificationErr := verifier.Verify(ctx, item.Provider, request.TestDomain, credentials) if verificationErr == nil { verificationErr = ctx.Err() } // 在线调用可能超时或断连,短独立上下文只用于记录实际结果,仍重新核对权限与版本。 recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 5*time.Second) defer recordCancel() updated, err := repo.RecordDNSAccountVerification(recordCtx, actor, item.ID, item.Revision, verificationErr == nil) if err != nil { s.writeDNSAccountError(c, err) return } if verificationErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "DNS 在线验证失败,请检查凭据、测试域名和权限", "account": updated}) return } c.JSON(http.StatusOK, gin.H{"verified": true, "account": updated}) }