detached ad80185

feat(pki): implement use case service with cert cache, allowlist, and renewal

commit
ad801858724149e22156520fbf0ffcc47af631d7
author
brice · · 2026-03-29 16:44 UTC
parent
1c01096
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +271 -0
A internal/usecase/pki/service.go +178 -0
@@ -0,0 +1,178 @@
1+package pki
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+const (
16+ renewalWindowRatio = 0.2
17+ checkInterval = 10 * time.Minute
18+)
19+
20+type cachedCert struct {
21+ cert *tls.Certificate
22+ expiresAt time.Time
23+}
24+
25+// RouteChecker provides route lookup for domain validation.
26+// Matches the subset of in.ConfigService needed here.
27+type RouteChecker interface {
28+ GetRoutes(ctx context.Context) []domain.Route
29+ GetExternalRoutes() map[string]string
30+}
31+
32+// Service provides on-demand TLS certificate issuance with caching
33+// and automatic intermediate CA renewal.
34+type Service struct {
35+ ca out.CertificateAuthority
36+ routes RouteChecker
37+ log zerowrap.Logger
38+
39+ cache sync.Map // map[string]*cachedCert
40+ cancel context.CancelFunc
41+ done chan struct{}
42+}
43+
44+// NewService creates a PKI service and starts background maintenance goroutines.
45+func NewService(ctx context.Context, ca out.CertificateAuthority, routes 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+ go svc.maintenanceLoop(ctx)
55+ return svc
56+}
57+
58+// Stop cancels background goroutines and waits for them to finish.
59+func (s *Service) Stop() {
60+ s.cancel()
61+ <-s.done
62+}
63+
64+// CachedCertCount returns the number of cached leaf certificates.
65+func (s *Service) CachedCertCount() int {
66+ count := 0
67+ s.cache.Range(func(_, _ any) bool {
68+ count++
69+ return true
70+ })
71+ return count
72+}
73+
74+// GetCertificate is the tls.Config.GetCertificate callback.
75+func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
76+ domain := hello.ServerName
77+ if domain == "" {
78+ return nil, fmt.Errorf("no SNI in ClientHello")
79+ }
80+
81+ // Check cache
82+ if entry, ok := s.cache.Load(domain); ok {
83+ cached := entry.(*cachedCert)
84+ if time.Now().Before(cached.expiresAt) {
85+ return cached.cert, nil
86+ }
87+ s.cache.Delete(domain)
88+ }
89+
90+ // Check allowlist
91+ if !s.isDomainAllowed(hello.Context(), domain) {
92+ return nil, fmt.Errorf("domain %q not in route table", domain)
93+ }
94+
95+ // Issue new cert
96+ cert, err := s.ca.IssueCertificate(domain)
97+ if err != nil {
98+ s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert")
99+ return nil, err
100+ }
101+
102+ s.cache.Store(domain, &cachedCert{
103+ cert: cert,
104+ expiresAt: time.Now().Add(s.leafLifetime()),
105+ })
106+
107+ s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
108+ return cert, nil
109+}
110+
111+func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
112+ for _, r := range s.routes.GetRoutes(ctx) {
113+ if r.Domain == domain {
114+ return true
115+ }
116+ }
117+ for d := range s.routes.GetExternalRoutes() {
118+ if d == domain {
119+ return true
120+ }
121+ }
122+ return false
123+}
124+
125+func (s *Service) leafLifetime() time.Duration {
126+ // Use the CA's leaf lifetime if it exposes it, otherwise default 12h.
127+ type lifetimer interface{ LeafLifetime() time.Duration }
128+ if lt, ok := s.ca.(lifetimer); ok {
129+ return lt.LeafLifetime()
130+ }
131+ return 12 * time.Hour
132+}
133+
134+func (s *Service) maintenanceLoop(ctx context.Context) {
135+ defer close(s.done)
136+ ticker := time.NewTicker(checkInterval)
137+ defer ticker.Stop()
138+
139+ for {
140+ select {
141+ case <-ctx.Done():
142+ return
143+ case <-ticker.C:
144+ s.renewIntermediateIfNeeded()
145+ s.sweepExpiredCerts()
146+ }
147+ }
148+}
149+
150+func (s *Service) renewIntermediateIfNeeded() {
151+ expiresAt := s.ca.IntermediateExpiresAt()
152+ remaining := time.Until(expiresAt)
153+ // Total lifetime ~ 7 days. Renewal window = 20% ~ 1.4 days.
154+ lifetime := 7 * 24 * time.Hour
155+ window := time.Duration(float64(lifetime) * renewalWindowRatio)
156+ if remaining < window {
157+ s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
158+ if err := s.ca.RenewIntermediate(); err != nil {
159+ s.log.Error().Err(err).Msg("intermediate CA renewal failed")
160+ }
161+ }
162+}
163+
164+func (s *Service) sweepExpiredCerts() {
165+ now := time.Now()
166+ swept := 0
167+ s.cache.Range(func(key, value any) bool {
168+ cached := value.(*cachedCert)
169+ if now.After(cached.expiresAt) {
170+ s.cache.Delete(key)
171+ swept++
172+ }
173+ return true
174+ })
175+ if swept > 0 {
176+ s.log.Debug().Int("swept", swept).Msg("cleaned expired leaf certs from cache")
177+ }
178+}
@@ -0,0 +1,178 @@
1+package pki
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+const (
16+ renewalWindowRatio = 0.2
17+ checkInterval = 10 * time.Minute
18+)
19+
20+type cachedCert struct {
21+ cert *tls.Certificate
22+ expiresAt time.Time
23+}
24+
25+// RouteChecker provides route lookup for domain validation.
26+// Matches the subset of in.ConfigService needed here.
27+type RouteChecker interface {
28+ GetRoutes(ctx context.Context) []domain.Route
29+ GetExternalRoutes() map[string]string
30+}
31+
32+// Service provides on-demand TLS certificate issuance with caching
33+// and automatic intermediate CA renewal.
34+type Service struct {
35+ ca out.CertificateAuthority
36+ routes RouteChecker
37+ log zerowrap.Logger
38+
39+ cache sync.Map // map[string]*cachedCert
40+ cancel context.CancelFunc
41+ done chan struct{}
42+}
43+
44+// NewService creates a PKI service and starts background maintenance goroutines.
45+func NewService(ctx context.Context, ca out.CertificateAuthority, routes 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+ go svc.maintenanceLoop(ctx)
55+ return svc
56+}
57+
58+// Stop cancels background goroutines and waits for them to finish.
59+func (s *Service) Stop() {
60+ s.cancel()
61+ <-s.done
62+}
63+
64+// CachedCertCount returns the number of cached leaf certificates.
65+func (s *Service) CachedCertCount() int {
66+ count := 0
67+ s.cache.Range(func(_, _ any) bool {
68+ count++
69+ return true
70+ })
71+ return count
72+}
73+
74+// GetCertificate is the tls.Config.GetCertificate callback.
75+func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
76+ domain := hello.ServerName
77+ if domain == "" {
78+ return nil, fmt.Errorf("no SNI in ClientHello")
79+ }
80+
81+ // Check cache
82+ if entry, ok := s.cache.Load(domain); ok {
83+ cached := entry.(*cachedCert)
84+ if time.Now().Before(cached.expiresAt) {
85+ return cached.cert, nil
86+ }
87+ s.cache.Delete(domain)
88+ }
89+
90+ // Check allowlist
91+ if !s.isDomainAllowed(hello.Context(), domain) {
92+ return nil, fmt.Errorf("domain %q not in route table", domain)
93+ }
94+
95+ // Issue new cert
96+ cert, err := s.ca.IssueCertificate(domain)
97+ if err != nil {
98+ s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert")
99+ return nil, err
100+ }
101+
102+ s.cache.Store(domain, &cachedCert{
103+ cert: cert,
104+ expiresAt: time.Now().Add(s.leafLifetime()),
105+ })
106+
107+ s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
108+ return cert, nil
109+}
110+
111+func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
112+ for _, r := range s.routes.GetRoutes(ctx) {
113+ if r.Domain == domain {
114+ return true
115+ }
116+ }
117+ for d := range s.routes.GetExternalRoutes() {
118+ if d == domain {
119+ return true
120+ }
121+ }
122+ return false
123+}
124+
125+func (s *Service) leafLifetime() time.Duration {
126+ // Use the CA's leaf lifetime if it exposes it, otherwise default 12h.
127+ type lifetimer interface{ LeafLifetime() time.Duration }
128+ if lt, ok := s.ca.(lifetimer); ok {
129+ return lt.LeafLifetime()
130+ }
131+ return 12 * time.Hour
132+}
133+
134+func (s *Service) maintenanceLoop(ctx context.Context) {
135+ defer close(s.done)
136+ ticker := time.NewTicker(checkInterval)
137+ defer ticker.Stop()
138+
139+ for {
140+ select {
141+ case <-ctx.Done():
142+ return
143+ case <-ticker.C:
144+ s.renewIntermediateIfNeeded()
145+ s.sweepExpiredCerts()
146+ }
147+ }
148+}
149+
150+func (s *Service) renewIntermediateIfNeeded() {
151+ expiresAt := s.ca.IntermediateExpiresAt()
152+ remaining := time.Until(expiresAt)
153+ // Total lifetime ~ 7 days. Renewal window = 20% ~ 1.4 days.
154+ lifetime := 7 * 24 * time.Hour
155+ window := time.Duration(float64(lifetime) * renewalWindowRatio)
156+ if remaining < window {
157+ s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
158+ if err := s.ca.RenewIntermediate(); err != nil {
159+ s.log.Error().Err(err).Msg("intermediate CA renewal failed")
160+ }
161+ }
162+}
163+
164+func (s *Service) sweepExpiredCerts() {
165+ now := time.Now()
166+ swept := 0
167+ s.cache.Range(func(key, value any) bool {
168+ cached := value.(*cachedCert)
169+ if now.After(cached.expiresAt) {
170+ s.cache.Delete(key)
171+ swept++
172+ }
173+ return true
174+ })
175+ if swept > 0 {
176+ s.log.Debug().Int("swept", swept).Msg("cleaned expired leaf certs from cache")
177+ }
178+}
A internal/usecase/pki/service_test.go +93 -0
@@ -0,0 +1,93 @@
1+package pki_test
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+
8+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
9+ "github.com/bnema/gordon/internal/domain"
10+ pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// stubConfigService satisfies pkiusecase.RouteChecker
17+type stubConfigService struct {
18+ routes []domain.Route
19+ externalRoutes map[string]string
20+}
21+
22+func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes }
23+func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes }
24+
25+func testLogger() zerowrap.Logger { return zerowrap.Default() }
26+
27+func TestService_GetCertificate_KnownDomain(t *testing.T) {
28+ dir := t.TempDir()
29+ ca, err := pkiadapter.NewCA(dir, testLogger())
30+ require.NoError(t, err)
31+
32+ cfg := &stubConfigService{
33+ routes: []domain.Route{{Domain: "app.example.com"}},
34+ }
35+
36+ ctx, cancel := context.WithCancel(context.Background())
37+ defer cancel()
38+
39+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
40+ defer svc.Stop()
41+
42+ hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
43+ cert, err := svc.GetCertificate(hello)
44+ require.NoError(t, err)
45+ require.NotNil(t, cert)
46+}
47+
48+func TestService_GetCertificate_UnknownDomain(t *testing.T) {
49+ dir := t.TempDir()
50+ ca, err := pkiadapter.NewCA(dir, testLogger())
51+ require.NoError(t, err)
52+
53+ cfg := &stubConfigService{
54+ routes: []domain.Route{{Domain: "app.example.com"}},
55+ }
56+
57+ ctx, cancel := context.WithCancel(context.Background())
58+ defer cancel()
59+
60+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
61+ defer svc.Stop()
62+
63+ hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
64+ cert, err := svc.GetCertificate(hello)
65+ assert.Error(t, err)
66+ assert.Nil(t, cert)
67+}
68+
69+func TestService_GetCertificate_Caching(t *testing.T) {
70+ dir := t.TempDir()
71+ ca, err := pkiadapter.NewCA(dir, testLogger())
72+ require.NoError(t, err)
73+
74+ cfg := &stubConfigService{
75+ routes: []domain.Route{{Domain: "cached.example.com"}},
76+ }
77+
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+
81+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
82+ defer svc.Stop()
83+
84+ hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
85+ cert1, err := svc.GetCertificate(hello)
86+ require.NoError(t, err)
87+
88+ cert2, err := svc.GetCertificate(hello)
89+ require.NoError(t, err)
90+
91+ // Same pointer = served from cache
92+ assert.Equal(t, cert1, cert2, "second call should return cached cert")
93+}
@@ -0,0 +1,93 @@
1+package pki_test
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "testing"
7+
8+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
9+ "github.com/bnema/gordon/internal/domain"
10+ pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// stubConfigService satisfies pkiusecase.RouteChecker
17+type stubConfigService struct {
18+ routes []domain.Route
19+ externalRoutes map[string]string
20+}
21+
22+func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes }
23+func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes }
24+
25+func testLogger() zerowrap.Logger { return zerowrap.Default() }
26+
27+func TestService_GetCertificate_KnownDomain(t *testing.T) {
28+ dir := t.TempDir()
29+ ca, err := pkiadapter.NewCA(dir, testLogger())
30+ require.NoError(t, err)
31+
32+ cfg := &stubConfigService{
33+ routes: []domain.Route{{Domain: "app.example.com"}},
34+ }
35+
36+ ctx, cancel := context.WithCancel(context.Background())
37+ defer cancel()
38+
39+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
40+ defer svc.Stop()
41+
42+ hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
43+ cert, err := svc.GetCertificate(hello)
44+ require.NoError(t, err)
45+ require.NotNil(t, cert)
46+}
47+
48+func TestService_GetCertificate_UnknownDomain(t *testing.T) {
49+ dir := t.TempDir()
50+ ca, err := pkiadapter.NewCA(dir, testLogger())
51+ require.NoError(t, err)
52+
53+ cfg := &stubConfigService{
54+ routes: []domain.Route{{Domain: "app.example.com"}},
55+ }
56+
57+ ctx, cancel := context.WithCancel(context.Background())
58+ defer cancel()
59+
60+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
61+ defer svc.Stop()
62+
63+ hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
64+ cert, err := svc.GetCertificate(hello)
65+ assert.Error(t, err)
66+ assert.Nil(t, cert)
67+}
68+
69+func TestService_GetCertificate_Caching(t *testing.T) {
70+ dir := t.TempDir()
71+ ca, err := pkiadapter.NewCA(dir, testLogger())
72+ require.NoError(t, err)
73+
74+ cfg := &stubConfigService{
75+ routes: []domain.Route{{Domain: "cached.example.com"}},
76+ }
77+
78+ ctx, cancel := context.WithCancel(context.Background())
79+ defer cancel()
80+
81+ svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
82+ defer svc.Stop()
83+
84+ hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
85+ cert1, err := svc.GetCertificate(hello)
86+ require.NoError(t, err)
87+
88+ cert2, err := svc.GetCertificate(hello)
89+ require.NoError(t, err)
90+
91+ // Same pointer = served from cache
92+ assert.Equal(t, cert1, cert2, "second call should return cached cert")
93+}

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