v2.31.0-dev16 8d0a092

service.go RawHistoryFolder

1package publictls
2
3import (
4	"context"
5	"crypto/tls"
6	"fmt"
7	"reflect"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/boundaries/out"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18// RouteSource provides routes from which certificate targets are derived.
19type RouteSource interface {
20	GetRoutes(ctx context.Context) []domain.Route
21	GetExternalRoutes() map[string]string
22}
23
24// ServiceDeps contains the dependencies for the Service.
25type ServiceDeps struct {
26	Config       Config
27	Routes       RouteSource
28	Issuer       out.PublicCertificateIssuer
29	Store        out.CertificateStore
30	ZoneResolver out.CloudflareZoneResolver
31	Challenges   *HTTP01Challenges
32	Effective    EffectiveChallenge
33	Log          zerowrap.Logger
34}
35
36const defaultObtainBatchSize = 1
37
38// Service manages public TLS certificates via ACME.
39type Service struct {
40	mu           sync.RWMutex
41	cfg          Config
42	deps         ServiceDeps
43	log          zerowrap.Logger
44	certs        map[string]*out.StoredCertificate // indexed by cert ID
45	lastErr      map[string]string                 // indexed by cert ID
46	routeErr     map[string]string                 // indexed by host
47	obtainCursor int                               // next missing target index for batched obtains
48
49	// requiredHosts is the set of hosts that must be covered by ACME certs.
50	requiredHosts map[string]struct{}
51
52	// cancel cancels the renewal loop context.
53	cancel context.CancelFunc
54	// done is closed when the renewal loop exits.
55	done chan struct{}
56}
57
58// NewService creates a new public TLS Service.
59func NewService(cfg Config, deps ServiceDeps) *Service {
60	if deps.Challenges == nil {
61		deps.Challenges = NewHTTP01Challenges()
62	}
63	if reflect.ValueOf(deps.Log).IsZero() {
64		deps.Log = zerowrap.Default()
65	}
66	return &Service{
67		cfg:           cfg,
68		deps:          deps,
69		log:           deps.Log,
70		certs:         make(map[string]*out.StoredCertificate),
71		lastErr:       make(map[string]string),
72		routeErr:      make(map[string]string),
73		requiredHosts: make(map[string]struct{}),
74	}
75}
76
77// Load loads all stored certificates from the store into the internal cache.
78// If the store is nil, this is a no-op.
79func (s *Service) Load(ctx context.Context) error {
80	if s.deps.Store == nil {
81		return nil
82	}
83
84	stored, err := s.deps.Store.LoadAll(ctx)
85	if err != nil {
86		return fmt.Errorf("load stored certificates: %w", err)
87	}
88	state, err := s.deps.Store.LoadState(ctx)
89	if err != nil {
90		s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor")
91		state = out.CertificateStoreState{}
92	}
93	if state.ObtainCursor < 0 {
94		state.ObtainCursor = 0
95	}
96
97	s.mu.Lock()
98	defer s.mu.Unlock()
99
100	s.certs = make(map[string]*out.StoredCertificate, len(stored))
101	s.lastErr = make(map[string]string)
102	s.requiredHosts = make(map[string]struct{})
103	s.obtainCursor = state.ObtainCursor
104
105	for i := range stored {
106		cert := &stored[i]
107		if parseErr := populateStoredCertificate(cert); parseErr != nil {
108			s.lastErr[cert.ID] = parseErr.Error()
109			continue
110		}
111		s.certs[cert.ID] = cert
112		addNamesToRequiredHosts(s.requiredHosts, cert.Names)
113		if cert.LastError != "" {
114			s.lastErr[cert.ID] = cert.LastError
115		}
116	}
117
118	return nil
119}
120
121// Reconcile ensures all desired certificates are obtained and cached.
122// If ACME is disabled, it is a no-op.
123func (s *Service) Reconcile(ctx context.Context) error {
124	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
125		zerowrap.FieldLayer:   "usecase",
126		zerowrap.FieldUseCase: "Reconcile",
127	})
128	log := zerowrap.FromCtx(ctx)
129
130	if !s.cfg.Enabled {
131		log.Debug().Msg("acme disabled, skipping reconcile")
132		return nil
133	}
134	log.Debug().Msg("starting certificate reconciliation")
135
136	if s.deps.Store == nil {
137		log.Warn().Msg("certificate store is nil, cannot reconcile")
138		return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
139	}
140	if s.deps.Issuer == nil {
141		log.Warn().Msg("certificate issuer is nil, cannot reconcile")
142		return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
143	}
144	if s.deps.Routes == nil {
145		log.Warn().Msg("route source is nil, cannot reconcile")
146		return fmt.Errorf("%w: route source is nil", domain.ErrRouteSourceRequired)
147	}
148
149	// Determine effective challenge mode.
150	effective := s.deps.Effective
151	if effective.Mode == "" {
152		resolved, err := ResolveEffectiveChallenge(ctx, s.cfg, nil)
153		if err != nil {
154			return fmt.Errorf("resolve effective challenge: %w", err)
155		}
156		effective = resolved
157	}
158
159	// Get route hosts early to build required hosts set before target derivation.
160	// This ensures GetCertificate returns ErrTLSRouteNotCovered even if
161	// DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).
162	routes := s.deps.Routes.GetRoutes(ctx)
163	external := s.deps.Routes.GetExternalRoutes()
164	hosts := routeHosts(routes, external)
165
166	// Build required hosts set from route hosts (before target derivation).
167	required := canonicalHostSet(hosts)
168
169	// Set requiredHosts before DeriveCertificateTargets so GetCertificate
170	// returns ErrTLSRouteNotCovered if target derivation/issuer fails.
171	s.mu.Lock()
172	s.requiredHosts = required
173	clearRouteErrorsLocked(s.routeErr, required)
174	s.mu.Unlock()
175
176	// Derive desired targets.
177	targets, err := DeriveCertificateTargets(ctx, effective.Mode,
178		routes, external,
179		s.deps.ZoneResolver,
180	)
181	if err != nil {
182		s.mu.Lock()
183		setRouteErrorsLocked(s.routeErr, required, fmt.Sprintf("derive certificate targets: %v", err))
184		s.mu.Unlock()
185		return fmt.Errorf("derive certificate targets: %w", err)
186	}
187
188	// Merge target names into required hosts set.
189	// For DNS-01 wildcard targets, this adds wildcard entries as well;
190	// the exact route hosts already present suffice for isRequiredHostLocked.
191	addTargetNames(required, targets)
192
193	// Under mu: update requiredHosts (with target names) and compute missing targets.
194	s.mu.Lock()
195	s.requiredHosts = required
196
197	batchSize := s.cfg.ObtainBatchSize
198	if batchSize <= 0 {
199		batchSize = defaultObtainBatchSize
200	}
201
202	previousCursor := s.obtainCursor
203	missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize)
204	s.mu.Unlock()
205
206	if cursorChanged {
207		if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
208			log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor")
209		}
210		s.mu.Lock()
211		if s.obtainCursor == previousCursor {
212			s.obtainCursor = nextCursor
213		}
214		s.mu.Unlock()
215	}
216
217	// Process missing targets without holding s.mu or the store lock so
218	// GetCertificate/Status are not blocked during network I/O (Obtain).
219	if remaining := pendingCount - len(missing); remaining > 0 {
220		log.Info().
221			Int("obtain_batch_size", len(missing)).
222			Int("remaining_count", remaining).
223			Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later")
224	}
225	s.reconcileMissingTargets(ctx, missing, required)
226
227	log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets")
228	return nil
229}
230
231// selectMissingTargetsLocked returns up to batchSize missing certificate targets.
232// It rotates the starting point across reconcile runs so one repeatedly failing
233// target cannot indefinitely block later targets when batching is enabled.
234// Must be called with s.mu held.
235func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) {
236	if batchSize <= 0 {
237		batchSize = defaultObtainBatchSize
238	}
239	previousCursor := s.obtainCursor
240
241	allMissing := make([]CertificateTarget, 0, len(targets))
242	for _, target := range targets {
243		if !s.certificateExistsLocked(target) {
244			allMissing = append(allMissing, target)
245		}
246	}
247
248	pendingCount := len(allMissing)
249	if pendingCount == 0 {
250		nextCursor := 0
251		return nil, 0, nextCursor, nextCursor != previousCursor
252	}
253	if batchSize >= pendingCount {
254		nextCursor := 0
255		return allMissing, pendingCount, nextCursor, nextCursor != previousCursor
256	}
257
258	start := s.obtainCursor % pendingCount
259	missing := make([]CertificateTarget, 0, batchSize)
260	for i := range batchSize {
261		missing = append(missing, allMissing[(start+i)%pendingCount])
262	}
263	nextCursor := (start + batchSize) % pendingCount
264	return missing, pendingCount, nextCursor, nextCursor != previousCursor
265}
266
267func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error {
268	unlock, err := s.deps.Store.Lock(ctx)
269	if err != nil {
270		return fmt.Errorf("acquire store lock: %w", err)
271	}
272	saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor})
273	if unlockErr := unlock(); unlockErr != nil {
274		log := zerowrap.FromCtx(ctx)
275		log.Warn().Err(unlockErr).Msg("failed to release store lock")
276	}
277	if saveErr != nil {
278		return fmt.Errorf("save certificate store state: %w", saveErr)
279	}
280	return nil
281}
282
283func populateStoredCertificate(cert *out.StoredCertificate) error {
284	if cert == nil {
285		return fmt.Errorf("stored certificate is nil")
286	}
287	if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
288		parsed, err := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
289		if err != nil {
290			return fmt.Errorf("parse stored certificate: %w", err)
291		}
292		cert.Certificate = parsed
293	}
294	if len(cert.Certificate.Certificate) == 0 {
295		return fmt.Errorf("stored certificate is empty")
296	}
297	return nil
298}
299
300func canonicalHostSet(hosts []string) map[string]struct{} {
301	required := make(map[string]struct{}, len(hosts))
302	for _, host := range hosts {
303		host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
304		if host != "" {
305			required[host] = struct{}{}
306		}
307	}
308	return required
309}
310
311func addNamesToRequiredHosts(required map[string]struct{}, names []string) {
312	for _, name := range names {
313		name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
314		if name != "" {
315			required[name] = struct{}{}
316		}
317	}
318}
319
320func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
321	for _, t := range targets {
322		addNamesToRequiredHosts(required, t.Names)
323	}
324}
325
326func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) {
327	for host := range hosts {
328		delete(routeErr, host)
329	}
330}
331
332func setRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}, message string) {
333	for host := range hosts {
334		routeErr[host] = message
335	}
336}
337
338func setTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget, message string) {
339	for host := range required {
340		if hostMatchesCert(target.Names, host) {
341			routeErr[host] = message
342		}
343	}
344}
345
346func clearTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget) {
347	for host := range required {
348		if hostMatchesCert(target.Names, host) {
349			delete(routeErr, host)
350		}
351	}
352}
353
354// reconcileMissingTargets obtains and saves certificates for each missing
355// target. Called without s.mu held so network I/O does not block readers.
356func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget, required map[string]struct{}) {
357	log := zerowrap.FromCtx(ctx)
358	for _, target := range missing {
359		order := out.CertificateOrder{
360			ID:        target.ID,
361			Names:     target.Names,
362			Challenge: target.Challenge,
363		}
364
365		stored, err := s.deps.Issuer.Obtain(ctx, order)
366		if err != nil {
367			errorMessage := fmt.Sprintf("obtain certificate: %v", err)
368			s.mu.Lock()
369			s.lastErr[target.ID] = errorMessage
370			setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
371			s.mu.Unlock()
372			continue
373		}
374		if stored == nil {
375			errorMessage := "obtain certificate returned nil"
376			s.mu.Lock()
377			s.lastErr[target.ID] = errorMessage
378			setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
379			s.mu.Unlock()
380			continue
381		}
382		if err := populateStoredCertificate(stored); err != nil {
383			errorMessage := err.Error()
384			s.mu.Lock()
385			s.lastErr[target.ID] = errorMessage
386			setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
387			s.mu.Unlock()
388			continue
389		}
390
391		unlock, err := s.deps.Store.Lock(ctx)
392		if err != nil {
393			errorMessage := fmt.Sprintf("acquire store lock: %v", err)
394			s.mu.Lock()
395			s.lastErr[target.ID] = errorMessage
396			setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
397			s.mu.Unlock()
398			continue
399		}
400		saveErr := s.deps.Store.Save(ctx, *stored)
401		if unlockErr := unlock(); unlockErr != nil {
402			log.Warn().Err(unlockErr).Msg("failed to release store lock")
403		}
404		if saveErr != nil {
405			errorMessage := fmt.Sprintf("save certificate: %v", saveErr)
406			s.mu.Lock()
407			s.lastErr[target.ID] = errorMessage
408			setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage)
409			s.mu.Unlock()
410			continue
411		}
412
413		s.mu.Lock()
414		s.certs[stored.ID] = stored
415		delete(s.lastErr, stored.ID)
416		clearTargetRouteErrorLocked(s.routeErr, required, target)
417		s.mu.Unlock()
418	}
419}
420
421// certificateExistsLocked checks if a valid non-expired cached certificate
422// covers the target. Must be called with s.mu held.
423func (s *Service) certificateExistsLocked(target CertificateTarget) bool {
424	now := time.Now()
425	for _, cert := range s.certs {
426		if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
427			continue // expired or no expiry
428		}
429		if exactNamesCoverAll(cert.Names, target.Names) {
430			return true
431		}
432	}
433	return false
434}
435
436// exactNamesCoverAll reports whether names contains every required name exactly, case-insensitively.
437func exactNamesCoverAll(names, required []string) bool {
438	covered := make(map[string]bool)
439	for _, n := range names {
440		covered[strings.ToLower(strings.TrimSuffix(n, "."))] = true
441	}
442	for _, r := range required {
443		r = strings.ToLower(strings.TrimSuffix(r, "."))
444		if !covered[r] {
445			return false
446		}
447	}
448	return true
449}
450
451// hostMatchesCert checks if any name in the given list matches host,
452// including wildcard matching.
453func hostMatchesCert(names []string, host string) bool {
454	return domain.CertificateNamesCoverHost(names, host)
455}
456
457// GetCertificateForHost returns a TLS certificate for the given SNI host.
458//
459// If the SNI host does not require ACME coverage, returns nil, nil.
460// If ACME is required but no cert covers the host, returns
461// ErrTLSRouteNotCovered.
462// If a valid cert is found, returns a pointer to it.
463func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
464	host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
465	if host == "" {
466		return nil, nil
467	}
468
469	s.mu.RLock()
470	defer s.mu.RUnlock()
471
472	// Check if this host requires ACME coverage.
473	if !s.isRequiredHostLocked(host) {
474		return nil, nil
475	}
476
477	// Look for a valid non-expired cached certificate covering this host.
478	now := time.Now()
479	for _, cert := range s.certs {
480		if cert.NotAfter.IsZero() || now.After(cert.NotAfter) {
481			continue
482		}
483		if hostMatchesCert(cert.Names, host) {
484			return &cert.Certificate, nil
485		}
486	}
487
488	return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)
489}
490
491// isRequiredHostLocked checks if host or any wildcard covering it is in
492// the required hosts set. Must be called with s.mu held.
493func (s *Service) isRequiredHostLocked(host string) bool {
494	if _, ok := s.requiredHosts[host]; ok {
495		return true
496	}
497	// Check wildcard: split at the first dot.
498	parts := strings.SplitN(host, ".", 2)
499	if len(parts) == 2 {
500		if _, ok := s.requiredHosts["*."+parts[1]]; ok {
501			return true
502		}
503	}
504	return false
505}
506
507// GetHTTP01Challenge delegates to the HTTP-01 challenge store.
508func (s *Service) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
509	return s.deps.Challenges.Get(ctx, token)
510}
511
512// getStoredCertificate returns a copy of the stored certificate for the given
513// ID, or nil if not found.
514func (s *Service) getStoredCertificate(id string) *out.StoredCertificate {
515	s.mu.RLock()
516	defer s.mu.RUnlock()
517	cert := s.certs[id]
518	if cert == nil {
519		return nil
520	}
521	cpy := *cert
522	return &cpy
523}
524
525// Stop gracefully stops the service. If a renewal loop is running, it is
526// cancelled and Stop waits for it to finish (subject to ctx deadline).
527func (s *Service) Stop(ctx context.Context) error {
528	s.mu.Lock()
529	cancel := s.cancel
530	done := s.done
531	s.cancel = nil
532	s.done = nil
533	s.mu.Unlock()
534
535	if cancel != nil {
536		cancel()
537	}
538	if done != nil {
539		select {
540		case <-done:
541		case <-ctx.Done():
542			return ctx.Err()
543		}
544	}
545	return nil
546}

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