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}