package certificate import ( "context" "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/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) } 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 accounts AccountManager store FileStore newIssuer IssuerFactory preferredChain string } 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 } 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} }, } } 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 或凭据引用未配置") } 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)) } 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 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) } saved, err := s.store.Save(ctx, item.Name, 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 } 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 } saved, err := s.store.Save(ctx, item.Name, 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 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) }