308 lines
10 KiB
Go
308 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
|
)
|
|
|
|
func (s *Server) issueDomainAsTask(c *gin.Context) {
|
|
if s.issueCertificate == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
|
|
return
|
|
}
|
|
if s.repository == nil || s.tasks == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "证书任务接口尚未配置", errors.New("域名仓储或任务仓储未配置"))
|
|
return
|
|
}
|
|
domainID := strings.TrimSpace(c.Param("id"))
|
|
if _, err := s.repository.GetByID(c.Request.Context(), domainID); err != nil {
|
|
if repository.IsNotFound(err) {
|
|
s.writeError(c, http.StatusNotFound, "域名不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
|
|
return
|
|
}
|
|
s.issueTaskMu.Lock()
|
|
idempotencyKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
|
if idempotencyKey != "" {
|
|
if !validTaskIdempotencyKey(idempotencyKey) {
|
|
s.issueTaskMu.Unlock()
|
|
s.writeError(c, http.StatusBadRequest, "幂等键格式无效", errors.New("幂等键长度或字符无效"))
|
|
return
|
|
}
|
|
if existing, err := s.tasks.FindTaskByIdempotencyKey(c.Request.Context(), domainID, idempotencyKey); err == nil {
|
|
status := http.StatusOK
|
|
if existing.Status == "queued" || existing.Status == "running" {
|
|
status = http.StatusConflict
|
|
}
|
|
c.JSON(status, gin.H{"task": existing, "replayed": true})
|
|
s.issueTaskMu.Unlock()
|
|
return
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
s.issueTaskMu.Unlock()
|
|
s.writeError(c, http.StatusInternalServerError, "检查幂等任务失败", err)
|
|
return
|
|
}
|
|
}
|
|
if active, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), domainID, "certificate_issue"); err == nil {
|
|
s.issueTaskMu.Unlock()
|
|
c.JSON(http.StatusConflict, gin.H{"error": "该域名已有正在执行的证书任务", "task": active})
|
|
return
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
s.issueTaskMu.Unlock()
|
|
s.writeError(c, http.StatusInternalServerError, "检查正在执行的任务失败", err)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
task := repository.Task{
|
|
ID: domain.NewID(), DomainID: domainID, TaskType: "certificate_issue", Status: "queued", Stage: "queued",
|
|
Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
|
|
s.issueTaskMu.Unlock()
|
|
s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err)
|
|
return
|
|
}
|
|
s.issueTaskMu.Unlock()
|
|
attempt := repository.TaskAttempt{
|
|
ID: domain.NewID(), TaskID: task.ID, AttemptNumber: 1, Stage: "certificate_issue", Status: "running",
|
|
Message: "开始申请证书", StartedAt: &now, CreatedAt: now,
|
|
}
|
|
if err := s.tasks.CreateAttempt(c.Request.Context(), attempt); err != nil {
|
|
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "certificate_issue", "创建任务尝试失败", &now, &now)
|
|
s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err)
|
|
return
|
|
}
|
|
if err := s.tasks.UpdateTask(c.Request.Context(), task.ID, "running", "certificate_issue", "正在申请证书并部署", &now, nil); err != nil {
|
|
finished := time.Now().UTC()
|
|
failureMessage := "任务启动失败"
|
|
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "certificate_issue", failureMessage, &now, &finished)
|
|
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished)
|
|
if s.audit != nil {
|
|
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
|
|
Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID,
|
|
Status: "error", Message: failureMessage,
|
|
})
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "更新任务状态失败", err)
|
|
return
|
|
}
|
|
executionCtx, cancel := context.WithTimeout(c.Request.Context(), certificateTaskTimeout)
|
|
s.registerTaskCancel(task.ID, cancel)
|
|
defer func() {
|
|
cancel()
|
|
s.unregisterTaskCancel(task.ID)
|
|
}()
|
|
certificateResult, deploymentReport, issueErr := s.issueCertificate(executionCtx, domainID)
|
|
deploymentReport = sanitizeDeploymentReport(deploymentReport)
|
|
finished := time.Now().UTC()
|
|
status := "success"
|
|
message := "证书申请和部署完成"
|
|
cancelled := false
|
|
if issueErr != nil {
|
|
status = "failed"
|
|
message = certificateTaskErrorMessage(issueErr)
|
|
if s.logger != nil {
|
|
s.logger.Error("证书任务执行失败", "task_id", task.ID, "domain_id", domainID, "error", issueErr)
|
|
}
|
|
if errors.Is(issueErr, context.Canceled) {
|
|
status = "cancelled"
|
|
}
|
|
}
|
|
if persisted, err := s.tasks.GetTask(context.Background(), task.ID); err == nil && persisted.Status == "cancelled" {
|
|
status = "cancelled"
|
|
message = persisted.Message
|
|
cancelled = true
|
|
if issueErr == nil {
|
|
issueErr = errors.New(message)
|
|
}
|
|
}
|
|
if !cancelled {
|
|
_ = s.tasks.UpdateTask(context.Background(), task.ID, status, "certificate_issue", message, &now, &finished)
|
|
}
|
|
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, status, message, &finished)
|
|
task.Status = status
|
|
task.Stage = "certificate_issue"
|
|
task.Message = message
|
|
task.StartedAt = &now
|
|
task.FinishedAt = &finished
|
|
task.UpdatedAt = finished
|
|
if s.audit != nil {
|
|
auditStatus := status
|
|
if issueErr != nil && status != "cancelled" {
|
|
auditStatus = "error"
|
|
}
|
|
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
|
|
Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID, Status: auditStatus, Message: message,
|
|
})
|
|
}
|
|
response := gin.H{
|
|
"task": task, "certificate": certificateResult, "deployment": deploymentReport,
|
|
"task_status": status, "finished_at": finished,
|
|
}
|
|
if issueErr != nil {
|
|
response["error"] = message
|
|
responseStatus := http.StatusBadGateway
|
|
if status == "cancelled" {
|
|
responseStatus = http.StatusConflict
|
|
} else if errors.Is(issueErr, context.DeadlineExceeded) {
|
|
responseStatus = http.StatusGatewayTimeout
|
|
}
|
|
c.JSON(responseStatus, response)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
const certificateTaskTimeout = 30 * time.Minute
|
|
|
|
func (s *Server) registerTaskCancel(taskID string, cancel context.CancelFunc) {
|
|
s.taskMu.Lock()
|
|
defer s.taskMu.Unlock()
|
|
if s.taskCancels == nil {
|
|
s.taskCancels = make(map[string]context.CancelFunc)
|
|
}
|
|
s.taskCancels[taskID] = cancel
|
|
}
|
|
|
|
func (s *Server) unregisterTaskCancel(taskID string) {
|
|
s.taskMu.Lock()
|
|
defer s.taskMu.Unlock()
|
|
delete(s.taskCancels, taskID)
|
|
}
|
|
|
|
func (s *Server) cancelTask(c *gin.Context) {
|
|
if s.tasks == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置"))
|
|
return
|
|
}
|
|
taskID := strings.TrimSpace(c.Param("id"))
|
|
item, err := s.tasks.GetTask(c.Request.Context(), taskID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.writeError(c, http.StatusNotFound, "任务不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取任务失败", err)
|
|
return
|
|
}
|
|
if item.Status != "queued" && item.Status != "running" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "任务当前无法取消", "task": item})
|
|
return
|
|
}
|
|
if item.TaskType == "management_renewal" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "管理证书任务由续期调度控制,不能单独修改取消状态", "task": item})
|
|
return
|
|
}
|
|
finished := time.Now().UTC()
|
|
if err := s.tasks.CancelTask(c.Request.Context(), taskID, "任务已由用户取消", &finished); err != nil {
|
|
s.writeError(c, http.StatusConflict, "取消任务失败", err)
|
|
return
|
|
}
|
|
s.taskMu.Lock()
|
|
cancel := s.taskCancels[taskID]
|
|
s.taskMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
item.Status = "cancelled"
|
|
item.Message = "任务已由用户取消"
|
|
item.FinishedAt = &finished
|
|
item.UpdatedAt = finished
|
|
if s.audit != nil {
|
|
_ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{
|
|
Action: "task_cancel", ResourceType: "task", ResourceID: taskID, Status: "success", Message: item.Message,
|
|
})
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"task": item})
|
|
}
|
|
|
|
func validTaskIdempotencyKey(value string) bool {
|
|
if len(value) < 8 || len(value) > 128 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' && char != '.' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func (s *Server) listTasks(c *gin.Context) {
|
|
if s.tasks == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置"))
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
items, err := s.tasks.ListTasks(c.Request.Context(), c.Query("domain_id"), limit)
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询任务失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) getTask(c *gin.Context) {
|
|
if s.tasks == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置"))
|
|
return
|
|
}
|
|
item, err := s.tasks.GetTask(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.writeError(c, http.StatusNotFound, "任务不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取任务失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) listTaskAttempts(c *gin.Context) {
|
|
if s.tasks == nil {
|
|
s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置"))
|
|
return
|
|
}
|
|
if _, err := s.tasks.GetTask(c.Request.Context(), c.Param("id")); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.writeError(c, http.StatusNotFound, "任务不存在", err)
|
|
return
|
|
}
|
|
s.writeError(c, http.StatusInternalServerError, "读取任务失败", err)
|
|
return
|
|
}
|
|
items, err := s.tasks.ListAttempts(c.Request.Context(), c.Param("id"))
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询任务尝试失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) listAuditLogs(c *gin.Context) {
|
|
reader, ok := s.audit.(repository.AuditReader)
|
|
if !ok {
|
|
s.writeError(c, http.StatusNotImplemented, "审计日志查询接口尚未配置", errors.New("审计日志读取服务未配置"))
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
items, err := reader.ListAuditLogs(c.Request.Context(), limit)
|
|
if err != nil {
|
|
s.writeError(c, http.StatusInternalServerError, "查询审计日志失败", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
|
}
|