Scert/backend/internal/certificate/service.go

518 lines
19 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"
)
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)
}
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
}
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},
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.credentials == nil || s.providers == 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)
}
domains, err := requestedDomains(item)
if err != nil {
return IssueResult{}, err
}
providerID := strings.ToLower(strings.TrimSpace(item.DNSProvider))
if providerID == "" {
providerID = strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
}
if providerID == "" || strings.TrimSpace(s.config.DNS.CredentialRef) == "" {
return IssueResult{}, errors.New("DNS Provider 或凭据引用未配置")
}
// 必须在读取凭据、构造 Provider 或访问 ACME 前拒绝受保护的任意 SAN。
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
return IssueResult{}, err
}
if configured := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)); configured != "" && providerID != configured {
return IssueResult{}, fmt.Errorf("域名配置的 DNS Provider %s 尚未绑定独立凭据,当前初始化凭据属于 %s", providerID, configured)
}
if s.config.DNS.VerificationState != "verified" {
return IssueResult{}, errors.New("DNS API 尚未通过验证")
}
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
}
credentials, err := s.credentials.Get(ctx, s.config.DNS.CredentialRef)
if err != nil {
return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err))
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); 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,
})
if issueErr != nil {
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
}
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
}
store := FileStore{Root: request.CertificateRoot, BackupRoot: request.BackupRoot}
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 := 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.RestoreVersion(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)
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter); err != nil {
rollbackErr := RollbackSavedResource(context.Background(), saved)
if rollbackErr != nil {
return IssueResult{}, errors.Join(err, rollbackErr)
}
return IssueResult{}, fmt.Errorf("certificate state update failed after restore: %w", err)
}
saved.SANs = append([]string(nil), domains...)
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
}