v3.4.1 de8f68e

issuer.go RawHistoryFolder

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 := &registration.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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close