v2.31.1 f37fe67

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

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