package app import ( "context" "errors" "fmt" "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/deployment" "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/notification" ) type CertificateWorkflow struct { Certificates certificateService Domains domainDeploymentRepository Servers serverLister Deployment savedDeployer 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 CurrentCertificate func(context.Context, string) (certificate.IssueResult, error) } const ( defaultRemoteVerificationAttempts = 6 defaultRemoteVerificationInterval = 500 * time.Millisecond ) type certificateService interface { Issue(context.Context, string) (certificate.IssueResult, error) Import(context.Context, certificate.ImportRequest) (certificate.IssueResult, error) } type serverLister interface { ListDeploymentServersByDomainID(context.Context, string) ([]deployment.Server, error) } type savedDeployer interface { DeploySaved(context.Context, []deployment.Target, deployment.SavedFiles) (deployment.Report, error) DeploySavedVerified(context.Context, []deployment.Target, deployment.SavedFiles, deployment.Verification) (deployment.Report, error) } type domainDeploymentRepository interface { GetByID(context.Context, string) (domain.Domain, error) UpdateCertificateState(context.Context, string, string, *time.Time) error } type CertificateWorkflowResult struct { Certificate certificate.IssueResult `json:"certificate"` Deployment deployment.Report `json:"deployment"` } func (w *CertificateWorkflow) Issue(ctx context.Context, domainID string) (certificate.IssueResult, error) { result, err := w.IssueWithReport(ctx, domainID) return result.Certificate, err } func (w *CertificateWorkflow) IssueWithReport(ctx context.Context, domainID string) (CertificateWorkflowResult, error) { if w == nil || w.Certificates == nil || w.Domains == nil || w.Servers == nil || w.Deployment == nil { return CertificateWorkflowResult{}, errors.New("证书部署流程未初始化") } domainItem, _ := w.Domains.GetByID(ctx, domainID) certificateResult, err := w.Certificates.Issue(ctx, domainID) if err != nil { w.notify(ctx, notification.Event{Type: "certificate_renewal", Status: "failed", DomainID: domainID, Domain: domainItem.Name, Error: err.Error()}) return CertificateWorkflowResult{}, err } report, err := w.deploy(ctx, certificateResult) err = appendDeploymentResultError(err, report) if err != nil { w.markDeploymentError(certificateResult) } status := "success" if err != nil { status = "error" } w.notify(ctx, notification.Event{ Type: "certificate_renewal", Status: status, DomainID: certificateResult.DomainID, Domain: domainItem.Name, Domains: certificateResult.Domains, ExpiresAt: &certificateResult.Saved.NotAfter, Deployment: report, Error: errorText(err), }) return CertificateWorkflowResult{Certificate: certificateResult, Deployment: report}, err } func (w *CertificateWorkflow) IssueForHTTP(ctx context.Context, domainID string) (certificate.IssueResult, deployment.Report, error) { result, err := w.IssueWithReport(ctx, domainID) return result.Certificate, result.Deployment, err } // DeployCurrent validates and deploys the active certificate without issuing a new one. func (w *CertificateWorkflow) DeployCurrent(ctx context.Context, domainID string) (CertificateWorkflowResult, error) { if w == nil || w.CurrentCertificate == nil || w.Domains == nil || w.Servers == nil || w.Deployment == nil { return CertificateWorkflowResult{}, errors.New("当前证书部署流程未初始化") } result, err := w.CurrentCertificate(ctx, domainID) if err != nil { return CertificateWorkflowResult{}, err } report, err := w.deploy(ctx, result) err = appendDeploymentResultError(err, report) if err != nil { w.markDeploymentError(result) } else if updateErr := w.Domains.UpdateCertificateState(ctx, result.DomainID, "valid", &result.Saved.NotAfter); updateErr != nil { err = fmt.Errorf("证书部署成功,但更新域名证书状态失败:%w", updateErr) } status := "success" if err != nil { status = "error" } w.notify(ctx, notification.Event{ Type: "certificate_deploy", Status: status, DomainID: result.DomainID, Domains: result.Domains, ExpiresAt: &result.Saved.NotAfter, Deployment: report, Error: errorText(err), }) return CertificateWorkflowResult{Certificate: result, Deployment: report}, err } func (w *CertificateWorkflow) Import(ctx context.Context, request certificate.ImportRequest) (CertificateWorkflowResult, error) { if w == nil || w.Certificates == nil || w.Domains == nil || w.Servers == nil || w.Deployment == nil { return CertificateWorkflowResult{}, errors.New("证书部署流程未初始化") } domainItem, _ := w.Domains.GetByID(ctx, request.DomainID) certificateResult, err := w.Certificates.Import(ctx, request) if err != nil { w.notify(ctx, notification.Event{Type: "certificate_import", Status: "failed", DomainID: request.DomainID, Domain: domainItem.Name, Error: err.Error()}) return CertificateWorkflowResult{}, err } report, err := w.deploy(ctx, certificateResult) err = appendDeploymentResultError(err, report) if err != nil { w.markDeploymentError(certificateResult) } status := "success" if err != nil { status = "error" } w.notify(ctx, notification.Event{ Type: "certificate_import", Status: status, DomainID: certificateResult.DomainID, Domain: domainItem.Name, Domains: certificateResult.Domains, ExpiresAt: &certificateResult.Saved.NotAfter, Deployment: report, Error: errorText(err), }) return CertificateWorkflowResult{Certificate: certificateResult, Deployment: report}, err } // RestoreVersion restores a certificate version through the certificate service. func (w *CertificateWorkflow) RestoreVersion(ctx context.Context, domainID, version string) (certificate.IssueResult, error) { if w == nil || w.Certificates == nil { return certificate.IssueResult{}, errors.New("证书恢复流程未初始化") } restorer, ok := w.Certificates.(interface { RestoreVersion(context.Context, string, string) (certificate.IssueResult, error) }) if !ok { return certificate.IssueResult{}, errors.New("证书恢复服务未配置") } return restorer.RestoreVersion(ctx, domainID, version) } // ValidateCurrent validates the active certificate through the workflow boundary. func (w *CertificateWorkflow) ValidateCurrent(ctx context.Context, domainID string) (certificate.IssueResult, certificate.ValidationResult, error) { if w == nil || w.CurrentCertificate == nil { return certificate.IssueResult{}, certificate.ValidationResult{}, errors.New("证书校验流程未初始化") } result, err := w.CurrentCertificate(ctx, domainID) if err != nil { return certificate.IssueResult{}, certificate.ValidationResult{}, err } validation, err := (certificate.LocalValidator{VerifyChain: true}).ValidateFiles( ctx, result.Saved.CertificatePath, result.Saved.FullChainPath, result.Saved.PrivateKeyPath, result.Domains, ) if err != nil { return certificate.IssueResult{}, certificate.ValidationResult{}, fmt.Errorf("当前证书校验失败:%w", err) } return result, validation, nil } func (w *CertificateWorkflow) notify(ctx context.Context, event notification.Event) { if w == nil || w.Notifier == nil { return } if err := w.Notifier.Send(ctx, event); err != nil && w.NotifyError != nil { w.NotifyError(err) } } func errorText(err error) string { if err == nil { return "" } return err.Error() } func appendDeploymentResultError(err error, report deployment.Report) error { if err == nil { return nil } messages := make([]string, 0, len(report.Results)) for _, result := range report.Results { message := strings.TrimSpace(result.Message) if result.Status == "success" || message == "" { continue } server := strings.TrimSpace(result.Server) if server == "" { server = strings.TrimSpace(result.ServerID) } if server != "" { message = server + ": " + message } messages = append(messages, message) } if len(messages) == 0 { return err } return fmt.Errorf("%w; %s", err, strings.Join(messages, "; ")) } func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.IssueResult) (deployment.Report, error) { domainItem, err := w.Domains.GetByID(ctx, result.DomainID) if err != nil { return deployment.Report{}, fmt.Errorf("查询域名配置失败:%w", err) } servers, err := w.Servers.ListDeploymentServersByDomainID(ctx, result.DomainID) if err != nil { return deployment.Report{}, fmt.Errorf("查询域名部署服务器失败:%w", err) } if len(servers) == 0 { return deployment.Report{Status: "failed"}, errors.New("\u57df\u540d\u5c1a\u672a\u5173\u8054\u4efb\u4f55\u90e8\u7f72\u670d\u52a1\u5668") } targets := make([]deployment.Target, 0, len(servers)) for _, server := range servers { resolved, resolveErr := deployment.ResolveServerPaths(server, domainItem.Name, string(domainItem.CertificateType)) if resolveErr != nil { return deployment.Report{}, fmt.Errorf("解析服务器 %s 的证书路径失败:%w", server.Name, resolveErr) } targets = append(targets, deployment.Target{Server: resolved}) } remoteVerificationEnabled := w.RemoteVerifier != nil || w.RemoteVerifierForServer != nil expectedFingerprint := "" if remoteVerificationEnabled { // 从即将部署的文件读取身份,不信任可能缺失或陈旧的元数据;远端仍必须验证可信证书链。 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 } saved := deployment.SavedFiles{ CertificatePath: result.Saved.CertificatePath, FullChainPath: result.Saved.FullChainPath, PrivateKeyPath: result.Saved.PrivateKeyPath, } if remoteVerificationEnabled { verificationDomain := verificationName(domainItem, result.Domains) if verificationDomain == "" { return deployment.Report{Status: "failed"}, errors.New("无法确定部署后的 HTTPS 校验域名,未执行推送") } endpoint := "https://" + verificationDomain 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, }) targets[len(verificationTargets)-1].Baseline = deployment.RecoveryBaseline{ Endpoint: endpoint, Domains: append([]string(nil), result.Domains...), PreviousFingerprint: baselineFingerprint, ExpectedFingerprint: expectedFingerprint, } } verification := deployment.Verification{ Verify: func(verificationContext context.Context) error { 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 allBaselinesAvailable { verification.VerifyRecovery = func(recoveryContext context.Context) error { 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) } report, err := w.Deployment.DeploySaved(ctx, targets, saved) if err != nil { return report, err } if report.Status != "success" { return report, fmt.Errorf("证书部署未全部成功:成功 %d 台,失败 %d 台", report.Success, report.Failed) } 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 } interval := w.RemoteVerificationInterval if interval <= 0 { interval = defaultRemoteVerificationInterval } wait := w.RemoteVerificationWait if wait == nil { wait = waitForRemoteVerification } var lastErr error for attempt := 1; attempt <= attempts; attempt++ { if err := ctx.Err(); err != nil { return err } validation, err := w.verifyRemoteTarget(ctx, server, 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 } if err := wait(ctx, interval); err != nil { return fmt.Errorf("等待 HTTPS 校验重试时被中断:%w", err) } } return fmt.Errorf("重试 %d 次后仍未通过:%w", attempts, lastErr) } func waitForRemoteVerification(ctx context.Context, interval time.Duration) error { timer := time.NewTimer(interval) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func verificationName(item domain.Domain, issuedDomains []string) string { for _, value := range issuedDomains { value = strings.TrimSpace(value) if value != "" && !strings.HasPrefix(value, "*.") { return value } } return strings.TrimPrefix(strings.TrimSpace(item.Name), "*.") } func (w *CertificateWorkflow) markDeploymentError(result certificate.IssueResult) { if w == nil || w.Domains == nil { return } _ = w.Domains.UpdateCertificateState(context.Background(), result.DomainID, "error", &result.Saved.NotAfter) }