Scert/backend/internal/certificate/service.go

572 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package certificate
import (
"context"
"crypto/x509"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-acme/lego/v5/challenge"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
)
type DomainStateRepository interface {
GetByID(ctx context.Context, id string) (domain.Domain, error)
UpdateCertificateState(ctx context.Context, id, status string, expiresAt *time.Time) error
}
type CredentialReader interface {
Get(ctx context.Context, reference string) (map[string]string, error)
}
type ProviderFactory interface {
Create(providerID string, credentials map[string]string) (challenge.Provider, error)
}
// DNSAuthorizationChecker 在证书申请前确认当前凭据仍能操作目标域名。
type DNSAuthorizationChecker interface {
VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error
}
type DNSAuthorizationCacheInvalidator interface {
InvalidateDomain(providerID, requestedDomain string, credentials map[string]string)
}
// ProgressReporter 用于把长耗时证书流程的阶段同步到任务中心。
type ProgressReporter func(context.Context, string, string) error
type progressReporterContextKey struct{}
// WithProgressReporter 将任务进度回调绑定到证书工作流上下文。
func WithProgressReporter(ctx context.Context, reporter ProgressReporter) context.Context {
if reporter == nil {
return ctx
}
return context.WithValue(ctx, progressReporterContextKey{}, reporter)
}
// ReportProgress 报告证书工作流的当前阶段;未绑定任务时不执行任何操作。
func ReportProgress(ctx context.Context, stage, message string) error {
reporter, _ := ctx.Value(progressReporterContextKey{}).(ProgressReporter)
if reporter == nil {
return nil
}
return reporter(ctx, strings.TrimSpace(stage), strings.TrimSpace(message))
}
type AccountManager interface {
LoadOrCreate(ctx context.Context, environment, email string) (*AccountUser, error)
Save(ctx context.Context, environment string, user *AccountUser) error
}
type IssuerFactory func(user *AccountUser, provider challenge.Provider, environment string) Issuer
type Service struct {
config config.Config
repository DomainStateRepository
credentials CredentialReader
providers ProviderFactory
authorization DNSAuthorizationChecker
accounts AccountManager
store FileStore
newIssuer IssuerFactory
preferredChain string
acmeRoots *x509.CertPool
httpChallenges *httpchallenge.Store
}
func (s *Service) SetHTTPChallenges(store *httpchallenge.Store) {
if s != nil {
s.httpChallenges = store
}
}
func (s *Service) SetDNSAuthorizationChecker(checker DNSAuthorizationChecker) {
if s != nil {
s.authorization = checker
}
}
type IssueResult struct {
DomainID string `json:"domain_id"`
Domains []string `json:"domains"`
Saved SavedResource `json:"saved"`
}
type ImportRequest struct {
DomainID string
Certificate string
PrivateKey string
IssuerBundle string
}
type SetupCertificateRequest struct {
Domains []string
CertificateType string
ProviderID string
CredentialRef string
AccountEmail string
Environment string
CertificateRoot string
BackupRoot string
}
func NewService(cfg config.Config, repository DomainStateRepository, credentials CredentialReader, providers ProviderFactory, accounts AccountManager) *Service {
return &Service{
config: cfg, repository: repository, credentials: credentials, providers: providers, accounts: accounts,
store: FileStore{
Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup,
ProtectedPaths: []string{cfg.Server.TLSCert, cfg.Server.TLSKey},
},
newIssuer: func(user *AccountUser, provider challenge.Provider, environment string) Issuer {
return &LegoIssuer{
User: user,
Provider: provider,
Environment: environment,
PropagationTimeout: time.Duration(cfg.ACME.PropagationTimeoutSecs) * time.Second,
PropagationInterval: time.Duration(cfg.ACME.PropagationPollingIntervalSecs) * time.Second,
}
},
}
}
func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, error) {
if s == nil || s.repository == nil || s.accounts == nil || s.newIssuer == nil {
return IssueResult{}, errors.New("证书申请服务未初始化")
}
domainID = strings.TrimSpace(domainID)
if domainID == "" {
return IssueResult{}, errors.New("域名 ID 不能为空")
}
item, err := s.repository.GetByID(ctx, domainID)
if err != nil {
return IssueResult{}, fmt.Errorf("读取域名配置失败:%w", err)
}
// 独立账户执行租约接入前禁止退回系统账户,避免配置与实际用证账户不一致。
if item.DNSAccountID != "" || item.DNSBindingID != "" {
return IssueResult{}, errors.New("独立 DNS 账户执行租约尚未就绪,拒绝使用系统凭据替代")
}
domains, err := requestedDomains(item)
if err != nil {
return IssueResult{}, err
}
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, domains)
if err != nil {
return IssueResult{}, err
}
if err := authorizeIssueSnapshot(ctx, item); err != nil {
return IssueResult{}, fmt.Errorf("签发配置权限校验失败:%w", err)
}
providerID := ""
var lease *httpchallenge.Lease
if challengeType == domain.ChallengeHTTP01 {
lease, err = s.httpChallenges.NewLease(ctx, domains)
if err != nil {
return IssueResult{}, err
}
defer lease.Close()
} else {
providerID, err = s.validateDNSIssueConfiguration(item, domains)
if err != nil {
return IssueResult{}, err
}
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "issuing", item.ExpiresAt); err != nil {
return IssueResult{}, fmt.Errorf("更新证书申请状态失败:%w", err)
}
fail := func(cause error) (IssueResult, error) {
if stateErr := s.repository.UpdateCertificateState(context.Background(), item.ID, "failed", item.ExpiresAt); stateErr != nil {
return IssueResult{}, fmt.Errorf("%v;更新失败状态时发生错误:%w", cause, stateErr)
}
return IssueResult{}, cause
}
var credentials map[string]string
var provider challenge.Provider
if lease != nil {
provider = lease
if err := ReportProgress(ctx, "certificate_issue", "正在通过 HTTP-01 申请证书,请确保全部域名的 80 端口挑战路径已接入"); err != nil {
return fail(err)
}
} else {
credentials, err = s.credentials.Get(ctx, s.config.DNS.CredentialRef)
if err != nil {
return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err))
}
if err := ReportProgress(ctx, "dns_validation", "正在验证 DNS 凭据权限"); err != nil {
return fail(err)
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return fail(err)
}
if err := ReportProgress(ctx, "certificate_issue", "DNS 凭据验证通过,正在申请证书"); err != nil {
return fail(err)
}
provider, err = s.providers.Create(providerID, credentials)
if err != nil {
return fail(fmt.Errorf("创建 DNS Provider 失败:%w", err))
}
}
email := strings.TrimSpace(s.config.ACME.AccountEmail)
if email == "" && len(s.config.Notification.Recipients) > 0 {
email = strings.TrimSpace(s.config.Notification.Recipients[0])
}
user, err := s.accounts.LoadOrCreate(ctx, item.ACMEEnvironment, email)
if err != nil {
return fail(err)
}
issuer := s.newIssuer(user, provider, item.ACMEEnvironment)
resource, issueErr := issuer.Obtain(ctx, ObtainRequest{
Domains: domains, AccountEmail: email, Environment: item.ACMEEnvironment, PreferredChain: s.preferredChain, ChallengeType: challengeType,
})
if issueErr != nil && challengeType == domain.ChallengeDNS01 {
s.invalidateDNSAuthorizationOnFailure(providerID, domains, credentials, issueErr)
}
if saveErr := s.accounts.Save(ctx, item.ACMEEnvironment, user); saveErr != nil {
if issueErr != nil {
return fail(fmt.Errorf("%v;保存 ACME 账户状态失败:%w", issueErr, saveErr))
}
return fail(saveErr)
}
if issueErr != nil {
return fail(issueErr)
}
resource, err = ValidateResourceForDomains(resource, domains)
if err != nil {
return fail(err)
}
if err := s.validateACMEResource(ctx, resource, domains, item.ACMEEnvironment); err != nil {
return fail(err)
}
saved, err := s.store.SaveForType(ctx, item.Name, string(item.CertificateType), resource)
if err != nil {
return fail(fmt.Errorf("保存证书文件失败:%w", err))
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter); err != nil {
return IssueResult{}, fmt.Errorf("证书已保存,但更新域名状态失败:%w", err)
}
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
}
// IssueForSetup 在初始化标记完成前申请并保存管理后台证书。
func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateRequest) (SavedResource, error) {
if s == nil || s.credentials == nil || s.providers == nil || s.accounts == nil || s.newIssuer == nil {
return SavedResource{}, errors.New("初始化证书申请服务未初始化")
}
domains, err := NormalizeDomains(request.Domains)
if err != nil {
return SavedResource{}, err
}
certificateType := strings.ToLower(strings.TrimSpace(request.CertificateType))
if certificateType != "single" && certificateType != "wildcard" && certificateType != "san" {
return SavedResource{}, fmt.Errorf("初始化证书类型无效:%s", certificateType)
}
providerID := strings.ToLower(strings.TrimSpace(request.ProviderID))
credentialRef := strings.TrimSpace(request.CredentialRef)
if providerID == "" || credentialRef == "" {
return SavedResource{}, errors.New("初始化证书的 DNS Provider 或凭据引用未配置")
}
// 初始化流程同样必须先拒绝整单保护 SAN,不能触碰凭据或云端。
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
return SavedResource{}, err
}
store, err := managementFileStore(request.CertificateRoot, request.BackupRoot)
if err != nil {
return SavedResource{}, err
}
environment := strings.ToLower(strings.TrimSpace(request.Environment))
if environment == "" {
environment = s.config.ACME.Environment
}
if environment == "" {
environment = "staging"
}
credentials, err := s.credentials.Get(ctx, credentialRef)
if err != nil {
return SavedResource{}, fmt.Errorf("读取初始化证书 DNS 凭据失败:%w", err)
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return SavedResource{}, err
}
provider, err := s.providers.Create(providerID, credentials)
if err != nil {
return SavedResource{}, fmt.Errorf("创建初始化证书 DNS Provider 失败:%w", err)
}
accountEmail := strings.TrimSpace(request.AccountEmail)
if accountEmail == "" {
accountEmail = strings.TrimSpace(s.config.ACME.AccountEmail)
}
user, err := s.accounts.LoadOrCreate(ctx, environment, accountEmail)
if err != nil {
return SavedResource{}, fmt.Errorf("读取 ACME 账户失败:%w", err)
}
issuer := s.newIssuer(user, provider, environment)
resource, issueErr := issuer.Obtain(ctx, ObtainRequest{
Domains: domains, AccountEmail: accountEmail, Environment: environment, PreferredChain: s.preferredChain,
})
if issueErr != nil {
s.invalidateDNSAuthorizationOnFailure(providerID, domains, credentials, issueErr)
}
if saveErr := s.accounts.Save(ctx, environment, user); saveErr != nil {
if issueErr != nil {
return SavedResource{}, fmt.Errorf("%v;保存 ACME 账户状态失败:%w", issueErr, saveErr)
}
return SavedResource{}, fmt.Errorf("保存 ACME 账户状态失败:%w", saveErr)
}
if issueErr != nil {
return SavedResource{}, issueErr
}
resource, err = ValidateResourceForDomains(resource, domains)
if err != nil {
return SavedResource{}, fmt.Errorf("初始化证书校验失败:%w", err)
}
if err := s.validateACMEResource(ctx, resource, domains, environment); err != nil {
return SavedResource{}, err
}
saved, err := store.SaveForType(ctx, domains[0], certificateType, resource)
if err != nil {
return SavedResource{}, fmt.Errorf("保存初始化证书失败:%w", err)
}
return saved, nil
}
func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error {
// 必须先校验全部 SAN;后续域名越界时也不能先写入前面的域名。
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
return err
}
if s == nil || s.authorization == nil {
if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) {
return errors.New("阿里云权限探测器未初始化,已阻止证书申请")
}
return nil
}
for _, requestedDomain := range domains {
if err := ReportProgress(ctx, "dns_validation", fmt.Sprintf("正在验证域名 %s 的 DNS 权限", requestedDomain)); err != nil {
return err
}
if err := s.authorization.VerifyDomain(ctx, providerID, requestedDomain, credentials); err != nil {
return fmt.Errorf("DNS 凭据无权操作域名 %s:%w", requestedDomain, err)
}
}
return nil
}
func (s *Service) invalidateDNSAuthorization(providerID string, domains []string, credentials map[string]string) {
invalidator, ok := s.authorization.(DNSAuthorizationCacheInvalidator)
if !ok {
return
}
for _, requestedDomain := range domains {
invalidator.InvalidateDomain(providerID, requestedDomain, credentials)
}
}
func (s *Service) invalidateDNSAuthorizationOnFailure(providerID string, domains []string, credentials map[string]string, issueErr error) {
if !looksLikeDNSAuthorizationFailure(issueErr) {
return
}
s.invalidateDNSAuthorization(providerID, domains, credentials)
}
func looksLikeDNSAuthorizationFailure(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{
"dns-01", "dns challenge", "txt record", "present", "cleanup", "clean up",
"unauthorized", "forbidden", "permission", "accessdenied", "权限", "无权", "授权",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (s *Service) Import(ctx context.Context, request ImportRequest) (IssueResult, error) {
if s == nil || s.repository == nil {
return IssueResult{}, errors.New("证书导入服务未初始化")
}
item, err := s.repository.GetByID(ctx, strings.TrimSpace(request.DomainID))
if err != nil {
return IssueResult{}, fmt.Errorf("读取域名配置失败:%w", err)
}
certificatePath, err := cleanInputPath(request.Certificate, "证书文件")
if err != nil {
return IssueResult{}, err
}
privateKeyPath, err := cleanInputPath(request.PrivateKey, "私钥文件")
if err != nil {
return IssueResult{}, err
}
certificateData, err := os.ReadFile(certificatePath)
if err != nil {
return IssueResult{}, fmt.Errorf("读取证书文件失败:%w", err)
}
privateKeyData, err := os.ReadFile(privateKeyPath)
if err != nil {
return IssueResult{}, fmt.Errorf("读取私钥文件失败:%w", err)
}
var issuerData []byte
if strings.TrimSpace(request.IssuerBundle) != "" {
issuerPath, err := cleanInputPath(request.IssuerBundle, "签发者证书文件")
if err != nil {
return IssueResult{}, err
}
issuerData, err = os.ReadFile(issuerPath)
if err != nil {
return IssueResult{}, fmt.Errorf("读取签发者证书文件失败:%w", err)
}
}
domains := requestedDomainsForImport(item)
resource, err := ValidateResourceForDomains(Resource{
Domains: domains, Certificate: certificateData, IssuerCertificate: issuerData, PrivateKey: privateKeyData,
}, domains)
if err != nil {
return IssueResult{}, err
}
if err := validateIssuedResource(ctx, resource, domains); err != nil {
return IssueResult{}, err
}
saved, err := s.store.SaveForType(ctx, item.Name, string(item.CertificateType), resource)
if err != nil {
return IssueResult{}, fmt.Errorf("保存导入证书失败:%w", err)
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter); err != nil {
return IssueResult{}, fmt.Errorf("证书已保存,但更新域名状态失败:%w", err)
}
return IssueResult{DomainID: item.ID, Domains: resource.Domains, Saved: saved}, nil
}
func validateIssuedResource(ctx context.Context, resource Resource, domains []string) error {
chainData := append([]byte(nil), resource.Certificate...)
chainData = append(chainData, resource.IssuerCertificate...)
_, err := (LocalValidator{VerifyChain: len(resource.IssuerCertificate) > 0}).ValidatePEM(ctx, chainData, domains)
if err != nil {
return fmt.Errorf("本地证书校验失败:%w", err)
}
return nil
}
func cleanInputPath(value, label string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("%s不能为空", label)
}
path := filepath.Clean(value)
if path == "." {
return "", fmt.Errorf("%s路径无效", label)
}
return path, nil
}
func requestedDomainsForImport(item domain.Domain) []string {
if item.CertificateType == domain.CertificateSAN && len(item.SANs) > 0 {
return item.SANs
}
return []string{item.Name}
}
func requestedDomains(item domain.Domain) ([]string, error) {
values := item.SANs
if item.CertificateType != domain.CertificateSAN {
values = []string{item.Name}
}
return NormalizeDomains(values)
}
// Current returns and validates the active certificate for a managed domain.
func (s *Service) Current(ctx context.Context, domainID string) (IssueResult, error) {
if s == nil || s.repository == nil {
return IssueResult{}, errors.New("当前证书服务未初始化")
}
item, err := s.repository.GetByID(ctx, strings.TrimSpace(domainID))
if err != nil {
return IssueResult{}, fmt.Errorf("读取域名配置失败:%w", err)
}
domains, err := requestedDomains(item)
if err != nil {
return IssueResult{}, err
}
saved, resource, err := s.store.LoadCurrent(ctx, item.Name, string(item.CertificateType))
if err != nil {
return IssueResult{}, err
}
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
return IssueResult{}, err
}
saved.SANs = append([]string(nil), resource.Domains...)
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
}
// ValidateCurrent 校验当前证书文件、私钥、证书链和域名覆盖范围。
func (s *Service) ValidateCurrent(ctx context.Context, domainID string) (ValidationResult, error) {
result, err := s.Current(ctx, domainID)
if err != nil {
return ValidationResult{}, err
}
validation, err := (LocalValidator{VerifyChain: true}).ValidateFiles(
ctx,
result.Saved.CertificatePath,
result.Saved.FullChainPath,
result.Saved.PrivateKeyPath,
result.Domains,
)
if err != nil {
return ValidationResult{}, fmt.Errorf("当前证书校验失败:%w", err)
}
return validation, nil
}
// ListVersions returns the certificate history for a managed domain.
func (s *Service) ListVersions(ctx context.Context, domainID string) ([]SavedResource, error) {
if s == nil || s.repository == nil {
return nil, errors.New("证书服务未初始化")
}
item, err := s.repository.GetByID(ctx, strings.TrimSpace(domainID))
if err != nil {
return nil, fmt.Errorf("读取域名配置失败:%w", err)
}
return s.store.ListVersions(ctx, item.Name, string(item.CertificateType))
}
// RestoreVersion 将指定历史版本恢复为当前证书,并更新域名证书状态。
func (s *Service) RestoreVersion(ctx context.Context, domainID, version string) (IssueResult, error) {
if s == nil || s.repository == nil {
return IssueResult{}, errors.New("证书服务未初始化")
}
item, err := s.repository.GetByID(ctx, strings.TrimSpace(domainID))
if err != nil {
return IssueResult{}, fmt.Errorf("读取域名配置失败:%w", err)
}
domains, err := requestedDomains(item)
if err != nil {
return IssueResult{}, err
}
_, resource, err := s.store.LoadVersion(ctx, item.Name, string(item.CertificateType), strings.TrimSpace(version))
if err != nil {
return IssueResult{}, err
}
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
return IssueResult{}, fmt.Errorf("历史证书不匹配当前域名配置:%w", err)
}
saved, err := s.store.restoreResource(ctx, item.Name, string(item.CertificateType), resource, domains, func(saved SavedResource) error {
return s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter)
})
if err != nil {
return IssueResult{}, err
}
saved.SANs = append([]string(nil), domains...)
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
}