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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||