修复启动恢复未等待实际证书切换导致残锁的问题

This commit is contained in:
xuans 2026-10-01 22:06:04 +08:00
parent adbb3980ff
commit 00aeedc3ef
3 changed files with 147 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/admin"
@ -114,11 +115,11 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
if expected == "" || baseline.Endpoint == "" || len(baseline.Domains) == 0 {
return fmt.Errorf("恢复站点基线不完整")
}
validation, err := remoteValidator.ValidateHTTPSAt(ctx, baseline.Endpoint, server.Address, baseline.Domains)
validation, err := verifyRecoveryHTTPS(ctx, remoteValidator.ValidateHTTPSAt, server, baseline, expected)
if err != nil {
return err
}
if validation.FingerprintSHA256 != expected {
if !strings.EqualFold(validation.FingerprintSHA256, expected) {
return fmt.Errorf("站点证书指纹不符合事务基线")
}
return nil

View File

@ -0,0 +1,27 @@
package app
import (
"context"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func verifyRecoveryHTTPS(
ctx context.Context,
validate func(context.Context, string, string, []string) (certificate.ValidationResult, error),
server deployment.Server,
baseline deployment.RecoveryBaseline,
expected string,
) (certificate.ValidationResult, error) {
var result certificate.ValidationResult
workflow := CertificateWorkflow{
RemoteVerifierForServer: func(ctx context.Context, target deployment.Server, endpoint string, domains []string) (certificate.ValidationResult, error) {
var err error
result, err = validate(ctx, endpoint, target.Address, domains)
return result, err
},
}
err := workflow.verifyRemoteDeploymentForServer(ctx, server, baseline.Endpoint, baseline.Domains, expected)
return result, err
}

View File

@ -0,0 +1,117 @@
package app
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func TestRecoveryHTTPSWaitsForActualCertificateSwitch(t *testing.T) {
t.Parallel()
previous, changed := workflowSavedCertificate(t), workflowSavedCertificate(t)
roots := x509.NewCertPool()
start := 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 := start(previous), start(changed)
validator := certificate.RemoteValidator{Roots: roots}
for _, committed := range []bool{false, true} {
name := "rollback"
expected, finalURL, initialURL := previous.FingerprintSHA256, oldServer.URL, newServer.URL
if committed {
name, expected, finalURL, initialURL = "commit", changed.FingerprintSHA256, newServer.URL, oldServer.URL
}
t.Run(name, func(t *testing.T) {
calls := 0
baseline := deployment.RecoveryBaseline{Endpoint: "https://example.com", Domains: []string{"example.com"}, PreviousFingerprint: previous.FingerprintSHA256, ExpectedFingerprint: changed.FingerprintSHA256}
result, err := verifyRecoveryHTTPS(t.Context(), func(ctx context.Context, endpoint, address string, domains []string) (certificate.ValidationResult, error) {
calls++
if endpoint != baseline.Endpoint || address != "127.0.0.1" || len(domains) != 1 || domains[0] != "example.com" {
t.Fatal("恢复核验必须保留原目标、地址和域名校验")
}
url := initialURL
if calls > 1 {
url = finalURL
}
return validator.ValidateHTTPS(ctx, url, domains)
}, deployment.Server{Address: "127.0.0.1"}, baseline, expected)
if err != nil || calls != 2 || !strings.EqualFold(result.FingerprintSHA256, expected) {
t.Fatalf("必须等待实际证书切换后才确认恢复:调用=%d,结果=%+v,错误=%v", calls, result, err)
}
})
}
}
func TestRecoveryHTTPSFailsClosed(t *testing.T) {
for _, name := range []string{"fingerprint_mismatch", "empty_fingerprint", "verification_error", "cancelled_match", "deadline"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
if name == "deadline" {
var deadlineCancel context.CancelFunc
ctx, deadlineCancel = context.WithTimeout(ctx, 30*time.Millisecond)
defer deadlineCancel()
}
expected := strings.Repeat("A", 64)
sentinel := errors.New("证书信任或域名校验失败")
calls := 0
_, err := verifyRecoveryHTTPS(ctx, func(context.Context, string, string, []string) (certificate.ValidationResult, error) {
calls++
switch name {
case "verification_error":
return certificate.ValidationResult{}, sentinel
case "empty_fingerprint":
return certificate.ValidationResult{}, nil
case "cancelled_match":
cancel()
return certificate.ValidationResult{FingerprintSHA256: expected}, nil
default:
return certificate.ValidationResult{FingerprintSHA256: strings.Repeat("B", 64)}, nil
}
}, deployment.Server{Address: "127.0.0.1"}, deployment.RecoveryBaseline{Endpoint: "https://example.com", Domains: []string{"example.com"}}, expected)
if err == nil {
t.Fatal("证据不足或核验中断不能认定恢复成功")
}
switch name {
case "cancelled_match":
if calls != 1 || !errors.Is(err, context.Canceled) {
t.Fatalf("取消后不得接受迟到的匹配结果:调用=%d,错误=%v", calls, err)
}
case "deadline":
if calls != 1 || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("恢复等待必须受截止时间约束:调用=%d,错误=%v", calls, err)
}
default:
if calls != defaultRemoteVerificationAttempts {
t.Fatalf("恢复核验次数必须有限:调用=%d,错误=%v", calls, err)
}
if name == "verification_error" && !errors.Is(err, sentinel) {
t.Fatalf("不能忽略证书校验错误:%v", err)
}
}
})
}
}