v2.31.0-dev5 4162c86

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close