411 lines
16 KiB
Go
411 lines
16 KiB
Go
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)
|
|
}
|