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}