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}