1package pki
2
3import (
4 "context"
5 "crypto/tls"
6 "crypto/x509"
7 "fmt"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "golang.org/x/sync/singleflight"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19const (
20 renewalWindowRatio = 0.2
21 checkInterval = 10 * time.Minute
22)
23
24type cachedCert struct {
25 cert *tls.Certificate
26 expiresAt time.Time
27}
28
29// Service provides on-demand TLS certificate issuance with caching
30// and automatic intermediate CA renewal.
31//
32// TODO: consider adding CRL/OCSP support for certificate revocation,
33// especially given the 10-year root CA lifetime.
34type Service struct {
35 ca out.CertificateAuthority
36 routes out.RouteChecker
37 allowedMu sync.RWMutex
38 allowedDomains map[string]struct{}
39 log zerowrap.Logger
40
41 cache sync.Map // map[string]*cachedCert
42 flight singleflight.Group
43 cancel context.CancelFunc
44 done chan struct{}
45}
46
47// NewService creates a PKI service and starts background maintenance goroutines.
48// It performs an initial intermediate renewal check synchronously so the first
49// TLS handshakes never use a nearly-expired intermediate.
50func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, allowedDomains []string, log zerowrap.Logger) *Service {
51 ctx, cancel := context.WithCancel(ctx)
52 allowed := canonicalDomainSet(allowedDomains)
53 svc := &Service{
54 ca: ca,
55 routes: routes,
56 allowedDomains: allowed,
57 log: log,
58 cancel: cancel,
59 done: make(chan struct{}),
60 }
61 svc.renewIntermediateIfNeeded()
62 go svc.maintenanceLoop(ctx)
63 return svc
64}
65
66// Stop cancels background goroutines and waits for them to finish.
67func (s *Service) Stop() {
68 s.cancel()
69 <-s.done
70}
71
72// CachedCertCount returns the number of cached leaf certificates.
73func (s *Service) CachedCertCount() int {
74 count := 0
75 s.cache.Range(func(_, _ any) bool {
76 count++
77 return true
78 })
79 return count
80}
81
82// GetCertificate is the tls.Config.GetCertificate callback.
83// It returns (nil, nil) for unknown domains so Go's TLS stack
84// falls back to tls.Config.Certificates.
85func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
86 domainName := canonicalServerName(hello.ServerName)
87 if domainName == "" {
88 return nil, nil
89 }
90
91 if cert := s.validCachedCertificate(domainName); cert != nil {
92 if s.isDomainAllowed(hello.Context(), domainName) {
93 return cert, nil
94 }
95 s.cache.Delete(domainName)
96 return nil, nil
97 }
98 if !s.isDomainAllowed(hello.Context(), domainName) {
99 return nil, nil
100 }
101
102 result, err, _ := s.flight.Do(domainName, func() (any, error) {
103 return s.issueCertificate(hello.Context(), domainName)
104 })
105 if err != nil {
106 return nil, err
107 }
108 if result == nil || !s.isDomainAllowed(hello.Context(), domainName) {
109 s.cache.Delete(domainName)
110 return nil, nil
111 }
112 return result.(*tls.Certificate), nil
113}
114
115func canonicalServerName(serverName string) string {
116 return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
117}
118
119func (s *Service) validCachedCertificate(domainName string) *tls.Certificate {
120 entry, ok := s.cache.Load(domainName)
121 if !ok {
122 return nil
123 }
124 cached, ok := entry.(*cachedCert)
125 if !ok || !time.Now().Before(cached.expiresAt) {
126 s.cache.Delete(domainName)
127 return nil
128 }
129 return cached.cert
130}
131
132func (s *Service) issueCertificate(ctx context.Context, domainName string) (*tls.Certificate, error) {
133 if ctx == nil {
134 ctx = context.Background()
135 }
136 ctx = zerowrap.WithCtx(ctx, s.log)
137 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
138 zerowrap.FieldLayer: "usecase",
139 zerowrap.FieldUseCase: "IssueCertificate",
140 "domain": domainName,
141 })
142 log := zerowrap.FromCtx(ctx)
143
144 if !s.isDomainAllowed(ctx, domainName) {
145 return nil, nil
146 }
147 if cert := s.validCachedCertificate(domainName); cert != nil {
148 return cert, nil
149 }
150
151 cert, err := s.ca.IssueCertificate(domainName)
152 if err != nil {
153 log.Error().Err(err).Msg("failed to issue leaf certificate")
154 return nil, fmt.Errorf("issue leaf certificate for %q: %w", domainName, err)
155 }
156 if !s.isDomainAllowed(ctx, domainName) {
157 return nil, nil
158 }
159
160 s.cache.Store(domainName, &cachedCert{cert: cert, expiresAt: certificateExpiry(cert, s.leafLifetime())})
161 log.Debug().Msg("issued new leaf certificate")
162 return cert, nil
163}
164
165func certificateExpiry(cert *tls.Certificate, fallbackLifetime time.Duration) time.Time {
166 if len(cert.Certificate) > 0 {
167 if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
168 return leaf.NotAfter
169 }
170 }
171 return time.Now().Add(fallbackLifetime)
172}
173
174// SetAdditionalDomains atomically replaces non-route domains authorized for
175// on-demand certificates and evicts certificates that are no longer allowed.
176func (s *Service) SetAdditionalDomains(domains []string) {
177 next := canonicalDomainSet(domains)
178
179 s.allowedMu.Lock()
180 previous := s.allowedDomains
181 s.allowedDomains = next
182 s.allowedMu.Unlock()
183
184 for name := range previous {
185 if _, ok := next[name]; !ok {
186 s.cache.Delete(name)
187 }
188 }
189}
190
191func (s *Service) isDomainAllowed(ctx context.Context, domainName string) bool {
192 s.allowedMu.RLock()
193 _, additionallyAllowed := s.allowedDomains[domainName]
194 s.allowedMu.RUnlock()
195 if additionallyAllowed {
196 return true
197 }
198 for _, r := range s.routes.GetRoutes(ctx) {
199 if r.Domain == domainName {
200 return true
201 }
202 }
203 if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
204 return true
205 }
206 return false
207}
208
209func canonicalDomainSet(domains []string) map[string]struct{} {
210 canonical := make(map[string]struct{}, len(domains))
211 for _, name := range domains {
212 name = strings.TrimSuffix(strings.TrimSpace(name), ".")
213 if name, ok := domain.CanonicalRouteDomain(name); ok {
214 canonical[name] = struct{}{}
215 }
216 }
217 return canonical
218}
219
220func (s *Service) leafLifetime() time.Duration {
221 return s.ca.LeafLifetime()
222}
223
224func (s *Service) maintenanceLoop(ctx context.Context) {
225 defer close(s.done)
226 ticker := time.NewTicker(checkInterval)
227 defer ticker.Stop()
228
229 for {
230 select {
231 case <-ctx.Done():
232 return
233 case <-ticker.C:
234 s.renewIntermediateIfNeeded()
235 s.sweepExpiredCerts(ctx)
236 }
237 }
238}
239
240func (s *Service) renewIntermediateIfNeeded() {
241 expiresAt := s.ca.IntermediateExpiresAt()
242 remaining := time.Until(expiresAt)
243 lifetime := s.ca.IntermediateLifetime()
244 window := time.Duration(float64(lifetime) * renewalWindowRatio)
245 if remaining < window {
246 s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
247 if err := s.ca.RenewIntermediate(); err != nil {
248 s.log.Error().Err(err).Msg("intermediate CA renewal failed")
249 }
250 }
251}
252
253func (s *Service) sweepExpiredCerts(ctx context.Context) {
254 now := time.Now()
255
256 // Fetch routes once for all cache entries.
257 routes := s.routes.GetRoutes(ctx)
258 extRoutes := s.routes.GetExternalRoutes()
259
260 s.allowedMu.RLock()
261 allowed := make(map[string]struct{}, len(routes)+len(extRoutes)+len(s.allowedDomains))
262 for domain := range s.allowedDomains {
263 allowed[domain] = struct{}{}
264 }
265 s.allowedMu.RUnlock()
266 for _, r := range routes {
267 allowed[r.Domain] = struct{}{}
268 }
269 for d := range extRoutes {
270 allowed[d] = struct{}{}
271 }
272
273 swept := 0
274 s.cache.Range(func(key, value any) bool {
275 domain := key.(string)
276 cached := value.(*cachedCert)
277 _, ok := allowed[domain]
278 if now.After(cached.expiresAt) || !ok {
279 s.cache.Delete(key)
280 swept++
281 }
282 return true
283 })
284 if swept > 0 {
285 s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache")
286 }
287}