保存证书校验后台任务及独立结果的修复前基线
校验结果事务持久化、三库迁移及前端详情接线已实现,完整回归仍在执行。审查发现前端校验幂等键包含路径分隔符,下一批补回归并修复;本提交不是正式发布或生产验收结论。
This commit is contained in:
parent
fce5a84dd4
commit
24a9683c43
@ -215,6 +215,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
ValidateCertificate: func(ctx context.Context, domainID string) (certificate.IssueResult, certificate.ValidationResult, error) {
|
||||
return trackedWorkflow.Validate(ctx, domainID)
|
||||
},
|
||||
ValidateCertificateTask: workflow.ValidateCurrent,
|
||||
RunRenewal: func(ctx context.Context) (certificate.RenewalReport, error) {
|
||||
return renewal.RunOnce(ctx)
|
||||
},
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
//go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql
|
||||
//go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql
|
||||
//go:embed migrations/sqlite/0020_task_deployment_reports.sql migrations/mysql/0020_task_deployment_reports.sql migrations/postgres/0020_task_deployment_reports.sql
|
||||
//go:embed migrations/sqlite/0021_task_validation_results.sql migrations/mysql/0021_task_validation_results.sql migrations/postgres/0021_task_validation_results.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
@ -45,7 +46,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const latestMigrationVersion = 20
|
||||
const latestMigrationVersion = 21
|
||||
|
||||
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||
@ -85,7 +86,7 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
|
||||
return applySQLiteTaskMigration(ctx, db, statements, version)
|
||||
}
|
||||
if driver == "mysql" {
|
||||
if (version >= 15 && version <= 17) || version == 20 {
|
||||
if (version >= 15 && version <= 17) || version == 20 || version == 21 {
|
||||
return applyMySQLDeploymentMigration(ctx, db, version, statements)
|
||||
}
|
||||
if version == 14 {
|
||||
@ -240,6 +241,8 @@ func migrationName(version int) string {
|
||||
return "password_auth_only"
|
||||
case 20:
|
||||
return "task_deployment_reports"
|
||||
case 21:
|
||||
return "task_validation_results"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE TABLE task_validation_results (
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
attempt_id VARCHAR(36) NOT NULL,
|
||||
attempt_number INT NOT NULL,
|
||||
validation_json MEDIUMTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (task_id, attempt_id)
|
||||
);
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE TABLE task_validation_results (
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
validation_json TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (task_id, attempt_id)
|
||||
);
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE TABLE task_validation_results (
|
||||
task_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
validation_json TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (task_id, attempt_id)
|
||||
);
|
||||
@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
|
||||
for _, version := range []int{15, 16, 17, 20} {
|
||||
for _, version := range []int{15, 16, 17, 20, 21} {
|
||||
statements := mysqlDeploymentStatements(t, version)
|
||||
shape, err := parseMySQLDeploymentShape(statements[0])
|
||||
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 {
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMySQLRealTaskValidationMigrationRecovery(t *testing.T) {
|
||||
f := startRealMySQL(t)
|
||||
baseline := f.database(t)
|
||||
if err := Migrate(t.Context(), baseline.DB, "mysql"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedSchema := mysqlSchemaSnapshot(t, baseline.DB)
|
||||
const validation = `{"domains":["example.test"],"fingerprint_sha256":"retained-validation"}`
|
||||
for _, boundary := range []string{"before_create", "after_create", "with_validation"} {
|
||||
t.Run(boundary, func(t *testing.T) {
|
||||
db := f.database(t)
|
||||
mysqlPrepareVersion(t, db.DB, 20)
|
||||
if boundary != "before_create" {
|
||||
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 21)[0])
|
||||
}
|
||||
if boundary == "with_validation" {
|
||||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO task_validation_results
|
||||
(task_id, attempt_id, attempt_number, validation_json) VALUES ('validation-task', 'validation-attempt', 1, ?)`, validation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mysqlAssertUpgrade(t, db.DB, expectedSchema)
|
||||
if boundary == "with_validation" {
|
||||
var stored string
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT validation_json FROM task_validation_results
|
||||
WHERE task_id = 'validation-task' AND attempt_id = 'validation-attempt'`).Scan(&stored); err != nil || stored != validation {
|
||||
t.Fatalf("恢复迁移后校验详情被改写:%v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, invalid := range []struct{ name, statement string }{
|
||||
{"short_result", "ALTER TABLE task_validation_results MODIFY COLUMN validation_json TEXT NOT NULL"},
|
||||
{"wrong_primary_order", "ALTER TABLE task_validation_results DROP PRIMARY KEY, ADD PRIMARY KEY (attempt_id, task_id)"},
|
||||
{"nullable_attempt", "ALTER TABLE task_validation_results MODIFY COLUMN attempt_number INT NULL"},
|
||||
{"wrong_engine", "ALTER TABLE task_validation_results ENGINE=MyISAM"},
|
||||
{"extra_column", "ALTER TABLE task_validation_results ADD COLUMN unexpected INT NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
t.Run(invalid.name, func(t *testing.T) {
|
||||
db := f.database(t)
|
||||
mysqlPrepareVersion(t, db.DB, 20)
|
||||
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 21)[0])
|
||||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO task_validation_results
|
||||
(task_id, attempt_id, attempt_number, validation_json) VALUES ('kept-task', 'kept-attempt', 1, ?)`, validation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mysqlDeploymentExec(t, db.DB, invalid.statement)
|
||||
before := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB))
|
||||
assertMySQLDeploymentMigrationRejected(t, db.DB, 20)
|
||||
if after := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB)); after != before {
|
||||
t.Fatal("校验结果表冲突不得被自动覆盖或修复")
|
||||
}
|
||||
var stored string
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT validation_json FROM task_validation_results
|
||||
WHERE task_id = 'kept-task' AND attempt_id = 'kept-attempt'`).Scan(&stored); err != nil || stored != validation {
|
||||
t.Fatalf("拒绝迁移时不得修改已有校验结果:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -94,6 +94,10 @@ func (s *Server) listCertificateVersions(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) validateCertificate(c *gin.Context) {
|
||||
if s.validateCertificateTask != nil {
|
||||
s.submitDomainTask(c, "certificate_validate")
|
||||
return
|
||||
}
|
||||
if !s.authorizeVisibleDomainPermission(c, domain.PermissionPrivateKeyRead) {
|
||||
return
|
||||
}
|
||||
|
||||
146
backend/internal/httpapi/certificate_validation_async_test.go
Normal file
146
backend/internal/httpapi/certificate_validation_async_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestAsyncValidationLifecycle(t *testing.T) {
|
||||
for _, scenario := range []string{"success", "failure", "panic", "cancel", "shutdown", "replay", "stopped", "full"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
var calls atomic.Int32
|
||||
server.validateCertificateTask = func(ctx context.Context, id string) (certificate.IssueResult, certificate.ValidationResult, error) {
|
||||
calls.Add(1)
|
||||
if ctx.Err() != nil || id != "domain-1" {
|
||||
t.Error("后台校验沿用已断开的请求或错误域名")
|
||||
}
|
||||
if scenario == "panic" {
|
||||
panic("private-validation-detail")
|
||||
}
|
||||
if scenario == "failure" {
|
||||
return certificate.IssueResult{}, certificate.ValidationResult{}, errors.New("private-validation-detail")
|
||||
}
|
||||
return certificate.IssueResult{Saved: certificate.SavedResource{PrivateKeyPath: "/private-path/key.pem"}},
|
||||
certificate.ValidationResult{Domains: []string{"example.com"}, FingerprintSHA256: "validated-fingerprint", ChainLength: 2}, nil
|
||||
}
|
||||
server.asyncTasks = make(map[string]bool)
|
||||
server.taskSlots = make(chan struct{}, 1)
|
||||
server.taskSlots <- struct{}{}
|
||||
if scenario == "stopped" {
|
||||
server.stopTaskWorkers()
|
||||
}
|
||||
if scenario == "full" {
|
||||
for index := range 32 {
|
||||
server.asyncTasks[string(rune('a'+index))] = true
|
||||
}
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/validate", nil)
|
||||
request.Header.Set("Idempotency-Key", "validation-request")
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
serveAdminHTTP(server, response, request.WithContext(ctx))
|
||||
cancel()
|
||||
if scenario == "stopped" || scenario == "full" {
|
||||
if response.Code != http.StatusServiceUnavailable || calls.Load() != 0 {
|
||||
t.Fatalf("服务拒绝后仍执行校验:%d %d", response.Code, calls.Load())
|
||||
}
|
||||
return
|
||||
}
|
||||
task := submittedTask(t, response)
|
||||
if task.TaskType != "certificate_validate" || calls.Load() != 0 {
|
||||
t.Fatalf("校验没有正确排队:%+v %d", task, calls.Load())
|
||||
}
|
||||
if scenario == "replay" {
|
||||
replayed := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, replayed, request)
|
||||
if replayed.Code != http.StatusConflict || !strings.Contains(replayed.Body.String(), task.ID) || !strings.Contains(replayed.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("校验重复请求没有复用原任务:%d %s", replayed.Code, replayed.Body.String())
|
||||
}
|
||||
}
|
||||
switch scenario {
|
||||
case "cancel":
|
||||
cancelled := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, cancelled, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
|
||||
if cancelled.Code != http.StatusAccepted {
|
||||
t.Fatalf("取消排队校验失败:%d", cancelled.Code)
|
||||
}
|
||||
case "shutdown":
|
||||
server.stopTaskWorkers()
|
||||
default:
|
||||
<-server.taskSlots
|
||||
}
|
||||
waitTaskWorkersForTest(t, server)
|
||||
want, wantCalls := "success", int32(1)
|
||||
if scenario == "failure" || scenario == "panic" {
|
||||
want = "failed"
|
||||
}
|
||||
if scenario == "cancel" || scenario == "shutdown" {
|
||||
want, wantCalls = "cancelled", 0
|
||||
}
|
||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||
if err != nil || stored.Status != want || calls.Load() != wantCalls || strings.Contains(stored.Message, "private-") {
|
||||
t.Fatalf("校验终态或调用次数错误:%+v %v %d", stored, err, calls.Load())
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, result, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID+"/validation-results", nil))
|
||||
var body struct {
|
||||
Items []repository.TaskValidationResult `json:"items"`
|
||||
}
|
||||
if result.Code != http.StatusOK || json.Unmarshal(result.Body.Bytes(), &body) != nil || strings.Contains(result.Body.String(), "private-") {
|
||||
t.Fatalf("读取校验结果失败或泄露内部内容:%d %s", result.Code, result.Body.String())
|
||||
}
|
||||
if want == "success" {
|
||||
if len(body.Items) != 1 || body.Items[0].Validation.FingerprintSHA256 != "validated-fingerprint" {
|
||||
t.Fatalf("后台校验详情丢失:%+v", body)
|
||||
}
|
||||
} else if len(body.Items) != 0 {
|
||||
t.Fatal("未通过校验却保存通过结果")
|
||||
}
|
||||
reports, err := tasks.ListTaskDeploymentReports(t.Context(), task.ID)
|
||||
if err != nil || len(reports) != 0 {
|
||||
t.Fatalf("校验生成伪部署报告:%+v %v", reports, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncValidationRequiresOnlyExistingPrivateKeyPermission(t *testing.T) {
|
||||
for _, allowed := range []bool{true, false} {
|
||||
server, _, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
server.validateCertificateTask = func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) {
|
||||
return certificate.IssueResult{}, certificate.ValidationResult{Domains: []string{"example.com"}}, nil
|
||||
}
|
||||
server.projectMembers = permissionProjectMemberRepository{
|
||||
ProjectMemberRepository: server.projectMembers,
|
||||
allow: func(permission domain.ProjectPermission) error {
|
||||
if allowed && permission == domain.PermissionPrivateKeyRead {
|
||||
return nil
|
||||
}
|
||||
return repository.ErrProjectAccessDenied
|
||||
},
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/validate", nil))
|
||||
want := http.StatusForbidden
|
||||
if allowed {
|
||||
want = http.StatusAccepted
|
||||
}
|
||||
if response.Code != want {
|
||||
t.Fatalf("校验改变了原有权限要求:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
waitTaskWorkersForTest(t, server)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@ -67,6 +67,8 @@ type Dependencies struct {
|
||||
DeployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||
RestoreCertificateVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
||||
ValidateCertificate func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
||||
// ValidateCertificateTask 直接校验当前证书,不重复创建任务。
|
||||
ValidateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
||||
|
||||
RunRenewal func(context.Context) (certificate.RenewalReport, error)
|
||||
UpdateRenewalPolicy func(certificate.RenewalPolicy)
|
||||
@ -106,6 +108,7 @@ type Server struct {
|
||||
deployCertificateTask func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
|
||||
restoreVersion func(context.Context, string, string) (certificate.IssueResult, error)
|
||||
validateCertificateWorkflow func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
||||
validateCertificateTask func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error)
|
||||
|
||||
runRenewal func(context.Context) (certificate.RenewalReport, error)
|
||||
updateRenewalPolicy func(certificate.RenewalPolicy)
|
||||
@ -175,6 +178,7 @@ func New(deps Dependencies) *Server {
|
||||
deployCertificateTask: deps.DeployCertificateTask,
|
||||
restoreVersion: deps.RestoreCertificateVersion,
|
||||
validateCertificateWorkflow: deps.ValidateCertificate,
|
||||
validateCertificateTask: deps.ValidateCertificateTask,
|
||||
runRenewal: deps.RunRenewal,
|
||||
updateRenewalPolicy: deps.UpdateRenewalPolicy,
|
||||
currentCertificate: deps.CurrentCertificate,
|
||||
@ -231,6 +235,7 @@ func (s *Server) registerRoutes() {
|
||||
api.POST("/tasks/:id/retry", s.retryTask)
|
||||
api.GET("/tasks/:id/attempts", s.listTaskAttempts)
|
||||
api.GET("/tasks/:id/deployment-results", s.listTaskDeploymentResults)
|
||||
api.GET("/tasks/:id/validation-results", s.listTaskValidationResults)
|
||||
|
||||
adminAPI.GET("/settings", s.getSettings)
|
||||
adminAPI.PUT("/settings", s.requireSuperAdminSystemConfig, s.updateSettings)
|
||||
|
||||
@ -38,7 +38,7 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
|
||||
prepared.cleanup()
|
||||
}
|
||||
}()
|
||||
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) {
|
||||
if (taskType == "certificate_issue" && s.issueCertificate == nil) || (taskType == "certificate_deploy" && s.deployCertificateTask == nil) || (taskType == "certificate_validate" && s.validateCertificateTask == nil) {
|
||||
s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置"))
|
||||
return
|
||||
}
|
||||
@ -75,9 +75,17 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, domain.PermissionDeploymentExecute); err != nil {
|
||||
permission := domain.PermissionDeploymentExecute
|
||||
if taskType == "certificate_validate" {
|
||||
permission = domain.PermissionPrivateKeyRead
|
||||
}
|
||||
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ResourceScope.Normalize().ProjectID, permission); err != nil {
|
||||
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||
s.writeError(c, http.StatusForbidden, "无权执行此项目的证书部署任务", nil)
|
||||
message := "无权执行此项目的证书部署任务"
|
||||
if taskType == "certificate_validate" {
|
||||
message = "无权读取此项目的私钥进行证书校验"
|
||||
}
|
||||
s.writeError(c, http.StatusForbidden, message, nil)
|
||||
return
|
||||
}
|
||||
s.writeError(c, http.StatusInternalServerError, "检查证书部署权限失败", err)
|
||||
@ -176,6 +184,10 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
|
||||
s.executeCertificateDeployTask(c, task, attempt, taskType)
|
||||
return
|
||||
}
|
||||
if taskType == "certificate_validate" {
|
||||
s.executeCertificateValidationTask(c, task, attempt)
|
||||
return
|
||||
}
|
||||
s.executeCertificateIssueTask(c, task, attempt, taskType)
|
||||
}
|
||||
|
||||
@ -192,6 +204,19 @@ func (s *Server) executeCertificateDeployTask(c *gin.Context, task repository.Ta
|
||||
}
|
||||
|
||||
func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string) {
|
||||
s.runCertificateOperation(ctx, task, attempt, auditAction, func(ctx context.Context, id string) (certificateTaskResult, error) {
|
||||
result, report, err := operation(ctx, id)
|
||||
return certificateTaskResult{Certificate: result, Deployment: &report}, err
|
||||
}, stage, runningMessage, successMessage)
|
||||
}
|
||||
|
||||
type certificateTaskResult struct {
|
||||
Certificate certificate.IssueResult
|
||||
Deployment *deployment.Report
|
||||
Validation *certificate.ValidationResult
|
||||
}
|
||||
|
||||
func (s *Server) runCertificateOperation(ctx context.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string, operation func(context.Context, string) (certificateTaskResult, error), stage, runningMessage, successMessage string) {
|
||||
executionCtx := deployment.WithTransactionContext(ctx, deployment.TransactionContext{
|
||||
TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber,
|
||||
})
|
||||
@ -241,7 +266,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
||||
if s.finishCancelledTask(task, attempt, executionCtx) {
|
||||
return
|
||||
}
|
||||
certificateResult, deploymentReport, issueErr := operation(executionCtx, task.DomainID)
|
||||
result, issueErr := operation(executionCtx, task.DomainID)
|
||||
certificateResult := result.Certificate
|
||||
var deploymentReport deployment.Report
|
||||
if result.Deployment != nil {
|
||||
deploymentReport = *result.Deployment
|
||||
}
|
||||
if task.TaskType == "certificate_import" {
|
||||
deploymentReport.CertificateImported = issueErr == nil || (certificateResult.DomainID == task.DomainID &&
|
||||
certificateResult.Saved.CertificatePath != "" && certificateResult.Saved.FullChainPath != "" && certificateResult.Saved.PrivateKeyPath != "")
|
||||
@ -275,6 +305,15 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
||||
message = "证书已导入,但部署未完成。请检查部署结果后重试部署,无需重新导入。"
|
||||
}
|
||||
}
|
||||
if task.TaskType == "certificate_validate" && issueErr != nil {
|
||||
message = "证书校验失败,请检查当前证书、私钥匹配及证书链"
|
||||
if status == "cancelled" {
|
||||
message = "证书校验已取消"
|
||||
}
|
||||
if errors.Is(issueErr, context.DeadlineExceeded) {
|
||||
message = "证书校验超时"
|
||||
}
|
||||
}
|
||||
s.taskMu.Lock()
|
||||
defer s.taskMu.Unlock()
|
||||
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
|
||||
@ -287,13 +326,23 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
||||
} else if status == "cancelled" {
|
||||
status = "cancelled"
|
||||
message = "任务已停止;已执行操作是否回滚请查看部署结果"
|
||||
if task.TaskType == "certificate_validate" {
|
||||
message = "证书校验已停止,未保存通过结果"
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
|
||||
completion := repository.TaskCompletion{
|
||||
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage,
|
||||
Message: message, StartedAt: &now, FinishedAt: finished,
|
||||
Report: &deploymentReport, AuditAction: auditAction,
|
||||
}); err != nil && s.logger != nil {
|
||||
AuditAction: auditAction,
|
||||
}
|
||||
if result.Deployment != nil {
|
||||
completion.Report = &deploymentReport
|
||||
}
|
||||
if status == "success" {
|
||||
completion.Validation = result.Validation
|
||||
}
|
||||
if err := s.tasks.FinishTaskAttempt(finishCtx, completion); err != nil && s.logger != nil {
|
||||
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep
|
||||
case "certificate_deploy":
|
||||
case "certificate_import":
|
||||
permissions = append(permissions, domain.PermissionCertificateManage)
|
||||
case "certificate_validate":
|
||||
permissions = []domain.ProjectPermission{domain.PermissionPrivateKeyRead}
|
||||
default:
|
||||
return repository.ErrProjectAccessDenied
|
||||
}
|
||||
|
||||
@ -17,11 +17,11 @@ import (
|
||||
)
|
||||
|
||||
func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import"} {
|
||||
for _, taskType := range []string{"certificate_issue", "certificate_deploy", "certificate_import", "certificate_validate"} {
|
||||
for _, scenario := range []string{
|
||||
"unchanged", "disabled_user", "removed_member", "read_only_member",
|
||||
"moved_domain", "deleted_domain", "deployment_denied", "certificate_denied",
|
||||
"dns_denied", "authorization_error", "missing_authorizer", "missing_reader",
|
||||
"dns_denied", "private_key_denied", "authorization_error", "missing_authorizer", "missing_reader",
|
||||
} {
|
||||
t.Run(taskType+"/"+scenario, func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
@ -32,6 +32,10 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
server, tasks, audit, cleanup := newTaskAPITestServer(t, operation)
|
||||
defer cleanup()
|
||||
server.deployCertificateTask = operation
|
||||
server.validateCertificateTask = func(context.Context, string) (certificate.IssueResult, certificate.ValidationResult, error) {
|
||||
calls.Add(1)
|
||||
return certificate.IssueResult{}, certificate.ValidationResult{Domains: []string{"example.com"}}, nil
|
||||
}
|
||||
server.importCertificateTask = func(ctx context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
|
||||
return operation(ctx, request.DomainID)
|
||||
}
|
||||
@ -45,6 +49,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
if taskType == "certificate_import" {
|
||||
path = "/api/v1/domains/domain-1/certificate/import"
|
||||
}
|
||||
if taskType == "certificate_validate" {
|
||||
path = "/api/v1/domains/domain-1/certificate/validate"
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path,
|
||||
strings.NewReader(`{"certificate_path":"/managed/cert.pem","private_key_path":"/managed/key.pem"}`)))
|
||||
@ -78,7 +85,7 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
server.projectMembers = nil
|
||||
case "missing_reader":
|
||||
server.projectResources = nil
|
||||
case "deployment_denied", "certificate_denied", "dns_denied", "authorization_error":
|
||||
case "deployment_denied", "certificate_denied", "dns_denied", "private_key_denied", "authorization_error":
|
||||
server.projectMembers = permissionProjectMemberRepository{
|
||||
ProjectMemberRepository: server.projectMembers,
|
||||
allow: func(permission domain.ProjectPermission) error {
|
||||
@ -87,7 +94,8 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
}
|
||||
if scenario == "deployment_denied" && permission == domain.PermissionDeploymentExecute ||
|
||||
scenario == "certificate_denied" && permission == domain.PermissionCertificateManage ||
|
||||
scenario == "dns_denied" && permission == domain.PermissionDNSCredentialUse {
|
||||
scenario == "dns_denied" && permission == domain.PermissionDNSCredentialUse ||
|
||||
scenario == "private_key_denied" && permission == domain.PermissionPrivateKeyRead {
|
||||
return repository.ErrProjectAccessDenied
|
||||
}
|
||||
return nil
|
||||
@ -102,6 +110,9 @@ func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) {
|
||||
<-server.taskSlots
|
||||
waitTaskWorkersForTest(t, server)
|
||||
want := "failed"
|
||||
if taskType == "certificate_validate" && (scenario == "deployment_denied" || scenario == "certificate_denied" || scenario == "dns_denied") || taskType != "certificate_validate" && scenario == "private_key_denied" {
|
||||
want = "success"
|
||||
}
|
||||
if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") || taskType == "certificate_import" && scenario == "dns_denied" {
|
||||
want = "success"
|
||||
}
|
||||
|
||||
@ -16,6 +16,12 @@ import (
|
||||
// 持久化任务与尝试后才调用此入口;后台执行不得持有请求或响应对象。
|
||||
// 进程中断由启动恢复标记失败,不自动重放可能已经签发或部署的外部操作。
|
||||
func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, action string, operation func(context.Context, string) (certificate.IssueResult, deployment.Report, error), stage, runningMessage, successMessage string, cleanup func()) bool {
|
||||
return s.enqueueTaskWorker(c, task, attempt, func(ctx context.Context) {
|
||||
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
||||
}, cleanup)
|
||||
}
|
||||
|
||||
func (s *Server) enqueueTaskWorker(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, execute func(context.Context), cleanup func()) bool {
|
||||
s.taskMu.Lock()
|
||||
if s.taskWorkersStopped || len(s.asyncTasks) >= 32 {
|
||||
s.taskMu.Unlock()
|
||||
@ -44,7 +50,11 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", "任务执行异常中断,请检查部署恢复状态后重试")
|
||||
message := "任务执行异常中断,请检查部署恢复状态后重试"
|
||||
if task.TaskType == "certificate_validate" {
|
||||
message = "证书校验异常中断,未保存通过结果"
|
||||
}
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", message)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
@ -54,7 +64,7 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
|
||||
s.finishTaskContext(task, attempt, ctx.Err())
|
||||
return
|
||||
}
|
||||
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage)
|
||||
execute(ctx)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
53
backend/internal/httpapi/task_validation.go
Normal file
53
backend/internal/httpapi/task_validation.go
Normal file
@ -0,0 +1,53 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func (s *Server) executeCertificateValidationTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt) {
|
||||
if s.enqueueTaskWorker(c, task, attempt, func(ctx context.Context) {
|
||||
s.runCertificateOperation(ctx, task, attempt, task.TaskType, func(ctx context.Context, id string) (certificateTaskResult, error) {
|
||||
_, validation, err := s.validateCertificateTask(ctx, id)
|
||||
if err != nil {
|
||||
return certificateTaskResult{}, err
|
||||
}
|
||||
return certificateTaskResult{Validation: &validation}, nil
|
||||
}, "certificate_validate", "正在校验证书、私钥匹配及证书链", "证书校验通过")
|
||||
}, nil) {
|
||||
writeAcceptedCertificateTask(c, task)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listTaskValidationResults(c *gin.Context) {
|
||||
reader, ok := s.projectTaskReader(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
actorID := c.GetString(authenticatedActorKey)
|
||||
if _, err := reader.GetVisibleTask(c.Request.Context(), actorID, c.Param("id")); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||
s.writeError(c, http.StatusForbidden, "无权访问此任务", nil)
|
||||
return
|
||||
}
|
||||
s.writeError(c, http.StatusInternalServerError, "读取任务失败", err)
|
||||
return
|
||||
}
|
||||
validationReader, ok := s.tasks.(repository.TaskValidationReader)
|
||||
if !ok {
|
||||
s.writeError(c, http.StatusNotImplemented, "任务校验结果接口尚未配置", nil)
|
||||
return
|
||||
}
|
||||
items, err := validationReader.ListVisibleTaskValidations(c.Request.Context(), actorID, c.Param("id"))
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "查询任务校验结果失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
@ -21,6 +22,7 @@ type TaskCompletion struct {
|
||||
StartedAt *time.Time
|
||||
FinishedAt time.Time
|
||||
Report *deployment.Report
|
||||
Validation *certificate.ValidationResult
|
||||
AuditAction string
|
||||
Retry bool
|
||||
WithoutAttempt bool
|
||||
@ -41,7 +43,7 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta
|
||||
default:
|
||||
return errors.New("任务终态无效")
|
||||
}
|
||||
if completion.WithoutAttempt && (completion.AttemptID != "" || completion.Report != nil || completion.Retry || (completion.Status != "failed" && completion.Status != "cancelled")) {
|
||||
if completion.WithoutAttempt && (completion.AttemptID != "" || completion.Report != nil || completion.Validation != nil || completion.Retry || (completion.Status != "failed" && completion.Status != "cancelled")) {
|
||||
return errors.New("无活动尝试的收尾参数无效")
|
||||
}
|
||||
if completion.Retry && (completion.Status != "failed" || completion.AuditAction != "") {
|
||||
@ -82,6 +84,11 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta
|
||||
return err
|
||||
}
|
||||
}
|
||||
if completion.Validation != nil {
|
||||
if err := r.recordCompletionValidation(ctx, tx, completion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if completion.AuditAction != "" {
|
||||
if err := r.recordCompletionAudit(ctx, tx, completion); err != nil {
|
||||
return err
|
||||
|
||||
86
backend/internal/repository/task_validation.go
Normal file
86
backend/internal/repository/task_validation.go
Normal file
@ -0,0 +1,86 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
// TaskValidationResult 仅保存已校验的公开元数据,不包含证书路径和私钥。
|
||||
type TaskValidationResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AttemptID string `json:"attempt_id"`
|
||||
AttemptNumber int `json:"attempt_number"`
|
||||
Validation certificate.ValidationResult `json:"validation"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TaskValidationReader interface {
|
||||
ListVisibleTaskValidations(context.Context, string, string) ([]TaskValidationResult, error)
|
||||
}
|
||||
|
||||
func (r *SQLTaskRepository) recordCompletionValidation(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error {
|
||||
if completion.Report != nil || completion.Status != "success" || completion.Retry {
|
||||
return errors.New("校验元数据只能随独立校验任务成功提交")
|
||||
}
|
||||
var attemptNumber int
|
||||
var taskType string
|
||||
if err := tx.QueryRowContext(ctx, r.query(`SELECT attempts.attempt_number, tasks.task_type
|
||||
FROM task_attempts attempts JOIN tasks ON tasks.id = attempts.task_id
|
||||
WHERE attempts.id = ? AND tasks.id = ?`), completion.AttemptID, completion.TaskID).Scan(&attemptNumber, &taskType); err != nil {
|
||||
return fmt.Errorf("读取校验结果所属任务失败:%w", err)
|
||||
}
|
||||
if taskType != "certificate_validate" {
|
||||
return errors.New("校验结果不能保存到其他类型任务")
|
||||
}
|
||||
content, err := json.Marshal(completion.Validation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化校验结果失败:%w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_validation_results
|
||||
(task_id, attempt_id, attempt_number, validation_json, created_at) VALUES (?, ?, ?, ?, ?)`),
|
||||
completion.TaskID, completion.AttemptID, attemptNumber, string(content), completion.FinishedAt.UTC()); err != nil {
|
||||
return fmt.Errorf("保存校验结果失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLTaskRepository) ListVisibleTaskValidations(ctx context.Context, actorID, taskID string) ([]TaskValidationResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("数据库连接不能为空")
|
||||
}
|
||||
predicate, args := taskVisibilityPredicate("tasks", domain.PermissionTaskRead)
|
||||
args[0] = strings.TrimSpace(actorID)
|
||||
args = append(args, strings.TrimSpace(taskID))
|
||||
rows, err := r.db.QueryContext(ctx, r.query(`SELECT results.task_id, results.attempt_id, results.attempt_number,
|
||||
results.validation_json, results.created_at FROM task_validation_results results
|
||||
JOIN tasks ON tasks.id = results.task_id WHERE `+predicate+` AND results.task_id = ? ORDER BY results.attempt_number`), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务校验结果失败:%w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]TaskValidationResult, 0)
|
||||
for rows.Next() {
|
||||
var item TaskValidationResult
|
||||
var content string
|
||||
if err := rows.Scan(&item.TaskID, &item.AttemptID, &item.AttemptNumber, &content, &item.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("读取任务校验结果失败:%w", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &item.Validation); err != nil {
|
||||
return nil, fmt.Errorf("解析任务校验结果失败:%w", err)
|
||||
}
|
||||
item.CreatedAt = item.CreatedAt.UTC()
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取任务校验结果列表失败:%w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
110
backend/internal/repository/task_validation_test.go
Normal file
110
backend/internal/repository/task_validation_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
)
|
||||
|
||||
func TestTaskValidationPersistsAndEnforcesVisibility(t *testing.T) {
|
||||
repo, db := newTaskRepositoryTest(t)
|
||||
defer db.Close()
|
||||
completion := seedTaskCompletion(t, repo)
|
||||
if _, err := db.ExecContext(t.Context(), `UPDATE tasks SET task_type = 'certificate_validate' WHERE id = ?`, completion.TaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), `INSERT INTO users (id, username, password_hash, role, status)
|
||||
VALUES ('reader', 'reader', 'test-only', 'user', 'active'), ('outsider', 'outsider', 'test-only', 'user', 'active')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'reader', 'read_only')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completion.Stage = "certificate_validate"
|
||||
completion.AuditAction = "certificate_validate"
|
||||
completion.Validation = &certificate.ValidationResult{
|
||||
Domains: []string{"example.test", "*.example.test"}, SerialNumber: "1234",
|
||||
FingerprintSHA256: "test-fingerprint", NotBefore: time.Now().UTC().Truncate(time.Second),
|
||||
NotAfter: time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second), ChainLength: 2,
|
||||
}
|
||||
if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := NewSQLTaskRepository(db)
|
||||
items, err := reader.ListVisibleTaskValidations(t.Context(), "reader", completion.TaskID)
|
||||
if err != nil || len(items) != 1 || items[0].AttemptID != completion.AttemptID || items[0].AttemptNumber != 1 {
|
||||
t.Fatalf("重建仓储后校验结果丢失:%+v %v", items, err)
|
||||
}
|
||||
got := items[0].Validation
|
||||
if len(got.Domains) != 2 || got.SerialNumber != "1234" || got.FingerprintSHA256 != "test-fingerprint" || got.ChainLength != 2 || !got.NotAfter.Equal(completion.Validation.NotAfter) {
|
||||
t.Fatalf("校验详情不完整:%+v", got)
|
||||
}
|
||||
for _, actor := range []string{"outsider", "", "missing"} {
|
||||
items, err := reader.ListVisibleTaskValidations(t.Context(), actor, completion.TaskID)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("无权限账户读取校验结果:%s %+v %v", actor, items, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), `DELETE FROM project_members WHERE user_id = 'reader'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err = reader.ListVisibleTaskValidations(t.Context(), "reader", completion.TaskID)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("撤权后仍可读取校验结果:%+v %v", items, err)
|
||||
}
|
||||
reports, err := repo.ListTaskDeploymentReports(t.Context(), completion.TaskID)
|
||||
if err != nil || len(reports) != 0 {
|
||||
t.Fatalf("校验结果被伪装成部署报告:%+v %v", reports, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskValidationFailureRollsBackAllRecords(t *testing.T) {
|
||||
for _, scenario := range []string{"result_insert", "audit_insert", "wrong_task_type", "mixed_report", "failed_status", "foreign_attempt"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
repo, db := newTaskRepositoryTest(t)
|
||||
defer db.Close()
|
||||
completion := seedTaskCompletion(t, repo)
|
||||
completion.Validation = &certificate.ValidationResult{Domains: []string{"example.test"}}
|
||||
completion.AuditAction = "certificate_validate"
|
||||
if scenario != "wrong_task_type" {
|
||||
if _, err := db.ExecContext(t.Context(), `UPDATE tasks SET task_type = 'certificate_validate' WHERE id = ?`, completion.TaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
switch scenario {
|
||||
case "result_insert", "audit_insert":
|
||||
table := "task_validation_results"
|
||||
if scenario == "audit_insert" {
|
||||
table = "audit_logs"
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_validation BEFORE INSERT ON `+table+` BEGIN SELECT RAISE(ABORT, 'injected failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "mixed_report":
|
||||
completion.Report = completionTestReport()
|
||||
case "failed_status":
|
||||
completion.Status = "failed"
|
||||
case "foreign_attempt":
|
||||
completion.AttemptID = "missing-attempt"
|
||||
}
|
||||
if err := repo.FinishTaskAttempt(t.Context(), completion); err == nil {
|
||||
t.Fatal("无效校验结果或写入失败必须回滚终态")
|
||||
}
|
||||
task, err := repo.GetTask(t.Context(), completion.TaskID)
|
||||
if err != nil || task.Status != "running" || task.FinishedAt != nil {
|
||||
t.Fatalf("失败后任务被部分提交:%+v %v", task, err)
|
||||
}
|
||||
attempts, err := repo.ListAttempts(t.Context(), completion.TaskID)
|
||||
if err != nil || len(attempts) != 1 || attempts[0].Status != "running" || attempts[0].FinishedAt != nil {
|
||||
t.Fatalf("失败后尝试被部分提交:%+v %v", attempts, err)
|
||||
}
|
||||
for _, table := range []string{"task_validation_results", "task_deployment_reports", "task_deployment_results", "audit_logs"} {
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM `+table+` WHERE task_id = ?`, completion.TaskID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("失败后出现部分记录:%s %d %v", table, count, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,15 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月2日 证书校验后台任务执行计划
|
||||
|
||||
本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。
|
||||
|
||||
- [~] 校验入口接入持久化后台任务;提交和执行前均检查现有私钥读取权限,不误加部署或 DNS 权限。
|
||||
- [ ] 校验元数据独立持久化,与任务、尝试和审计终态同事务提交;不伪装部署报告,不保存文件路径或私钥。
|
||||
- [ ] 前端跟踪校验任务,在任务详情展示校验域名、指纹、有效期、序列号和链长度;刷新与撤权保持真实状态。
|
||||
- [ ] 补充异步生命周期、事务故障回滚、实时权限和前端回归;核对迁移兼容性并更新接口说明及测试记录。
|
||||
- [!] 本批通过不关闭历史恢复、其他长操作、升级、通知重置、三库、备份恢复及最终生产门禁。
|
||||
|
||||
## 2026年10月2日19:34 证书导入异步化回归完成,等待复审
|
||||
|
||||
需求沿用已确认的 Linux 完整清单,先开发及自动回归,再统一部署,不处理 Windows。本批修改前已确认工作区干净并推送 09616e7 基线,无额外待提交文件。
|
||||
|
||||
@ -588,7 +588,7 @@ export function useDashboard() {
|
||||
notice.value = "";
|
||||
try {
|
||||
const options = { method: "POST" };
|
||||
if (action === "issue" || action === "deploy") {
|
||||
if (action === "issue" || action === "deploy" || action === "certificate/validate") {
|
||||
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
||||
options.headers = { "Idempotency-Key": `${action}-${item.id}-${suffix}` };
|
||||
}
|
||||
@ -597,7 +597,8 @@ export function useDashboard() {
|
||||
if (response.status === 202 && !result.task?.id) {
|
||||
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
|
||||
}
|
||||
if (response.ok && result.task?.id && (response.status === 202 || result.accepted || result.replayed)) {
|
||||
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
|
||||
(response.status === 202 || result.accepted || result.replayed)) {
|
||||
notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`;
|
||||
if (action === "deploy") {
|
||||
deploymentResult.value = {
|
||||
@ -606,6 +607,7 @@ export function useDashboard() {
|
||||
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
|
||||
};
|
||||
}
|
||||
if (action === "certificate/validate") await taskMonitor.openTaskDetails(result.task);
|
||||
return;
|
||||
}
|
||||
if (action === "deploy") {
|
||||
|
||||
@ -141,6 +141,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
|
||||
app.component("DeploymentResult", { render: () => null });
|
||||
app.component("TaskDeploymentResults", { render: () => null });
|
||||
app.component("TaskValidationResult", { render: () => null });
|
||||
const warnings = [];
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
const html = await renderToString(app);
|
||||
|
||||
@ -11,6 +11,7 @@ import { useDashboardNavigation } from "./DashboardNavigation.js";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import DeploymentResult from "./DeploymentResult.vue";
|
||||
import TaskDeploymentResults from "./TaskDeploymentResults.vue";
|
||||
import TaskValidationResult from "./TaskValidationResult.vue";
|
||||
|
||||
const dashboard = useDashboard();
|
||||
const {
|
||||
@ -40,6 +41,7 @@ const {
|
||||
notice,
|
||||
taskQueryError,
|
||||
taskDeploymentReport,
|
||||
taskValidationResult,
|
||||
formError,
|
||||
showForm,
|
||||
editingId,
|
||||
@ -521,6 +523,10 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
<p>{{ taskDetails.message || "无附加信息" }}</p>
|
||||
</div>
|
||||
<TaskDeploymentResults v-if="taskDeploymentReport" :report="taskDeploymentReport" />
|
||||
<TaskValidationResult v-if="taskValidationResult" :result="taskValidationResult" />
|
||||
<p v-else-if="taskDetails.task_type === 'certificate_validate' && taskDetails.status === 'success'" class="muted">
|
||||
该任务没有留存校验详情,不据此推断当前证书仍然有效。
|
||||
</p>
|
||||
<h3 class="modal-section-title">执行尝试</h3>
|
||||
<div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div>
|
||||
<div v-else class="attempt-list">
|
||||
|
||||
@ -6,6 +6,16 @@ export function taskIsPending(task) {
|
||||
return !terminalStatuses.has(task?.status);
|
||||
}
|
||||
|
||||
export function latestValidationResult(task, attempts, results) {
|
||||
if (task.task_type !== "certificate_validate" || task.status !== "success") return null;
|
||||
const latest = attempts.reduce((value, item) =>
|
||||
!value || item.attempt_number > value.attempt_number ? item : value, null);
|
||||
if (!latest || latest.status !== "success") return null;
|
||||
const stored = results.find((item) => item.task_id === task.id &&
|
||||
item.attempt_id === latest.id && item.attempt_number === latest.attempt_number);
|
||||
return Array.isArray(stored?.validation?.domains) ? stored.validation : null;
|
||||
}
|
||||
|
||||
export function latestDeploymentReport(task, attempts, results, reports = []) {
|
||||
const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0));
|
||||
if (taskIsPending(task) || latest === 0) return null;
|
||||
@ -31,6 +41,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails, taskAttempts, taskDetailsLoading, taskDetailsError, refreshResources }) {
|
||||
const taskQueryError = ref("");
|
||||
const taskDeploymentReport = ref(null);
|
||||
const taskValidationResult = ref(null);
|
||||
let selectedID = "";
|
||||
let stopped = false;
|
||||
let timer;
|
||||
@ -53,11 +64,13 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
const path = `/api/v1/tasks/${encodeURIComponent(id)}`;
|
||||
const task = await readJSON(path, signal);
|
||||
if (task.id !== id || !task.status) throw new Error("任务状态响应不完整,将自动重试");
|
||||
const [attempts, results] = await Promise.all([
|
||||
const [attempts, results, validations] = await Promise.all([
|
||||
readJSON(`${path}/attempts`, signal),
|
||||
readJSON(`${path}/deployment-results`, signal),
|
||||
task.task_type === "certificate_validate"
|
||||
? readJSON(`${path}/validation-results`, signal) : Promise.resolve({ items: [] }),
|
||||
]);
|
||||
if (!Array.isArray(attempts.items) || !Array.isArray(results.items)) {
|
||||
if (!Array.isArray(attempts.items) || !Array.isArray(results.items) || !Array.isArray(validations.items)) {
|
||||
throw new Error("任务执行记录响应不完整,将自动重试");
|
||||
}
|
||||
const latestAttempt = attempts.items.reduce((latest, item) =>
|
||||
@ -72,6 +85,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
task,
|
||||
attempts: attempts.items,
|
||||
report: latestDeploymentReport(task, attempts.items, results.items, results.reports),
|
||||
validation: latestValidationResult(task, attempts.items, validations.items),
|
||||
};
|
||||
}
|
||||
|
||||
@ -100,6 +114,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails.value = snapshot.task;
|
||||
taskAttempts.value = snapshot.attempts;
|
||||
taskDeploymentReport.value = snapshot.report;
|
||||
taskValidationResult.value = snapshot.validation;
|
||||
taskDetailsError.value = "";
|
||||
}
|
||||
if (deploymentResult.value?.taskID === id) {
|
||||
@ -125,6 +140,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails.value = null;
|
||||
taskAttempts.value = [];
|
||||
taskDeploymentReport.value = null;
|
||||
taskValidationResult.value = null;
|
||||
}
|
||||
}
|
||||
if (deploymentResult.value?.taskID === id) {
|
||||
@ -149,6 +165,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails.value = null;
|
||||
taskAttempts.value = [];
|
||||
taskDeploymentReport.value = null;
|
||||
taskValidationResult.value = null;
|
||||
if (deploymentResult.value) {
|
||||
deploymentResult.value.report = null;
|
||||
deploymentResult.value.certificateImported = false;
|
||||
@ -179,6 +196,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails.value = null;
|
||||
taskAttempts.value = [];
|
||||
taskDeploymentReport.value = null;
|
||||
taskValidationResult.value = null;
|
||||
taskDetailsError.value = "";
|
||||
taskDetailsLoading.value = true;
|
||||
await refreshTaskStatus();
|
||||
@ -192,6 +210,7 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
taskDetails.value = null;
|
||||
taskAttempts.value = [];
|
||||
taskDeploymentReport.value = null;
|
||||
taskValidationResult.value = null;
|
||||
taskDetailsError.value = "";
|
||||
taskDetailsLoading.value = false;
|
||||
}
|
||||
@ -203,5 +222,6 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
||||
controller?.abort();
|
||||
});
|
||||
|
||||
return { taskQueryError, taskDeploymentReport, refreshTaskStatus, openTaskDetails, closeTaskDetails };
|
||||
return { taskQueryError, taskDeploymentReport, taskValidationResult,
|
||||
refreshTaskStatus, openTaskDetails, closeTaskDetails };
|
||||
}
|
||||
|
||||
@ -1,13 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import { latestDeploymentReport } from "./TaskMonitor.js";
|
||||
import { latestDeploymentReport, latestValidationResult } from "./TaskMonitor.js";
|
||||
import { validationDate } from "./TaskValidationResult.js";
|
||||
import { deploymentSummary } from "./DeploymentFeedback.js";
|
||||
import { deferred, mountComposable } from "../../test-support/mount.js";
|
||||
|
||||
const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" };
|
||||
const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 };
|
||||
|
||||
test("校验只展示当前任务最近一次成功尝试,不挪用旧结果", () => {
|
||||
const task = { ...initialTask, task_type: "certificate_validate", status: "success" };
|
||||
const attempts = [{ id: "latest", attempt_number: 2, status: "success" }];
|
||||
const validation = { domains: ["example.test"], fingerprint_sha256: "verified" };
|
||||
const stored = { task_id: task.id, attempt_id: "latest", attempt_number: 2, validation };
|
||||
assert.deepEqual(latestValidationResult(task, attempts, [stored]), validation);
|
||||
for (const record of [
|
||||
{ ...stored, task_id: "another" },
|
||||
{ ...stored, attempt_id: "old" },
|
||||
{ ...stored, attempt_number: 1 },
|
||||
{ ...stored, validation: {} },
|
||||
]) assert.equal(latestValidationResult(task, attempts, [record]), null);
|
||||
for (const status of ["running", "failed", "cancelled"]) {
|
||||
assert.equal(latestValidationResult({ ...task, status }, attempts, [stored]), null);
|
||||
}
|
||||
assert.equal(latestValidationResult(initialTask, attempts, [stored]), null);
|
||||
assert.equal(latestValidationResult(task, [], [stored]), null);
|
||||
assert.equal(validationDate("invalid"), "未记录");
|
||||
assert.equal(validationDate(null), "未记录");
|
||||
});
|
||||
|
||||
test("校验任务提交后读取独立详情,页面重开可恢复,撤权后清除", async (t) => {
|
||||
let task = { ...initialTask, task_type: "certificate_validate", status: "queued" };
|
||||
let denied = false;
|
||||
let submitted = 0;
|
||||
const validation = {
|
||||
domains: ["example.test"], serial_number: "42", fingerprint_sha256: "verified",
|
||||
not_before: "2026-01-01T00:00:00Z", not_after: "2026-12-31T00:00:00Z", chain_length: 2,
|
||||
};
|
||||
const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => {
|
||||
if (path.endsWith("/certificate/validate")) {
|
||||
submitted++;
|
||||
assert.ok(options.headers["Idempotency-Key"]);
|
||||
return Response.json({ task, accepted: true }, { status: 202 });
|
||||
}
|
||||
if (path === `/api/v1/tasks/${task.id}`) return Response.json(task);
|
||||
if (path.endsWith("/attempts")) return Response.json({
|
||||
items: [{ id: "validation-attempt", attempt_number: 1, status: task.status }],
|
||||
});
|
||||
if (path.endsWith("/validation-results")) {
|
||||
if (denied) return Response.json({ error: "禁止访问" }, { status: 403 });
|
||||
return Response.json({ items: task.status === "success" ? [{
|
||||
task_id: task.id, attempt_id: "validation-attempt", attempt_number: 1, validation,
|
||||
}] : [] });
|
||||
}
|
||||
if (path.startsWith("/api/v1/tasks?")) return Response.json({ items: [task] });
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
await state.validateDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.equal(submitted, 1);
|
||||
assert.equal(state.showTaskDetails.value, true);
|
||||
assert.match(state.notice.value, /任务已提交/);
|
||||
assert.equal(state.taskValidationResult.value, null);
|
||||
task = { ...task, status: "success" };
|
||||
await state.refreshTaskStatus();
|
||||
assert.deepEqual(state.taskValidationResult.value, validation);
|
||||
assert.equal(state.taskDeploymentReport.value, null);
|
||||
state.closeTaskDetails();
|
||||
assert.equal(state.taskValidationResult.value, null);
|
||||
await state.openTaskDetails(task);
|
||||
assert.deepEqual(state.taskValidationResult.value, validation);
|
||||
denied = true;
|
||||
await state.refreshTaskStatus();
|
||||
assert.equal(state.taskValidationResult.value, null);
|
||||
assert.equal(state.taskDetails.value, null);
|
||||
assert.match(state.taskDetailsError.value, /无权/);
|
||||
assert.equal(submitted, 1);
|
||||
});
|
||||
|
||||
test("校验详情拆分展示元数据和本地校验边界,不展示私钥路径", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const source = await readFile(new URL("./TaskValidationResult.vue", import.meta.url), "utf8");
|
||||
for (const label of ["覆盖域名", "序列号", "生效时间", "到期时间", "证书链长度", "SHA-256 指纹", "不代表远端部署"]) {
|
||||
assert.ok(source.includes(label));
|
||||
}
|
||||
assert.match(source, /src="\.\/TaskValidationResult.css"/);
|
||||
assert.doesNotMatch(source, /private_key_path|certificate_path|&#x/);
|
||||
const { parse, compileTemplate } = await import("@vue/compiler-sfc");
|
||||
const { renderToString } = await import("@vue/server-renderer");
|
||||
const Vue = await import("vue");
|
||||
const { descriptor, errors } = parse(source);
|
||||
assert.deepEqual(errors, []);
|
||||
const compiled = compileTemplate({
|
||||
source: descriptor.template.content, filename: "TaskValidationResult.vue",
|
||||
id: "validation-result", compilerOptions: { mode: "function" },
|
||||
});
|
||||
assert.deepEqual(compiled.errors, []);
|
||||
const render = new Function("Vue", compiled.code)(Vue);
|
||||
const app = Vue.createSSRApp({
|
||||
render,
|
||||
setup: () => ({ validationDate, result: {
|
||||
domains: ["example.test", "*.example.test"], serial_number: "98765",
|
||||
fingerprint_sha256: "AB:CD:EF", chain_length: 2,
|
||||
not_before: "2026-01-01T00:00:00Z", not_after: "2026-12-31T00:00:00Z",
|
||||
} }),
|
||||
});
|
||||
const warnings = [];
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
const html = await renderToString(app);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.match(html, /example.test、\*.example.test/);
|
||||
assert.match(html, /AB:CD:EF/);
|
||||
assert.match(html, /98765/);
|
||||
});
|
||||
|
||||
test("完整报告恢复站点验证与回滚事实,不被逐目标结果覆盖", () => {
|
||||
const task = { ...initialTask, status: "failed" };
|
||||
const attempts = [{ id: "attempt-2", attempt_number: 2, status: "failed" }];
|
||||
|
||||
30
frontend/src/views/Dashboard/TaskValidationResult.css
Normal file
30
frontend/src/views/Dashboard/TaskValidationResult.css
Normal file
@ -0,0 +1,30 @@
|
||||
.task-validation {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.task-validation dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.task-validation .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.task-validation dt,
|
||||
.task-validation p {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-validation dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-validation dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
5
frontend/src/views/Dashboard/TaskValidationResult.js
Normal file
5
frontend/src/views/Dashboard/TaskValidationResult.js
Normal file
@ -0,0 +1,5 @@
|
||||
export function validationDate(value) {
|
||||
if (!value) return "未记录";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "未记录" : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
22
frontend/src/views/Dashboard/TaskValidationResult.vue
Normal file
22
frontend/src/views/Dashboard/TaskValidationResult.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { validationDate } from "./TaskValidationResult.js";
|
||||
|
||||
defineProps({ result: { type: Object, required: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="task-validation" aria-label="本次证书校验结果">
|
||||
<h3>本次证书校验结果</h3>
|
||||
<dl>
|
||||
<div class="wide"><dt>覆盖域名</dt><dd>{{ result.domains.join("、") || "未记录" }}</dd></div>
|
||||
<div><dt>生效时间</dt><dd>{{ validationDate(result.not_before) }}</dd></div>
|
||||
<div><dt>到期时间</dt><dd>{{ validationDate(result.not_after) }}</dd></div>
|
||||
<div><dt>序列号</dt><dd>{{ result.serial_number || "未记录" }}</dd></div>
|
||||
<div><dt>证书链长度</dt><dd>{{ result.chain_length ?? "未记录" }}</dd></div>
|
||||
<div class="wide"><dt>SHA-256 指纹</dt><dd>{{ result.fingerprint_sha256 || "未记录" }}</dd></div>
|
||||
</dl>
|
||||
<p>此结果仅表示本次本地证书、私钥匹配及信任链校验通过,不代表远端部署或公网 HTTPS 验收通过。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./TaskValidationResult.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user