1package pki
2
3import (
4 "context"
5 "crypto/tls"
6 "crypto/x509"
7 "sync"
8 "time"
9
10 "github.com/bnema/zerowrap"
11 "golang.org/x/sync/singleflight"
12
13 "github.com/bnema/gordon/internal/boundaries/out"
14)
15
16const (
17 renewalWindowRatio = 0.2
18 checkInterval = 10 * time.Minute
19)
20
21type cachedCert struct {
22 cert *tls.Certificate
23 expiresAt time.Time
24}
25
26// Service provides on-demand TLS certificate issuance with caching
27// and automatic intermediate CA renewal.
28//
29// TODO: consider adding CRL/OCSP support for certificate revocation,
30// especially given the 10-year root CA lifetime.
31type Service struct {
32 ca out.CertificateAuthority
33 routes out.RouteChecker
34 log zerowrap.Logger
35
36 cache sync.Map // map[string]*cachedCert
37 flight singleflight.Group
38 cancel context.CancelFunc
39 done chan struct{}
40}
41
42// NewService creates a PKI service and starts background maintenance goroutines.
43// It performs an initial intermediate renewal check synchronously so the first
44// TLS handshakes never use a nearly-expired intermediate.
45func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
46 ctx, cancel := context.WithCancel(ctx)
47 svc := &Service{
48 ca: ca,
49 routes: routes,
50 log: log,
51 cancel: cancel,
52 done: make(chan struct{}),
53 }
54 svc.renewIntermediateIfNeeded()
55 go svc.maintenanceLoop(ctx)
56 return svc
57}
58
59// Stop cancels background goroutines and waits for them to finish.
60func (s *Service) Stop() {
61 s.cancel()
62 <-s.done
63}
64
65// CachedCertCount returns the number of cached leaf certificates.
66func (s *Service) CachedCertCount() int {
67 count := 0
68 s.cache.Range(func(_, _ any) bool {
69 count++
70 return true
71 })
72 return count
73}
74
75// GetCertificate is the tls.Config.GetCertificate callback.
76// It returns (nil, nil) for unknown domains so Go's TLS stack
77// falls back to tls.Config.Certificates.
78func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
79 domain := hello.ServerName
80 if domain == "" {
81 return nil, nil
82 }
83
84 if entry, ok := s.cache.Load(domain); ok {
85 if cached, ok := entry.(*cachedCert); ok {
86 if time.Now().Before(cached.expiresAt) {
87 if !s.isDomainAllowed(hello.Context(), domain) {
88 s.cache.Delete(domain)
89 return nil, nil
90 }
91 return cached.cert, nil
92 }
93 }
94 s.cache.Delete(domain)
95 }
96
97 if !s.isDomainAllowed(hello.Context(), domain) {
98 return nil, nil
99 }
100
101 result, err, _ := s.flight.Do(domain, func() (any, error) {
102 // Re-check cache; another goroutine in the same flight may have populated it.
103 if entry, ok := s.cache.Load(domain); ok {
104 if cached, ok := entry.(*cachedCert); ok {
105 if time.Now().Before(cached.expiresAt) {
106 return cached.cert, nil
107 }
108 }
109 s.cache.Delete(domain)
110 }
111
112 cert, err := s.ca.IssueCertificate(domain)
113 if err != nil {
114 s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert")
115 return nil, err
116 }
117
118 expiresAt := time.Now().Add(s.leafLifetime())
119 if len(cert.Certificate) > 0 {
120 if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
121 expiresAt = leaf.NotAfter
122 }
123 }
124 s.cache.Store(domain, &cachedCert{
125 cert: cert,
126 expiresAt: expiresAt,
127 })
128
129 s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
130 return cert, nil
131 })
132 if err != nil {
133 return nil, err
134 }
135 if result == nil {
136 return nil, nil
137 }
138 return result.(*tls.Certificate), nil
139}
140
141func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
142 for _, r := range s.routes.GetRoutes(ctx) {
143 if r.Domain == domain {
144 return true
145 }
146 }
147 if _, ok := s.routes.GetExternalRoutes()[domain]; ok {
148 return true
149 }
150 return false
151}
152
153func (s *Service) leafLifetime() time.Duration {
154 return s.ca.LeafLifetime()
155}
156
157func (s *Service) maintenanceLoop(ctx context.Context) {
158 defer close(s.done)
159 ticker := time.NewTicker(checkInterval)
160 defer ticker.Stop()
161
162 for {
163 select {
164 case <-ctx.Done():
165 return
166 case <-ticker.C:
167 s.renewIntermediateIfNeeded()
168 s.sweepExpiredCerts(ctx)
169 }
170 }
171}
172
173func (s *Service) renewIntermediateIfNeeded() {
174 expiresAt := s.ca.IntermediateExpiresAt()
175 remaining := time.Until(expiresAt)
176 lifetime := s.ca.IntermediateLifetime()
177 window := time.Duration(float64(lifetime) * renewalWindowRatio)
178 if remaining < window {
179 s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
180 if err := s.ca.RenewIntermediate(); err != nil {
181 s.log.Error().Err(err).Msg("intermediate CA renewal failed")
182 }
183 }
184}
185
186func (s *Service) sweepExpiredCerts(ctx context.Context) {
187 now := time.Now()
188
189 // Fetch routes once for all cache entries.
190 routes := s.routes.GetRoutes(ctx)
191 extRoutes := s.routes.GetExternalRoutes()
192
193 allowed := make(map[string]struct{}, len(routes)+len(extRoutes))
194 for _, r := range routes {
195 allowed[r.Domain] = struct{}{}
196 }
197 for d := range extRoutes {
198 allowed[d] = struct{}{}
199 }
200
201 swept := 0
202 s.cache.Range(func(key, value any) bool {
203 domain := key.(string)
204 cached := value.(*cachedCert)
205 _, ok := allowed[domain]
206 if now.After(cached.expiresAt) || !ok {
207 s.cache.Delete(key)
208 swept++
209 }
210 return true
211 })
212 if swept > 0 {
213 s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache")
214 }
215}