Files
Havline/internal/acme/service.go
T
2026-10-01 16:54:35 +08:00

702 lines
22 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 acme
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"log/slog"
"net/http"
"net/mail"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
legocert "github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
certstore "github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
type Service struct {
cfg config.Config
store *certstore.Store
ddnsSvc *ddns.Service
settings *settings.Store
proxySvc *service.ProxyService
logger *slog.Logger
notify *notify.Service
jobs *JobManager
expiryAlertMu sync.Mutex
expiryAlerted map[string]string
}
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
return &Service{
cfg: cfg,
store: store,
ddnsSvc: ddnsSvc,
settings: settings,
proxySvc: proxySvc,
logger: logger.With("module", "ACME"),
notify: notifySvc,
jobs: NewJobManager(),
expiryAlerted: map[string]string{},
}
}
func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
return s.store.List(ctx)
}
func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
if ddnsConfigID <= 0 {
return "", fmt.Errorf("请选择 DNS 任务")
}
domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
if err != nil {
return "", err
}
job := s.jobs.Create()
go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
return job.ID(), nil
}
func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
return s.jobs.Stream(ctx, w, jobID, flush)
}
func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
domains, err := NormalizeCertDomains(domains)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
primary := PrimaryCertDomain(domains)
if primary == "" {
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
}
email, err = s.resolveACMEEmail(ctx, email)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := ValidateCADomains(ca, domains); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := validateDNSCredentials(provider, cred); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
dnsZone := cfg.RootDomain
zones := cfg.ManagedDNSZones()
if len(zones) > 0 {
dnsZone = zones[0]
}
return domains, ca, email, primary, dnsZone, provider, cred, nil
}
func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
domains, err := NormalizeCertDomains(domains)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
primary := PrimaryCertDomain(domains)
if primary == "" {
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
}
email, err = s.resolveACMEEmail(ctx, email)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := ValidateCADomains(ca, domains); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
return domains, ca, email, primary, dnsZone, provider, cred, nil
}
func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
ctx := context.Background()
job.Info("开始申请证书…")
job.Info(fmt.Sprintf("主域名: %s", primary))
job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
if err != nil {
s.markCertError(ctx, primary, domains, ca, err)
job.Error(err.Error())
job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
return
}
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
job.Warn("Nginx 重载失败: " + err.Error())
} else {
job.Info("Nginx 已重载")
}
job.Info("证书申请完成")
job.Finish(jobDoneFromRecord(rec, nil))
}
func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
if err != nil {
return nil, err
}
rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
if err != nil {
s.markCertError(ctx, primary, domains, ca, err)
return nil, err
}
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
s.logger.Warn("save acme ca setting failed", "error", err.Error())
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
}
_ = rec
return s.store.List(ctx)
}
func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
if err != nil {
return certstore.Record{}, fmt.Errorf("证书:%w", err)
}
keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
if err != nil {
return certstore.Record{}, fmt.Errorf("私钥:%w", err)
}
info, err := certstore.InspectImport(certBytes, keyBytes)
if err != nil {
return certstore.Record{}, err
}
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
if err != nil {
return certstore.Record{}, err
}
status := "ok"
if time.Now().After(info.ExpiresAt) {
status = "expired"
}
domainsJSON := EncodeCertDomains(info.Domains)
record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
if err != nil {
return certstore.Record{}, err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
}
s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
return record, nil
}
func (s *Service) Delete(ctx context.Context, domain string) error {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return fmt.Errorf("域名不能为空")
}
existing, err := s.store.GetByDomain(ctx, domain)
if err != nil {
return err
}
if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
}
if err := s.store.Delete(ctx, domain); err != nil {
return err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
}
s.logger.Info("certificate deleted", "domain", domain)
return nil
}
func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
if rootDomain == "" {
return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
}
existing, err := s.store.GetByDomain(ctx, rootDomain)
if err != nil {
return certstore.Record{}, err
}
if existing.ACMECA == "imported" {
return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
}
if ca == "" {
ca = existing.ACMECA
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return certstore.Record{}, err
}
email, err := s.resolveACMEEmail(ctx, "")
if err != nil {
return certstore.Record{}, err
}
domains := existing.Domains
if len(domains) == 0 {
domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
}
dnsZone, err := s.resolveDNSZone(ctx, domains)
if err != nil {
return certstore.Record{}, err
}
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
if err != nil {
return certstore.Record{}, err
}
if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
s.markCertError(ctx, rootDomain, domains, ca, err)
return certstore.Record{}, err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
}
rec, err := s.store.GetByDomain(ctx, rootDomain)
if err != nil {
return certstore.Record{}, err
}
if s.notify != nil {
expires := "未知"
if rec.ExpiresAt != nil {
expires = rec.ExpiresAt.Local().Format("2006-01-02")
}
s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
}
return rec, nil
}
func (s *Service) Tick(ctx context.Context) {
records, err := s.store.List(ctx)
if err != nil {
return
}
threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
if err != nil || threshold <= 0 {
threshold = 30
}
for _, rec := range records {
if rec.ExpiresAt == nil {
continue
}
if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
}
if rec.DaysLeft <= threshold {
if rec.ACMECA == "imported" {
continue
}
if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
if s.notify != nil {
s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
}
}
}
}
}
func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
if s.notify == nil {
return
}
today := time.Now().Format("2006-01-02")
s.expiryAlertMu.Lock()
if s.expiryAlerted[domain] == today {
s.expiryAlertMu.Unlock()
return
}
s.expiryAlerted[domain] = today
s.expiryAlertMu.Unlock()
msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
if daysLeft <= 0 {
msg = fmt.Sprintf("%s 已到期或即将失效", domain)
}
s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
}
func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
if err == nil {
return
}
errMsg := err.Error()
s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
_, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
if upsertErr != nil {
s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
}
}
func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
if strings.TrimSpace(ca) == "" {
ca, err := s.settings.Get(ctx, settings.KeyACMECA)
if err != nil || strings.TrimSpace(ca) == "" {
ca = CALetsEncrypt
}
}
ca = NormalizeCA(ca)
if err := ValidateCA(ca); err != nil {
return "", err
}
if reason := CAUnavailableReason(ca); reason != "" {
return "", fmt.Errorf("%s", reason)
}
return ca, nil
}
func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
reg, err := client.Registration.ResolveAccountByKey()
if err == nil {
return reg, nil
}
logStep("注册 ACME 账户…")
switch NormalizeCA(ca) {
case CAZeroSSL:
apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
if err != nil {
return nil, err
}
kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
if err != nil {
return nil, err
}
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: kid,
HmacEncoded: hmac,
})
case CALiteSSL:
// LiteSSL 的目录里 meta.externalAccountRequired = true:必须带 EAB。
// 与 ZeroSSL 不同,不经过厂商 API 换取,而是用户在 FreeSSL 平台自建 EAB 后直接填 kid/hmac。
kid, err := s.settings.Get(ctx, settings.KeyLiteSSLEABKid)
if err != nil {
return nil, err
}
hmac, err := s.settings.Get(ctx, settings.KeyLiteSSLEABHMAC)
if err != nil {
return nil, err
}
kid = strings.TrimSpace(kid)
hmac = strings.TrimSpace(hmac)
if kid == "" || hmac == "" {
return nil, fmt.Errorf("LiteSSL 要求 EAB 凭据:请在设置里填写 EAB Kid 与 EAB HMAC(在 FreeSSL 的「证书自动化 → EAB 管理」创建后原样粘贴)")
}
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: kid,
HmacEncoded: hmac,
})
default:
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
}
}
func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
logStep := func(msg string) {
if job != nil {
job.Info(msg)
}
}
caDir, err := DirectoryURL(ca)
if err != nil {
return certstore.Record{}, err
}
logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
user, err := newUser(email)
if err != nil {
return certstore.Record{}, err
}
config := lego.NewConfig(user)
config.CADirURL = caDir
config.Certificate.KeyType = certcrypto.EC256
client, err := lego.NewClient(config)
if err != nil {
return certstore.Record{}, err
}
dnsProvider, err := newDNS01Provider(provider, cred)
if err != nil {
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
}
dnsProvider = wrapDNSProvider(dnsProvider, job)
// DNS-01 的传播检查由 lego 自己做,它默认读系统 /etc/resolv.conf;
// 容器里那通常是 Docker 的 127.0.0.11,它不返回权威 NS,于是报
// 「[zone=example.com.] could not determine authoritative nameservers」。
// 所以显式指定可靠递归解析器(设置项可改;设为 system 则显式回到系统解析器)。
resolvers, err := s.acmeDNSResolvers(ctx)
if err != nil {
return certstore.Record{}, err
}
var challengeOpts []dns01.ChallengeOption
if len(resolvers) > 0 {
challengeOpts = append(challengeOpts, dns01.AddRecursiveNameservers(resolvers))
}
if err := client.Challenge.SetDNS01Provider(dnsProvider, challengeOpts...); err != nil {
return certstore.Record{}, err
}
logStep("检查 ACME 账户…")
reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
if err != nil {
return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
}
user.registration = reg
logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
request := legocert.ObtainRequest{
Domains: domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(request)
if err != nil {
return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
}
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
if err != nil {
return certstore.Record{}, err
}
logStep(fmt.Sprintf("证书已保存: %s", certPath))
logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
expiresAt, err := parseCertExpiry(cert.Certificate)
if err != nil {
expiresAt = time.Now().Add(90 * 24 * time.Hour)
}
logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
if err != nil {
return certstore.Record{}, err
}
s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
return rec, nil
}
func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
email = strings.TrimSpace(email)
if email == "" {
stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
if err != nil || strings.TrimSpace(stored) == "" {
return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
}
email = strings.TrimSpace(stored)
}
if err := validateACMEEmail(email); err != nil {
return "", err
}
if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
s.logger.Warn("save acme email failed", "error", err.Error())
}
return email, nil
}
// defaultACMEDNSResolvers 默认递归解析器(阿里/Cloudflare/Google)。
// 容器里的系统解析器常只指向 127.0.0.11,无法用于 DNS-01 的权威 NS 查询;
// 设置项写 system 则回到系统解析器,写其它值则按逗号/空格分隔逐条使用。
const defaultACMEDNSResolvers = "223.5.5.5:53,1.1.1.1:53,8.8.8.8:53"
// acmeDNSResolvers 返回本次申请要用的解析器;空 slice 表示交给 lego 读系统解析器。
func (s *Service) acmeDNSResolvers(ctx context.Context) ([]string, error) {
raw, err := s.settings.Get(ctx, settings.KeyACMEDNSResolvers)
if err != nil || strings.TrimSpace(raw) == "" {
raw = defaultACMEDNSResolvers
}
if strings.EqualFold(strings.TrimSpace(raw), "system") {
return nil, nil
}
resolvers := parseResolverList(raw)
if len(resolvers) == 0 {
return nil, fmt.Errorf("DNS 解析器配置无效(%q):请填 host 或 host:port,多个用逗号分隔;留空用默认值,填 system 用系统解析器", raw)
}
return resolvers, nil
}
// parseResolverList 解析解析器列表:逗号/空格/换行分隔,未写端口时补 53,最多取前 8 个。
func parseResolverList(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
})
out := make([]string, 0, len(fields))
for _, item := range fields {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if !strings.Contains(item, ":") {
item += ":53"
}
out = append(out, item)
if len(out) >= 8 {
break
}
}
return out
}
func validateACMEEmail(email string) error {
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
}
return nil
}
func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
for _, domain := range domains {
cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
if err == nil {
return cfg.RootDomain, nil
}
}
return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
}
func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
if err != nil {
return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
}
if err := validateDNSCredentials(provider, cred); err != nil {
return "", ddns.Credentials{}, err
}
return provider, cred, nil
}
func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
names := certStorageNames(domains)
if len(names) == 0 {
return "", "", fmt.Errorf("证书域名不能为空")
}
var certPath, keyPath string
for _, name := range names {
dir := filepath.Join(certsDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", "", err
}
cp := filepath.Join(dir, "fullchain.pem")
kp := filepath.Join(dir, "privatekey.pem")
if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
return "", "", err
}
if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
return "", "", err
}
certPath, keyPath = cp, kp
}
return certPath, keyPath, nil
}
func removeCertFiles(certsDir string, record certstore.Record) error {
domains := record.Domains
if len(domains) == 0 {
domains = LegacyCertDomains(record.Domain, record.Wildcard)
}
var firstErr error
for _, name := range certStorageNames(domains) {
dir := filepath.Join(certsDir, name)
if err := os.RemoveAll(dir); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func certStorageNames(domains []string) []string {
seen := map[string]bool{}
var names []string
for _, domain := range domains {
name := domain
if strings.HasPrefix(domain, "*.") {
name = "wildcard." + strings.TrimPrefix(domain, "*.")
}
if seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
return names
}
func parseCertExpiry(pemBytes []byte) (time.Time, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return time.Time{}, fmt.Errorf("invalid pem")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, err
}
return cert.NotAfter, nil
}
type acmeUser struct {
email string
registration *registration.Resource
key crypto.PrivateKey
}
func (u *acmeUser) GetEmail() string { return u.email }
func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func newUser(email string) (*acmeUser, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
return &acmeUser{email: email, key: privateKey}, nil
}