完善多服务器逐台HTTPS校验

为证书部署流程增加按服务器地址连接、逐台验证和恢复校验能力,作为本轮继续开发前的检查点。
This commit is contained in:
xuans 2026-10-01 06:41:59 +08:00
parent d06a001c64
commit 840d48d2e5
2 changed files with 85 additions and 13 deletions

View File

@ -21,6 +21,7 @@ type CertificateWorkflow struct {
Notifier notification.Notifier
NotifyError func(error)
RemoteVerifier func(context.Context, string, []string) (certificate.ValidationResult, error)
RemoteVerifierForServer func(context.Context, deployment.Server, string, []string) (certificate.ValidationResult, error)
RemoteVerificationAttempts int
RemoteVerificationInterval time.Duration
RemoteVerificationWait func(context.Context, time.Duration) error
@ -243,8 +244,9 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss
}
targets = append(targets, deployment.Target{Server: resolved})
}
remoteVerificationEnabled := w.RemoteVerifier != nil || w.RemoteVerifierForServer != nil
expectedFingerprint := ""
if w.RemoteVerifier != nil {
if remoteVerificationEnabled {
// 从即将部署的文件读取身份,不信任可能缺失或陈旧的元数据;远端仍必须验证可信证书链。
validation, validationErr := (certificate.LocalValidator{}).ValidateFiles(
ctx, result.Saved.CertificatePath, result.Saved.FullChainPath, result.Saved.PrivateKeyPath, result.Domains,
@ -259,25 +261,54 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss
FullChainPath: result.Saved.FullChainPath,
PrivateKeyPath: result.Saved.PrivateKeyPath,
}
if w.RemoteVerifier != nil {
if remoteVerificationEnabled {
verificationDomain := verificationName(domainItem, result.Domains)
if verificationDomain == "" {
return deployment.Report{Status: "failed"}, errors.New("无法确定部署后的 HTTPS 校验域名,未执行推送")
}
endpoint := "https://" + verificationDomain
// 首次部署允许不存在旧站点,但不得在缺少可信基线时宣称运行态已恢复。
baseline, baselineErr := w.RemoteVerifier(ctx, endpoint, result.Domains)
if err := ctx.Err(); err != nil {
return deployment.Report{Status: "failed"}, err
type verificationTarget struct {
server deployment.Server
baselineFingerprint string
}
verificationTargets := make([]verificationTarget, 0, len(targets))
allBaselinesAvailable := true
for _, target := range targets {
// 首次部署允许不存在旧站点,但不得在缺少可信基线时宣称运行态已恢复。
baseline, baselineErr := w.verifyRemoteTarget(ctx, target.Server, endpoint, result.Domains)
if err := ctx.Err(); err != nil {
return deployment.Report{Status: "failed"}, err
}
baselineFingerprint := ""
if baselineErr == nil {
baselineFingerprint = baseline.FingerprintSHA256
}
if baselineFingerprint == "" {
allBaselinesAvailable = false
}
verificationTargets = append(verificationTargets, verificationTarget{
server: target.Server,
baselineFingerprint: baselineFingerprint,
})
}
verification := deployment.Verification{
Verify: func(verificationContext context.Context) error {
return w.verifyRemoteDeployment(verificationContext, endpoint, result.Domains, expectedFingerprint)
for _, target := range verificationTargets {
if err := w.verifyRemoteDeploymentForServer(verificationContext, target.server, endpoint, result.Domains, expectedFingerprint); err != nil {
return fmt.Errorf("服务器 %s 的 HTTPS 校验失败:%w", target.server.Name, err)
}
}
return nil
},
}
if baselineErr == nil && baseline.FingerprintSHA256 != "" {
if allBaselinesAvailable {
verification.VerifyRecovery = func(recoveryContext context.Context) error {
return w.verifyRemoteDeployment(recoveryContext, endpoint, result.Domains, baseline.FingerprintSHA256)
for _, target := range verificationTargets {
if err := w.verifyRemoteDeploymentForServer(recoveryContext, target.server, endpoint, result.Domains, target.baselineFingerprint); err != nil {
return fmt.Errorf("服务器 %s 的恢复校验失败:%w", target.server.Name, err)
}
}
return nil
}
}
return w.Deployment.DeploySavedVerified(ctx, targets, saved, verification)
@ -292,7 +323,21 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss
return report, nil
}
func (w *CertificateWorkflow) verifyRemoteTarget(ctx context.Context, server deployment.Server, endpoint string, expectedDomains []string) (certificate.ValidationResult, error) {
if w.RemoteVerifierForServer != nil {
return w.RemoteVerifierForServer(ctx, server, endpoint, expectedDomains)
}
if w.RemoteVerifier == nil {
return certificate.ValidationResult{}, errors.New("远端 HTTPS 校验器未配置")
}
return w.RemoteVerifier(ctx, endpoint, expectedDomains)
}
func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoint string, expectedDomains []string, expectedFingerprint string) error {
return w.verifyRemoteDeploymentForServer(ctx, deployment.Server{}, endpoint, expectedDomains, expectedFingerprint)
}
func (w *CertificateWorkflow) verifyRemoteDeploymentForServer(ctx context.Context, server deployment.Server, endpoint string, expectedDomains []string, expectedFingerprint string) error {
attempts := w.RemoteVerificationAttempts
if attempts <= 0 {
attempts = defaultRemoteVerificationAttempts
@ -311,7 +356,7 @@ func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoi
if err := ctx.Err(); err != nil {
return err
}
validation, err := w.RemoteVerifier(ctx, endpoint, expectedDomains)
validation, err := w.verifyRemoteTarget(ctx, server, endpoint, expectedDomains)
if err == nil {
if contextErr := ctx.Err(); contextErr != nil {
return contextErr

View File

@ -119,6 +119,15 @@ type RemoteValidator struct {
// ValidateHTTPS 连接目标 HTTPS 地址并校验证书链和域名。
func (v RemoteValidator) ValidateHTTPS(ctx context.Context, endpoint string, expectedDomains []string) (ValidationResult, error) {
return v.validateHTTPS(ctx, endpoint, "", expectedDomains)
}
// ValidateHTTPSAt 使用指定的远端地址建立 TCP 连接,同时保留 endpoint 的域名作为 SNI 和证书域名校验值。
func (v RemoteValidator) ValidateHTTPSAt(ctx context.Context, endpoint, address string, expectedDomains []string) (ValidationResult, error) {
return v.validateHTTPS(ctx, endpoint, strings.TrimSpace(address), expectedDomains)
}
func (v RemoteValidator) validateHTTPS(ctx context.Context, endpoint, dialAddress string, expectedDomains []string) (ValidationResult, error) {
target, err := normalizeHTTPSURL(endpoint)
if err != nil {
return ValidationResult{}, err
@ -127,9 +136,13 @@ func (v RemoteValidator) ValidateHTTPS(ctx context.Context, endpoint string, exp
if err != nil {
return ValidationResult{}, fmt.Errorf("解析 HTTPS 地址失败:%w", err)
}
address := parsed.Host
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
address = net.JoinHostPort(parsed.Hostname(), "443")
address := dialAddress
if address == "" {
address = parsed.Host
}
address, err = normalizeHTTPSDialAddress(address, parsed.Port())
if err != nil {
return ValidationResult{}, err
}
dialer := v.Dialer
if dialer == nil {
@ -173,6 +186,20 @@ func (v RemoteValidator) ValidateHTTPS(ctx context.Context, endpoint string, exp
return validator.ValidatePEM(ctx, chainData, wanted)
}
func normalizeHTTPSDialAddress(address, port string) (string, error) {
address = strings.TrimSpace(address)
if address == "" {
return "", errors.New("HTTPS 连接地址不能为空")
}
if port == "" {
port = "443"
}
if _, _, err := net.SplitHostPort(address); err == nil {
return address, nil
}
return net.JoinHostPort(address, port), nil
}
func (v RemoteValidator) timeout() time.Duration {
if v.Timeout > 0 {
return v.Timeout