From 840d48d2e5963dde531b93fb6f195469387feb94 Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 1 Oct 2026 06:41:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=A4=9A=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E9=80=90=E5=8F=B0HTTPS=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为证书部署流程增加按服务器地址连接、逐台验证和恢复校验能力,作为本轮继续开发前的检查点。 --- backend/internal/app/certificate_workflow.go | 65 +++++++++++++++++--- backend/internal/certificate/validation.go | 33 +++++++++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/backend/internal/app/certificate_workflow.go b/backend/internal/app/certificate_workflow.go index a1c232c..577240b 100644 --- a/backend/internal/app/certificate_workflow.go +++ b/backend/internal/app/certificate_workflow.go @@ -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 diff --git a/backend/internal/certificate/validation.go b/backend/internal/certificate/validation.go index 0d6eb07..6e5fbbe 100644 --- a/backend/internal/certificate/validation.go +++ b/backend/internal/certificate/validation.go @@ -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