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

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