修复部署后旧证书误通过及校验失败仍显示成功

This commit is contained in:
xuans 2026-09-30 21:21:56 +08:00
parent 1b2b45d059
commit fc57b8e1da
11 changed files with 416 additions and 13 deletions

View File

@ -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
}

View File

@ -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
},
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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()

View File

@ -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,未覆盖历史包。

View File

@ -1,4 +1,18 @@
# Scert 整改任务执行清单
## 当前整改任务(2026-09-30,部署后 HTTPS 校验)
需求确认:沿用已确认生产验收范围,先消除成功误报,再推进真实失败恢复;不以状态文案修改替代恢复能力。修改前检查点 1b2b45d 已推送。
- [x] 先写回归并复现旧有效证书误通过、校验失败报告仍成功。
- [x] 从实际证书文件计算待部署指纹,先校验私钥和完整链一致性;缺失或损坏时不执行远端写入。
- [x] 旧指纹、空指纹重试后仍不匹配则失败;元数据缺失或陈旧不影响从文件核对;取消不接受迟到成功。
- [x] 区分单台推送与站点验证,校验失败整体报告失败;任务、尝试、接口和页面不再宣称完成,不伪造回滚。
- [x] 本地 TLS 握手回归、任务持久化和接口回归已加入;前端全量 239 项及生产前端构建通过。
- [x] 后端全量测试和静态检查通过。
- [~] 应用/接口/部署竞态检查正在收尾,不以尚未退出的进程声明全通过。
- [ ] 提交后从规范克隆构建正式新候选,并推进尚未完成的实机测试。
- [!] 线上暂未更新;后置校验仍在文件部署事务之外,自动恢复、多服务器和完整生产门禁不提升状态。
## 最新执行结果(2026-09-30 21:07)
需求确认:沿用已确认范围,完成最新构建整改的正式发布验证并同步证据,不用本地通过替代生产部署验收。本批修改前检查点 7c727d3 已推送。

View File

@ -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

View File

@ -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 "证书部署未全部成功,请检查各服务器结果";

View File

@ -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);

View File

@ -49,7 +49,7 @@ defineEmits(["close"]);
</span>
</article>
</div>
<p class="muted">此处显示本次推送结果;远程站点是否使用新证书,仍需访问站点验证。</p>
<p class="muted">各服务器结果记录文件推送、配置校验与重载;站点 HTTPS 校验单独汇总,不代表逐台服务器均已验证。</p>
</template>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">