完善证书部署路径与任务状态处理
This commit is contained in:
parent
09e449226a
commit
e68b8ec342
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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("证书部署文件不完整")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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*` 未删除、未纳入本轮提交,等待用户单独授权清理。
|
||||
- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user