From fc57b8e1da5f1e7e145ae191748451deabbea96c Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 30 Sep 2026 21:21:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=83=A8=E7=BD=B2=E5=90=8E?= =?UTF-8?q?=E6=97=A7=E8=AF=81=E4=B9=A6=E8=AF=AF=E9=80=9A=E8=BF=87=E5=8F=8A?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5=E4=BB=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/certificate_workflow.go | 34 ++- .../internal/app/certificate_workflow_test.go | 17 +- .../app/deployment_verification_test.go | 257 ++++++++++++++++++ backend/internal/deployment/service.go | 2 + .../httpapi/task_deployment_results_test.go | 36 +++ doc/Linux生产流程测试记录.md | 19 ++ doc/Scert整改任务执行清单.md | 14 + doc/Scert整改合并优化清单.md | 14 + .../src/views/Dashboard/DeploymentFeedback.js | 6 + .../views/Dashboard/DeploymentResult.test.js | 28 ++ .../src/views/Dashboard/DeploymentResult.vue | 2 +- 11 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 backend/internal/app/deployment_verification_test.go diff --git a/backend/internal/app/certificate_workflow.go b/backend/internal/app/certificate_workflow.go index 2cd7f66..87225e0 100644 --- a/backend/internal/app/certificate_workflow.go +++ b/backend/internal/app/certificate_workflow.go @@ -242,6 +242,17 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss } targets = append(targets, deployment.Target{Server: resolved}) } + expectedFingerprint := "" + if w.RemoteVerifier != nil { + // 从即将部署的文件读取身份,不信任可能缺失或陈旧的元数据;远端仍必须验证可信证书链。 + validation, validationErr := (certificate.LocalValidator{}).ValidateFiles( + ctx, result.Saved.CertificatePath, result.Saved.FullChainPath, result.Saved.PrivateKeyPath, result.Domains, + ) + if validationErr != nil { + return deployment.Report{Status: "failed"}, fmt.Errorf("部署前证书文件校验失败:%w", validationErr) + } + expectedFingerprint = validation.FingerprintSHA256 + } report, err := w.Deployment.DeploySaved(ctx, targets, deployment.SavedFiles{ CertificatePath: result.Saved.CertificatePath, FullChainPath: result.Saved.FullChainPath, @@ -255,17 +266,22 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss } if w.RemoteVerifier != nil { verificationDomain := verificationName(domainItem, result.Domains) + // 单台结果保留真实文件推送结果,站点生效校验单独记录,不能宣称已回滚。 + report.VerificationStatus = "failed" + report.Status = "failed" if verificationDomain == "" { return report, errors.New("无法确定部署后的 HTTPS 校验域名") } - if err := w.verifyRemoteDeployment(ctx, "https://"+verificationDomain, result.Domains); err != nil { + if err := w.verifyRemoteDeployment(ctx, "https://"+verificationDomain, result.Domains, expectedFingerprint); err != nil { return report, fmt.Errorf("部署后 HTTPS 校验失败:%w", err) } + report.VerificationStatus = "success" + report.Status = "success" } return report, nil } -func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoint string, expectedDomains []string) error { +func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoint string, expectedDomains []string, expectedFingerprint string) error { attempts := w.RemoteVerificationAttempts if attempts <= 0 { attempts = defaultRemoteVerificationAttempts @@ -284,11 +300,17 @@ func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoi if err := ctx.Err(); err != nil { return err } - if _, err := w.RemoteVerifier(ctx, endpoint, expectedDomains); err == nil { - return nil - } else { - lastErr = err + validation, err := w.RemoteVerifier(ctx, endpoint, expectedDomains) + if err == nil { + if contextErr := ctx.Err(); contextErr != nil { + return contextErr + } + if expectedFingerprint != "" && strings.EqualFold(validation.FingerprintSHA256, expectedFingerprint) { + return nil + } + err = errors.New("站点证书指纹与本次部署的证书不一致,尚未确认新证书生效") } + lastErr = err if attempt == attempts { break } diff --git a/backend/internal/app/certificate_workflow_test.go b/backend/internal/app/certificate_workflow_test.go index 7eba5e4..aa15ec8 100644 --- a/backend/internal/app/certificate_workflow_test.go +++ b/backend/internal/app/certificate_workflow_test.go @@ -199,12 +199,13 @@ func TestCertificateWorkflowResolvesSameServerForDifferentDomains(t *testing.T) } func TestCertificateWorkflowRetriesRemoteVerificationUntilSuccess(t *testing.T) { + saved := workflowSavedCertificate(t) deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}} state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}} verificationCalls := 0 waitCalls := 0 workflow := &CertificateWorkflow{ - Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}}, + Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}}, Domains: state, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, Deployment: deployer, @@ -225,7 +226,7 @@ func TestCertificateWorkflowRetriesRemoteVerificationUntilSuccess(t *testing.T) if verificationCalls < 3 { return certificate.ValidationResult{}, errors.New("仍返回旧证书") } - return certificate.ValidationResult{}, nil + return certificate.ValidationResult{FingerprintSHA256: saved.FingerprintSHA256}, nil }, } @@ -238,12 +239,13 @@ func TestCertificateWorkflowRetriesRemoteVerificationUntilSuccess(t *testing.T) } func TestCertificateWorkflowRemoteVerificationStopsAfterConfiguredAttempts(t *testing.T) { + saved := workflowSavedCertificate(t) deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}} state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}} verificationCalls := 0 waitCalls := 0 workflow := &CertificateWorkflow{ - Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}}, + Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}}, Domains: state, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, Deployment: deployer, @@ -268,12 +270,13 @@ func TestCertificateWorkflowRemoteVerificationStopsAfterConfiguredAttempts(t *te } func TestCertificateWorkflowRemoteVerificationWaitHonorsContextCancellation(t *testing.T) { + saved := workflowSavedCertificate(t) deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}} state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}} ctx, cancel := context.WithCancel(context.Background()) verificationCalls := 0 workflow := &CertificateWorkflow{ - Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}}, + Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}}, Domains: state, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, Deployment: deployer, @@ -297,6 +300,8 @@ func TestCertificateWorkflowRemoteVerificationWaitHonorsContextCancellation(t *t func TestCertificateWorkflowDeployCurrentRestoresValidState(t *testing.T) { notAfter := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second) + saved := workflowSavedCertificate(t) + saved.NotAfter = notAfter state := &workflowDomainState{ status: "error", item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}, @@ -306,14 +311,14 @@ func TestCertificateWorkflowDeployCurrentRestoresValidState(t *testing.T) { return certificate.IssueResult{ DomainID: "domain-1", Domains: []string{"example.com"}, - Saved: certificate.SavedResource{NotAfter: notAfter}, + Saved: saved, }, nil }, Domains: state, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}, RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { - return certificate.ValidationResult{}, nil + return certificate.ValidationResult{FingerprintSHA256: saved.FingerprintSHA256}, nil }, } diff --git a/backend/internal/app/deployment_verification_test.go b/backend/internal/app/deployment_verification_test.go new file mode 100644 index 0000000..34d5256 --- /dev/null +++ b/backend/internal/app/deployment_verification_test.go @@ -0,0 +1,257 @@ +package app + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" +) + +func workflowSavedCertificate(t *testing.T) certificate.SavedResource { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), DNSNames: []string{"example.com"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + saved := certificate.SavedResource{ + CertificatePath: filepath.Join(directory, "cert.pem"), + FullChainPath: filepath.Join(directory, "fullchain.pem"), + PrivateKeyPath: filepath.Join(directory, "key.pem"), + FingerprintSHA256: fmt.Sprintf("%X", sha256.Sum256(der)), NotAfter: template.NotAfter, + } + for path, data := range map[string][]byte{ + saved.CertificatePath: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + saved.FullChainPath: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + saved.PrivateKeyPath: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + } { + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + return saved +} + +func TestDeploymentVerificationRejectsWrongOrMissingCertificateIdentity(t *testing.T) { + for _, action := range []string{"issue", "deploy", "import"} { + for _, remoteFingerprint := range []string{strings.Repeat("A", 64), ""} { + t.Run(action+"/"+remoteFingerprint, func(t *testing.T) { + saved := workflowSavedCertificate(t) + issued := certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved} + state := &workflowDomainState{} + deployer := &workflowDeployer{report: deployment.Report{ + Total: 1, Success: 1, Status: "success", + Results: []deployment.Result{{ServerID: "server-1", Status: "success", Message: "文件已推送并重载"}}, + }} + workflow := &CertificateWorkflow{ + Certificates: &workflowCertificateService{result: issued}, + CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) { return issued, nil }, + Domains: state, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: deployer, RemoteVerificationAttempts: 1, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + return certificate.ValidationResult{FingerprintSHA256: remoteFingerprint}, nil + }, + } + var result CertificateWorkflowResult + var err error + switch action { + case "issue": + result, err = workflow.IssueWithReport(t.Context(), issued.DomainID) + case "deploy": + result, err = workflow.DeployCurrent(t.Context(), issued.DomainID) + case "import": + result, err = workflow.Import(t.Context(), certificate.ImportRequest{DomainID: issued.DomainID}) + } + if err == nil || result.Deployment.Status != "failed" || result.Deployment.VerificationStatus != "failed" || state.status != "error" { + t.Fatalf("错误或缺失证书身份不得报告生效成功:%+v,%s,%v", result.Deployment, state.status, err) + } + if result.Deployment.Success != 1 || result.Deployment.Results[0].Status != "success" { + t.Fatalf("站点校验失败不得伪造单台文件推送失败或回滚:%+v", result.Deployment) + } + }) + } + } +} + +func TestDeploymentVerificationFailureDoesNotReturnSuccessfulReport(t *testing.T) { + saved := workflowSavedCertificate(t) + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}, + RemoteVerificationAttempts: 1, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + return certificate.ValidationResult{}, errors.New("模拟站点不可达") + }, + } + report, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}) + if err == nil || report.Status != "failed" || report.VerificationStatus != "failed" { + t.Fatalf("站点不可达时不得保留成功汇总:%+v,%v", report, err) + } +} + +func TestDeploymentVerificationUsesFileIdentityInsteadOfMetadata(t *testing.T) { + for _, metadata := range []string{"", strings.Repeat("B", 64)} { + t.Run(metadata, func(t *testing.T) { + saved := workflowSavedCertificate(t) + expected := saved.FingerprintSHA256 + saved.FingerprintSHA256 = metadata + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}, + RemoteVerificationAttempts: 1, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + return certificate.ValidationResult{FingerprintSHA256: strings.ToLower(expected)}, nil + }, + } + report, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}) + if err != nil || report.Status != "success" || report.VerificationStatus != "success" { + t.Fatalf("应按实际文件指纹确认生效,不依赖元数据或大小写:%+v,%v", report, err) + } + }) + } +} + +func TestDeploymentVerificationRejectsInvalidLocalFilesBeforeRemoteWrite(t *testing.T) { + for _, broken := range []string{"certificate", "chain", "key"} { + t.Run(broken, func(t *testing.T) { + saved := workflowSavedCertificate(t) + path := map[string]string{"certificate": saved.CertificatePath, "chain": saved.FullChainPath, "key": saved.PrivateKeyPath}[broken] + if err := os.WriteFile(path, []byte("invalid"), 0o600); err != nil { + t.Fatal(err) + } + deployer := &workflowDeployer{} + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: deployer, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + t.Fatal("本地文件损坏时不应执行远端校验") + return certificate.ValidationResult{}, nil + }, + } + report, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}) + if err == nil || report.Status != "failed" || deployer.targets != nil || report.VerificationStatus != "" { + t.Fatalf("本地文件损坏后不得推送或声称已校验站点:%+v,%v", report, err) + } + }) + } +} + +func TestDeploymentVerificationRetriesRealValidOldCertificate(t *testing.T) { + oldSaved, newSaved := workflowSavedCertificate(t), workflowSavedCertificate(t) + roots := x509.NewCertPool() + startServer := func(saved certificate.SavedResource) *httptest.Server { + pair, err := tls.LoadX509KeyPair(saved.CertificatePath, saved.PrivateKeyPath) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + t.Fatal(err) + } + roots.AddCert(leaf) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12} + server.StartTLS() + t.Cleanup(server.Close) + return server + } + oldServer, newServer := startServer(oldSaved), startServer(newSaved) + validator := certificate.RemoteValidator{Roots: roots} + calls, waits := 0, 0 + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}, + RemoteVerificationAttempts: 2, + RemoteVerificationWait: func(context.Context, time.Duration) error { waits++; return nil }, + RemoteVerifier: func(ctx context.Context, _ string, domains []string) (certificate.ValidationResult, error) { + calls++ + endpoint := oldServer.URL + if calls > 1 { + endpoint = newServer.URL + } + return validator.ValidateHTTPS(ctx, endpoint, domains) + }, + } + report, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: newSaved}) + if err != nil || report.VerificationStatus != "success" || calls != 2 || waits != 1 { + t.Fatalf("有效旧证书必须重试,只有实际新证书才能通过:%+v,调用 %d,等待 %d,%v", report, calls, waits, err) + } +} + +func TestDeploymentVerificationCancellationCannotBecomeSuccess(t *testing.T) { + saved := workflowSavedCertificate(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + cancel() + return certificate.ValidationResult{FingerprintSHA256: saved.FingerprintSHA256}, nil + }, + } + report, err := workflow.deploy(ctx, certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}, Saved: saved}) + if !errors.Is(err, context.Canceled) || report.Status != "failed" || report.VerificationStatus != "failed" { + t.Fatalf("取消后不得接受迟到的成功校验:%+v,%v", report, err) + } +} + +func TestDeploymentVerificationFailurePersistsFailedTask(t *testing.T) { + tracked, app := newCertificateTaskFixture(t) + saved := workflowSavedCertificate(t) + tracked.workflow = &CertificateWorkflow{ + CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) { + return certificate.IssueResult{DomainID: "task-domain", Domains: []string{"example.com"}, Saved: saved}, nil + }, + Domains: &workflowDomainState{}, Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}}, + Deployment: &workflowDeployer{report: deployment.Report{ + Total: 1, Success: 1, Status: "success", Results: []deployment.Result{{ServerID: "server-1", Status: "success"}}, + }}, + RemoteVerificationAttempts: 1, + RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) { + return certificate.ValidationResult{FingerprintSHA256: strings.Repeat("A", 64)}, nil + }, + } + result, err := tracked.DeployCurrent(t.Context(), "task-domain") + if err == nil || result.Deployment.VerificationStatus != "failed" { + t.Fatalf("任务工作流未保留校验失败:%+v,%v", result, err) + } + task := assertCertificateTask(t, app, "failed", "failed") + items, err := app.Tasks.ListTaskDeploymentResults(t.Context(), task.ID) + if err != nil || len(items) != 1 || items[0].Status != "success" { + t.Fatalf("失败任务应保留真实的文件推送结果:%+v,%v", items, err) + } +} diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index 413f05b..07e26ec 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -44,6 +44,8 @@ type Report struct { Failed int `json:"failed"` Status string `json:"status"` Results []Result `json:"results"` + // 站点校验与各服务器文件推送分开记录,不表示所有服务器均完成独立 HTTPS 验证。 + VerificationStatus string `json:"verification_status,omitempty"` } type Service struct { diff --git a/backend/internal/httpapi/task_deployment_results_test.go b/backend/internal/httpapi/task_deployment_results_test.go index 67efc58..eab7eb4 100644 --- a/backend/internal/httpapi/task_deployment_results_test.go +++ b/backend/internal/httpapi/task_deployment_results_test.go @@ -78,6 +78,42 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) { } } +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{ + Total: 1, Success: 1, Status: "failed", VerificationStatus: "failed", + Results: []deployment.Result{{ServerID: "server-a", Status: "success", Message: "文件已推送并重载"}}, + }, errors.New("HTTPS 校验失败:/secret-path 不应返回客户端") + }) + defer closeDB() + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if response.Code != http.StatusBadGateway { + t.Fatalf("HTTPS 校验失败应返回失败响应:%d %s", response.Code, response.Body.String()) + } + var payload struct { + Deployment deployment.Report `json:"deployment"` + TaskStatus string `json:"task_status"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.TaskStatus != "failed" || payload.Deployment.Status != "failed" || payload.Deployment.VerificationStatus != "failed" { + t.Fatalf("接口未保留整体失败与站点校验失败:%+v", payload) + } + if payload.Deployment.Success != 1 || payload.Deployment.Results[0].Status != "success" || strings.Contains(response.Body.String(), "/secret-path") { + t.Fatalf("接口丢失真实推送结果或泄露底层错误:%s", response.Body.String()) + } + items, err := tasks.ListTasks(t.Context(), "", 10) + if err != nil || len(items) != 1 || items[0].Status != "failed" { + t.Fatalf("任务持久化状态错误:%+v,%v", items, err) + } + results, err := tasks.ListTaskDeploymentResults(t.Context(), items[0].ID) + if err != nil || len(results) != 1 || results[0].Status != "success" { + t.Fatalf("持久化单台推送结果错误:%+v,%v", results, err) + } +} + func TestTaskDeploymentResultsVisibility(t *testing.T) { server, tasks, _, closeDB := newTaskAPITestServer(t, nil) defer closeDB() diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 8e0c7f3..b3c9f35 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,4 +1,23 @@ # Linux 生产流程测试记录 +## 部署后 HTTPS 误报整改(2026-09-30,本地验证,未部署) + +### 问题与修复 + +- 原验证器会验证证书有效期、域名与可信链,但工作流忽略其返回指纹;同域名旧证书仍有效时可直接成功。新增回归覆盖申请、部署已有证书、导入三条入口,旧指纹及空指纹均在修复前复现误成功。 +- 原后置 HTTPS 报错只返回外层错误,report.status 仍为 success。修复前独立回归复现,修复后整体状态 failed,域名 error;单台推送结果保留原事实,不伪称文件推送失败或已回滚。 +- 部署前校验本地证书、完整链与私钥,从实际文件获取指纹,不依赖 saved 元数据。远端仍沿用可信链校验,再比较指纹;不关闭 TLS 验证,不增加强制 IP 的生产验收绕过方式。 +- 本地隔离测试使用临时证书与自有信任根启动真实 TLS 服务,旧有效证书触发重试,新证书才成功。该用例不是外部真实站点、多服务器或重载恢复验收。 + +### 部署报告补充约定 + +申请、部署已有证书、导入响应中的 deployment 新增可选 verification_status:success 表示站点校验通过且指纹相同,failed 表示校验失败或未能确认,省略表示未执行该阶段。旧响应无该字段时不能推断已经校验站点。 + +total、success、failed、results 继续记录每台服务器的文件推送结果;整体 status 还受后置校验影响。因此可能出现 success=1、failed=0,但 status=failed、verification_status=failed,这表示文件已推送但生效校验未通过,并不矛盾。前端需优先显示生效校验失败,不能单看成功数量或一台结果放行。 + +任务和尝试整体持久化为 failed;服务器结果表保留成功的文件推送记录。verification_status 当前随执行响应返回,不是新增数据库持久化字段,也不代表对每台服务器独立验证。 + +前端 239 项及生产前端构建、后端全量测试、静态检查通过。专项竞态结果待本批收尾;未部署本批代码。重载失败、后置 HTTPS 失败后的实际恢复、任务恢复、三数据库和首次清库流程继续保留待办。 + ## 最新构建验证记录(2026-09-30 21:07,未部署) - 本轮沿用既有规范克隆,签出精确提交 9e89edae282d8c18ddf9588751961015d220efcf,正式发布入口输出到 build/review-release-eol,未覆盖历史包。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 261c8db..ef35002 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,4 +1,18 @@ # Scert 整改任务执行清单 +## 当前整改任务(2026-09-30,部署后 HTTPS 校验) + +需求确认:沿用已确认生产验收范围,先消除成功误报,再推进真实失败恢复;不以状态文案修改替代恢复能力。修改前检查点 1b2b45d 已推送。 + +- [x] 先写回归并复现旧有效证书误通过、校验失败报告仍成功。 +- [x] 从实际证书文件计算待部署指纹,先校验私钥和完整链一致性;缺失或损坏时不执行远端写入。 +- [x] 旧指纹、空指纹重试后仍不匹配则失败;元数据缺失或陈旧不影响从文件核对;取消不接受迟到成功。 +- [x] 区分单台推送与站点验证,校验失败整体报告失败;任务、尝试、接口和页面不再宣称完成,不伪造回滚。 +- [x] 本地 TLS 握手回归、任务持久化和接口回归已加入;前端全量 239 项及生产前端构建通过。 +- [x] 后端全量测试和静态检查通过。 +- [~] 应用/接口/部署竞态检查正在收尾,不以尚未退出的进程声明全通过。 +- [ ] 提交后从规范克隆构建正式新候选,并推进尚未完成的实机测试。 +- [!] 线上暂未更新;后置校验仍在文件部署事务之外,自动恢复、多服务器和完整生产门禁不提升状态。 + ## 最新执行结果(2026-09-30 21:07) 需求确认:沿用已确认范围,完成最新构建整改的正式发布验证并同步证据,不用本地通过替代生产部署验收。本批修改前检查点 7c727d3 已推送。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index db6e373..8521069 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,4 +1,16 @@ # Scert 整改、合并与优化清单 +## 最新整改进度(2026-09-30,部署后 HTTPS 身份与状态一致性) + +- [x] 回归已复现两个缺陷:站点仍返回同域名旧有效证书时被判成功;站点校验报错时部署报告仍为 success。 +- [x] 部署前从实际证书、完整链及私钥校验结果提取指纹,不信任历史元数据;本地文件损坏时在远端写入前停止。 +- [x] HTTPS 必须返回本次待部署证书的相同指纹;旧指纹或空指纹进入有界重试,取消后不接收迟到的成功结果。远端可信链验证没有取消。 +- [x] 增加 verification_status 区分站点校验与单台文件推送;校验失败时整体报告失败,保留真实推送结果,不伪称自动回滚。页面同步展示,任务、尝试及接口回归覆盖失败状态。 +- [x] 新增本地真实 TLS 握手用例验证旧有效证书切换新证书;前端 239 项测试及生产前端构建通过。 +- [x] 后端全量测试、静态检查均通过;前端 239 项测试与生产前端构建通过。 +- [~] 专项竞态最终结果待本批收尾记录;提交后需规范构建并部署新候选。 +- [ ] 多域名、多服务器、重载失败、后置 HTTPS 失败实机恢复、三数据库及清库首次初始化仍待完成。 +- [!] 本轮仅本地开发与隔离验证,不清空业务库、不修改线上现场、不新增生产放行。P0-03 已复审结论不变,P0-10 整项仍进行中。 + ## 最新验收进度(2026-09-30 21:07,规范源码双平台发布构建) - [x] 规范独立克隆签出 9e89eda,正式发布入口完成 Windows amd64 和 Linux amd64 双目标构建;不是只运行工具链预检。 @@ -755,6 +767,8 @@ updated_by **状态:** `[~] 进行中` +**最新整改(2026-09-30):** 已复现并修复后置 HTTPS 校验接受旧有效证书、校验失败仍返回成功部署报告的问题;身份来自实际待部署文件。报告以 verification_status 单独记录站点生效校验,整体失败不改写单台真实文件推送事实;任务、尝试和接口保持失败,页面不再汇总为完成。本批本地测试不等于实机恢复通过;该校验仍位于文件部署事务之后,不具备据此宣称自动回滚的依据。 + 所有长耗时操作统一进入任务中心: ```text diff --git a/frontend/src/views/Dashboard/DeploymentFeedback.js b/frontend/src/views/Dashboard/DeploymentFeedback.js index ed53e5d..4da1940 100644 --- a/frontend/src/views/Dashboard/DeploymentFeedback.js +++ b/frontend/src/views/Dashboard/DeploymentFeedback.js @@ -10,11 +10,17 @@ export function serverAuthLabel(server) { export function deploymentSummary(report) { if (!report || !Array.isArray(report.results)) return "未收到有效部署结果"; + if (report.verification_status === "failed") { + return "证书已推送,但站点 HTTPS 校验失败,尚未确认新证书生效;未自动回滚"; + } if (report.total === 0 && report.results.length === 0) return "未配置部署目标,未执行推送"; if (report.status === "success" && report.total > 0 && report.success === report.total && report.failed === 0 && report.results.length === report.total && report.results.every((item) => item.status === "success")) { + if (report.verification_status === "success") { + return `证书部署完成:${report.success} 台推送成功,站点 HTTPS 校验通过`; + } return `证书部署完成:${report.success} 台成功`; } return "证书部署未全部成功,请检查各服务器结果"; diff --git a/frontend/src/views/Dashboard/DeploymentResult.test.js b/frontend/src/views/Dashboard/DeploymentResult.test.js index 4e0f695..f496ac5 100644 --- a/frontend/src/views/Dashboard/DeploymentResult.test.js +++ b/frontend/src/views/Dashboard/DeploymentResult.test.js @@ -30,6 +30,18 @@ test("部署汇总不把空目标、缺失结果或部分失败称为成功", () assert.match(deploymentSummary({ ...report, status: "partial", failed: 1 }), /未全部成功/); }); +test("站点生效校验与单台推送分开展示,不把旧证书或校验失败宣称为部署成功", () => { + const failed = { ...report, status: "failed", verification_status: "failed" }; + assert.match(deploymentSummary(failed), /HTTPS 校验失败/); + assert.match(deploymentSummary(failed), /未自动回滚/); + assert.doesNotMatch(deploymentSummary(failed), /部署完成/); + assert.match(deploymentSummary({ ...report, verification_status: "failed" }), /尚未确认/); + assert.equal( + deploymentSummary({ ...report, verification_status: "success" }), + "证书部署完成:1 台推送成功,站点 HTTPS 校验通过", + ); +}); + test("手动部署展示真实返回结果并阻止执行期间重复提交", async (t) => { const pending = deferred(); let requests = 0; @@ -72,6 +84,22 @@ test("部署失败仍保留各服务器结果,网络失败不会残留执行 assert.equal(state.deploymentResult.value.error, "network-test-failure"); }); +test("HTTPS 校验失败响应保留推送明细,但不弹出成功通知", async (t) => { + const failed = { ...report, status: "failed", verification_status: "failed" }; + 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.running, false); + assert.equal(state.deploymentResult.value.error, "证书部署失败"); + assert.deepEqual(state.deploymentResult.value.report, failed); + assert.match(deploymentSummary(state.deploymentResult.value.report), /尚未确认新证书生效/); + assert.equal(state.notice.value, ""); +}); + test("部署结果组件可编译且认证提示绑定真实配置", async () => { const source = await readFile(new URL("./DeploymentResult.vue", import.meta.url), "utf8"); const { descriptor, errors } = parse(source); diff --git a/frontend/src/views/Dashboard/DeploymentResult.vue b/frontend/src/views/Dashboard/DeploymentResult.vue index 0ee118f..8b3529c 100644 --- a/frontend/src/views/Dashboard/DeploymentResult.vue +++ b/frontend/src/views/Dashboard/DeploymentResult.vue @@ -49,7 +49,7 @@ defineEmits(["close"]); -

此处显示本次推送结果;远程站点是否使用新证书,仍需访问站点验证。

+

各服务器结果记录文件推送、配置校验与重载;站点 HTTPS 校验单独汇总,不代表逐台服务器均已验证。