1// Package acmelego implements ACME certificate operations using the lego library.
2package acmelego
3
4import (
5 "context"
6 "crypto/tls"
7 "crypto/x509"
8 "encoding/json"
9 "fmt"
10 "net"
11 "net/http"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/go-acme/lego/v4/acme"
18 "github.com/go-acme/lego/v4/certcrypto"
19 "github.com/go-acme/lego/v4/certificate"
20 "github.com/go-acme/lego/v4/challenge/dns01"
21 "github.com/go-acme/lego/v4/lego"
22 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
23 "github.com/go-acme/lego/v4/registration"
24
25 "github.com/bnema/gordon/internal/boundaries/out"
26 "github.com/bnema/gordon/internal/domain"
27)
28
29// Config holds the configuration for the ACME certificate issuer.
30type Config struct {
31 // Email is the ACME account contact email.
32 Email string
33
34 // Challenge is the ACME challenge mode to use.
35 Challenge domain.ACMEChallengeMode
36
37 // Token is the Cloudflare API token (required for DNS-01 challenge).
38 Token string
39
40 // DNSResolvers are recursive resolvers used for DNS-01 propagation checks.
41 DNSResolvers []string
42
43 // DNSPropagationTimeout is the maximum wait for DNS-01 TXT propagation.
44 DNSPropagationTimeout time.Duration
45
46 // DNSPollingInterval is the interval between DNS-01 propagation checks.
47 DNSPollingInterval time.Duration
48
49 // Store persists ACME accounts and certificates.
50 Store out.CertificateStore
51
52 // HTTPChallengeSink is used for HTTP-01 challenge token storage.
53 HTTPChallengeSink out.HTTPChallengeSink
54
55 // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used.
56 CADirectoryURL string
57
58 // HTTPClient is an optional HTTP client for the ACME client. If nil, a default is used.
59 HTTPClient *http.Client
60}
61
62// Issuer implements out.PublicCertificateIssuer using the lego ACME client.
63type Issuer struct {
64 cfg Config
65 mu sync.Mutex
66 client *lego.Client
67 user *AccountUser
68 initializing bool
69 initDone chan struct{}
70}
71
72// NewIssuer creates a new Issuer. Config is validated; the ACME account is
73// loaded or created lazily on first Obtain or Renew call.
74func NewIssuer(cfg Config) (*Issuer, error) {
75 if cfg.Email == "" {
76 return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)
77 }
78 if cfg.Store == nil {
79 return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
80 }
81 if cfg.CADirectoryURL != "" && !strings.HasPrefix(cfg.CADirectoryURL, "https://") {
82 return nil, fmt.Errorf("acmelego: CADirectoryURL must use HTTPS, got %q", cfg.CADirectoryURL)
83 }
84 if cfg.Challenge == "" {
85 cfg.Challenge = domain.ACMEChallengeHTTP01
86 }
87 switch cfg.Challenge {
88 case domain.ACMEChallengeHTTP01:
89 if cfg.HTTPChallengeSink == nil {
90 return nil, fmt.Errorf("acmelego: %w", domain.ErrHTTPChallengeSinkRequired)
91 }
92 case domain.ACMEChallengeCloudflareDNS01:
93 if cfg.Token == "" {
94 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
95 }
96 resolvers, err := normalizeDNSResolvers(cfg.DNSResolvers)
97 if err != nil {
98 return nil, err
99 }
100 cfg.DNSResolvers = resolvers
101 if cfg.DNSPropagationTimeout <= 0 {
102 return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
103 }
104 if cfg.DNSPollingInterval <= 0 {
105 return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be positive", domain.ErrDNSConfigInvalid)
106 }
107 if cfg.DNSPollingInterval >= cfg.DNSPropagationTimeout {
108 return nil, fmt.Errorf("acmelego: %w: DNSPollingInterval must be less than DNSPropagationTimeout", domain.ErrDNSConfigInvalid)
109 }
110 default:
111 return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
112 }
113
114 return &Issuer{cfg: cfg}, nil
115}
116
117func normalizeDNSResolvers(resolvers []string) ([]string, error) {
118 if len(resolvers) == 0 {
119 return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
120 }
121
122 normalized := make([]string, len(resolvers))
123 for i, resolver := range resolvers {
124 trimmed := strings.TrimSpace(resolver)
125 if trimmed == "" {
126 return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q", domain.ErrDNSConfigInvalid, i, trimmed)
127 }
128 host, port, err := net.SplitHostPort(trimmed)
129 if err != nil {
130 return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
131 }
132 if host == "" {
133 return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
134 }
135 portNumber, err := strconv.Atoi(port)
136 if err != nil || portNumber < 1 || portNumber > 65535 {
137 return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry at index %d value %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
138 }
139 normalized[i] = trimmed
140 }
141
142 return normalized, nil
143}
144
145// compile-time interface check
146var _ out.PublicCertificateIssuer = (*Issuer)(nil)
147
148// Obtain obtains a new certificate for the given order. The ACME client is
149// initialized lazily on the first call.
150func (i *Issuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
151 if err := i.ensureClient(ctx); err != nil {
152 return nil, err
153 }
154
155 resource, err := i.client.Certificate.Obtain(certificate.ObtainRequest{
156 Domains: order.Names,
157 Bundle: true,
158 })
159 if err != nil {
160 return nil, fmt.Errorf("obtain certificate: %w", err)
161 }
162
163 return resourceToStored(order, resource)
164}
165
166// Renew renews an existing certificate. The ACME client is initialized lazily
167// on the first call. Bundle and MustStaple are set to true/false respectively.
168func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
169 if err := i.ensureClient(ctx); err != nil {
170 return nil, err
171 }
172
173 domainName := ""
174 if len(cert.Names) > 0 {
175 domainName = cert.Names[0]
176 }
177
178 legoCert := certificate.Resource{
179 Domain: domainName,
180 PrivateKey: cert.PrivateKeyPEM,
181 Certificate: cert.CertPEM,
182 IssuerCertificate: cert.ChainPEM,
183 }
184
185 // If FullchainPEM is available but CertPEM is empty, use FullchainPEM.
186 if len(legoCert.Certificate) == 0 && len(cert.FullchainPEM) > 0 {
187 legoCert.Certificate = cert.FullchainPEM
188 }
189
190 renewed, err := i.client.Certificate.RenewWithOptions(legoCert, &certificate.RenewOptions{
191 Bundle: true,
192 })
193 if err != nil {
194 return nil, fmt.Errorf("renew certificate: %w", err)
195 }
196
197 // Preserve the original order info
198 order := out.CertificateOrder{
199 ID: cert.ID,
200 Names: cert.Names,
201 Challenge: cert.Challenge,
202 }
203
204 return resourceToStored(order, renewed)
205}
206
207// ensureClient initializes the lego client and ACME account if not yet done.
208func (i *Issuer) ensureClient(ctx context.Context) error {
209 for {
210 i.mu.Lock()
211 if i.client != nil {
212 i.mu.Unlock()
213 return nil
214 }
215 if i.initializing {
216 done := i.initDone
217 i.mu.Unlock()
218 select {
219 case <-done:
220 continue
221 case <-ctx.Done():
222 return ctx.Err()
223 }
224 }
225 i.initializing = true
226 i.initDone = make(chan struct{})
227 done := i.initDone
228 i.mu.Unlock()
229
230 user, client, err := i.buildClient(ctx)
231
232 i.mu.Lock()
233 if err == nil {
234 i.user = user
235 i.client = client
236 }
237 i.initializing = false
238 close(done)
239 i.initDone = nil
240 i.mu.Unlock()
241
242 return err
243 }
244}
245
246func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, error) {
247 // Load or create ACME account
248 user, err := i.loadOrCreateAccount(ctx)
249 if err != nil {
250 return nil, nil, fmt.Errorf("acme account: %w", err)
251 }
252
253 client, err := lego.NewClient(i.newLegoConfig(user))
254 if err != nil {
255 return nil, nil, fmt.Errorf("create lego client: %w", err)
256 }
257
258 // Set up challenge providers
259 switch i.cfg.Challenge {
260 case domain.ACMEChallengeHTTP01:
261 if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
262 return nil, nil, fmt.Errorf("set http-01 provider: %w", err)
263 }
264 case domain.ACMEChallengeCloudflareDNS01:
265 cfProvider, err := cloudflare.NewDNSProviderConfig(newCloudflareDNSProviderConfig(i.cfg))
266 if err != nil {
267 return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err)
268 }
269 if err := client.Challenge.SetDNS01Provider(cfProvider, dns01.AddRecursiveNameservers(i.cfg.DNSResolvers)); err != nil {
270 return nil, nil, fmt.Errorf("set dns-01 provider: %w", err)
271 }
272 }
273
274 return user, client, nil
275}
276
277// loadOrCreateAccount loads an existing ACME account from the store or creates
278// a new one. If creating, it registers the account with the ACME server.
279func (i *Issuer) loadOrCreateAccount(ctx context.Context) (*AccountUser, error) {
280 stored, err := i.cfg.Store.LoadAccount(ctx)
281 if err != nil {
282 return nil, fmt.Errorf("load account: %w", err)
283 }
284
285 if stored != nil && len(stored.PrivateKeyPEM) > 0 {
286 return restoreAccount(stored)
287 }
288
289 // Generate new ECDSA P-256 private key for the ACME account
290 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
291 if err != nil {
292 return nil, fmt.Errorf("generate account key: %w", err)
293 }
294
295 user := NewAccountUser(i.cfg.Email, privateKey, nil)
296
297 // We need a temporary client to register the account
298 tmpClient, err := lego.NewClient(i.newLegoConfig(user))
299 if err != nil {
300 return nil, fmt.Errorf("create temporary lego client: %w", err)
301 }
302
303 reg, err := tmpClient.Registration.Register(registration.RegisterOptions{
304 TermsOfServiceAgreed: true,
305 })
306 if err != nil {
307 return nil, fmt.Errorf("register acme account: %w", err)
308 }
309
310 user = NewAccountUser(i.cfg.Email, privateKey, reg)
311
312 // Persist the account
313 bodyJSON, err := json.Marshal(reg.Body)
314 if err != nil {
315 return nil, fmt.Errorf("marshal account body: %w", err)
316 }
317
318 privateKeyPEM := certcrypto.PEMEncode(privateKey)
319
320 if err := i.cfg.Store.SaveAccount(ctx, out.ACMEAccount{
321 Email: i.cfg.Email,
322 PrivateKeyPEM: privateKeyPEM,
323 RegistrationURI: reg.URI,
324 BodyJSON: bodyJSON,
325 }); err != nil {
326 return nil, fmt.Errorf("save account: %w", err)
327 }
328
329 return user, nil
330}
331
332// restoreAccount reconstructs an AccountUser from a stored ACMEAccount.
333func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) {
334 privateKey, err := certcrypto.ParsePEMPrivateKey(stored.PrivateKeyPEM)
335 if err != nil {
336 return nil, fmt.Errorf("parse account private key: %w", err)
337 }
338
339 reg := ®istration.Resource{
340 URI: stored.RegistrationURI,
341 }
342
343 if len(stored.BodyJSON) > 0 {
344 var body acme.Account
345 if err := json.Unmarshal(stored.BodyJSON, &body); err != nil {
346 return nil, fmt.Errorf("unmarshal account body: %w", err)
347 }
348 reg.Body = body
349 }
350
351 return NewAccountUser(stored.Email, privateKey, reg), nil
352}
353
354// newLegoConfig creates a lego.Config with common settings from the Issuer config.
355func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config {
356 legoCfg := lego.NewConfig(user)
357 legoCfg.Certificate.KeyType = certcrypto.EC256 // ECDSA P-256
358 if i.cfg.CADirectoryURL != "" {
359 legoCfg.CADirURL = i.cfg.CADirectoryURL
360 }
361 if i.cfg.HTTPClient != nil {
362 legoCfg.HTTPClient = i.cfg.HTTPClient
363 }
364 return legoCfg
365}
366
367// newCloudflareDNSProviderConfig creates a cloudflare DNS provider config from the issuer config.
368func newCloudflareDNSProviderConfig(cfg Config) *cloudflare.Config {
369 cfCfg := cloudflare.NewDefaultConfig()
370 cfCfg.AuthToken = cfg.Token
371 cfCfg.PropagationTimeout = cfg.DNSPropagationTimeout
372 cfCfg.PollingInterval = cfg.DNSPollingInterval
373 return cfCfg
374}
375
376// resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
377func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) {
378 var fullchainPEM []byte
379 if len(res.IssuerCertificate) > 0 {
380 fullchainPEM = append(append([]byte{}, res.Certificate...), res.IssuerCertificate...)
381 } else {
382 fullchainPEM = res.Certificate
383 }
384
385 // Parse the certificate to extract NotAfter and populate tls.Certificate.
386 var tlsCert tls.Certificate
387 notAfter := time.Time{}
388 parsedTLSCert, tlsErr := tls.X509KeyPair(fullchainPEM, res.PrivateKey)
389 if tlsErr != nil {
390 return nil, fmt.Errorf("parse tls certificate: %w", tlsErr)
391 }
392 tlsCert = parsedTLSCert
393 if len(parsedTLSCert.Certificate) > 0 {
394 parsed, parseErr := x509.ParseCertificate(parsedTLSCert.Certificate[0])
395 if parseErr != nil {
396 return nil, fmt.Errorf("parse x509 certificate: %w", parseErr)
397 }
398 notAfter = parsed.NotAfter
399 }
400
401 names := order.Names
402 if names == nil {
403 names = []string{}
404 }
405
406 return &out.StoredCertificate{
407 ID: order.ID,
408 Names: names,
409 Challenge: order.Challenge,
410 Certificate: tlsCert,
411 CertPEM: res.Certificate,
412 ChainPEM: res.IssuerCertificate,
413 FullchainPEM: fullchainPEM,
414 PrivateKeyPEM: res.PrivateKey,
415 NotAfter: notAfter,
416 }, nil
417}