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