diff --git a/backend/internal/deployment/model.go b/backend/internal/deployment/model.go index 0391768..3bf9c8b 100644 --- a/backend/internal/deployment/model.go +++ b/backend/internal/deployment/model.go @@ -127,6 +127,9 @@ func resolveServerPathTemplate(label, value string, replacer *strings.Replacer) if cleaned == "" || cleaned == "." { return "", fmt.Errorf("%s无效", label) } + if !strings.HasPrefix(cleaned, "/") { + return "", fmt.Errorf("%s必须是绝对路径", label) + } return resolved, nil } diff --git a/backend/internal/deployment/model_test.go b/backend/internal/deployment/model_test.go index 9a1de51..6125ea7 100644 --- a/backend/internal/deployment/model_test.go +++ b/backend/internal/deployment/model_test.go @@ -67,6 +67,14 @@ func TestResolveServerPathsKeepsCustomPathAndRejectsUnknownPlaceholder(t *testin } } +func TestResolveServerPathsRejectsRelativePath(t *testing.T) { + server := testServer("one") + server.CertificatePath = "cert.pem" + if _, err := ResolveServerPaths(server, "example.com", "single"); err == nil { + t.Fatal("相对远程证书路径应被拒绝") + } +} + func TestServerRejectsPasswordStyleOrUnsafeConfiguration(t *testing.T) { server := testServer("one") server.PrivateKeyRef = "" diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index e0c42d2..fbe29f6 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -132,6 +132,10 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return result } } + if err := validateDeploymentPaths(paths); err != nil { + result.Message = err.Error() + return result + } backupID := s.backupID() type backup struct{ original, saved string } backups := make([]backup, 0, len(paths)) @@ -211,6 +215,21 @@ type remoteFile struct { mode uint32 } +func validateDeploymentPaths(files []remoteFile) error { + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + cleaned := RemotePath(file.path) + if cleaned == "" || cleaned == "." || !strings.HasPrefix(cleaned, "/") { + return errors.New("远程证书路径必须是绝对路径") + } + if _, exists := seen[cleaned]; exists { + return errors.New("远程证书、完整证书链和私钥不能使用相同路径") + } + seen[cleaned] = struct{}{} + } + return nil +} + func validateArtifact(artifact Artifact) error { if len(artifact.Certificate) == 0 || len(artifact.FullChain) == 0 || len(artifact.PrivateKey) == 0 { return errors.New("证书部署文件不完整") diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index 88284ff..4811ae4 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -167,6 +167,36 @@ func TestDeployReportsPartialSuccess(t *testing.T) { } } +func TestDeployRejectsDuplicateCertificatePaths(t *testing.T) { + client := newMemoryClient() + server := testServer("server-1") + server.PrivateKeyPath = server.CertificatePath + service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) + + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + if report.Status != "failed" || report.Failed != 1 { + t.Fatalf("重复远程路径应直接失败:%+v", report) + } + if !strings.Contains(report.Results[0].Message, "不能使用相同路径") { + t.Fatalf("重复远程路径错误提示不正确:%s", report.Results[0].Message) + } + if len(client.files) != 0 { + t.Fatalf("路径校验失败前不应连接并写入远程文件:%+v", client.files) + } +} + +func TestDeployRejectsRelativeCertificatePaths(t *testing.T) { + client := newMemoryClient() + server := testServer("server-1") + server.CertificatePath = "cert.pem" + service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) + + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "必须是绝对路径") { + t.Fatalf("相对远程路径应被拒绝:%+v", report) + } +} + func testServer(id string) Server { return Server{ ID: id, Name: id, Address: "192.0.2.10", Port: 22, Username: "deploy", diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 07130ab..d02c9c8 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -85,6 +85,16 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { 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 } diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 979362a..2922050 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -100,6 +100,44 @@ func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) { } } +type failingTaskStartRepository struct { + repository.TaskRepository +} + +func (r *failingTaskStartRepository) UpdateTask(ctx context.Context, id, status, stage, message string, startedAt, finishedAt *time.Time) error { + if status == "running" { + return errors.New("模拟任务启动状态写入失败") + } + return r.TaskRepository.UpdateTask(ctx, id, status, stage, message, startedAt, finishedAt) +} + +func TestTaskAPIClosesTaskWhenStartStateUpdateFails(t *testing.T) { + server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + t.Fatal("任务启动失败时不应执行证书申请") + return certificate.IssueResult{}, deployment.Report{}, nil + }) + defer closeDB() + server.tasks = &failingTaskStartRepository{TaskRepository: taskRepo} + + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "更新任务状态失败") { + t.Fatalf("任务启动状态写入失败响应错误:%d %s", response.Code, response.Body.String()) + } + items, err := taskRepo.ListTasks(context.Background(), "", 10) + if err != nil || len(items) != 1 || items[0].Status != "failed" || items[0].Message != "任务启动失败" { + t.Fatalf("任务启动失败后未正确收敛任务状态:%+v %v", items, err) + } + attempts, err := taskRepo.ListAttempts(context.Background(), items[0].ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].Message != "任务启动失败" { + t.Fatalf("任务启动失败后未正确收敛尝试状态:%+v %v", attempts, err) + } + logs, err := auditRepo.ListAuditLogs(context.Background(), 10) + if err != nil || len(logs) != 1 || logs[0].Status != "error" || logs[0].Message != "任务启动失败" { + t.Fatalf("任务启动失败后审计记录错误:%+v %v", logs, err) + } +} + func TestTaskAPIReturnsFailureAndKeepsTaskTrace(t *testing.T) { server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { return certificate.IssueResult{}, deployment.Report{ diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 40dfe7a..3d3c3b3 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3104,6 +3104,33 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 证书部署、Nginx 反向代理、生产 HTTPS、Windows Service、Linux systemd、Wails 打包、Windows ACL 和 MySQL 实例仍需在授权目标环境执行实机验收。 - `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。 + +## 八十二、证书部署路径与任务启动状态一致性(2026-08-31) + +### 82.1 本轮需求清单 + +- `[✓]` 服务器证书、完整证书链和私钥路径必须使用绝对路径,避免相对路径写入未知远程目录。 +- `[✓]` 拒绝证书、完整证书链和私钥配置为同一路径,避免部署时发生静默覆盖。 +- `[✓]` 服务器配置解析层和实际部署层均执行路径校验,避免绕过单层校验。 +- `[✓]` 证书任务在创建尝试成功但切换到“执行中”状态失败时,自动收敛为失败状态。 +- `[✓]` 任务启动失败时同步结束任务尝试并记录错误审计,避免数据库残留执行中任务。 + +### 82.2 代码与验证状态 + +- `[✓]` 更新 `backend/internal/deployment/model.go` 和 `service.go`,增加绝对路径与重复路径校验。 +- `[✓]` 更新 `backend/internal/httpapi/task_api.go`,补充任务启动状态写入失败的清理和审计逻辑。 +- `[✓]` 增加部署路径和任务启动失败回归测试。 +- `[✓]` 后端 `go test ./...` 通过。 +- `[✓]` 后端 `go test -race ./...` 通过。 +- `[✓]` 后端 `go vet ./...` 通过。 +- `[✓]` 后端 `go mod verify` 通过。 +- `[~]` 网页端、桌面端前端和桌面端 Go 全量验证待本轮后续命令完成。 +- `[~]` 本轮源码提交和远程推送待完成。 + +### 82.3 验收边界 + +- `[!]` 真实 DNS 服务商、Let’s Encrypt、SSH 主机、Nginx/Caddy/Apache、生产 HTTPS、Windows Service、Linux systemd、Wails 打包、Windows ACL、MySQL 和真实防火墙链路仍需在授权目标环境验收。 +- `[!]` 工作区已有的 `desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和构建产生的 `.exe` 未删除、未纳入本轮源码提交。 ## 八十一、DNS 在线测试传播参数接入与全量复核(2026-08-30) ### 81.1 本轮需求清单 @@ -3137,4 +3164,4 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` 尚未使用新的真实 DNS 服务商凭据执行公网 TXT 创建、传播和清理验收。 - `[!]` Let’s Encrypt Production、真实 SSH 主机、公钥安装、证书部署、Nginx 反向代理和生产 HTTPS 仍需在用户授权的目标环境执行实机验收。 - `[!]` Windows Service、Linux systemd、Wails 桌面打包、Windows ACL、MySQL 实例和真实防火墙链路仍需目标环境验收。 -- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。 \ No newline at end of file +- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。