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}