diff --git a/backend/internal/app/app_test.go b/backend/internal/app/app_test.go index bc57116..d6964d2 100644 --- a/backend/internal/app/app_test.go +++ b/backend/internal/app/app_test.go @@ -87,7 +87,7 @@ func TestNewWithConfigPathRecoversInterruptedTasks(t *testing.T) { if task.Status != "failed" || task.Stage != "service_restart" || task.FinishedAt == nil { t.Fatalf("任务 %s 未按服务重启恢复:%+v", expectedID, task) } - if task.Message != "服务重启导致任务中断,请重新执行" { + if task.Message != "服务重启导致任务中断,执行结果未确认;请先核查关联资源,证书部署需检查远端文件、部署锁及服务状态,确认安全后再重试" { t.Fatalf("任务 %s 中断说明不正确:%s", expectedID, task.Message) } } diff --git a/backend/internal/app/certificate_diagnostics_test.go b/backend/internal/app/certificate_diagnostics_test.go new file mode 100644 index 0000000..0a40545 --- /dev/null +++ b/backend/internal/app/certificate_diagnostics_test.go @@ -0,0 +1,22 @@ +package app + +import ( + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func TestTaskDeploymentResultsPersistSafeFailureDiagnostics(t *testing.T) { + report := deployment.Report{Results: []deployment.Result{ + {Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path"}, + {Status: "failed", FailureCode: "secret-code", Message: "secret-output"}, + {Status: "success", Message: "部署完成"}, + }} + results := taskDeploymentResults(report) + if len(results) != 3 || results[0].Message != deployment.PublicResult(report.Results[0]).Message || results[1].Message != "证书部署失败" || results[2].Message != "部署完成" { + t.Fatalf("持久化部署诊断不正确:%+v", results) + } + if report.Results[0].Message != "secret-path" { + t.Fatal("不得覆盖受保护的原始错误") + } +} diff --git a/backend/internal/app/certificate_tasks.go b/backend/internal/app/certificate_tasks.go index 13a5668..d83c2d9 100644 --- a/backend/internal/app/certificate_tasks.go +++ b/backend/internal/app/certificate_tasks.go @@ -179,6 +179,7 @@ func taskDeploymentResults(report deployment.Report) []repository.TaskDeployment } items := make([]repository.TaskDeploymentResult, 0, len(report.Results)) for index, item := range report.Results { + item = deployment.PublicResult(item) items = append(items, repository.TaskDeploymentResult{ ResultIndex: index, ServerID: item.ServerID, diff --git a/backend/internal/deployment/diagnostics.go b/backend/internal/deployment/diagnostics.go new file mode 100644 index 0000000..247aee6 --- /dev/null +++ b/backend/internal/deployment/diagnostics.go @@ -0,0 +1,61 @@ +package deployment + +type FailureCode string + +const ( + FailureUnavailable FailureCode = "unavailable" + FailureArtifact FailureCode = "artifact_invalid" + FailureConfiguration FailureCode = "configuration_invalid" + FailureConnection FailureCode = "connection_failed" + FailureDirectory FailureCode = "directory_failed" + FailureLock FailureCode = "lock_unavailable" + FailureUpload FailureCode = "upload_failed" + FailureReplace FailureCode = "replace_failed" + FailureValidation FailureCode = "validation_failed" + FailureRollback FailureCode = "rollback_failed" + FailureRuntimeUnknown FailureCode = "runtime_unconfirmed" + FailureCleanup FailureCode = "cleanup_failed" + FailureLockRelease FailureCode = "lock_release_failed" +) + +// 只公开固定诊断,不从远端输出、错误文本或文件路径推断可展示的消息。 +var publicFailureMessages = map[FailureCode]string{ + FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置", + FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器", + FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置", + FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置", + FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限", + FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁", + FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试", + FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试", + FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试", + FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁", + FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试", + FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效", + FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试", +} + +// PublicResult 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。 +func PublicResult(result Result) Result { + if result.Status != "failed" { + result.FailureCode = "" + return result + } + message, ok := publicFailureMessages[result.FailureCode] + if !ok { + result.FailureCode = "" + message = "证书部署失败" + } + result.Message = message + return result +} + +// PublicStoredFailureMessage 兼容历史任务;仅原样放行完整匹配的固定诊断。 +func PublicStoredFailureMessage(message string) string { + for _, safe := range publicFailureMessages { + if message == safe { + return safe + } + } + return "证书部署失败" +} diff --git a/backend/internal/deployment/diagnostics_test.go b/backend/internal/deployment/diagnostics_test.go new file mode 100644 index 0000000..9c4f87e --- /dev/null +++ b/backend/internal/deployment/diagnostics_test.go @@ -0,0 +1,97 @@ +package deployment + +import ( + "context" + "errors" + "strings" + "testing" +) + +type lockReleaseFailureClient struct { + *memoryClient +} + +func (c *lockReleaseFailureClient) Unlock(context.Context, string) error { + return errors.New("secret-path lock-release-error") +} + +func TestDeploymentLockReleaseFailureOverridesSuccess(t *testing.T) { + client := &lockReleaseFailureClient{memoryClient: newMemoryClient()} + server := testServer("unlock") + report := NewService(fixedClientFactory{client}).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || report.Success != 0 || report.Failed != 1 || report.Results[0].FailureCode != FailureLockRelease { + t.Fatalf("锁释放失败不得保留成功状态:%+v", report) + } + public := PublicResult(report.Results[0]) + if strings.Contains(public.Message, "secret-path") || !strings.Contains(public.Message, "部署锁释放未完成") { + t.Fatalf("锁释放诊断错误:%+v", public) + } +} + +func TestPublicDeploymentDiagnosticsAreAllowlisted(t *testing.T) { + for code, expected := range publicFailureMessages { + t.Run(string(code), func(t *testing.T) { + raw := Result{ServerID: "server-1", Status: "failed", FailureCode: code, Message: "secret-path private-key remote-output"} + public := PublicResult(raw) + if public.Message != expected || public.FailureCode != code || public.ServerID != raw.ServerID { + t.Fatalf("固定诊断或结果身份丢失:%+v", public) + } + if PublicStoredFailureMessage(public.Message) != expected { + t.Fatal("持久化诊断无法读取") + } + if PublicStoredFailureMessage(public.Message+" secret-path") != "证书部署失败" { + t.Fatal("包含额外敏感内容的诊断不应放行") + } + }) + } + for _, code := range []FailureCode{"", "secret-path", "lock_unavailable\nprivate-key"} { + public := PublicResult(Result{Status: "failed", FailureCode: code, Message: "无法取得部署锁 /secret-path"}) + if public.FailureCode != "" || public.Message != "证书部署失败" { + t.Fatalf("未知错误码或原始错误泄露:%+v", public) + } + } + public := PublicResult(Result{Status: "success", FailureCode: "secret-path", Message: "部署完成"}) + if public.FailureCode != "" || public.Message != "部署完成" { + t.Fatalf("成功结果错误:%+v", public) + } +} + +func TestDeploymentFailureCodesDescribeActualRecoveryBoundary(t *testing.T) { + for _, scenario := range []struct { + name string + code FailureCode + configure func(*memoryClient, Server) + }{ + {"lock", FailureLock, func(c *memoryClient, s Server) { c.files[s.PrivateKeyPath+".scert-lock"] = memoryFile{} }}, + {"upload", FailureUpload, func(c *memoryClient, _ Server) { c.uploadError = ".scert-tmp-" }}, + {"replace", FailureReplace, func(c *memoryClient, _ Server) { c.renameError = ".scert-tmp-" }}, + {"validation", FailureValidation, func(c *memoryClient, _ Server) { c.runErrors = []error{errors.New("secret-path")} }}, + {"reload", FailureRuntimeUnknown, func(c *memoryClient, _ Server) { c.runErrors = []error{nil, errors.New("secret-path")} }}, + {"validation_rollback", FailureRollback, func(c *memoryClient, _ Server) { + c.runErrors = []error{errors.New("secret-path")} + c.restoreError = true + }}, + {"reload_rollback", FailureRollback, func(c *memoryClient, _ Server) { + c.runErrors = []error{nil, errors.New("secret-path")} + c.restoreError = true + }}, + {"cleanup", FailureCleanup, func(c *memoryClient, _ Server) { c.removeError = ".scert-backup-" }}, + } { + t.Run(scenario.name, func(t *testing.T) { + client := newMemoryClient() + server := testServer("diagnostics") + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + client.files[path] = memoryFile{data: []byte("original"), mode: 0o600} + } + scenario.configure(client, server) + report := NewService(fixedClientFactory{client}).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || len(report.Results) != 1 || report.Results[0].FailureCode != scenario.code { + t.Fatalf("失败阶段或恢复边界错误:%+v", report) + } + public := PublicResult(report.Results[0]) + if strings.Contains(public.Message, "secret-path") || public.Message == "证书部署失败" { + t.Fatalf("阶段诊断缺失或泄露底层信息:%+v", public) + } + }) + } +} diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index 07e26ec..c41cff8 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -29,11 +29,12 @@ type SavedFiles struct { } type Result struct { - ServerID string `json:"server_id"` - Server string `json:"server"` - Status string `json:"status"` - Message string `json:"message"` - BackupID string `json:"backup_id,omitempty"` + ServerID string `json:"server_id"` + Server string `json:"server"` + Status string `json:"status"` + Message string `json:"message"` + BackupID string `json:"backup_id,omitempty"` + FailureCode FailureCode `json:"failure_code,omitempty"` } const deploymentRollbackTimeout = 30 * time.Second @@ -67,7 +68,7 @@ func (s *Service) Deploy(ctx context.Context, targets []Target, artifact Artifac report.Status = "failed" report.Failed = len(targets) for _, target := range targets { - report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: "部署服务未初始化"}) + report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: "部署服务未初始化", FailureCode: FailureUnavailable}) } return report } @@ -75,7 +76,7 @@ func (s *Service) Deploy(ctx context.Context, targets []Target, artifact Artifac report.Status = "failed" report.Failed = len(targets) for _, target := range targets { - report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: err.Error()}) + report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: err.Error(), FailureCode: FailureArtifact}) } return report } @@ -119,13 +120,14 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() server = server.Normalize() - result = Result{ServerID: server.ID, Server: server.Name, Status: "failed"} + result = Result{ServerID: server.ID, Server: server.Name, Status: "failed", FailureCode: FailureConfiguration} if err := server.Validate(); err != nil { result.Message = err.Error() return result } client, err := s.factory.Connect(ctx, server) if err != nil { + result.FailureCode = FailureConnection result.Message = fmt.Sprintf("连接服务器失败:%v", err) return result } @@ -149,6 +151,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac lockPaths := make([]string, 0, len(paths)) for _, file := range paths { if err := client.MkdirAll(ctx, parentPath(file.path)); err != nil { + result.FailureCode = FailureDirectory result.Message = fmt.Sprintf("创建远程目录失败:%v", err) return result } @@ -165,18 +168,21 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac defer cancel() if err := reconnectDeploymentClient(releaseCtx, s.factory, server, &client); err != nil { result.Status = "failed" + result.FailureCode = FailureLockRelease result.Message += fmt.Sprintf(";释放部署锁失败,需人工检查:%v", err) return } for index := len(heldLocks) - 1; index >= 0; index-- { if err := client.Unlock(releaseCtx, heldLocks[index]); err != nil { result.Status = "failed" + result.FailureCode = FailureLockRelease result.Message += fmt.Sprintf(";释放部署锁 %s 失败,需人工检查:%v", heldLocks[index], err) } } }() for _, lockPath := range lockPaths { if err := client.Lock(ctx, lockPath); err != nil { + result.FailureCode = FailureLock result.Message = fmt.Sprintf("无法取得部署锁 %s,可能存在并发部署或遗留锁;未修改当前证书,请人工核查:%v", lockPath, err) return result } @@ -224,6 +230,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac reloadAttempted := false failed := func(message string) Result { if rollbackErr := rollback(); rollbackErr != nil { + result.FailureCode = FailureRollback message += fmt.Sprintf(";自动回滚失败:%v", rollbackErr) retainLocks = true message += ";已保留本次部署锁,修复并确认文件状态后再由管理员释放" @@ -231,12 +238,16 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac message += ";文件已回滚" } if reloadAttempted { + if !retainLocks { + result.FailureCode = FailureRuntimeUnknown + } // 命令可能已部分执行;文件回滚并不证明运行中的服务已经恢复。 message += ";服务运行状态未确认,请检查服务并在确认旧配置后重新加载" } result.Message = message return result } + result.FailureCode = FailureUpload for _, file := range paths { temporaryPath := file.path + ".scert-tmp-" + backupID // 上传失败也可能留下半份私钥,必须在开始上传前登记清理路径。 @@ -251,6 +262,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac } } // 所有文件上传完成后才触碰当前版本,避免上传失败造成不必要的证书替换。 + result.FailureCode = FailureReplace for _, file := range paths { exists, err := client.Exists(ctx, file.path) if err != nil { @@ -272,6 +284,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return failed(fmt.Sprintf("替换远程证书失败:%v", err)) } } + result.FailureCode = FailureValidation validationResult, err := client.Run(ctx, server.ValidationCommand) if err != nil || validationResult.ExitCode != 0 { if err == nil { @@ -289,10 +302,12 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac } if err := cleanupBackups(ctx, client, backups, paths); err != nil { result.Status = "failed" + result.FailureCode = FailureCleanup result.Message = fmt.Sprintf("\u8bc1\u4e66\u5df2\u90e8\u7f72\u5e76 reload \u6210\u529f\uff0c\u4f46\u65e7\u8bc1\u4e66\u5907\u4efd\u6e05\u7406\u5931\u8d25\uff1a%v", err) return result } result.Status = "success" + result.FailureCode = "" result.Message = "证书部署、配置校验并 reload 成功" result.BackupID = backupID return result diff --git a/backend/internal/httpapi/error_sanitization.go b/backend/internal/httpapi/error_sanitization.go index 0b12f4a..429ffa6 100644 --- a/backend/internal/httpapi/error_sanitization.go +++ b/backend/internal/httpapi/error_sanitization.go @@ -26,9 +26,7 @@ func sanitizeDeploymentReport(report deployment.Report) deployment.Report { result := report result.Results = append([]deployment.Result(nil), report.Results...) for index := range result.Results { - if result.Results[index].Status == "failed" { - result.Results[index].Message = "证书部署失败" - } + result.Results[index] = deployment.PublicResult(result.Results[index]) } return result } diff --git a/backend/internal/httpapi/error_sanitization_test.go b/backend/internal/httpapi/error_sanitization_test.go new file mode 100644 index 0000000..a05e15c --- /dev/null +++ b/backend/internal/httpapi/error_sanitization_test.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func TestSanitizeDeploymentReportKeepsSafeDiagnosisWithoutMutatingEvidence(t *testing.T) { + report := deployment.Report{Status: "failed", Results: []deployment.Result{ + {Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path"}, + {Status: "failed", FailureCode: "secret-code", Message: "secret-output"}, + }} + public := sanitizeDeploymentReport(report) + if public.Results[0].FailureCode != deployment.FailureLock || public.Results[0].Message != deployment.PublicResult(report.Results[0]).Message { + t.Fatalf("固定锁诊断丢失:%+v", public) + } + if public.Results[1].FailureCode != "" || public.Results[1].Message != "证书部署失败" { + t.Fatalf("未知诊断未脱敏:%+v", public) + } + if report.Results[0].Message != "secret-path" || report.Results[1].FailureCode != "secret-code" { + t.Fatal("公开响应脱敏修改了受保护的原始证据") + } + stored := taskDeploymentResults(report) + if stored[0].Message != public.Results[0].Message || stored[1].Message != "证书部署失败" { + t.Fatalf("接口工作流未持久化安全诊断:%+v", stored) + } +} diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 68b0fa6..249fc87 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -553,7 +553,7 @@ func (s *Server) listTaskDeploymentResults(c *gin.Context) { } for index := range items { if items[index].Status == "failed" { - items[index].Message = "证书部署失败" + items[index].Message = deployment.PublicStoredFailureMessage(items[index].Message) } } c.JSON(http.StatusOK, gin.H{"items": items}) @@ -565,6 +565,7 @@ func taskDeploymentResults(report deployment.Report) []repository.TaskDeployment } items := make([]repository.TaskDeploymentResult, 0, len(report.Results)) for index, item := range report.Results { + item = deployment.PublicResult(item) items = append(items, repository.TaskDeploymentResult{ ResultIndex: index, ServerID: item.ServerID, diff --git a/backend/internal/httpapi/task_deployment_results_test.go b/backend/internal/httpapi/task_deployment_results_test.go index eab7eb4..2f00a84 100644 --- a/backend/internal/httpapi/task_deployment_results_test.go +++ b/backend/internal/httpapi/task_deployment_results_test.go @@ -78,6 +78,34 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) { } } +func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) { + raw := deployment.Result{ServerID: "server-a", Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path remote-command-output"} + server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "failed", Total: 1, Failed: 1, Results: []deployment.Result{raw}}, errors.New("部署失败") + }) + defer closeDB() + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "secret-path") || !strings.Contains(response.Body.String(), "lock_unavailable") { + t.Fatalf("部署接口缺少安全的锁诊断:%d %s", response.Code, response.Body.String()) + } + items, err := tasks.ListTasks(t.Context(), "", 10) + if err != nil || len(items) != 1 { + t.Fatalf("任务未保存:%+v %v", items, err) + } + response = httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/deployment-results", nil)) + var payload struct { + Items []repository.TaskDeploymentResult `json:"items"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || len(payload.Items) != 1 || payload.Items[0].Message != deployment.PublicResult(raw).Message { + t.Fatalf("任务查询丢失固定锁诊断:%d %s", response.Code, response.Body.String()) + } +} + func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T) { server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) { return certificate.IssueResult{DomainID: id}, deployment.Report{ diff --git a/backend/internal/repository/task_recovery_diagnostics_test.go b/backend/internal/repository/task_recovery_diagnostics_test.go new file mode 100644 index 0000000..6837423 --- /dev/null +++ b/backend/internal/repository/task_recovery_diagnostics_test.go @@ -0,0 +1,39 @@ +package repository + +import ( + "strings" + "testing" + "time" +) + +func TestTaskRecoveryRequiresResourceInspectionBeforeRetry(t *testing.T) { + repo, db := newTaskRepositoryTest(t) + defer db.Close() + ctx := t.Context() + if err := repo.CreateTask(ctx, Task{ID: "interrupted-deploy", DomainID: "domain-1", TaskType: "certificate_deploy", Status: "running", Stage: "certificate_deploy"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateAttempt(ctx, TaskAttempt{ID: "interrupted-attempt", TaskID: "interrupted-deploy", AttemptNumber: 1, Stage: "certificate_deploy", Status: "running"}); err != nil { + t.Fatal(err) + } + if count, err := repo.RecoverInterruptedTasks(ctx, time.Now()); err != nil || count != 1 { + t.Fatalf("恢复任务失败:%d %v", count, err) + } + task, err := repo.GetTask(ctx, "interrupted-deploy") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"执行结果未确认", "远端文件", "部署锁", "服务状态", "确认安全后再重试"} { + if !strings.Contains(task.Message, expected) { + t.Errorf("中断任务缺少恢复边界提示 %q:%s", expected, task.Message) + } + } + attempts, err := repo.ListAttempts(ctx, task.ID) + if err != nil || len(attempts) != 1 || attempts[0].Message != task.Message { + t.Fatalf("执行尝试与任务恢复说明不一致:%+v %v", attempts, err) + } + var auditMessage string + if err := db.QueryRowContext(ctx, `SELECT message FROM audit_logs WHERE action = 'task_recovery' AND task_id = ?`, task.ID).Scan(&auditMessage); err != nil || auditMessage != task.Message { + t.Fatalf("恢复审计说明不一致:%s %v", auditMessage, err) + } +} diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 4b23384..de5d30a 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -346,7 +346,7 @@ func (r *SQLTaskRepository) RecoverInterruptedTasks(ctx context.Context, finishe rollback := func() { _ = tx.Rollback() } - recoveryMessage := "\u670d\u52a1\u91cd\u542f\u5bfc\u81f4\u4efb\u52a1\u4e2d\u65ad\uff0c\u8bf7\u91cd\u65b0\u6267\u884c" + recoveryMessage := "服务重启导致任务中断,执行结果未确认;请先核查关联资源,证书部署需检查远端文件、部署锁及服务状态,确认安全后再重试" auditResult, err := tx.ExecContext(ctx, r.query(`INSERT INTO audit_logs (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) SELECT 'task_recovery', 'task', id, 'failed', ?, ?, scope_type, project_id, id, operator_id diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 8462093..47dc1ee 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,4 +1,17 @@ # Linux 生产流程测试记录 +## 真实部署中断与人工恢复(2026-09-30 22:35) + +- 候选仍为 91e1123。沿用已有证书和数据执行破坏性中断验证,没有清库,不能替代最终首次生产流程;未新增辅助验收脚本。 +- 通过正式服务器配置接口临时设定校验命令为有界延迟后执行 nginx 配置校验,重载命令保持正常。22:25:00 从正式页面触发第二域名部署,任务 8aa3db95-ab7a-4559-a558-a1a9c8fa5f00、尝试 656a9679-33ab-458e-bcbe-2ed6694735eb 均为 running,远端可见 sleep 45 进程及三个部署锁和对应事务旧文件。 +- 首次 systemctl 命令使用当前系统不支持的参数,未终止进程;确认任务与延迟进程仍活动后改用该系统支持的 --kill-who=main。22:25:30 SIGKILL 主 PID 81906,22:25:31 systemd 自动启动 PID 83070,NRestarts=1。不是空闲服务重启,也没有将首条失败命令记为成功。 +- 任务恢复为 failed/service_restart,尝试为 failed,task_recovery 审计为 failed;三者旧提示均为“服务重启导致任务中断,请重新执行”。无自动重新签发或重放远端命令。 +- 22:26:42 重新登录后通过产品接口恢复 nginx -t 与 nginx -s reload。延迟进程已结束;远端保留三个锁和三个事务旧文件,nginx worker 未变,新旧 cert/fullchain/key 逐一内容一致。 +- 22:27:09 产品直接重试,任务 0d6dfdf0-65a0-4af5-8544-0f9c3b604018 失败,接口 502,没有误报成功;原有脱敏只显示“证书部署失败”,不能告知用户遗留锁原因。 +- 确认活动任务为零、延迟进程结束、目录真实路径正确、三个锁为空且非符号链接、新旧文件一致后,将六个现场项目移到 /ssl/scert-interruption-evidence/8aa3db95-ab7a-4559-a558-a1a9c8fa5f00。受限目录权限 700,私钥旧文件 600;未删除文件、未自动抢占未知锁,也未覆盖另一域名文件。 +- 22:28:14 正式产品接口部署已有证书,任务 800815bd-7fcd-4262-87c0-63fd6174dd29 成功,返回 verification_status=success。22:33 再查主服务、目录 socket、nginx 均 active,活动任务为零,目标目录无残留锁;两域名正常 TLS HTTPS 200,公网指纹各自保持与托管证书一致。 +- 结论:启动任务恢复、遗留锁拒绝和人工核查后重新部署取得证据;远端自动回滚及自动解除崩溃事务未实现。已开发固定阶段安全诊断及中断核查提示,尚未部署,不能将本次人工恢复记为自动恢复通过。 +- 22:41 开发回归完成:后端全量测试及静态检查退出零,部署、仓储、应用、接口四包竞态通过,前端 240 项通过;开始干净源码正式构建,构建通过不替代实机复测。 + ## 单台服务器双域名闭环(2026-09-30 22:18) - 新域名 scert-multi-20260930.apmmk.cn,资源 ID fd0b6b70-9cca-48d7-9d9f-40551cb95fc5,与第一域名关联同一服务器 3cfe6b82-6807-421d-88ab-a2034370375b。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 1bf3346..e6dad1e 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,4 +1,19 @@ # Scert 整改任务执行清单 +## 当前任务看板(2026-09-30 22:35,中断恢复实测与诊断整改) + +需求确认:沿用完整 P0-10 与生产验收范围。修改前检查点 b27eedc 已推送;本轮验证部署中断、遗留锁、人工核查后的恢复,并修正公开诊断,不把诊断改善当作远端事务自动恢复。 + +- [x] 真实部署执行中 SIGKILL 主进程,systemd 自动重启;任务、尝试恢复为 failed,阶段 service_restart,审计写入,没有悬挂或误报成功。 +- [x] 实测直接重试被遗留锁拒绝;核实无活动任务、远程延迟进程结束及新旧文件一致后,将三个锁和三个事务旧文件移入受限证据目录,未删除现场;产品重新部署成功,两站点正常。 +- [x] 新增固定阶段错误码和中文安全诊断,覆盖连接、目录、锁、上传、替换、校验、回滚、运行态未确认及清理;接口和持久化结果只放行固定诊断,保留底层受保护证据。 +- [x] 中断任务提示不再要求直接重试,明确执行结果未确认及关联资源核查;正式回归修复前失败、修复后通过。部署、仓储、应用和接口四包测试通过。 +- [x] 接口读取与前端诊断回归通过;后端全量测试、静态检查、部署/仓储/应用/接口四包竞态均退出零,前端 240 项通过。 +- [~] 提交当前修复,使用干净源码正式构建及部署复测。 +- [!] 当前线上仍为 91e1123;新诊断尚未部署。远端崩溃事务没有自动恢复,P0-10 整项未完成。 +- [ ] 第二台真实服务器、三数据库矩阵、Linux 原生与 Windows 服务、备份恢复和最终清库首次生产流程继续保留。 + +以下带时间章节为历史证据,当前状态以上节为准。 + ## 当前任务看板(2026-09-30 22:18) 需求确认:沿用已确认完整整改范围;新包正式安装、DNS 复测、旧证书负向与恢复、单机多域名依次推进,不缩减多服务器、数据库和最终首次流程验收。修改前检查点 7d0ce94 已推送。下方各时间章节保留为历史证据,当前状态以本节为准。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 552d2c3..4a919b5 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,4 +1,15 @@ # Scert 整改、合并与优化清单 +## 当前验收总览(2026-09-30 22:35,中断恢复边界已实测) + +- [✓] P0-03 局部复审结论保持,不把后续事务门禁重新挂到源码目录任务下。 +- [x] 主进程在真实部署的远端配置校验阶段被强制终止,systemd 自动重启;任务、尝试和恢复审计一致为失败,没有永久执行中或误报成功。 +- [x] 已实证遗留三个部署锁阻止直接重试;人工核查新旧文件一致并隔离保留事务现场后,正式产品接口重新部署成功,两域名正常 HTTPS。此为人工恢复,不是自动事务恢复。 +- [x] 新增固定阶段中文诊断及错误码,任务查询兼容历史错误脱敏;中断提示改为先核查关联资源、远端文件、锁及服务状态。22:41 后端全量、静态检查、四包竞态及前端 240 项均通过。 +- [~] P0-10 继续进行:诊断代码尚未正式构建部署;进程崩溃后的远端自动恢复、后置 HTTPS 失败的运行态恢复均未完成。 +- [!] P0-04、P0-11、P0-12 等完整范围不变;第二台真实服务器、数据库矩阵、双平台服务与最终清库首次流程没有新增通过证据。不承诺当晚生产放行。 + +以下较早章节保留历史时间,不代表当前阻塞位置。 + ## 当前验收总览(2026-09-30 22:18,以本节为准) 下方带时间的进度为历史证据,不代表当前阻塞位置。P0-03 已按源码与产物边界范围复审通过,不再使用历史“仍停在 P0-03”作为当前结论。完整整改及生产验收仍未完成,不承诺当晚放行。 @@ -800,6 +811,8 @@ updated_by **状态:** `[~] 进行中` +**中断恢复实测(2026-09-30 22:35):** 真实部署校验中强制终止主进程,systemd 自动重启,任务、尝试及审计正确标记中断失败;远端锁和事务旧文件保留,直接重试被拒绝。人工核查并隔离现场后可通过产品重新部署。已开发固定阶段诊断和先核查后重试提示,专项通过但未部署;远端崩溃事务自动恢复仍未完成,不能标记整项通过。 + **最新整改(2026-09-30):** 已复现并修复后置 HTTPS 校验接受旧有效证书、校验失败仍返回成功部署报告的问题;身份来自实际待部署文件。报告以 verification_status 单独记录站点生效校验,整体失败不改写单台真实文件推送事实;任务、尝试和接口保持失败,页面不再汇总为完成。本批本地测试不等于实机恢复通过;该校验仍位于文件部署事务之后,不具备据此宣称自动回滚的依据。 所有长耗时操作统一进入任务中心: diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index e596aee..f36e002 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -1,5 +1,24 @@ # Scert 生产部署与运维说明 +## 部署中断处理与阶段诊断(2026-09-30) + +服务重启会将未完成任务和执行尝试标记为失败并记录恢复审计,但这不表示远端文件、部署锁或服务运行态已经恢复。不得仅凭任务失败提示直接重复申请证书或删除远端锁。 + +处理前应确认没有活动部署和仍在执行的远程命令,核对任务关联服务器、三个目标文件、对应事务旧文件、权限及实际站点证书。只有确认现场归属和恢复方案后才能由管理员处置;保留证据,不抢占无法确认归属的锁。修正配置后优先部署已有证书,避免重复签发。 + +新候选的部署响应结果增加可选 failure_code,message 为固定中文诊断;任务部署结果查询保留同一安全说明,不要求数据库结构升级。旧记录和未知错误仍返回通用提示。代码、路径和远端输出不从错误文本直接透传;详细原因查受保护日志。新增诊断尚需正式部署复测,本节不代表自动恢复功能已完成。 + +| 错误码 | 处理含义 | +| --- | --- | +| lock_unavailable | 并发部署、遗留锁或锁创建失败;先核查,不直接删除锁 | +| validation_failed | 配置校验失败,文件已回滚,未执行重载 | +| rollback_failed | 文件回滚未完成,保留锁并人工核查 | +| runtime_unconfirmed | 重载失败后文件回滚,但运行态未确认 | +| lock_release_failed | 锁释放失败,核查现场后再重试 | +| cleanup_failed | 已推送并重载,但旧文件清理失败,不能宣称站点已生效 | + +连接、目录、上传和文件替换等阶段也有独立固定诊断。站点 HTTPS 校验继续使用 verification_status,与单台服务器文件推送结果分开判断。 + > 验证收尾同步(2026-09-28):最终候选已完成 Windows 独立程序历史备份/升级/成员恢复和 Windows/Linux 构建,产物指纹与最终回归见支持矩阵第二十二节。Linux 为交叉构建,未进行原生运行;本批没有清理生产数据库、执行 SSH 部署或真实服务重启,仍禁止将候选构建通过解释为生产准入。 > 当前运行时候选(2026-09-28):SQLite 驱动固定为 v1.59.0,配套 libc v1.75.7,实测引擎 3.53.4;模式仍为 14。旧 v1.40.1 的专项通过不意味着其引擎满足当前 WAL 数据完整性要求,不使用中间版本替代最终候选。升级需先验证正式备份、历史模式升级及恢复;不直接部署本批工作区或降低 TLS。当前验证与未关闭的 MySQL 索引异常见支持矩阵第二十二节,本节不是生产放行许可。 diff --git a/frontend/src/views/Dashboard/DeploymentResult.test.js b/frontend/src/views/Dashboard/DeploymentResult.test.js index f496ac5..07f095e 100644 --- a/frontend/src/views/Dashboard/DeploymentResult.test.js +++ b/frontend/src/views/Dashboard/DeploymentResult.test.js @@ -100,6 +100,28 @@ test("HTTPS 校验失败响应保留推送明细,但不弹出成功通知", as assert.equal(state.notice.value, ""); }); +test("遗留锁的安全诊断保留在失败明细中,不提示部署成功", async (t) => { + const message = "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁"; + const failed = { + total: 1, + success: 0, + failed: 1, + status: "failed", + results: [{ server_id: "server-1", server: "测试服务器", status: "failed", failure_code: "lock_unavailable", message }], + }; + const { state } = await mountComposable(t, useDashboard, async (path) => { + if (path.endsWith("/deploy")) { + return Response.json({ error: "证书部署失败", deployment: failed }, { status: 502 }); + } + return Response.json({ items: [] }); + }); + await state.deployDomain({ id: "domain-1", name: "example.test" }); + assert.equal(state.deploymentResult.value.report.results[0].message, message); + assert.equal(state.deploymentResult.value.report.results[0].failure_code, "lock_unavailable"); + assert.equal(state.notice.value, ""); + assert.equal(state.deploymentResult.value.running, false); +}); + test("部署结果组件可编译且认证提示绑定真实配置", async () => { const source = await readFile(new URL("./DeploymentResult.vue", import.meta.url), "utf8"); const { descriptor, errors } = parse(source);