v2.31.0-dev22 8d342c4

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

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