detached 8f4a170

Merge pull request #218 from bnema/fix/gordon-domain-public-tls

fix(tls): cover Gordon management domain
commit
8f4a170d141b3e6f9ced7632dd5ac76cf7f9f842
author
bnema · · 2026-07-16 11:51 UTC
committer
GitHub · · 2026-07-16 11:51 UTC
merge of
8d342c4 + 339691e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
17 files · +1166 -162
M .mockery.yaml +1 -0
@@ -30,6 +30,7 @@ packages:
3030 CertificateStore:
3131 SecretResolver:
3232 CloudflareZoneResolver:
33+ CertificateAuthority:
3334 github.com/bnema/gordon/internal/boundaries/in:
3435 interfaces:
3536 ContainerService:
@@ -30,6 +30,7 @@ packages:
30 CertificateStore:30 CertificateStore:
31 SecretResolver:31 SecretResolver:
32 CloudflareZoneResolver:32 CloudflareZoneResolver:
33+ CertificateAuthority:
33 github.com/bnema/gordon/internal/boundaries/in:34 github.com/bnema/gordon/internal/boundaries/in:
34 interfaces:35 interfaces:
35 ContainerService:36 ContainerService:
M docs/config/server.md +3 -1
@@ -102,7 +102,9 @@ Challenge behavior:
102102
103103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
104104
105-Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
105+Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to configured HTTPS routes. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
106+
107+Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
106108
107109 If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and keeps serving any existing certificates. The renewal loop retries reconcile work periodically, so missing certificates self-heal after the underlying issue is fixed without requiring a restart.
108110
@@ -102,7 +102,9 @@ Challenge behavior:
102 102
103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.103 HTTP-01 requires public access to external port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to the HTTP-capable smart TCP entrypoint. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation.
104 104
105-Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.105+Gordon automatically includes `server.gordon_domain` in public ACME coverage in addition to configured HTTPS routes. The management hostname therefore needs the same challenge reachability: public port 80 for HTTP-01, or zone-read and DNS-edit permissions for its zone with Cloudflare DNS-01.
106+
107+Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. The management hostname consumes a place in the same batch; later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates.
106 108
107 If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and keeps serving any existing certificates. The renewal loop retries reconcile work periodically, so missing certificates self-heal after the underlying issue is fixed without requiring a restart.109 If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and keeps serving any existing certificates. The renewal loop retries reconcile work periodically, so missing certificates self-heal after the underlying issue is fixed without requiring a restart.
108 110
M internal/app/pki_integration_test.go +2 -2
@@ -40,7 +40,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) {
4040 ctx, cancel := context.WithCancel(context.Background())
4141 defer cancel()
4242
43- svc := pkiusecase.NewService(ctx, ca, routes, log)
43+ svc := pkiusecase.NewService(ctx, ca, routes, nil, log)
4444 defer svc.Stop()
4545
4646 // Start a TLS server using the PKI service
@@ -93,7 +93,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) {
9393 ctx, cancel := context.WithCancel(context.Background())
9494 defer cancel()
9595
96- svc := pkiusecase.NewService(ctx, ca, routes, log)
96+ svc := pkiusecase.NewService(ctx, ca, routes, nil, log)
9797 defer svc.Stop()
9898
9999 tlsCfg := &tls.Config{
@@ -40,7 +40,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) {
40 ctx, cancel := context.WithCancel(context.Background())40 ctx, cancel := context.WithCancel(context.Background())
41 defer cancel()41 defer cancel()
42 42
43- svc := pkiusecase.NewService(ctx, ca, routes, log)43+ svc := pkiusecase.NewService(ctx, ca, routes, nil, log)
44 defer svc.Stop()44 defer svc.Stop()
45 45
46 // Start a TLS server using the PKI service46 // Start a TLS server using the PKI service
@@ -93,7 +93,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) {
93 ctx, cancel := context.WithCancel(context.Background())93 ctx, cancel := context.WithCancel(context.Background())
94 defer cancel()94 defer cancel()
95 95
96- svc := pkiusecase.NewService(ctx, ca, routes, log)96+ svc := pkiusecase.NewService(ctx, ca, routes, nil, log)
97 defer svc.Stop()97 defer svc.Stop()
98 98
99 tlsCfg := &tls.Config{99 tlsCfg := &tls.Config{
M internal/app/public_tls_wiring_test.go +7 -2
@@ -160,13 +160,18 @@ func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
160160 svc: &services{configSvc: cfgServiceForPKITest(t)},
161161 }
162162 si.cfg.Server.DataDir = t.TempDir()
163+ si.cfg.Server.GordonDomain = "gordon.example.com"
163164 si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
164165 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
165166 }
166167
167168 require.NoError(t, si.initPKI())
168169 require.NotNil(t, si.svc.pkiSvc)
169- si.svc.pkiSvc.Stop()
170+ defer si.svc.pkiSvc.Stop()
171+
172+ cert, err := si.svc.pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "gordon.example.com"})
173+ require.NoError(t, err)
174+ require.NotNil(t, cert)
170175 }
171176
172177 func cfgServiceForPKITest(t *testing.T) *config.Service {
@@ -244,7 +249,7 @@ func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T)
244249 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
245250 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
246251 require.NoError(t, err)
247- pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())
252+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
248253 defer pkiSvc.Stop()
249254
250255 selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
@@ -160,13 +160,18 @@ func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
160 svc: &services{configSvc: cfgServiceForPKITest(t)},160 svc: &services{configSvc: cfgServiceForPKITest(t)},
161 }161 }
162 si.cfg.Server.DataDir = t.TempDir()162 si.cfg.Server.DataDir = t.TempDir()
163+ si.cfg.Server.GordonDomain = "gordon.example.com"
163 si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{164 si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
164 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},165 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
165 }166 }
166 167
167 require.NoError(t, si.initPKI())168 require.NoError(t, si.initPKI())
168 require.NotNil(t, si.svc.pkiSvc)169 require.NotNil(t, si.svc.pkiSvc)
169- si.svc.pkiSvc.Stop()170+ defer si.svc.pkiSvc.Stop()
171+
172+ cert, err := si.svc.pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "gordon.example.com"})
173+ require.NoError(t, err)
174+ require.NotNil(t, cert)
170 }175 }
171 176
172 func cfgServiceForPKITest(t *testing.T) *config.Service {177 func cfgServiceForPKITest(t *testing.T) *config.Service {
@@ -244,7 +249,7 @@ func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T)
244 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()249 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
245 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())250 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
246 require.NoError(t, err)251 require.NoError(t, err)
247- pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())252+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
248 defer pkiSvc.Stop()253 defer pkiSvc.Stop()
249 254
250 selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}255 selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
M internal/app/run.go +24 -15
@@ -596,7 +596,7 @@ func (si *serviceInit) initPKI() error {
596596 return si.log.WrapErr(err, "failed to initialize internal CA")
597597 }
598598 si.svc.caAdapter = caAdapter
599- si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log)
599+ si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, []string{si.cfg.Server.GordonDomain}, si.log)
600600 return nil
601601 }
602602
@@ -666,12 +666,13 @@ func (si *serviceInit) initPublicTLS() error {
666666 }
667667
668668 svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{
669- Routes: si.svc.configSvc,
670- Issuer: issuer,
671- Store: store,
672- ZoneResolver: zoneResolver,
673- Challenges: challenges,
674- Effective: effective,
669+ Routes: si.svc.configSvc,
670+ Issuer: issuer,
671+ Store: store,
672+ ZoneResolver: zoneResolver,
673+ Challenges: challenges,
674+ Effective: effective,
675+ AdditionalHosts: []string{si.cfg.Server.GordonDomain},
675676 })
676677
677678 if err := svc.Load(ctx); err != nil {
@@ -779,6 +780,13 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
779780 if err != nil {
780781 return err
781782 }
783+ managementHosts := []string{reloadCfg.Server.GordonDomain}
784+ if si.svc.pkiSvc != nil {
785+ si.svc.pkiSvc.SetAdditionalDomains(managementHosts)
786+ }
787+ if si.svc.publicTLSSvc != nil {
788+ si.svc.publicTLSSvc.SetAdditionalHosts(reloadCtx, managementHosts)
789+ }
782790 var tlsConfig *tls.Config
783791 if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
784792 var tlsErr error
@@ -1843,6 +1851,15 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
18431851 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
18441852 }
18451853
1854+ // Reconcile public TLS before publishing reload events so certificate
1855+ // authorization reflects the loaded config even if event delivery fails.
1856+ // A transient ACME issue must not abort the rest of the reload.
1857+ if c.publicTLS != nil {
1858+ if err := c.publicTLS.Reconcile(ctx); err != nil {
1859+ c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1860+ }
1861+ }
1862+
18461863 if c.eventBus != nil {
18471864 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
18481865 c.log.Error().Err(err).Msg("failed to publish config reload event")
@@ -1852,14 +1869,6 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
18521869
18531870 c.lastRun = now
18541871
1855- // Reconcile public TLS certificates after config reload.
1856- // Log and continue on failure — a transient ACME issue should not abort the entire reload.
1857- if c.publicTLS != nil {
1858- if err := c.publicTLS.Reconcile(ctx); err != nil {
1859- c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1860- }
1861- }
1862-
18631872 c.log.Debug().Msg("config hot reload complete")
18641873 return nil
18651874 }
@@ -596,7 +596,7 @@ func (si *serviceInit) initPKI() error {
596 return si.log.WrapErr(err, "failed to initialize internal CA")596 return si.log.WrapErr(err, "failed to initialize internal CA")
597 }597 }
598 si.svc.caAdapter = caAdapter598 si.svc.caAdapter = caAdapter
599- si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, si.log)599+ si.svc.pkiSvc = pkiusecase.NewService(si.ctx, caAdapter, si.svc.configSvc, []string{si.cfg.Server.GordonDomain}, si.log)
600 return nil600 return nil
601 }601 }
602 602
@@ -666,12 +666,13 @@ func (si *serviceInit) initPublicTLS() error {
666 }666 }
667 667
668 svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{668 svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{
669- Routes: si.svc.configSvc,669+ Routes: si.svc.configSvc,
670- Issuer: issuer,670+ Issuer: issuer,
671- Store: store,671+ Store: store,
672- ZoneResolver: zoneResolver,672+ ZoneResolver: zoneResolver,
673- Challenges: challenges,673+ Challenges: challenges,
674- Effective: effective,674+ Effective: effective,
675+ AdditionalHosts: []string{si.cfg.Server.GordonDomain},
675 })676 })
676 677
677 if err := svc.Load(ctx); err != nil {678 if err := svc.Load(ctx); err != nil {
@@ -779,6 +780,13 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
779 if err != nil {780 if err != nil {
780 return err781 return err
781 }782 }
783+ managementHosts := []string{reloadCfg.Server.GordonDomain}
784+ if si.svc.pkiSvc != nil {
785+ si.svc.pkiSvc.SetAdditionalDomains(managementHosts)
786+ }
787+ if si.svc.publicTLSSvc != nil {
788+ si.svc.publicTLSSvc.SetAdditionalHosts(reloadCtx, managementHosts)
789+ }
782 var tlsConfig *tls.Config790 var tlsConfig *tls.Config
783 if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {791 if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil {
784 var tlsErr error792 var tlsErr error
@@ -1843,6 +1851,15 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1843 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)1851 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1844 }1852 }
1845 1853
1854+ // Reconcile public TLS before publishing reload events so certificate
1855+ // authorization reflects the loaded config even if event delivery fails.
1856+ // A transient ACME issue must not abort the rest of the reload.
1857+ if c.publicTLS != nil {
1858+ if err := c.publicTLS.Reconcile(ctx); err != nil {
1859+ c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1860+ }
1861+ }
1862+
1846 if c.eventBus != nil {1863 if c.eventBus != nil {
1847 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {1864 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
1848 c.log.Error().Err(err).Msg("failed to publish config reload event")1865 c.log.Error().Err(err).Msg("failed to publish config reload event")
@@ -1852,14 +1869,6 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1852 1869
1853 c.lastRun = now1870 c.lastRun = now
1854 1871
1855- // Reconcile public TLS certificates after config reload.
1856- // Log and continue on failure — a transient ACME issue should not abort the entire reload.
1857- if c.publicTLS != nil {
1858- if err := c.publicTLS.Reconcile(ctx); err != nil {
1859- c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
1860- }
1861- }
1862-
1863 c.log.Debug().Msg("config hot reload complete")1872 c.log.Debug().Msg("config hot reload complete")
1864 return nil1873 return nil
1865 }1874 }
M internal/app/run_reload_test.go +56 -1
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "context"
5+ "crypto/tls"
56 "errors"
67 "net"
78 "net/http"
@@ -21,6 +22,7 @@ import (
2122
2223 "github.com/bnema/gordon/internal/adapters/in/http/registry"
2324 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
25+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2426 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2527 "github.com/bnema/gordon/internal/domain"
2628 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
@@ -169,6 +171,15 @@ func (r *standaloneServiceRecorder) Calls() int {
169171 return r.calls
170172 }
171173
174+type publicTLSReconcileRecorder struct {
175+ calls int
176+}
177+
178+func (r *publicTLSReconcileRecorder) Reconcile(context.Context) error {
179+ r.calls++
180+ return nil
181+}
182+
172183 type eventBusRecorder struct {
173184 mu sync.Mutex
174185 calls int
@@ -312,6 +323,10 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
312323 }
313324
314325 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
326+ managementCert, err := pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "reload.example.com"})
327+ require.NoError(t, err)
328+ require.NotNil(t, managementCert)
329+
315330 _, portValue, err := net.SplitHostPort(customAddress)
316331 require.NoError(t, err)
317332 port, err := strconv.Atoi(portValue)
@@ -320,6 +335,44 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
320335 require.Contains(t, body, "reload secure")
321336 }
322337
338+func TestServiceInit_ReloadUpdatesManagementHostsBeforeLaterFailure(t *testing.T) {
339+ ctx := t.Context()
340+ v := viper.New()
341+ v.Set("server.gordon_domain", "old.example.com")
342+ v.Set("server.registry_port", 5000)
343+
344+ configSvc := cfgusecase.NewService(v, nil)
345+ require.NoError(t, configSvc.Load(ctx))
346+ publicTLS := inmocks.NewMockPublicTLSService(t)
347+ publicTLS.EXPECT().SetAdditionalHosts(mock.Anything, []string{"new.example.com"}).Once()
348+ reconcileErr := errors.New("standalone reconcile failed")
349+
350+ si := &serviceInit{
351+ ctx: ctx,
352+ v: v,
353+ cfg: Config{},
354+ log: zerowrap.Default(),
355+ svc: &services{
356+ configSvc: configSvc,
357+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
358+ internalRegUser: "gordon",
359+ internalRegPass: "secret",
360+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
361+ publicTLSSvc: publicTLS,
362+ standaloneServiceSvc: &standaloneServiceRecorder{err: reconcileErr},
363+ },
364+ }
365+ si.registerReloadCoordinatorHooks()
366+
367+ reloadCfg := Config{}
368+ reloadCfg.Server.GordonDomain = "new.example.com"
369+ reloadCfg.Server.RegistryPort = 5000
370+ reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
371+
372+ err := si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)
373+ require.ErrorIs(t, err, reconcileErr)
374+}
375+
323376 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
324377 ctx := context.Background()
325378 v := viper.New()
@@ -538,8 +591,9 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
538591 reloadSvc := &reloadRecorder{}
539592 proxySvc := &proxyRecorder{}
540593 events := &eventBusRecorder{err: publishErr}
594+ publicTLS := &publicTLSReconcileRecorder{}
541595
542- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default())
596+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, publicTLS, zerowrap.Default())
543597
544598 err := coord.Trigger(ctx)
545599 require.ErrorIs(t, err, publishErr)
@@ -550,6 +604,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
550604 require.Equal(t, 2, reloadSvc.Calls())
551605 require.Equal(t, 2, proxySvc.calls)
552606 require.Equal(t, 2, events.Calls())
607+ require.Equal(t, 2, publicTLS.calls)
553608 require.Equal(t, domain.EventConfigReload, events.eventType)
554609 }
555610
@@ -2,6 +2,7 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/tls"
5 "errors"6 "errors"
6 "net"7 "net"
7 "net/http"8 "net/http"
@@ -21,6 +22,7 @@ import (
21 22
22 "github.com/bnema/gordon/internal/adapters/in/http/registry"23 "github.com/bnema/gordon/internal/adapters/in/http/registry"
23 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"24 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
25+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
24 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"26 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
25 "github.com/bnema/gordon/internal/domain"27 "github.com/bnema/gordon/internal/domain"
26 cfgusecase "github.com/bnema/gordon/internal/usecase/config"28 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
@@ -169,6 +171,15 @@ func (r *standaloneServiceRecorder) Calls() int {
169 return r.calls171 return r.calls
170 }172 }
171 173
174+type publicTLSReconcileRecorder struct {
175+ calls int
176+}
177+
178+func (r *publicTLSReconcileRecorder) Reconcile(context.Context) error {
179+ r.calls++
180+ return nil
181+}
182+
172 type eventBusRecorder struct {183 type eventBusRecorder struct {
173 mu sync.Mutex184 mu sync.Mutex
174 calls int185 calls int
@@ -312,6 +323,10 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
312 }323 }
313 324
314 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))325 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
326+ managementCert, err := pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "reload.example.com"})
327+ require.NoError(t, err)
328+ require.NotNil(t, managementCert)
329+
315 _, portValue, err := net.SplitHostPort(customAddress)330 _, portValue, err := net.SplitHostPort(customAddress)
316 require.NoError(t, err)331 require.NoError(t, err)
317 port, err := strconv.Atoi(portValue)332 port, err := strconv.Atoi(portValue)
@@ -320,6 +335,44 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
320 require.Contains(t, body, "reload secure")335 require.Contains(t, body, "reload secure")
321 }336 }
322 337
338+func TestServiceInit_ReloadUpdatesManagementHostsBeforeLaterFailure(t *testing.T) {
339+ ctx := t.Context()
340+ v := viper.New()
341+ v.Set("server.gordon_domain", "old.example.com")
342+ v.Set("server.registry_port", 5000)
343+
344+ configSvc := cfgusecase.NewService(v, nil)
345+ require.NoError(t, configSvc.Load(ctx))
346+ publicTLS := inmocks.NewMockPublicTLSService(t)
347+ publicTLS.EXPECT().SetAdditionalHosts(mock.Anything, []string{"new.example.com"}).Once()
348+ reconcileErr := errors.New("standalone reconcile failed")
349+
350+ si := &serviceInit{
351+ ctx: ctx,
352+ v: v,
353+ cfg: Config{},
354+ log: zerowrap.Default(),
355+ svc: &services{
356+ configSvc: configSvc,
357+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
358+ internalRegUser: "gordon",
359+ internalRegPass: "secret",
360+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
361+ publicTLSSvc: publicTLS,
362+ standaloneServiceSvc: &standaloneServiceRecorder{err: reconcileErr},
363+ },
364+ }
365+ si.registerReloadCoordinatorHooks()
366+
367+ reloadCfg := Config{}
368+ reloadCfg.Server.GordonDomain = "new.example.com"
369+ reloadCfg.Server.RegistryPort = 5000
370+ reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
371+
372+ err := si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)
373+ require.ErrorIs(t, err, reconcileErr)
374+}
375+
323 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {376 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
324 ctx := context.Background()377 ctx := context.Background()
325 v := viper.New()378 v := viper.New()
@@ -538,8 +591,9 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
538 reloadSvc := &reloadRecorder{}591 reloadSvc := &reloadRecorder{}
539 proxySvc := &proxyRecorder{}592 proxySvc := &proxyRecorder{}
540 events := &eventBusRecorder{err: publishErr}593 events := &eventBusRecorder{err: publishErr}
594+ publicTLS := &publicTLSReconcileRecorder{}
541 595
542- coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default())596+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, publicTLS, zerowrap.Default())
543 597
544 err := coord.Trigger(ctx)598 err := coord.Trigger(ctx)
545 require.ErrorIs(t, err, publishErr)599 require.ErrorIs(t, err, publishErr)
@@ -550,6 +604,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
550 require.Equal(t, 2, reloadSvc.Calls())604 require.Equal(t, 2, reloadSvc.Calls())
551 require.Equal(t, 2, proxySvc.calls)605 require.Equal(t, 2, proxySvc.calls)
552 require.Equal(t, 2, events.Calls())606 require.Equal(t, 2, events.Calls())
607+ require.Equal(t, 2, publicTLS.calls)
553 require.Equal(t, domain.EventConfigReload, events.eventType)608 require.Equal(t, domain.EventConfigReload, events.eventType)
554 }609 }
555 610
M internal/app/start_proxy_servers_test.go +1 -1
@@ -287,7 +287,7 @@ func newTestPKIService(t *testing.T) *pkiusecase.Service {
287287 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
288288 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
289289 require.NoError(t, err)
290- pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())
290+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
291291 t.Cleanup(pkiSvc.Stop)
292292 return pkiSvc
293293 }
@@ -287,7 +287,7 @@ func newTestPKIService(t *testing.T) *pkiusecase.Service {
287 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()287 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
288 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())288 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
289 require.NoError(t, err)289 require.NoError(t, err)
290- pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())290+ pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
291 t.Cleanup(pkiSvc.Stop)291 t.Cleanup(pkiSvc.Stop)
292 return pkiSvc292 return pkiSvc
293 }293 }
M internal/boundaries/in/mocks/mock_public_tls_service.go +46 -0
@@ -218,6 +218,52 @@ func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context
218218 return _c
219219 }
220220
221+// SetAdditionalHosts provides a mock function for the type MockPublicTLSService
222+func (_mock *MockPublicTLSService) SetAdditionalHosts(ctx context.Context, hosts []string) {
223+ _mock.Called(ctx, hosts)
224+ return
225+}
226+
227+// MockPublicTLSService_SetAdditionalHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAdditionalHosts'
228+type MockPublicTLSService_SetAdditionalHosts_Call struct {
229+ *mock.Call
230+}
231+
232+// SetAdditionalHosts is a helper method to define mock.On call
233+// - ctx context.Context
234+// - hosts []string
235+func (_e *MockPublicTLSService_Expecter) SetAdditionalHosts(ctx any, hosts any) *MockPublicTLSService_SetAdditionalHosts_Call {
236+ return &MockPublicTLSService_SetAdditionalHosts_Call{Call: _e.mock.On("SetAdditionalHosts", ctx, hosts)}
237+}
238+
239+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) Run(run func(ctx context.Context, hosts []string)) *MockPublicTLSService_SetAdditionalHosts_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 []string
246+ if args[1] != nil {
247+ arg1 = args[1].([]string)
248+ }
249+ run(
250+ arg0,
251+ arg1,
252+ )
253+ })
254+ return _c
255+}
256+
257+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) Return() *MockPublicTLSService_SetAdditionalHosts_Call {
258+ _c.Call.Return()
259+ return _c
260+}
261+
262+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) RunAndReturn(run func(ctx context.Context, hosts []string)) *MockPublicTLSService_SetAdditionalHosts_Call {
263+ _c.Run(run)
264+ return _c
265+}
266+
221267 // Status provides a mock function for the type MockPublicTLSService
222268 func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
223269 ret := _mock.Called(ctx)
@@ -218,6 +218,52 @@ func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context
218 return _c218 return _c
219 }219 }
220 220
221+// SetAdditionalHosts provides a mock function for the type MockPublicTLSService
222+func (_mock *MockPublicTLSService) SetAdditionalHosts(ctx context.Context, hosts []string) {
223+ _mock.Called(ctx, hosts)
224+ return
225+}
226+
227+// MockPublicTLSService_SetAdditionalHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAdditionalHosts'
228+type MockPublicTLSService_SetAdditionalHosts_Call struct {
229+ *mock.Call
230+}
231+
232+// SetAdditionalHosts is a helper method to define mock.On call
233+// - ctx context.Context
234+// - hosts []string
235+func (_e *MockPublicTLSService_Expecter) SetAdditionalHosts(ctx any, hosts any) *MockPublicTLSService_SetAdditionalHosts_Call {
236+ return &MockPublicTLSService_SetAdditionalHosts_Call{Call: _e.mock.On("SetAdditionalHosts", ctx, hosts)}
237+}
238+
239+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) Run(run func(ctx context.Context, hosts []string)) *MockPublicTLSService_SetAdditionalHosts_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 []string
246+ if args[1] != nil {
247+ arg1 = args[1].([]string)
248+ }
249+ run(
250+ arg0,
251+ arg1,
252+ )
253+ })
254+ return _c
255+}
256+
257+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) Return() *MockPublicTLSService_SetAdditionalHosts_Call {
258+ _c.Call.Return()
259+ return _c
260+}
261+
262+func (_c *MockPublicTLSService_SetAdditionalHosts_Call) RunAndReturn(run func(ctx context.Context, hosts []string)) *MockPublicTLSService_SetAdditionalHosts_Call {
263+ _c.Run(run)
264+ return _c
265+}
266+
221 // Status provides a mock function for the type MockPublicTLSService267 // Status provides a mock function for the type MockPublicTLSService
222 func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {268 func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
223 ret := _mock.Called(ctx)269 ret := _mock.Called(ctx)
M internal/boundaries/in/public_tls.go +3 -0
@@ -21,6 +21,9 @@ type PublicTLSService interface {
2121 // Status returns the current public TLS status.
2222 Status(ctx context.Context) domain.PublicTLSStatus
2323
24+ // SetAdditionalHosts replaces non-route hosts requiring public certificate coverage.
25+ SetAdditionalHosts(ctx context.Context, hosts []string)
26+
2427 // Reconcile ensures the desired certificate state matches the actual state.
2528 Reconcile(ctx context.Context) error
2629
@@ -21,6 +21,9 @@ type PublicTLSService interface {
21 // Status returns the current public TLS status.21 // Status returns the current public TLS status.
22 Status(ctx context.Context) domain.PublicTLSStatus22 Status(ctx context.Context) domain.PublicTLSStatus
23 23
24+ // SetAdditionalHosts replaces non-route hosts requiring public certificate coverage.
25+ SetAdditionalHosts(ctx context.Context, hosts []string)
26+
24 // Reconcile ensures the desired certificate state matches the actual state.27 // Reconcile ensures the desired certificate state matches the actual state.
25 Reconcile(ctx context.Context) error28 Reconcile(ctx context.Context) error
26 29
A internal/boundaries/out/mocks/mock_certificate_authority.go +560 -0
@@ -0,0 +1,560 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "crypto/tls"
9+ "crypto/x509"
10+ "time"
11+
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockCertificateAuthority creates a new instance of MockCertificateAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockCertificateAuthority(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockCertificateAuthority {
21+ mock := &MockCertificateAuthority{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockCertificateAuthority is an autogenerated mock type for the CertificateAuthority type
30+type MockCertificateAuthority struct {
31+ mock.Mock
32+}
33+
34+type MockCertificateAuthority_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockCertificateAuthority) EXPECT() *MockCertificateAuthority_Expecter {
39+ return &MockCertificateAuthority_Expecter{mock: &_m.Mock}
40+}
41+
42+// InstallRoot provides a mock function for the type MockCertificateAuthority
43+func (_mock *MockCertificateAuthority) InstallRoot(cert *x509.Certificate) error {
44+ ret := _mock.Called(cert)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for InstallRoot")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func(*x509.Certificate) error); ok {
52+ r0 = returnFunc(cert)
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockCertificateAuthority_InstallRoot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InstallRoot'
60+type MockCertificateAuthority_InstallRoot_Call struct {
61+ *mock.Call
62+}
63+
64+// InstallRoot is a helper method to define mock.On call
65+// - cert *x509.Certificate
66+func (_e *MockCertificateAuthority_Expecter) InstallRoot(cert any) *MockCertificateAuthority_InstallRoot_Call {
67+ return &MockCertificateAuthority_InstallRoot_Call{Call: _e.mock.On("InstallRoot", cert)}
68+}
69+
70+func (_c *MockCertificateAuthority_InstallRoot_Call) Run(run func(cert *x509.Certificate)) *MockCertificateAuthority_InstallRoot_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 *x509.Certificate
73+ if args[0] != nil {
74+ arg0 = args[0].(*x509.Certificate)
75+ }
76+ run(
77+ arg0,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockCertificateAuthority_InstallRoot_Call) Return(err error) *MockCertificateAuthority_InstallRoot_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *MockCertificateAuthority_InstallRoot_Call) RunAndReturn(run func(cert *x509.Certificate) error) *MockCertificateAuthority_InstallRoot_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// IntermediateExpiresAt provides a mock function for the type MockCertificateAuthority
94+func (_mock *MockCertificateAuthority) IntermediateExpiresAt() time.Time {
95+ ret := _mock.Called()
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for IntermediateExpiresAt")
99+ }
100+
101+ var r0 time.Time
102+ if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
103+ r0 = returnFunc()
104+ } else {
105+ r0 = ret.Get(0).(time.Time)
106+ }
107+ return r0
108+}
109+
110+// MockCertificateAuthority_IntermediateExpiresAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateExpiresAt'
111+type MockCertificateAuthority_IntermediateExpiresAt_Call struct {
112+ *mock.Call
113+}
114+
115+// IntermediateExpiresAt is a helper method to define mock.On call
116+func (_e *MockCertificateAuthority_Expecter) IntermediateExpiresAt() *MockCertificateAuthority_IntermediateExpiresAt_Call {
117+ return &MockCertificateAuthority_IntermediateExpiresAt_Call{Call: _e.mock.On("IntermediateExpiresAt")}
118+}
119+
120+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) Run(run func()) *MockCertificateAuthority_IntermediateExpiresAt_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ run()
123+ })
124+ return _c
125+}
126+
127+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) Return(time1 time.Time) *MockCertificateAuthority_IntermediateExpiresAt_Call {
128+ _c.Call.Return(time1)
129+ return _c
130+}
131+
132+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) RunAndReturn(run func() time.Time) *MockCertificateAuthority_IntermediateExpiresAt_Call {
133+ _c.Call.Return(run)
134+ return _c
135+}
136+
137+// IntermediateLifetime provides a mock function for the type MockCertificateAuthority
138+func (_mock *MockCertificateAuthority) IntermediateLifetime() time.Duration {
139+ ret := _mock.Called()
140+
141+ if len(ret) == 0 {
142+ panic("no return value specified for IntermediateLifetime")
143+ }
144+
145+ var r0 time.Duration
146+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
147+ r0 = returnFunc()
148+ } else {
149+ r0 = ret.Get(0).(time.Duration)
150+ }
151+ return r0
152+}
153+
154+// MockCertificateAuthority_IntermediateLifetime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateLifetime'
155+type MockCertificateAuthority_IntermediateLifetime_Call struct {
156+ *mock.Call
157+}
158+
159+// IntermediateLifetime is a helper method to define mock.On call
160+func (_e *MockCertificateAuthority_Expecter) IntermediateLifetime() *MockCertificateAuthority_IntermediateLifetime_Call {
161+ return &MockCertificateAuthority_IntermediateLifetime_Call{Call: _e.mock.On("IntermediateLifetime")}
162+}
163+
164+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) Run(run func()) *MockCertificateAuthority_IntermediateLifetime_Call {
165+ _c.Call.Run(func(args mock.Arguments) {
166+ run()
167+ })
168+ return _c
169+}
170+
171+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) Return(duration time.Duration) *MockCertificateAuthority_IntermediateLifetime_Call {
172+ _c.Call.Return(duration)
173+ return _c
174+}
175+
176+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) RunAndReturn(run func() time.Duration) *MockCertificateAuthority_IntermediateLifetime_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
180+
181+// IssueCertificate provides a mock function for the type MockCertificateAuthority
182+func (_mock *MockCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
183+ ret := _mock.Called(domain)
184+
185+ if len(ret) == 0 {
186+ panic("no return value specified for IssueCertificate")
187+ }
188+
189+ var r0 *tls.Certificate
190+ var r1 error
191+ if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok {
192+ return returnFunc(domain)
193+ }
194+ if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok {
195+ r0 = returnFunc(domain)
196+ } else {
197+ if ret.Get(0) != nil {
198+ r0 = ret.Get(0).(*tls.Certificate)
199+ }
200+ }
201+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
202+ r1 = returnFunc(domain)
203+ } else {
204+ r1 = ret.Error(1)
205+ }
206+ return r0, r1
207+}
208+
209+// MockCertificateAuthority_IssueCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IssueCertificate'
210+type MockCertificateAuthority_IssueCertificate_Call struct {
211+ *mock.Call
212+}
213+
214+// IssueCertificate is a helper method to define mock.On call
215+// - domain string
216+func (_e *MockCertificateAuthority_Expecter) IssueCertificate(domain any) *MockCertificateAuthority_IssueCertificate_Call {
217+ return &MockCertificateAuthority_IssueCertificate_Call{Call: _e.mock.On("IssueCertificate", domain)}
218+}
219+
220+func (_c *MockCertificateAuthority_IssueCertificate_Call) Run(run func(domain string)) *MockCertificateAuthority_IssueCertificate_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 string
223+ if args[0] != nil {
224+ arg0 = args[0].(string)
225+ }
226+ run(
227+ arg0,
228+ )
229+ })
230+ return _c
231+}
232+
233+func (_c *MockCertificateAuthority_IssueCertificate_Call) Return(certificate *tls.Certificate, err error) *MockCertificateAuthority_IssueCertificate_Call {
234+ _c.Call.Return(certificate, err)
235+ return _c
236+}
237+
238+func (_c *MockCertificateAuthority_IssueCertificate_Call) RunAndReturn(run func(domain string) (*tls.Certificate, error)) *MockCertificateAuthority_IssueCertificate_Call {
239+ _c.Call.Return(run)
240+ return _c
241+}
242+
243+// LeafLifetime provides a mock function for the type MockCertificateAuthority
244+func (_mock *MockCertificateAuthority) LeafLifetime() time.Duration {
245+ ret := _mock.Called()
246+
247+ if len(ret) == 0 {
248+ panic("no return value specified for LeafLifetime")
249+ }
250+
251+ var r0 time.Duration
252+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
253+ r0 = returnFunc()
254+ } else {
255+ r0 = ret.Get(0).(time.Duration)
256+ }
257+ return r0
258+}
259+
260+// MockCertificateAuthority_LeafLifetime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LeafLifetime'
261+type MockCertificateAuthority_LeafLifetime_Call struct {
262+ *mock.Call
263+}
264+
265+// LeafLifetime is a helper method to define mock.On call
266+func (_e *MockCertificateAuthority_Expecter) LeafLifetime() *MockCertificateAuthority_LeafLifetime_Call {
267+ return &MockCertificateAuthority_LeafLifetime_Call{Call: _e.mock.On("LeafLifetime")}
268+}
269+
270+func (_c *MockCertificateAuthority_LeafLifetime_Call) Run(run func()) *MockCertificateAuthority_LeafLifetime_Call {
271+ _c.Call.Run(func(args mock.Arguments) {
272+ run()
273+ })
274+ return _c
275+}
276+
277+func (_c *MockCertificateAuthority_LeafLifetime_Call) Return(duration time.Duration) *MockCertificateAuthority_LeafLifetime_Call {
278+ _c.Call.Return(duration)
279+ return _c
280+}
281+
282+func (_c *MockCertificateAuthority_LeafLifetime_Call) RunAndReturn(run func() time.Duration) *MockCertificateAuthority_LeafLifetime_Call {
283+ _c.Call.Return(run)
284+ return _c
285+}
286+
287+// RenewIntermediate provides a mock function for the type MockCertificateAuthority
288+func (_mock *MockCertificateAuthority) RenewIntermediate() error {
289+ ret := _mock.Called()
290+
291+ if len(ret) == 0 {
292+ panic("no return value specified for RenewIntermediate")
293+ }
294+
295+ var r0 error
296+ if returnFunc, ok := ret.Get(0).(func() error); ok {
297+ r0 = returnFunc()
298+ } else {
299+ r0 = ret.Error(0)
300+ }
301+ return r0
302+}
303+
304+// MockCertificateAuthority_RenewIntermediate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenewIntermediate'
305+type MockCertificateAuthority_RenewIntermediate_Call struct {
306+ *mock.Call
307+}
308+
309+// RenewIntermediate is a helper method to define mock.On call
310+func (_e *MockCertificateAuthority_Expecter) RenewIntermediate() *MockCertificateAuthority_RenewIntermediate_Call {
311+ return &MockCertificateAuthority_RenewIntermediate_Call{Call: _e.mock.On("RenewIntermediate")}
312+}
313+
314+func (_c *MockCertificateAuthority_RenewIntermediate_Call) Run(run func()) *MockCertificateAuthority_RenewIntermediate_Call {
315+ _c.Call.Run(func(args mock.Arguments) {
316+ run()
317+ })
318+ return _c
319+}
320+
321+func (_c *MockCertificateAuthority_RenewIntermediate_Call) Return(err error) *MockCertificateAuthority_RenewIntermediate_Call {
322+ _c.Call.Return(err)
323+ return _c
324+}
325+
326+func (_c *MockCertificateAuthority_RenewIntermediate_Call) RunAndReturn(run func() error) *MockCertificateAuthority_RenewIntermediate_Call {
327+ _c.Call.Return(run)
328+ return _c
329+}
330+
331+// RootCertificate provides a mock function for the type MockCertificateAuthority
332+func (_mock *MockCertificateAuthority) RootCertificate() []byte {
333+ ret := _mock.Called()
334+
335+ if len(ret) == 0 {
336+ panic("no return value specified for RootCertificate")
337+ }
338+
339+ var r0 []byte
340+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
341+ r0 = returnFunc()
342+ } else {
343+ if ret.Get(0) != nil {
344+ r0 = ret.Get(0).([]byte)
345+ }
346+ }
347+ return r0
348+}
349+
350+// MockCertificateAuthority_RootCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificate'
351+type MockCertificateAuthority_RootCertificate_Call struct {
352+ *mock.Call
353+}
354+
355+// RootCertificate is a helper method to define mock.On call
356+func (_e *MockCertificateAuthority_Expecter) RootCertificate() *MockCertificateAuthority_RootCertificate_Call {
357+ return &MockCertificateAuthority_RootCertificate_Call{Call: _e.mock.On("RootCertificate")}
358+}
359+
360+func (_c *MockCertificateAuthority_RootCertificate_Call) Run(run func()) *MockCertificateAuthority_RootCertificate_Call {
361+ _c.Call.Run(func(args mock.Arguments) {
362+ run()
363+ })
364+ return _c
365+}
366+
367+func (_c *MockCertificateAuthority_RootCertificate_Call) Return(bytes []byte) *MockCertificateAuthority_RootCertificate_Call {
368+ _c.Call.Return(bytes)
369+ return _c
370+}
371+
372+func (_c *MockCertificateAuthority_RootCertificate_Call) RunAndReturn(run func() []byte) *MockCertificateAuthority_RootCertificate_Call {
373+ _c.Call.Return(run)
374+ return _c
375+}
376+
377+// RootCertificateDER provides a mock function for the type MockCertificateAuthority
378+func (_mock *MockCertificateAuthority) RootCertificateDER() []byte {
379+ ret := _mock.Called()
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for RootCertificateDER")
383+ }
384+
385+ var r0 []byte
386+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
387+ r0 = returnFunc()
388+ } else {
389+ if ret.Get(0) != nil {
390+ r0 = ret.Get(0).([]byte)
391+ }
392+ }
393+ return r0
394+}
395+
396+// MockCertificateAuthority_RootCertificateDER_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificateDER'
397+type MockCertificateAuthority_RootCertificateDER_Call struct {
398+ *mock.Call
399+}
400+
401+// RootCertificateDER is a helper method to define mock.On call
402+func (_e *MockCertificateAuthority_Expecter) RootCertificateDER() *MockCertificateAuthority_RootCertificateDER_Call {
403+ return &MockCertificateAuthority_RootCertificateDER_Call{Call: _e.mock.On("RootCertificateDER")}
404+}
405+
406+func (_c *MockCertificateAuthority_RootCertificateDER_Call) Run(run func()) *MockCertificateAuthority_RootCertificateDER_Call {
407+ _c.Call.Run(func(args mock.Arguments) {
408+ run()
409+ })
410+ return _c
411+}
412+
413+func (_c *MockCertificateAuthority_RootCertificateDER_Call) Return(bytes []byte) *MockCertificateAuthority_RootCertificateDER_Call {
414+ _c.Call.Return(bytes)
415+ return _c
416+}
417+
418+func (_c *MockCertificateAuthority_RootCertificateDER_Call) RunAndReturn(run func() []byte) *MockCertificateAuthority_RootCertificateDER_Call {
419+ _c.Call.Return(run)
420+ return _c
421+}
422+
423+// RootCommonName provides a mock function for the type MockCertificateAuthority
424+func (_mock *MockCertificateAuthority) RootCommonName() string {
425+ ret := _mock.Called()
426+
427+ if len(ret) == 0 {
428+ panic("no return value specified for RootCommonName")
429+ }
430+
431+ var r0 string
432+ if returnFunc, ok := ret.Get(0).(func() string); ok {
433+ r0 = returnFunc()
434+ } else {
435+ r0 = ret.Get(0).(string)
436+ }
437+ return r0
438+}
439+
440+// MockCertificateAuthority_RootCommonName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCommonName'
441+type MockCertificateAuthority_RootCommonName_Call struct {
442+ *mock.Call
443+}
444+
445+// RootCommonName is a helper method to define mock.On call
446+func (_e *MockCertificateAuthority_Expecter) RootCommonName() *MockCertificateAuthority_RootCommonName_Call {
447+ return &MockCertificateAuthority_RootCommonName_Call{Call: _e.mock.On("RootCommonName")}
448+}
449+
450+func (_c *MockCertificateAuthority_RootCommonName_Call) Run(run func()) *MockCertificateAuthority_RootCommonName_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ run()
453+ })
454+ return _c
455+}
456+
457+func (_c *MockCertificateAuthority_RootCommonName_Call) Return(s string) *MockCertificateAuthority_RootCommonName_Call {
458+ _c.Call.Return(s)
459+ return _c
460+}
461+
462+func (_c *MockCertificateAuthority_RootCommonName_Call) RunAndReturn(run func() string) *MockCertificateAuthority_RootCommonName_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
467+// RootFingerprint provides a mock function for the type MockCertificateAuthority
468+func (_mock *MockCertificateAuthority) RootFingerprint() string {
469+ ret := _mock.Called()
470+
471+ if len(ret) == 0 {
472+ panic("no return value specified for RootFingerprint")
473+ }
474+
475+ var r0 string
476+ if returnFunc, ok := ret.Get(0).(func() string); ok {
477+ r0 = returnFunc()
478+ } else {
479+ r0 = ret.Get(0).(string)
480+ }
481+ return r0
482+}
483+
484+// MockCertificateAuthority_RootFingerprint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootFingerprint'
485+type MockCertificateAuthority_RootFingerprint_Call struct {
486+ *mock.Call
487+}
488+
489+// RootFingerprint is a helper method to define mock.On call
490+func (_e *MockCertificateAuthority_Expecter) RootFingerprint() *MockCertificateAuthority_RootFingerprint_Call {
491+ return &MockCertificateAuthority_RootFingerprint_Call{Call: _e.mock.On("RootFingerprint")}
492+}
493+
494+func (_c *MockCertificateAuthority_RootFingerprint_Call) Run(run func()) *MockCertificateAuthority_RootFingerprint_Call {
495+ _c.Call.Run(func(args mock.Arguments) {
496+ run()
497+ })
498+ return _c
499+}
500+
501+func (_c *MockCertificateAuthority_RootFingerprint_Call) Return(s string) *MockCertificateAuthority_RootFingerprint_Call {
502+ _c.Call.Return(s)
503+ return _c
504+}
505+
506+func (_c *MockCertificateAuthority_RootFingerprint_Call) RunAndReturn(run func() string) *MockCertificateAuthority_RootFingerprint_Call {
507+ _c.Call.Return(run)
508+ return _c
509+}
510+
511+// UninstallRoot provides a mock function for the type MockCertificateAuthority
512+func (_mock *MockCertificateAuthority) UninstallRoot(cert *x509.Certificate) error {
513+ ret := _mock.Called(cert)
514+
515+ if len(ret) == 0 {
516+ panic("no return value specified for UninstallRoot")
517+ }
518+
519+ var r0 error
520+ if returnFunc, ok := ret.Get(0).(func(*x509.Certificate) error); ok {
521+ r0 = returnFunc(cert)
522+ } else {
523+ r0 = ret.Error(0)
524+ }
525+ return r0
526+}
527+
528+// MockCertificateAuthority_UninstallRoot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UninstallRoot'
529+type MockCertificateAuthority_UninstallRoot_Call struct {
530+ *mock.Call
531+}
532+
533+// UninstallRoot is a helper method to define mock.On call
534+// - cert *x509.Certificate
535+func (_e *MockCertificateAuthority_Expecter) UninstallRoot(cert any) *MockCertificateAuthority_UninstallRoot_Call {
536+ return &MockCertificateAuthority_UninstallRoot_Call{Call: _e.mock.On("UninstallRoot", cert)}
537+}
538+
539+func (_c *MockCertificateAuthority_UninstallRoot_Call) Run(run func(cert *x509.Certificate)) *MockCertificateAuthority_UninstallRoot_Call {
540+ _c.Call.Run(func(args mock.Arguments) {
541+ var arg0 *x509.Certificate
542+ if args[0] != nil {
543+ arg0 = args[0].(*x509.Certificate)
544+ }
545+ run(
546+ arg0,
547+ )
548+ })
549+ return _c
550+}
551+
552+func (_c *MockCertificateAuthority_UninstallRoot_Call) Return(err error) *MockCertificateAuthority_UninstallRoot_Call {
553+ _c.Call.Return(err)
554+ return _c
555+}
556+
557+func (_c *MockCertificateAuthority_UninstallRoot_Call) RunAndReturn(run func(cert *x509.Certificate) error) *MockCertificateAuthority_UninstallRoot_Call {
558+ _c.Call.Return(run)
559+ return _c
560+}
@@ -0,0 +1,560 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "crypto/tls"
9+ "crypto/x509"
10+ "time"
11+
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockCertificateAuthority creates a new instance of MockCertificateAuthority. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockCertificateAuthority(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockCertificateAuthority {
21+ mock := &MockCertificateAuthority{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockCertificateAuthority is an autogenerated mock type for the CertificateAuthority type
30+type MockCertificateAuthority struct {
31+ mock.Mock
32+}
33+
34+type MockCertificateAuthority_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockCertificateAuthority) EXPECT() *MockCertificateAuthority_Expecter {
39+ return &MockCertificateAuthority_Expecter{mock: &_m.Mock}
40+}
41+
42+// InstallRoot provides a mock function for the type MockCertificateAuthority
43+func (_mock *MockCertificateAuthority) InstallRoot(cert *x509.Certificate) error {
44+ ret := _mock.Called(cert)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for InstallRoot")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func(*x509.Certificate) error); ok {
52+ r0 = returnFunc(cert)
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockCertificateAuthority_InstallRoot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InstallRoot'
60+type MockCertificateAuthority_InstallRoot_Call struct {
61+ *mock.Call
62+}
63+
64+// InstallRoot is a helper method to define mock.On call
65+// - cert *x509.Certificate
66+func (_e *MockCertificateAuthority_Expecter) InstallRoot(cert any) *MockCertificateAuthority_InstallRoot_Call {
67+ return &MockCertificateAuthority_InstallRoot_Call{Call: _e.mock.On("InstallRoot", cert)}
68+}
69+
70+func (_c *MockCertificateAuthority_InstallRoot_Call) Run(run func(cert *x509.Certificate)) *MockCertificateAuthority_InstallRoot_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 *x509.Certificate
73+ if args[0] != nil {
74+ arg0 = args[0].(*x509.Certificate)
75+ }
76+ run(
77+ arg0,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockCertificateAuthority_InstallRoot_Call) Return(err error) *MockCertificateAuthority_InstallRoot_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *MockCertificateAuthority_InstallRoot_Call) RunAndReturn(run func(cert *x509.Certificate) error) *MockCertificateAuthority_InstallRoot_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// IntermediateExpiresAt provides a mock function for the type MockCertificateAuthority
94+func (_mock *MockCertificateAuthority) IntermediateExpiresAt() time.Time {
95+ ret := _mock.Called()
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for IntermediateExpiresAt")
99+ }
100+
101+ var r0 time.Time
102+ if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
103+ r0 = returnFunc()
104+ } else {
105+ r0 = ret.Get(0).(time.Time)
106+ }
107+ return r0
108+}
109+
110+// MockCertificateAuthority_IntermediateExpiresAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateExpiresAt'
111+type MockCertificateAuthority_IntermediateExpiresAt_Call struct {
112+ *mock.Call
113+}
114+
115+// IntermediateExpiresAt is a helper method to define mock.On call
116+func (_e *MockCertificateAuthority_Expecter) IntermediateExpiresAt() *MockCertificateAuthority_IntermediateExpiresAt_Call {
117+ return &MockCertificateAuthority_IntermediateExpiresAt_Call{Call: _e.mock.On("IntermediateExpiresAt")}
118+}
119+
120+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) Run(run func()) *MockCertificateAuthority_IntermediateExpiresAt_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ run()
123+ })
124+ return _c
125+}
126+
127+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) Return(time1 time.Time) *MockCertificateAuthority_IntermediateExpiresAt_Call {
128+ _c.Call.Return(time1)
129+ return _c
130+}
131+
132+func (_c *MockCertificateAuthority_IntermediateExpiresAt_Call) RunAndReturn(run func() time.Time) *MockCertificateAuthority_IntermediateExpiresAt_Call {
133+ _c.Call.Return(run)
134+ return _c
135+}
136+
137+// IntermediateLifetime provides a mock function for the type MockCertificateAuthority
138+func (_mock *MockCertificateAuthority) IntermediateLifetime() time.Duration {
139+ ret := _mock.Called()
140+
141+ if len(ret) == 0 {
142+ panic("no return value specified for IntermediateLifetime")
143+ }
144+
145+ var r0 time.Duration
146+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
147+ r0 = returnFunc()
148+ } else {
149+ r0 = ret.Get(0).(time.Duration)
150+ }
151+ return r0
152+}
153+
154+// MockCertificateAuthority_IntermediateLifetime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IntermediateLifetime'
155+type MockCertificateAuthority_IntermediateLifetime_Call struct {
156+ *mock.Call
157+}
158+
159+// IntermediateLifetime is a helper method to define mock.On call
160+func (_e *MockCertificateAuthority_Expecter) IntermediateLifetime() *MockCertificateAuthority_IntermediateLifetime_Call {
161+ return &MockCertificateAuthority_IntermediateLifetime_Call{Call: _e.mock.On("IntermediateLifetime")}
162+}
163+
164+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) Run(run func()) *MockCertificateAuthority_IntermediateLifetime_Call {
165+ _c.Call.Run(func(args mock.Arguments) {
166+ run()
167+ })
168+ return _c
169+}
170+
171+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) Return(duration time.Duration) *MockCertificateAuthority_IntermediateLifetime_Call {
172+ _c.Call.Return(duration)
173+ return _c
174+}
175+
176+func (_c *MockCertificateAuthority_IntermediateLifetime_Call) RunAndReturn(run func() time.Duration) *MockCertificateAuthority_IntermediateLifetime_Call {
177+ _c.Call.Return(run)
178+ return _c
179+}
180+
181+// IssueCertificate provides a mock function for the type MockCertificateAuthority
182+func (_mock *MockCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
183+ ret := _mock.Called(domain)
184+
185+ if len(ret) == 0 {
186+ panic("no return value specified for IssueCertificate")
187+ }
188+
189+ var r0 *tls.Certificate
190+ var r1 error
191+ if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok {
192+ return returnFunc(domain)
193+ }
194+ if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok {
195+ r0 = returnFunc(domain)
196+ } else {
197+ if ret.Get(0) != nil {
198+ r0 = ret.Get(0).(*tls.Certificate)
199+ }
200+ }
201+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
202+ r1 = returnFunc(domain)
203+ } else {
204+ r1 = ret.Error(1)
205+ }
206+ return r0, r1
207+}
208+
209+// MockCertificateAuthority_IssueCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IssueCertificate'
210+type MockCertificateAuthority_IssueCertificate_Call struct {
211+ *mock.Call
212+}
213+
214+// IssueCertificate is a helper method to define mock.On call
215+// - domain string
216+func (_e *MockCertificateAuthority_Expecter) IssueCertificate(domain any) *MockCertificateAuthority_IssueCertificate_Call {
217+ return &MockCertificateAuthority_IssueCertificate_Call{Call: _e.mock.On("IssueCertificate", domain)}
218+}
219+
220+func (_c *MockCertificateAuthority_IssueCertificate_Call) Run(run func(domain string)) *MockCertificateAuthority_IssueCertificate_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 string
223+ if args[0] != nil {
224+ arg0 = args[0].(string)
225+ }
226+ run(
227+ arg0,
228+ )
229+ })
230+ return _c
231+}
232+
233+func (_c *MockCertificateAuthority_IssueCertificate_Call) Return(certificate *tls.Certificate, err error) *MockCertificateAuthority_IssueCertificate_Call {
234+ _c.Call.Return(certificate, err)
235+ return _c
236+}
237+
238+func (_c *MockCertificateAuthority_IssueCertificate_Call) RunAndReturn(run func(domain string) (*tls.Certificate, error)) *MockCertificateAuthority_IssueCertificate_Call {
239+ _c.Call.Return(run)
240+ return _c
241+}
242+
243+// LeafLifetime provides a mock function for the type MockCertificateAuthority
244+func (_mock *MockCertificateAuthority) LeafLifetime() time.Duration {
245+ ret := _mock.Called()
246+
247+ if len(ret) == 0 {
248+ panic("no return value specified for LeafLifetime")
249+ }
250+
251+ var r0 time.Duration
252+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
253+ r0 = returnFunc()
254+ } else {
255+ r0 = ret.Get(0).(time.Duration)
256+ }
257+ return r0
258+}
259+
260+// MockCertificateAuthority_LeafLifetime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LeafLifetime'
261+type MockCertificateAuthority_LeafLifetime_Call struct {
262+ *mock.Call
263+}
264+
265+// LeafLifetime is a helper method to define mock.On call
266+func (_e *MockCertificateAuthority_Expecter) LeafLifetime() *MockCertificateAuthority_LeafLifetime_Call {
267+ return &MockCertificateAuthority_LeafLifetime_Call{Call: _e.mock.On("LeafLifetime")}
268+}
269+
270+func (_c *MockCertificateAuthority_LeafLifetime_Call) Run(run func()) *MockCertificateAuthority_LeafLifetime_Call {
271+ _c.Call.Run(func(args mock.Arguments) {
272+ run()
273+ })
274+ return _c
275+}
276+
277+func (_c *MockCertificateAuthority_LeafLifetime_Call) Return(duration time.Duration) *MockCertificateAuthority_LeafLifetime_Call {
278+ _c.Call.Return(duration)
279+ return _c
280+}
281+
282+func (_c *MockCertificateAuthority_LeafLifetime_Call) RunAndReturn(run func() time.Duration) *MockCertificateAuthority_LeafLifetime_Call {
283+ _c.Call.Return(run)
284+ return _c
285+}
286+
287+// RenewIntermediate provides a mock function for the type MockCertificateAuthority
288+func (_mock *MockCertificateAuthority) RenewIntermediate() error {
289+ ret := _mock.Called()
290+
291+ if len(ret) == 0 {
292+ panic("no return value specified for RenewIntermediate")
293+ }
294+
295+ var r0 error
296+ if returnFunc, ok := ret.Get(0).(func() error); ok {
297+ r0 = returnFunc()
298+ } else {
299+ r0 = ret.Error(0)
300+ }
301+ return r0
302+}
303+
304+// MockCertificateAuthority_RenewIntermediate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenewIntermediate'
305+type MockCertificateAuthority_RenewIntermediate_Call struct {
306+ *mock.Call
307+}
308+
309+// RenewIntermediate is a helper method to define mock.On call
310+func (_e *MockCertificateAuthority_Expecter) RenewIntermediate() *MockCertificateAuthority_RenewIntermediate_Call {
311+ return &MockCertificateAuthority_RenewIntermediate_Call{Call: _e.mock.On("RenewIntermediate")}
312+}
313+
314+func (_c *MockCertificateAuthority_RenewIntermediate_Call) Run(run func()) *MockCertificateAuthority_RenewIntermediate_Call {
315+ _c.Call.Run(func(args mock.Arguments) {
316+ run()
317+ })
318+ return _c
319+}
320+
321+func (_c *MockCertificateAuthority_RenewIntermediate_Call) Return(err error) *MockCertificateAuthority_RenewIntermediate_Call {
322+ _c.Call.Return(err)
323+ return _c
324+}
325+
326+func (_c *MockCertificateAuthority_RenewIntermediate_Call) RunAndReturn(run func() error) *MockCertificateAuthority_RenewIntermediate_Call {
327+ _c.Call.Return(run)
328+ return _c
329+}
330+
331+// RootCertificate provides a mock function for the type MockCertificateAuthority
332+func (_mock *MockCertificateAuthority) RootCertificate() []byte {
333+ ret := _mock.Called()
334+
335+ if len(ret) == 0 {
336+ panic("no return value specified for RootCertificate")
337+ }
338+
339+ var r0 []byte
340+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
341+ r0 = returnFunc()
342+ } else {
343+ if ret.Get(0) != nil {
344+ r0 = ret.Get(0).([]byte)
345+ }
346+ }
347+ return r0
348+}
349+
350+// MockCertificateAuthority_RootCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificate'
351+type MockCertificateAuthority_RootCertificate_Call struct {
352+ *mock.Call
353+}
354+
355+// RootCertificate is a helper method to define mock.On call
356+func (_e *MockCertificateAuthority_Expecter) RootCertificate() *MockCertificateAuthority_RootCertificate_Call {
357+ return &MockCertificateAuthority_RootCertificate_Call{Call: _e.mock.On("RootCertificate")}
358+}
359+
360+func (_c *MockCertificateAuthority_RootCertificate_Call) Run(run func()) *MockCertificateAuthority_RootCertificate_Call {
361+ _c.Call.Run(func(args mock.Arguments) {
362+ run()
363+ })
364+ return _c
365+}
366+
367+func (_c *MockCertificateAuthority_RootCertificate_Call) Return(bytes []byte) *MockCertificateAuthority_RootCertificate_Call {
368+ _c.Call.Return(bytes)
369+ return _c
370+}
371+
372+func (_c *MockCertificateAuthority_RootCertificate_Call) RunAndReturn(run func() []byte) *MockCertificateAuthority_RootCertificate_Call {
373+ _c.Call.Return(run)
374+ return _c
375+}
376+
377+// RootCertificateDER provides a mock function for the type MockCertificateAuthority
378+func (_mock *MockCertificateAuthority) RootCertificateDER() []byte {
379+ ret := _mock.Called()
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for RootCertificateDER")
383+ }
384+
385+ var r0 []byte
386+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
387+ r0 = returnFunc()
388+ } else {
389+ if ret.Get(0) != nil {
390+ r0 = ret.Get(0).([]byte)
391+ }
392+ }
393+ return r0
394+}
395+
396+// MockCertificateAuthority_RootCertificateDER_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCertificateDER'
397+type MockCertificateAuthority_RootCertificateDER_Call struct {
398+ *mock.Call
399+}
400+
401+// RootCertificateDER is a helper method to define mock.On call
402+func (_e *MockCertificateAuthority_Expecter) RootCertificateDER() *MockCertificateAuthority_RootCertificateDER_Call {
403+ return &MockCertificateAuthority_RootCertificateDER_Call{Call: _e.mock.On("RootCertificateDER")}
404+}
405+
406+func (_c *MockCertificateAuthority_RootCertificateDER_Call) Run(run func()) *MockCertificateAuthority_RootCertificateDER_Call {
407+ _c.Call.Run(func(args mock.Arguments) {
408+ run()
409+ })
410+ return _c
411+}
412+
413+func (_c *MockCertificateAuthority_RootCertificateDER_Call) Return(bytes []byte) *MockCertificateAuthority_RootCertificateDER_Call {
414+ _c.Call.Return(bytes)
415+ return _c
416+}
417+
418+func (_c *MockCertificateAuthority_RootCertificateDER_Call) RunAndReturn(run func() []byte) *MockCertificateAuthority_RootCertificateDER_Call {
419+ _c.Call.Return(run)
420+ return _c
421+}
422+
423+// RootCommonName provides a mock function for the type MockCertificateAuthority
424+func (_mock *MockCertificateAuthority) RootCommonName() string {
425+ ret := _mock.Called()
426+
427+ if len(ret) == 0 {
428+ panic("no return value specified for RootCommonName")
429+ }
430+
431+ var r0 string
432+ if returnFunc, ok := ret.Get(0).(func() string); ok {
433+ r0 = returnFunc()
434+ } else {
435+ r0 = ret.Get(0).(string)
436+ }
437+ return r0
438+}
439+
440+// MockCertificateAuthority_RootCommonName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootCommonName'
441+type MockCertificateAuthority_RootCommonName_Call struct {
442+ *mock.Call
443+}
444+
445+// RootCommonName is a helper method to define mock.On call
446+func (_e *MockCertificateAuthority_Expecter) RootCommonName() *MockCertificateAuthority_RootCommonName_Call {
447+ return &MockCertificateAuthority_RootCommonName_Call{Call: _e.mock.On("RootCommonName")}
448+}
449+
450+func (_c *MockCertificateAuthority_RootCommonName_Call) Run(run func()) *MockCertificateAuthority_RootCommonName_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ run()
453+ })
454+ return _c
455+}
456+
457+func (_c *MockCertificateAuthority_RootCommonName_Call) Return(s string) *MockCertificateAuthority_RootCommonName_Call {
458+ _c.Call.Return(s)
459+ return _c
460+}
461+
462+func (_c *MockCertificateAuthority_RootCommonName_Call) RunAndReturn(run func() string) *MockCertificateAuthority_RootCommonName_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
467+// RootFingerprint provides a mock function for the type MockCertificateAuthority
468+func (_mock *MockCertificateAuthority) RootFingerprint() string {
469+ ret := _mock.Called()
470+
471+ if len(ret) == 0 {
472+ panic("no return value specified for RootFingerprint")
473+ }
474+
475+ var r0 string
476+ if returnFunc, ok := ret.Get(0).(func() string); ok {
477+ r0 = returnFunc()
478+ } else {
479+ r0 = ret.Get(0).(string)
480+ }
481+ return r0
482+}
483+
484+// MockCertificateAuthority_RootFingerprint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RootFingerprint'
485+type MockCertificateAuthority_RootFingerprint_Call struct {
486+ *mock.Call
487+}
488+
489+// RootFingerprint is a helper method to define mock.On call
490+func (_e *MockCertificateAuthority_Expecter) RootFingerprint() *MockCertificateAuthority_RootFingerprint_Call {
491+ return &MockCertificateAuthority_RootFingerprint_Call{Call: _e.mock.On("RootFingerprint")}
492+}
493+
494+func (_c *MockCertificateAuthority_RootFingerprint_Call) Run(run func()) *MockCertificateAuthority_RootFingerprint_Call {
495+ _c.Call.Run(func(args mock.Arguments) {
496+ run()
497+ })
498+ return _c
499+}
500+
501+func (_c *MockCertificateAuthority_RootFingerprint_Call) Return(s string) *MockCertificateAuthority_RootFingerprint_Call {
502+ _c.Call.Return(s)
503+ return _c
504+}
505+
506+func (_c *MockCertificateAuthority_RootFingerprint_Call) RunAndReturn(run func() string) *MockCertificateAuthority_RootFingerprint_Call {
507+ _c.Call.Return(run)
508+ return _c
509+}
510+
511+// UninstallRoot provides a mock function for the type MockCertificateAuthority
512+func (_mock *MockCertificateAuthority) UninstallRoot(cert *x509.Certificate) error {
513+ ret := _mock.Called(cert)
514+
515+ if len(ret) == 0 {
516+ panic("no return value specified for UninstallRoot")
517+ }
518+
519+ var r0 error
520+ if returnFunc, ok := ret.Get(0).(func(*x509.Certificate) error); ok {
521+ r0 = returnFunc(cert)
522+ } else {
523+ r0 = ret.Error(0)
524+ }
525+ return r0
526+}
527+
528+// MockCertificateAuthority_UninstallRoot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UninstallRoot'
529+type MockCertificateAuthority_UninstallRoot_Call struct {
530+ *mock.Call
531+}
532+
533+// UninstallRoot is a helper method to define mock.On call
534+// - cert *x509.Certificate
535+func (_e *MockCertificateAuthority_Expecter) UninstallRoot(cert any) *MockCertificateAuthority_UninstallRoot_Call {
536+ return &MockCertificateAuthority_UninstallRoot_Call{Call: _e.mock.On("UninstallRoot", cert)}
537+}
538+
539+func (_c *MockCertificateAuthority_UninstallRoot_Call) Run(run func(cert *x509.Certificate)) *MockCertificateAuthority_UninstallRoot_Call {
540+ _c.Call.Run(func(args mock.Arguments) {
541+ var arg0 *x509.Certificate
542+ if args[0] != nil {
543+ arg0 = args[0].(*x509.Certificate)
544+ }
545+ run(
546+ arg0,
547+ )
548+ })
549+ return _c
550+}
551+
552+func (_c *MockCertificateAuthority_UninstallRoot_Call) Return(err error) *MockCertificateAuthority_UninstallRoot_Call {
553+ _c.Call.Return(err)
554+ return _c
555+}
556+
557+func (_c *MockCertificateAuthority_UninstallRoot_Call) RunAndReturn(run func(cert *x509.Certificate) error) *MockCertificateAuthority_UninstallRoot_Call {
558+ _c.Call.Return(run)
559+ return _c
560+}
M internal/usecase/pki/service.go +128 -56
@@ -4,6 +4,8 @@ import (
44 "context"
55 "crypto/tls"
66 "crypto/x509"
7+ "fmt"
8+ "strings"
79 "sync"
810 "time"
911
@@ -11,6 +13,7 @@ import (
1113 "golang.org/x/sync/singleflight"
1214
1315 "github.com/bnema/gordon/internal/boundaries/out"
16+ "github.com/bnema/gordon/internal/domain"
1417 )
1518
1619 const (
@@ -29,9 +32,11 @@ type cachedCert struct {
2932 // TODO: consider adding CRL/OCSP support for certificate revocation,
3033 // especially given the 10-year root CA lifetime.
3134 type Service struct {
32- ca out.CertificateAuthority
33- routes out.RouteChecker
34- log zerowrap.Logger
35+ ca out.CertificateAuthority
36+ routes out.RouteChecker
37+ allowedMu sync.RWMutex
38+ allowedDomains map[string]struct{}
39+ log zerowrap.Logger
3540
3641 cache sync.Map // map[string]*cachedCert
3742 flight singleflight.Group
@@ -42,14 +47,16 @@ type Service struct {
4247 // NewService creates a PKI service and starts background maintenance goroutines.
4348 // It performs an initial intermediate renewal check synchronously so the first
4449 // TLS handshakes never use a nearly-expired intermediate.
45-func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
50+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, allowedDomains []string, log zerowrap.Logger) *Service {
4651 ctx, cancel := context.WithCancel(ctx)
52+ allowed := canonicalDomainSet(allowedDomains)
4753 svc := &Service{
48- ca: ca,
49- routes: routes,
50- log: log,
51- cancel: cancel,
52- done: make(chan struct{}),
54+ ca: ca,
55+ routes: routes,
56+ allowedDomains: allowed,
57+ log: log,
58+ cancel: cancel,
59+ done: make(chan struct{}),
5360 }
5461 svc.renewIntermediateIfNeeded()
5562 go svc.maintenanceLoop(ctx)
@@ -76,80 +83,140 @@ func (s *Service) CachedCertCount() int {
7683 // It returns (nil, nil) for unknown domains so Go's TLS stack
7784 // falls back to tls.Config.Certificates.
7885 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
79- domain := hello.ServerName
80- if domain == "" {
86+ domainName := canonicalServerName(hello.ServerName)
87+ if domainName == "" {
8188 return nil, nil
8289 }
8390
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- }
91+ if cert := s.validCachedCertificate(domainName); cert != nil {
92+ if s.isDomainAllowed(hello.Context(), domainName) {
93+ return cert, nil
9394 }
94- s.cache.Delete(domain)
95+ s.cache.Delete(domainName)
96+ return nil, nil
97+ }
98+ if !s.isDomainAllowed(hello.Context(), domainName) {
99+ return nil, nil
95100 }
96101
97- if !s.isDomainAllowed(hello.Context(), domain) {
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)
98110 return nil, nil
99111 }
112+ return result.(*tls.Certificate), nil
113+}
100114
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- }
115+func canonicalServerName(serverName string) string {
116+ return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
117+}
111118
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- }
119+func (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+}
117131
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- })
132+func (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)
128143
129- s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
144+ if !s.isDomainAllowed(ctx, domainName) {
145+ return nil, nil
146+ }
147+ if cert := s.validCachedCertificate(domainName); cert != nil {
130148 return cert, nil
131- })
149+ }
150+
151+ cert, err := s.ca.IssueCertificate(domainName)
132152 if err != nil {
133- return nil, err
153+ log.Error().Err(err).Msg("failed to issue leaf certificate")
154+ return nil, fmt.Errorf("issue leaf certificate for %q: %w", domainName, err)
134155 }
135- if result == nil {
156+ if !s.isDomainAllowed(ctx, domainName) {
136157 return nil, nil
137158 }
138- return result.(*tls.Certificate), nil
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
139163 }
140164
141-func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
165+func 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.
176+func (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+
191+func (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+ }
142198 for _, r := range s.routes.GetRoutes(ctx) {
143- if r.Domain == domain {
199+ if r.Domain == domainName {
144200 return true
145201 }
146202 }
147- if _, ok := s.routes.GetExternalRoutes()[domain]; ok {
203+ if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
148204 return true
149205 }
150206 return false
151207 }
152208
209+func 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+
153220 func (s *Service) leafLifetime() time.Duration {
154221 return s.ca.LeafLifetime()
155222 }
@@ -190,7 +257,12 @@ func (s *Service) sweepExpiredCerts(ctx context.Context) {
190257 routes := s.routes.GetRoutes(ctx)
191258 extRoutes := s.routes.GetExternalRoutes()
192259
193- allowed := make(map[string]struct{}, len(routes)+len(extRoutes))
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()
194266 for _, r := range routes {
195267 allowed[r.Domain] = struct{}{}
196268 }
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6 "crypto/x509"6 "crypto/x509"
7+ "fmt"
8+ "strings"
7 "sync"9 "sync"
8 "time"10 "time"
9 11
@@ -11,6 +13,7 @@ import (
11 "golang.org/x/sync/singleflight"13 "golang.org/x/sync/singleflight"
12 14
13 "github.com/bnema/gordon/internal/boundaries/out"15 "github.com/bnema/gordon/internal/boundaries/out"
16+ "github.com/bnema/gordon/internal/domain"
14 )17 )
15 18
16 const (19 const (
@@ -29,9 +32,11 @@ type cachedCert struct {
29 // TODO: consider adding CRL/OCSP support for certificate revocation,32 // TODO: consider adding CRL/OCSP support for certificate revocation,
30 // especially given the 10-year root CA lifetime.33 // especially given the 10-year root CA lifetime.
31 type Service struct {34 type Service struct {
32- ca out.CertificateAuthority35+ ca out.CertificateAuthority
33- routes out.RouteChecker36+ routes out.RouteChecker
34- log zerowrap.Logger37+ allowedMu sync.RWMutex
38+ allowedDomains map[string]struct{}
39+ log zerowrap.Logger
35 40
36 cache sync.Map // map[string]*cachedCert41 cache sync.Map // map[string]*cachedCert
37 flight singleflight.Group42 flight singleflight.Group
@@ -42,14 +47,16 @@ type Service struct {
42 // NewService creates a PKI service and starts background maintenance goroutines.47 // NewService creates a PKI service and starts background maintenance goroutines.
43 // It performs an initial intermediate renewal check synchronously so the first48 // It performs an initial intermediate renewal check synchronously so the first
44 // TLS handshakes never use a nearly-expired intermediate.49 // TLS handshakes never use a nearly-expired intermediate.
45-func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {50+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, allowedDomains []string, log zerowrap.Logger) *Service {
46 ctx, cancel := context.WithCancel(ctx)51 ctx, cancel := context.WithCancel(ctx)
52+ allowed := canonicalDomainSet(allowedDomains)
47 svc := &Service{53 svc := &Service{
48- ca: ca,54+ ca: ca,
49- routes: routes,55+ routes: routes,
50- log: log,56+ allowedDomains: allowed,
51- cancel: cancel,57+ log: log,
52- done: make(chan struct{}),58+ cancel: cancel,
59+ done: make(chan struct{}),
53 }60 }
54 svc.renewIntermediateIfNeeded()61 svc.renewIntermediateIfNeeded()
55 go svc.maintenanceLoop(ctx)62 go svc.maintenanceLoop(ctx)
@@ -76,80 +83,140 @@ func (s *Service) CachedCertCount() int {
76 // It returns (nil, nil) for unknown domains so Go's TLS stack83 // It returns (nil, nil) for unknown domains so Go's TLS stack
77 // falls back to tls.Config.Certificates.84 // falls back to tls.Config.Certificates.
78 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {85 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
79- domain := hello.ServerName86+ domainName := canonicalServerName(hello.ServerName)
80- if domain == "" {87+ if domainName == "" {
81 return nil, nil88 return nil, nil
82 }89 }
83 90
84- if entry, ok := s.cache.Load(domain); ok {91+ if cert := s.validCachedCertificate(domainName); cert != nil {
85- if cached, ok := entry.(*cachedCert); ok {92+ if s.isDomainAllowed(hello.Context(), domainName) {
86- if time.Now().Before(cached.expiresAt) {93+ return cert, nil
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 }
94- s.cache.Delete(domain)95+ s.cache.Delete(domainName)
96+ return nil, nil
97+ }
98+ if !s.isDomainAllowed(hello.Context(), domainName) {
99+ return nil, nil
95 }100 }
96 101
97- if !s.isDomainAllowed(hello.Context(), domain) {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)
98 return nil, nil110 return nil, nil
99 }111 }
112+ return result.(*tls.Certificate), nil
113+}
100 114
101- result, err, _ := s.flight.Do(domain, func() (any, error) {115+func canonicalServerName(serverName string) string {
102- // Re-check cache; another goroutine in the same flight may have populated it.116+ return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
103- if entry, ok := s.cache.Load(domain); ok {117+}
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 118
112- cert, err := s.ca.IssueCertificate(domain)119+func (s *Service) validCachedCertificate(domainName string) *tls.Certificate {
113- if err != nil {120+ entry, ok := s.cache.Load(domainName)
114- s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert")121+ if !ok {
115- return nil, err122+ return nil
116- }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+}
117 131
118- expiresAt := time.Now().Add(s.leafLifetime())132+func (s *Service) issueCertificate(ctx context.Context, domainName string) (*tls.Certificate, error) {
119- if len(cert.Certificate) > 0 {133+ if ctx == nil {
120- if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {134+ ctx = context.Background()
121- expiresAt = leaf.NotAfter135+ }
122- }136+ ctx = zerowrap.WithCtx(ctx, s.log)
123- }137+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
124- s.cache.Store(domain, &cachedCert{138+ zerowrap.FieldLayer: "usecase",
125- cert: cert,139+ zerowrap.FieldUseCase: "IssueCertificate",
126- expiresAt: expiresAt,140+ "domain": domainName,
127- })141+ })
142+ log := zerowrap.FromCtx(ctx)
128 143
129- s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")144+ if !s.isDomainAllowed(ctx, domainName) {
145+ return nil, nil
146+ }
147+ if cert := s.validCachedCertificate(domainName); cert != nil {
130 return cert, nil148 return cert, nil
131- })149+ }
150+
151+ cert, err := s.ca.IssueCertificate(domainName)
132 if err != nil {152 if err != nil {
133- return nil, err153+ log.Error().Err(err).Msg("failed to issue leaf certificate")
154+ return nil, fmt.Errorf("issue leaf certificate for %q: %w", domainName, err)
134 }155 }
135- if result == nil {156+ if !s.isDomainAllowed(ctx, domainName) {
136 return nil, nil157 return nil, nil
137 }158 }
138- return result.(*tls.Certificate), nil159+
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
139 }163 }
140 164
141-func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {165+func 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.
176+func (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+
191+func (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+ }
142 for _, r := range s.routes.GetRoutes(ctx) {198 for _, r := range s.routes.GetRoutes(ctx) {
143- if r.Domain == domain {199+ if r.Domain == domainName {
144 return true200 return true
145 }201 }
146 }202 }
147- if _, ok := s.routes.GetExternalRoutes()[domain]; ok {203+ if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
148 return true204 return true
149 }205 }
150 return false206 return false
151 }207 }
152 208
209+func 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+
153 func (s *Service) leafLifetime() time.Duration {220 func (s *Service) leafLifetime() time.Duration {
154 return s.ca.LeafLifetime()221 return s.ca.LeafLifetime()
155 }222 }
@@ -190,7 +257,12 @@ func (s *Service) sweepExpiredCerts(ctx context.Context) {
190 routes := s.routes.GetRoutes(ctx)257 routes := s.routes.GetRoutes(ctx)
191 extRoutes := s.routes.GetExternalRoutes()258 extRoutes := s.routes.GetExternalRoutes()
192 259
193- allowed := make(map[string]struct{}, len(routes)+len(extRoutes))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()
194 for _, r := range routes {266 for _, r := range routes {
195 allowed[r.Domain] = struct{}{}267 allowed[r.Domain] = struct{}{}
196 }268 }
M internal/usecase/pki/service_test.go +148 -6
@@ -3,7 +3,9 @@ package pki_test
33 import (
44 "context"
55 "crypto/tls"
6+ "errors"
67 "testing"
8+ "time"
79
810 "github.com/bnema/zerowrap"
911 "github.com/stretchr/testify/assert"
@@ -11,6 +13,7 @@ import (
1113 "github.com/stretchr/testify/require"
1214
1315 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16+ "github.com/bnema/gordon/internal/boundaries/out"
1417 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1518 "github.com/bnema/gordon/internal/domain"
1619 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
@@ -18,6 +21,18 @@ import (
1821
1922 func testLogger() zerowrap.Logger { return zerowrap.Default() }
2023
24+type blockingCertificateAuthority struct {
25+ out.CertificateAuthority
26+ started chan struct{}
27+ release chan struct{}
28+}
29+
30+func (b *blockingCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
31+ close(b.started)
32+ <-b.release
33+ return b.CertificateAuthority.IssueCertificate(domain)
34+}
35+
2136 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
2237 m := mocks.NewMockRouteChecker(t)
2338 routes := make([]domain.Route, len(domains))
@@ -36,10 +51,10 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
3651
3752 cfg := newRouteCheckerMock(t, "app.example.com")
3853
39- ctx, cancel := context.WithCancel(context.Background())
54+ ctx, cancel := context.WithCancel(t.Context())
4055 defer cancel()
4156
42- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
57+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
4358 defer svc.Stop()
4459
4560 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
@@ -48,6 +63,133 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
4863 require.NotNil(t, cert)
4964 }
5065
66+func TestService_GetCertificate_AdditionalDomain(t *testing.T) {
67+ dir := t.TempDir()
68+ ca, err := pkiadapter.NewCA(dir, testLogger())
69+ require.NoError(t, err)
70+
71+ cfg := newRouteCheckerMock(t, "app.example.com")
72+
73+ ctx, cancel := context.WithCancel(t.Context())
74+ defer cancel()
75+
76+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"gordon.example.com"}, testLogger())
77+ defer svc.Stop()
78+
79+ hello := &tls.ClientHelloInfo{ServerName: "GORDON.EXAMPLE.COM."}
80+ cert, err := svc.GetCertificate(hello)
81+ require.NoError(t, err)
82+ require.NotNil(t, cert)
83+}
84+
85+func TestService_SetAdditionalDomains_ReplacesAuthorization(t *testing.T) {
86+ dir := t.TempDir()
87+ ca, err := pkiadapter.NewCA(dir, testLogger())
88+ require.NoError(t, err)
89+
90+ cfg := newRouteCheckerMock(t)
91+ ctx, cancel := context.WithCancel(t.Context())
92+ defer cancel()
93+
94+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"old.example.com"}, testLogger())
95+ defer svc.Stop()
96+
97+ oldHello := &tls.ClientHelloInfo{ServerName: "old.example.com"}
98+ cert, err := svc.GetCertificate(oldHello)
99+ require.NoError(t, err)
100+ require.NotNil(t, cert)
101+
102+ svc.SetAdditionalDomains([]string{"NEW.EXAMPLE.COM."})
103+
104+ cert, err = svc.GetCertificate(oldHello)
105+ require.NoError(t, err)
106+ assert.Nil(t, cert)
107+
108+ cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "new.example.com"})
109+ require.NoError(t, err)
110+ require.NotNil(t, cert)
111+}
112+
113+func TestService_SetAdditionalDomains_RevokesConcurrentIssuance(t *testing.T) {
114+ dir := t.TempDir()
115+ ca, err := pkiadapter.NewCA(dir, testLogger())
116+ require.NoError(t, err)
117+ blockingCA := &blockingCertificateAuthority{
118+ CertificateAuthority: ca,
119+ started: make(chan struct{}),
120+ release: make(chan struct{}),
121+ }
122+
123+ cfg := newRouteCheckerMock(t)
124+ ctx, cancel := context.WithCancel(t.Context())
125+ defer cancel()
126+
127+ svc := pkiusecase.NewService(ctx, blockingCA, cfg, []string{"old.example.com"}, testLogger())
128+ defer svc.Stop()
129+
130+ result := make(chan *tls.Certificate, 1)
131+ errs := make(chan error, 1)
132+ go func() {
133+ cert, getErr := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "old.example.com"})
134+ result <- cert
135+ errs <- getErr
136+ }()
137+
138+ require.Eventually(t, func() bool {
139+ select {
140+ case <-blockingCA.started:
141+ return true
142+ default:
143+ return false
144+ }
145+ }, time.Second, 10*time.Millisecond, "certificate issuance did not start")
146+ svc.SetAdditionalDomains([]string{"new.example.com"})
147+ close(blockingCA.release)
148+
149+ var getErr error
150+ require.Eventually(t, func() bool {
151+ select {
152+ case getErr = <-errs:
153+ return true
154+ default:
155+ return false
156+ }
157+ }, time.Second, 10*time.Millisecond, "certificate issuance did not return an error result")
158+ require.NoError(t, getErr)
159+
160+ var cert *tls.Certificate
161+ require.Eventually(t, func() bool {
162+ select {
163+ case cert = <-result:
164+ return true
165+ default:
166+ return false
167+ }
168+ }, time.Second, 10*time.Millisecond, "certificate issuance did not return a certificate result")
169+ assert.Nil(t, cert)
170+ assert.Equal(t, 0, svc.CachedCertCount())
171+}
172+
173+func TestService_GetCertificate_WrapsIssuanceError(t *testing.T) {
174+ issuerErr := errors.New("issuer unavailable")
175+ ca := mocks.NewMockCertificateAuthority(t)
176+ ca.EXPECT().IntermediateExpiresAt().Return(time.Now().Add(24 * time.Hour)).Maybe()
177+ ca.EXPECT().IntermediateLifetime().Return(24 * time.Hour).Maybe()
178+ ca.EXPECT().IssueCertificate("broken.example.com").Return(nil, issuerErr).Once()
179+ cfg := newRouteCheckerMock(t, "broken.example.com")
180+
181+ ctx, cancel := context.WithCancel(t.Context())
182+ defer cancel()
183+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
184+ defer svc.Stop()
185+
186+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "broken.example.com"})
187+
188+ assert.Nil(t, cert)
189+ require.ErrorIs(t, err, issuerErr)
190+ assert.Equal(t, `issue leaf certificate for "broken.example.com": issuer unavailable`, err.Error())
191+}
192+
51193 func TestService_GetCertificate_UnknownDomain(t *testing.T) {
52194 dir := t.TempDir()
53195 ca, err := pkiadapter.NewCA(dir, testLogger())
@@ -55,10 +197,10 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
55197
56198 cfg := newRouteCheckerMock(t, "app.example.com")
57199
58- ctx, cancel := context.WithCancel(context.Background())
200+ ctx, cancel := context.WithCancel(t.Context())
59201 defer cancel()
60202
61- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
203+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
62204 defer svc.Stop()
63205
64206 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
@@ -74,10 +216,10 @@ func TestService_GetCertificate_Caching(t *testing.T) {
74216
75217 cfg := newRouteCheckerMock(t, "cached.example.com")
76218
77- ctx, cancel := context.WithCancel(context.Background())
219+ ctx, cancel := context.WithCancel(t.Context())
78220 defer cancel()
79221
80- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
222+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
81223 defer svc.Stop()
82224
83225 hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
@@ -3,7 +3,9 @@ package pki_test
3 import (3 import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6+ "errors"
6 "testing"7 "testing"
8+ "time"
7 9
8 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
@@ -11,6 +13,7 @@ import (
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
12 14
13 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"15 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16+ "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/boundaries/out/mocks"17 "github.com/bnema/gordon/internal/boundaries/out/mocks"
15 "github.com/bnema/gordon/internal/domain"18 "github.com/bnema/gordon/internal/domain"
16 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"19 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
@@ -18,6 +21,18 @@ import (
18 21
19 func testLogger() zerowrap.Logger { return zerowrap.Default() }22 func testLogger() zerowrap.Logger { return zerowrap.Default() }
20 23
24+type blockingCertificateAuthority struct {
25+ out.CertificateAuthority
26+ started chan struct{}
27+ release chan struct{}
28+}
29+
30+func (b *blockingCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
31+ close(b.started)
32+ <-b.release
33+ return b.CertificateAuthority.IssueCertificate(domain)
34+}
35+
21 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {36 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
22 m := mocks.NewMockRouteChecker(t)37 m := mocks.NewMockRouteChecker(t)
23 routes := make([]domain.Route, len(domains))38 routes := make([]domain.Route, len(domains))
@@ -36,10 +51,10 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
36 51
37 cfg := newRouteCheckerMock(t, "app.example.com")52 cfg := newRouteCheckerMock(t, "app.example.com")
38 53
39- ctx, cancel := context.WithCancel(context.Background())54+ ctx, cancel := context.WithCancel(t.Context())
40 defer cancel()55 defer cancel()
41 56
42- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())57+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
43 defer svc.Stop()58 defer svc.Stop()
44 59
45 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}60 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
@@ -48,6 +63,133 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
48 require.NotNil(t, cert)63 require.NotNil(t, cert)
49 }64 }
50 65
66+func TestService_GetCertificate_AdditionalDomain(t *testing.T) {
67+ dir := t.TempDir()
68+ ca, err := pkiadapter.NewCA(dir, testLogger())
69+ require.NoError(t, err)
70+
71+ cfg := newRouteCheckerMock(t, "app.example.com")
72+
73+ ctx, cancel := context.WithCancel(t.Context())
74+ defer cancel()
75+
76+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"gordon.example.com"}, testLogger())
77+ defer svc.Stop()
78+
79+ hello := &tls.ClientHelloInfo{ServerName: "GORDON.EXAMPLE.COM."}
80+ cert, err := svc.GetCertificate(hello)
81+ require.NoError(t, err)
82+ require.NotNil(t, cert)
83+}
84+
85+func TestService_SetAdditionalDomains_ReplacesAuthorization(t *testing.T) {
86+ dir := t.TempDir()
87+ ca, err := pkiadapter.NewCA(dir, testLogger())
88+ require.NoError(t, err)
89+
90+ cfg := newRouteCheckerMock(t)
91+ ctx, cancel := context.WithCancel(t.Context())
92+ defer cancel()
93+
94+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"old.example.com"}, testLogger())
95+ defer svc.Stop()
96+
97+ oldHello := &tls.ClientHelloInfo{ServerName: "old.example.com"}
98+ cert, err := svc.GetCertificate(oldHello)
99+ require.NoError(t, err)
100+ require.NotNil(t, cert)
101+
102+ svc.SetAdditionalDomains([]string{"NEW.EXAMPLE.COM."})
103+
104+ cert, err = svc.GetCertificate(oldHello)
105+ require.NoError(t, err)
106+ assert.Nil(t, cert)
107+
108+ cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "new.example.com"})
109+ require.NoError(t, err)
110+ require.NotNil(t, cert)
111+}
112+
113+func TestService_SetAdditionalDomains_RevokesConcurrentIssuance(t *testing.T) {
114+ dir := t.TempDir()
115+ ca, err := pkiadapter.NewCA(dir, testLogger())
116+ require.NoError(t, err)
117+ blockingCA := &blockingCertificateAuthority{
118+ CertificateAuthority: ca,
119+ started: make(chan struct{}),
120+ release: make(chan struct{}),
121+ }
122+
123+ cfg := newRouteCheckerMock(t)
124+ ctx, cancel := context.WithCancel(t.Context())
125+ defer cancel()
126+
127+ svc := pkiusecase.NewService(ctx, blockingCA, cfg, []string{"old.example.com"}, testLogger())
128+ defer svc.Stop()
129+
130+ result := make(chan *tls.Certificate, 1)
131+ errs := make(chan error, 1)
132+ go func() {
133+ cert, getErr := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "old.example.com"})
134+ result <- cert
135+ errs <- getErr
136+ }()
137+
138+ require.Eventually(t, func() bool {
139+ select {
140+ case <-blockingCA.started:
141+ return true
142+ default:
143+ return false
144+ }
145+ }, time.Second, 10*time.Millisecond, "certificate issuance did not start")
146+ svc.SetAdditionalDomains([]string{"new.example.com"})
147+ close(blockingCA.release)
148+
149+ var getErr error
150+ require.Eventually(t, func() bool {
151+ select {
152+ case getErr = <-errs:
153+ return true
154+ default:
155+ return false
156+ }
157+ }, time.Second, 10*time.Millisecond, "certificate issuance did not return an error result")
158+ require.NoError(t, getErr)
159+
160+ var cert *tls.Certificate
161+ require.Eventually(t, func() bool {
162+ select {
163+ case cert = <-result:
164+ return true
165+ default:
166+ return false
167+ }
168+ }, time.Second, 10*time.Millisecond, "certificate issuance did not return a certificate result")
169+ assert.Nil(t, cert)
170+ assert.Equal(t, 0, svc.CachedCertCount())
171+}
172+
173+func TestService_GetCertificate_WrapsIssuanceError(t *testing.T) {
174+ issuerErr := errors.New("issuer unavailable")
175+ ca := mocks.NewMockCertificateAuthority(t)
176+ ca.EXPECT().IntermediateExpiresAt().Return(time.Now().Add(24 * time.Hour)).Maybe()
177+ ca.EXPECT().IntermediateLifetime().Return(24 * time.Hour).Maybe()
178+ ca.EXPECT().IssueCertificate("broken.example.com").Return(nil, issuerErr).Once()
179+ cfg := newRouteCheckerMock(t, "broken.example.com")
180+
181+ ctx, cancel := context.WithCancel(t.Context())
182+ defer cancel()
183+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
184+ defer svc.Stop()
185+
186+ cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "broken.example.com"})
187+
188+ assert.Nil(t, cert)
189+ require.ErrorIs(t, err, issuerErr)
190+ assert.Equal(t, `issue leaf certificate for "broken.example.com": issuer unavailable`, err.Error())
191+}
192+
51 func TestService_GetCertificate_UnknownDomain(t *testing.T) {193 func TestService_GetCertificate_UnknownDomain(t *testing.T) {
52 dir := t.TempDir()194 dir := t.TempDir()
53 ca, err := pkiadapter.NewCA(dir, testLogger())195 ca, err := pkiadapter.NewCA(dir, testLogger())
@@ -55,10 +197,10 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
55 197
56 cfg := newRouteCheckerMock(t, "app.example.com")198 cfg := newRouteCheckerMock(t, "app.example.com")
57 199
58- ctx, cancel := context.WithCancel(context.Background())200+ ctx, cancel := context.WithCancel(t.Context())
59 defer cancel()201 defer cancel()
60 202
61- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())203+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
62 defer svc.Stop()204 defer svc.Stop()
63 205
64 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}206 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
@@ -74,10 +216,10 @@ func TestService_GetCertificate_Caching(t *testing.T) {
74 216
75 cfg := newRouteCheckerMock(t, "cached.example.com")217 cfg := newRouteCheckerMock(t, "cached.example.com")
76 218
77- ctx, cancel := context.WithCancel(context.Background())219+ ctx, cancel := context.WithCancel(t.Context())
78 defer cancel()220 defer cancel()
79 221
80- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())222+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
81 defer svc.Stop()223 defer svc.Stop()
82 224
83 hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}225 hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
M internal/usecase/publictls/integration_test.go +23 -9
@@ -29,9 +29,9 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
2929 },
3030 }
3131
32- // Mock zone resolver returns "example.com" zone for 3 routes.
32+ // Mock zone resolver returns "example.com" for routes and the management host.
3333 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
34- zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)
34+ zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(4)
3535
3636 // Mock issuer: returns valid stored cert for any order.
3737 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
@@ -48,12 +48,13 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
4848 }
4949
5050 svc := NewService(cfg, ServiceDeps{
51- Config: cfg,
52- Routes: routes,
53- Issuer: issuer,
54- Store: store,
55- ZoneResolver: zoneResolver,
56- Challenges: NewHTTP01Challenges(),
51+ Config: cfg,
52+ Routes: routes,
53+ Issuer: issuer,
54+ Store: store,
55+ ZoneResolver: zoneResolver,
56+ Challenges: NewHTTP01Challenges(),
57+ AdditionalHosts: []string{"gordon.example.com"},
5758 Effective: EffectiveChallenge{
5859 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
5960 Mode: domain.ACMEChallengeCloudflareDNS01,
@@ -141,8 +142,21 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
141142 require.NotEmpty(t, cert.Certificate)
142143 })
143144
145+ t.Run("GetCertificate_returns_cert_for_management_host", func(t *testing.T) {
146+ cert, err := svc.GetCertificateForHost("gordon.example.com")
147+ require.NoError(t, err)
148+ require.NotNil(t, cert)
149+ require.NotEmpty(t, cert.Certificate)
150+ })
151+
152+ t.Run("GetCertificate_returns_nil_for_unknown_wildcard_sibling", func(t *testing.T) {
153+ cert, err := svc.GetCertificateForHost("unknown.example.com")
154+ require.NoError(t, err)
155+ assert.Nil(t, cert, "wildcard coverage must not authorize an unconfigured sibling")
156+ })
157+
144158 // Also verify that a host not in the routes returns nil (no required ACME).
145- // "unrelated.com" is not covered by any wildcard base in requiredHosts.
159+ // "unrelated.com" is not configured as a route or management host.
146160 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
147161 cert, err := svc.GetCertificateForHost("unrelated.com")
148162 require.NoError(t, err)
@@ -29,9 +29,9 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
29 },29 },
30 }30 }
31 31
32- // Mock zone resolver returns "example.com" zone for 3 routes.32+ // Mock zone resolver returns "example.com" for routes and the management host.
33 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)33 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
34- zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)34+ zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(4)
35 35
36 // Mock issuer: returns valid stored cert for any order.36 // Mock issuer: returns valid stored cert for any order.
37 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {37 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
@@ -48,12 +48,13 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
48 }48 }
49 49
50 svc := NewService(cfg, ServiceDeps{50 svc := NewService(cfg, ServiceDeps{
51- Config: cfg,51+ Config: cfg,
52- Routes: routes,52+ Routes: routes,
53- Issuer: issuer,53+ Issuer: issuer,
54- Store: store,54+ Store: store,
55- ZoneResolver: zoneResolver,55+ ZoneResolver: zoneResolver,
56- Challenges: NewHTTP01Challenges(),56+ Challenges: NewHTTP01Challenges(),
57+ AdditionalHosts: []string{"gordon.example.com"},
57 Effective: EffectiveChallenge{58 Effective: EffectiveChallenge{
58 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,59 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
59 Mode: domain.ACMEChallengeCloudflareDNS01,60 Mode: domain.ACMEChallengeCloudflareDNS01,
@@ -141,8 +142,21 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
141 require.NotEmpty(t, cert.Certificate)142 require.NotEmpty(t, cert.Certificate)
142 })143 })
143 144
145+ t.Run("GetCertificate_returns_cert_for_management_host", func(t *testing.T) {
146+ cert, err := svc.GetCertificateForHost("gordon.example.com")
147+ require.NoError(t, err)
148+ require.NotNil(t, cert)
149+ require.NotEmpty(t, cert.Certificate)
150+ })
151+
152+ t.Run("GetCertificate_returns_nil_for_unknown_wildcard_sibling", func(t *testing.T) {
153+ cert, err := svc.GetCertificateForHost("unknown.example.com")
154+ require.NoError(t, err)
155+ assert.Nil(t, cert, "wildcard coverage must not authorize an unconfigured sibling")
156+ })
157+
144 // Also verify that a host not in the routes returns nil (no required ACME).158 // Also verify that a host not in the routes returns nil (no required ACME).
145- // "unrelated.com" is not covered by any wildcard base in requiredHosts.159+ // "unrelated.com" is not configured as a route or management host.
146 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {160 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
147 cert, err := svc.GetCertificateForHost("unrelated.com")161 cert, err := svc.GetCertificateForHost("unrelated.com")
148 require.NoError(t, err)162 require.NoError(t, err)
M internal/usecase/publictls/service.go +70 -59
@@ -23,29 +23,31 @@ type RouteSource interface {
2323
2424 // ServiceDeps contains the dependencies for the Service.
2525 type ServiceDeps struct {
26- Config Config
27- Routes RouteSource
28- Issuer out.PublicCertificateIssuer
29- Store out.CertificateStore
30- ZoneResolver out.CloudflareZoneResolver
31- Challenges *HTTP01Challenges
32- Effective EffectiveChallenge
33- Log zerowrap.Logger
26+ Config Config
27+ Routes RouteSource
28+ Issuer out.PublicCertificateIssuer
29+ Store out.CertificateStore
30+ ZoneResolver out.CloudflareZoneResolver
31+ Challenges *HTTP01Challenges
32+ Effective EffectiveChallenge
33+ AdditionalHosts []string
34+ Log zerowrap.Logger
3435 }
3536
3637 const defaultObtainBatchSize = 1
3738
3839 // Service manages public TLS certificates via ACME.
3940 type Service struct {
40- mu sync.RWMutex
41- reconcileMu sync.Mutex
42- cfg Config
43- deps ServiceDeps
44- log zerowrap.Logger
45- certs map[string]*out.StoredCertificate // indexed by cert ID
46- lastErr map[string]string // indexed by cert ID
47- routeErr map[string]string // indexed by host
48- obtainCursor int // next missing target index for batched obtains
41+ mu sync.RWMutex
42+ reconcileMu sync.Mutex
43+ cfg Config
44+ deps ServiceDeps
45+ log zerowrap.Logger
46+ certs map[string]*out.StoredCertificate // indexed by cert ID
47+ lastErr map[string]string // indexed by cert ID
48+ routeErr map[string]string // indexed by host
49+ obtainCursor int // next missing target index for batched obtains
50+ additionalHosts []string // non-route hosts requiring certificate coverage
4951
5052 // requiredHosts is the set of hosts that must be covered by ACME certs.
5153 requiredHosts map[string]struct{}
@@ -58,6 +60,7 @@ type Service struct {
5860
5961 // NewService creates a new public TLS Service.
6062 func NewService(cfg Config, deps ServiceDeps) *Service {
63+ deps.AdditionalHosts = append([]string(nil), deps.AdditionalHosts...)
6164 if deps.Challenges == nil {
6265 deps.Challenges = NewHTTP01Challenges()
6366 }
@@ -65,13 +68,14 @@ func NewService(cfg Config, deps ServiceDeps) *Service {
6568 deps.Log = zerowrap.Default()
6669 }
6770 return &Service{
68- cfg: cfg,
69- deps: deps,
70- log: deps.Log,
71- certs: make(map[string]*out.StoredCertificate),
72- lastErr: make(map[string]string),
73- routeErr: make(map[string]string),
74- requiredHosts: make(map[string]struct{}),
71+ cfg: cfg,
72+ deps: deps,
73+ log: deps.Log,
74+ certs: make(map[string]*out.StoredCertificate),
75+ lastErr: make(map[string]string),
76+ routeErr: make(map[string]string),
77+ additionalHosts: deps.AdditionalHosts,
78+ requiredHosts: make(map[string]struct{}),
7579 }
7680 }
7781
@@ -82,6 +86,9 @@ func (s *Service) Load(ctx context.Context) error {
8286 return nil
8387 }
8488
89+ s.reconcileMu.Lock()
90+ defer s.reconcileMu.Unlock()
91+
8592 stored, err := s.deps.Store.LoadAll(ctx)
8693 if err != nil {
8794 return fmt.Errorf("load stored certificates: %w", err)
@@ -95,6 +102,13 @@ func (s *Service) Load(ctx context.Context) error {
95102 state.ObtainCursor = 0
96103 }
97104
105+ required := make(map[string]struct{})
106+ if s.deps.Routes != nil {
107+ routes := s.deps.Routes.GetRoutes(ctx)
108+ external := s.deps.Routes.GetExternalRoutes()
109+ required = canonicalHostSet(routeHosts(routes, external, s.additionalHosts))
110+ }
111+
98112 s.mu.Lock()
99113 defer s.mu.Unlock()
100114
@@ -110,15 +124,36 @@ func (s *Service) Load(ctx context.Context) error {
110124 continue
111125 }
112126 s.certs[cert.ID] = cert
113- addNamesToRequiredHosts(s.requiredHosts, cert.Names)
114127 if cert.LastError != "" {
115128 s.lastErr[cert.ID] = cert.LastError
116129 }
117130 }
118131
132+ s.requiredHosts = required
133+
119134 return nil
120135 }
121136
137+// SetAdditionalHosts atomically replaces non-route hosts that require public
138+// certificate coverage and immediately revokes hosts that are no longer required.
139+func (s *Service) SetAdditionalHosts(ctx context.Context, hosts []string) {
140+ s.reconcileMu.Lock()
141+ defer s.reconcileMu.Unlock()
142+
143+ s.additionalHosts = append([]string(nil), hosts...)
144+ required := canonicalHostSet(s.additionalHosts)
145+ if s.deps.Routes != nil {
146+ routes := s.deps.Routes.GetRoutes(ctx)
147+ external := s.deps.Routes.GetExternalRoutes()
148+ required = canonicalHostSet(routeHosts(routes, external, s.additionalHosts))
149+ }
150+
151+ s.mu.Lock()
152+ s.requiredHosts = required
153+ clearRouteErrorsLocked(s.routeErr, required)
154+ s.mu.Unlock()
155+}
156+
122157 // Reconcile ensures all desired certificates are obtained and cached.
123158 // If ACME is disabled, it is a no-op.
124159 func (s *Service) Reconcile(ctx context.Context) error {
@@ -166,7 +201,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
166201 // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).
167202 routes := s.deps.Routes.GetRoutes(ctx)
168203 external := s.deps.Routes.GetExternalRoutes()
169- hosts := routeHosts(routes, external)
204+ hosts := routeHosts(routes, external, s.additionalHosts)
170205
171206 // Build required hosts set from route hosts (before target derivation).
172207 required := canonicalHostSet(hosts)
@@ -181,6 +216,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
181216 // Derive desired targets.
182217 targets, err := DeriveCertificateTargets(ctx, effective.Mode,
183218 routes, external,
219+ s.additionalHosts,
184220 s.deps.ZoneResolver,
185221 )
186222 if err != nil {
@@ -190,12 +226,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
190226 return fmt.Errorf("derive certificate targets: %w", err)
191227 }
192228
193- // Merge target names into required hosts set.
194- // For DNS-01 wildcard targets, this adds wildcard entries as well;
195- // the exact route hosts already present suffice for isRequiredHostLocked.
196- addTargetNames(required, targets)
229+ // Keep authorization limited to exact configured hosts. Wildcard names on
230+ // certificates provide coverage but must not authorize unknown sibling SNI.
197231
198- // Under mu: update requiredHosts (with target names) and compute missing targets.
232+ // Under mu: update requiredHosts and compute missing targets.
199233 s.mu.Lock()
200234 s.requiredHosts = required
201235
@@ -313,21 +347,6 @@ func canonicalHostSet(hosts []string) map[string]struct{} {
313347 return required
314348 }
315349
316-func addNamesToRequiredHosts(required map[string]struct{}, names []string) {
317- for _, name := range names {
318- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
319- if name != "" {
320- required[name] = struct{}{}
321- }
322- }
323-}
324-
325-func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
326- for _, t := range targets {
327- addNamesToRequiredHosts(required, t.Names)
328- }
329-}
330-
331350 func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) {
332351 for host := range hosts {
333352 delete(routeErr, host)
@@ -493,20 +512,12 @@ func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
493512 return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)
494513 }
495514
496-// isRequiredHostLocked checks if host or any wildcard covering it is in
497-// the required hosts set. Must be called with s.mu held.
515+// isRequiredHostLocked checks whether host is explicitly configured to require
516+// public TLS. Wildcard certificate names are coverage, not authorization.
517+// Must be called with s.mu held.
498518 func (s *Service) isRequiredHostLocked(host string) bool {
499- if _, ok := s.requiredHosts[host]; ok {
500- return true
501- }
502- // Check wildcard: split at the first dot.
503- parts := strings.SplitN(host, ".", 2)
504- if len(parts) == 2 {
505- if _, ok := s.requiredHosts["*."+parts[1]]; ok {
506- return true
507- }
508- }
509- return false
519+ _, ok := s.requiredHosts[host]
520+ return ok
510521 }
511522
512523 // GetHTTP01Challenge delegates to the HTTP-01 challenge store.
@@ -23,29 +23,31 @@ type RouteSource interface {
23 23
24 // ServiceDeps contains the dependencies for the Service.24 // ServiceDeps contains the dependencies for the Service.
25 type ServiceDeps struct {25 type ServiceDeps struct {
26- Config Config26+ Config Config
27- Routes RouteSource27+ Routes RouteSource
28- Issuer out.PublicCertificateIssuer28+ Issuer out.PublicCertificateIssuer
29- Store out.CertificateStore29+ Store out.CertificateStore
30- ZoneResolver out.CloudflareZoneResolver30+ ZoneResolver out.CloudflareZoneResolver
31- Challenges *HTTP01Challenges31+ Challenges *HTTP01Challenges
32- Effective EffectiveChallenge32+ Effective EffectiveChallenge
33- Log zerowrap.Logger33+ AdditionalHosts []string
34+ Log zerowrap.Logger
34 }35 }
35 36
36 const defaultObtainBatchSize = 137 const defaultObtainBatchSize = 1
37 38
38 // Service manages public TLS certificates via ACME.39 // Service manages public TLS certificates via ACME.
39 type Service struct {40 type Service struct {
40- mu sync.RWMutex41+ mu sync.RWMutex
41- reconcileMu sync.Mutex42+ reconcileMu sync.Mutex
42- cfg Config43+ cfg Config
43- deps ServiceDeps44+ deps ServiceDeps
44- log zerowrap.Logger45+ log zerowrap.Logger
45- certs map[string]*out.StoredCertificate // indexed by cert ID46+ certs map[string]*out.StoredCertificate // indexed by cert ID
46- lastErr map[string]string // indexed by cert ID47+ lastErr map[string]string // indexed by cert ID
47- routeErr map[string]string // indexed by host48+ routeErr map[string]string // indexed by host
48- obtainCursor int // next missing target index for batched obtains49+ obtainCursor int // next missing target index for batched obtains
50+ additionalHosts []string // non-route hosts requiring certificate coverage
49 51
50 // requiredHosts is the set of hosts that must be covered by ACME certs.52 // requiredHosts is the set of hosts that must be covered by ACME certs.
51 requiredHosts map[string]struct{}53 requiredHosts map[string]struct{}
@@ -58,6 +60,7 @@ type Service struct {
58 60
59 // NewService creates a new public TLS Service.61 // NewService creates a new public TLS Service.
60 func NewService(cfg Config, deps ServiceDeps) *Service {62 func NewService(cfg Config, deps ServiceDeps) *Service {
63+ deps.AdditionalHosts = append([]string(nil), deps.AdditionalHosts...)
61 if deps.Challenges == nil {64 if deps.Challenges == nil {
62 deps.Challenges = NewHTTP01Challenges()65 deps.Challenges = NewHTTP01Challenges()
63 }66 }
@@ -65,13 +68,14 @@ func NewService(cfg Config, deps ServiceDeps) *Service {
65 deps.Log = zerowrap.Default()68 deps.Log = zerowrap.Default()
66 }69 }
67 return &Service{70 return &Service{
68- cfg: cfg,71+ cfg: cfg,
69- deps: deps,72+ deps: deps,
70- log: deps.Log,73+ log: deps.Log,
71- certs: make(map[string]*out.StoredCertificate),74+ certs: make(map[string]*out.StoredCertificate),
72- lastErr: make(map[string]string),75+ lastErr: make(map[string]string),
73- routeErr: make(map[string]string),76+ routeErr: make(map[string]string),
74- requiredHosts: make(map[string]struct{}),77+ additionalHosts: deps.AdditionalHosts,
78+ requiredHosts: make(map[string]struct{}),
75 }79 }
76 }80 }
77 81
@@ -82,6 +86,9 @@ func (s *Service) Load(ctx context.Context) error {
82 return nil86 return nil
83 }87 }
84 88
89+ s.reconcileMu.Lock()
90+ defer s.reconcileMu.Unlock()
91+
85 stored, err := s.deps.Store.LoadAll(ctx)92 stored, err := s.deps.Store.LoadAll(ctx)
86 if err != nil {93 if err != nil {
87 return fmt.Errorf("load stored certificates: %w", err)94 return fmt.Errorf("load stored certificates: %w", err)
@@ -95,6 +102,13 @@ func (s *Service) Load(ctx context.Context) error {
95 state.ObtainCursor = 0102 state.ObtainCursor = 0
96 }103 }
97 104
105+ required := make(map[string]struct{})
106+ if s.deps.Routes != nil {
107+ routes := s.deps.Routes.GetRoutes(ctx)
108+ external := s.deps.Routes.GetExternalRoutes()
109+ required = canonicalHostSet(routeHosts(routes, external, s.additionalHosts))
110+ }
111+
98 s.mu.Lock()112 s.mu.Lock()
99 defer s.mu.Unlock()113 defer s.mu.Unlock()
100 114
@@ -110,15 +124,36 @@ func (s *Service) Load(ctx context.Context) error {
110 continue124 continue
111 }125 }
112 s.certs[cert.ID] = cert126 s.certs[cert.ID] = cert
113- addNamesToRequiredHosts(s.requiredHosts, cert.Names)
114 if cert.LastError != "" {127 if cert.LastError != "" {
115 s.lastErr[cert.ID] = cert.LastError128 s.lastErr[cert.ID] = cert.LastError
116 }129 }
117 }130 }
118 131
132+ s.requiredHosts = required
133+
119 return nil134 return nil
120 }135 }
121 136
137+// SetAdditionalHosts atomically replaces non-route hosts that require public
138+// certificate coverage and immediately revokes hosts that are no longer required.
139+func (s *Service) SetAdditionalHosts(ctx context.Context, hosts []string) {
140+ s.reconcileMu.Lock()
141+ defer s.reconcileMu.Unlock()
142+
143+ s.additionalHosts = append([]string(nil), hosts...)
144+ required := canonicalHostSet(s.additionalHosts)
145+ if s.deps.Routes != nil {
146+ routes := s.deps.Routes.GetRoutes(ctx)
147+ external := s.deps.Routes.GetExternalRoutes()
148+ required = canonicalHostSet(routeHosts(routes, external, s.additionalHosts))
149+ }
150+
151+ s.mu.Lock()
152+ s.requiredHosts = required
153+ clearRouteErrorsLocked(s.routeErr, required)
154+ s.mu.Unlock()
155+}
156+
122 // Reconcile ensures all desired certificates are obtained and cached.157 // Reconcile ensures all desired certificates are obtained and cached.
123 // If ACME is disabled, it is a no-op.158 // If ACME is disabled, it is a no-op.
124 func (s *Service) Reconcile(ctx context.Context) error {159 func (s *Service) Reconcile(ctx context.Context) error {
@@ -166,7 +201,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
166 // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).201 // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver).
167 routes := s.deps.Routes.GetRoutes(ctx)202 routes := s.deps.Routes.GetRoutes(ctx)
168 external := s.deps.Routes.GetExternalRoutes()203 external := s.deps.Routes.GetExternalRoutes()
169- hosts := routeHosts(routes, external)204+ hosts := routeHosts(routes, external, s.additionalHosts)
170 205
171 // Build required hosts set from route hosts (before target derivation).206 // Build required hosts set from route hosts (before target derivation).
172 required := canonicalHostSet(hosts)207 required := canonicalHostSet(hosts)
@@ -181,6 +216,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
181 // Derive desired targets.216 // Derive desired targets.
182 targets, err := DeriveCertificateTargets(ctx, effective.Mode,217 targets, err := DeriveCertificateTargets(ctx, effective.Mode,
183 routes, external,218 routes, external,
219+ s.additionalHosts,
184 s.deps.ZoneResolver,220 s.deps.ZoneResolver,
185 )221 )
186 if err != nil {222 if err != nil {
@@ -190,12 +226,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
190 return fmt.Errorf("derive certificate targets: %w", err)226 return fmt.Errorf("derive certificate targets: %w", err)
191 }227 }
192 228
193- // Merge target names into required hosts set.229+ // Keep authorization limited to exact configured hosts. Wildcard names on
194- // For DNS-01 wildcard targets, this adds wildcard entries as well;230+ // certificates provide coverage but must not authorize unknown sibling SNI.
195- // the exact route hosts already present suffice for isRequiredHostLocked.
196- addTargetNames(required, targets)
197 231
198- // Under mu: update requiredHosts (with target names) and compute missing targets.232+ // Under mu: update requiredHosts and compute missing targets.
199 s.mu.Lock()233 s.mu.Lock()
200 s.requiredHosts = required234 s.requiredHosts = required
201 235
@@ -313,21 +347,6 @@ func canonicalHostSet(hosts []string) map[string]struct{} {
313 return required347 return required
314 }348 }
315 349
316-func addNamesToRequiredHosts(required map[string]struct{}, names []string) {
317- for _, name := range names {
318- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
319- if name != "" {
320- required[name] = struct{}{}
321- }
322- }
323-}
324-
325-func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
326- for _, t := range targets {
327- addNamesToRequiredHosts(required, t.Names)
328- }
329-}
330-
331 func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) {350 func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) {
332 for host := range hosts {351 for host := range hosts {
333 delete(routeErr, host)352 delete(routeErr, host)
@@ -493,20 +512,12 @@ func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
493 return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)512 return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host)
494 }513 }
495 514
496-// isRequiredHostLocked checks if host or any wildcard covering it is in515+// isRequiredHostLocked checks whether host is explicitly configured to require
497-// the required hosts set. Must be called with s.mu held.516+// public TLS. Wildcard certificate names are coverage, not authorization.
517+// Must be called with s.mu held.
498 func (s *Service) isRequiredHostLocked(host string) bool {518 func (s *Service) isRequiredHostLocked(host string) bool {
499- if _, ok := s.requiredHosts[host]; ok {519+ _, ok := s.requiredHosts[host]
500- return true520+ return ok
501- }
502- // Check wildcard: split at the first dot.
503- parts := strings.SplitN(host, ".", 2)
504- if len(parts) == 2 {
505- if _, ok := s.requiredHosts["*."+parts[1]]; ok {
506- return true
507- }
508- }
509- return false
510 }521 }
511 522
512 // GetHTTP01Challenge delegates to the HTTP-01 challenge store.523 // GetHTTP01Challenge delegates to the HTTP-01 challenge store.
M internal/usecase/publictls/service_test.go +67 -2
@@ -348,6 +348,71 @@ func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
348348 assert.Len(t, storeState.All(), 3)
349349 }
350350
351+func TestServiceSetAdditionalHosts_ImmediatelyRevokesRemovedManagementDomain(t *testing.T) {
352+ ctx := t.Context()
353+ routes := outmocks.NewMockRouteChecker(t)
354+ routes.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe()
355+ routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
356+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
357+ store, _ := newMockCertificateStore(t)
358+
359+ svc := NewService(Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}, ServiceDeps{
360+ Routes: routes,
361+ Issuer: issuer,
362+ Store: store,
363+ AdditionalHosts: []string{"old.example.com"},
364+ Effective: EffectiveChallenge{
365+ ConfiguredMode: domain.ACMEChallengeHTTP01,
366+ Mode: domain.ACMEChallengeHTTP01,
367+ },
368+ })
369+
370+ require.NoError(t, svc.Load(ctx))
371+ require.NoError(t, svc.Reconcile(ctx))
372+ cert, err := svc.GetCertificateForHost("old.example.com")
373+ require.NoError(t, err)
374+ require.NotNil(t, cert)
375+
376+ svc.SetAdditionalHosts(ctx, []string{"new.example.com"})
377+
378+ cert, err = svc.GetCertificateForHost("old.example.com")
379+ require.NoError(t, err)
380+ assert.Nil(t, cert)
381+}
382+
383+func TestServiceSetAdditionalHosts_ReconcileReplacesManagementDomain(t *testing.T) {
384+ ctx := context.Background()
385+ routes := &fakeRoutes{}
386+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
387+ store, _ := newMockCertificateStore(t)
388+
389+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}
390+ svc := NewService(cfg, ServiceDeps{
391+ Routes: routes,
392+ Issuer: issuer,
393+ Store: store,
394+ AdditionalHosts: []string{"old.example.com"},
395+ Effective: EffectiveChallenge{
396+ ConfiguredMode: domain.ACMEChallengeHTTP01,
397+ Mode: domain.ACMEChallengeHTTP01,
398+ },
399+ })
400+
401+ require.NoError(t, svc.Load(ctx))
402+ require.NoError(t, svc.Reconcile(ctx))
403+ svc.SetAdditionalHosts(ctx, []string{"NEW.EXAMPLE.COM."})
404+ require.NoError(t, svc.Reconcile(ctx))
405+
406+ orders := recorder.Orders()
407+ require.Len(t, orders, 2)
408+ assert.Equal(t, "http01-old.example.com", orders[0].ID)
409+ assert.Equal(t, "http01-new.example.com", orders[1].ID)
410+
411+ cert, err := svc.GetCertificateForHost("old.example.com")
412+ require.NoError(t, err)
413+ assert.Nil(t, cert)
414+}
415+
351416 func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
352417 ctx := context.Background()
353418
@@ -895,7 +960,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
895960 // Certificate field is zero — will be populated by Load.
896961 })
897962
898- routes := &fakeRoutes{}
963+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "test.example.com"}}}
899964 cfg := Config{Enabled: true}
900965
901966 svc := NewService(cfg, ServiceDeps{
@@ -921,7 +986,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
921986
922987 served, err := svc.GetCertificateForHost("test.example.com")
923988 require.NoError(t, err)
924- require.NotNil(t, served, "loaded certificates should seed required hosts")
989+ require.NotNil(t, served, "loaded certificate should cover the configured host")
925990 }
926991
927992 func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
@@ -348,6 +348,71 @@ func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
348 assert.Len(t, storeState.All(), 3)348 assert.Len(t, storeState.All(), 3)
349 }349 }
350 350
351+func TestServiceSetAdditionalHosts_ImmediatelyRevokesRemovedManagementDomain(t *testing.T) {
352+ ctx := t.Context()
353+ routes := outmocks.NewMockRouteChecker(t)
354+ routes.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe()
355+ routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
356+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
357+ store, _ := newMockCertificateStore(t)
358+
359+ svc := NewService(Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}, ServiceDeps{
360+ Routes: routes,
361+ Issuer: issuer,
362+ Store: store,
363+ AdditionalHosts: []string{"old.example.com"},
364+ Effective: EffectiveChallenge{
365+ ConfiguredMode: domain.ACMEChallengeHTTP01,
366+ Mode: domain.ACMEChallengeHTTP01,
367+ },
368+ })
369+
370+ require.NoError(t, svc.Load(ctx))
371+ require.NoError(t, svc.Reconcile(ctx))
372+ cert, err := svc.GetCertificateForHost("old.example.com")
373+ require.NoError(t, err)
374+ require.NotNil(t, cert)
375+
376+ svc.SetAdditionalHosts(ctx, []string{"new.example.com"})
377+
378+ cert, err = svc.GetCertificateForHost("old.example.com")
379+ require.NoError(t, err)
380+ assert.Nil(t, cert)
381+}
382+
383+func TestServiceSetAdditionalHosts_ReconcileReplacesManagementDomain(t *testing.T) {
384+ ctx := context.Background()
385+ routes := &fakeRoutes{}
386+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
387+ store, _ := newMockCertificateStore(t)
388+
389+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}
390+ svc := NewService(cfg, ServiceDeps{
391+ Routes: routes,
392+ Issuer: issuer,
393+ Store: store,
394+ AdditionalHosts: []string{"old.example.com"},
395+ Effective: EffectiveChallenge{
396+ ConfiguredMode: domain.ACMEChallengeHTTP01,
397+ Mode: domain.ACMEChallengeHTTP01,
398+ },
399+ })
400+
401+ require.NoError(t, svc.Load(ctx))
402+ require.NoError(t, svc.Reconcile(ctx))
403+ svc.SetAdditionalHosts(ctx, []string{"NEW.EXAMPLE.COM."})
404+ require.NoError(t, svc.Reconcile(ctx))
405+
406+ orders := recorder.Orders()
407+ require.Len(t, orders, 2)
408+ assert.Equal(t, "http01-old.example.com", orders[0].ID)
409+ assert.Equal(t, "http01-new.example.com", orders[1].ID)
410+
411+ cert, err := svc.GetCertificateForHost("old.example.com")
412+ require.NoError(t, err)
413+ assert.Nil(t, cert)
414+}
415+
351 func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {416 func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
352 ctx := context.Background()417 ctx := context.Background()
353 418
@@ -895,7 +960,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
895 // Certificate field is zero — will be populated by Load.960 // Certificate field is zero — will be populated by Load.
896 })961 })
897 962
898- routes := &fakeRoutes{}963+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "test.example.com"}}}
899 cfg := Config{Enabled: true}964 cfg := Config{Enabled: true}
900 965
901 svc := NewService(cfg, ServiceDeps{966 svc := NewService(cfg, ServiceDeps{
@@ -921,7 +986,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
921 986
922 served, err := svc.GetCertificateForHost("test.example.com")987 served, err := svc.GetCertificateForHost("test.example.com")
923 require.NoError(t, err)988 require.NoError(t, err)
924- require.NotNil(t, served, "loaded certificates should seed required hosts")989+ require.NotNil(t, served, "loaded certificate should cover the configured host")
925 }990 }
926 991
927 func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {992 func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
M internal/usecase/publictls/targets.go +6 -2
@@ -24,9 +24,10 @@ func DeriveCertificateTargets(
2424 mode domain.ACMEChallengeMode,
2525 routes []domain.Route,
2626 external map[string]string,
27+ additionalHosts []string,
2728 resolver out.CloudflareZoneResolver,
2829 ) ([]CertificateTarget, error) {
29- hosts := routeHosts(routes, external)
30+ hosts := routeHosts(routes, external, additionalHosts)
3031
3132 switch mode {
3233 case domain.ACMEChallengeHTTP01:
@@ -40,7 +41,7 @@ func DeriveCertificateTargets(
4041
4142 // routeHosts collects all unique canonical hosts from routes and external keys,
4243 // sorted alphabetically. Trailing dots are stripped before canonicalization.
43-func routeHosts(routes []domain.Route, external map[string]string) []string {
44+func routeHosts(routes []domain.Route, external map[string]string, additionalHosts []string) []string {
4445 seen := make(map[string]struct{})
4546 var hosts []string
4647
@@ -62,6 +63,9 @@ func routeHosts(routes []domain.Route, external map[string]string) []string {
6263 for h := range external {
6364 addHost(h)
6465 }
66+ for _, h := range additionalHosts {
67+ addHost(h)
68+ }
6569
6670 sort.Strings(hosts)
6771 return hosts
@@ -24,9 +24,10 @@ func DeriveCertificateTargets(
24 mode domain.ACMEChallengeMode,24 mode domain.ACMEChallengeMode,
25 routes []domain.Route,25 routes []domain.Route,
26 external map[string]string,26 external map[string]string,
27+ additionalHosts []string,
27 resolver out.CloudflareZoneResolver,28 resolver out.CloudflareZoneResolver,
28 ) ([]CertificateTarget, error) {29 ) ([]CertificateTarget, error) {
29- hosts := routeHosts(routes, external)30+ hosts := routeHosts(routes, external, additionalHosts)
30 31
31 switch mode {32 switch mode {
32 case domain.ACMEChallengeHTTP01:33 case domain.ACMEChallengeHTTP01:
@@ -40,7 +41,7 @@ func DeriveCertificateTargets(
40 41
41 // routeHosts collects all unique canonical hosts from routes and external keys,42 // routeHosts collects all unique canonical hosts from routes and external keys,
42 // sorted alphabetically. Trailing dots are stripped before canonicalization.43 // sorted alphabetically. Trailing dots are stripped before canonicalization.
43-func routeHosts(routes []domain.Route, external map[string]string) []string {44+func routeHosts(routes []domain.Route, external map[string]string, additionalHosts []string) []string {
44 seen := make(map[string]struct{})45 seen := make(map[string]struct{})
45 var hosts []string46 var hosts []string
46 47
@@ -62,6 +63,9 @@ func routeHosts(routes []domain.Route, external map[string]string) []string {
62 for h := range external {63 for h := range external {
63 addHost(h)64 addHost(h)
64 }65 }
66+ for _, h := range additionalHosts {
67+ addHost(h)
68+ }
65 69
66 sort.Strings(hosts)70 sort.Strings(hosts)
67 return hosts71 return hosts
M internal/usecase/publictls/targets_test.go +21 -6
@@ -15,7 +15,7 @@ import (
1515
1616 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
1717 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}
18- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
18+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil, nil)
1919 require.NoError(t, err)
2020 require.Len(t, targets, 2)
2121
@@ -31,7 +31,7 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
3131 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}
3232 resolver := outmocks.NewMockCloudflareZoneResolver(t)
3333 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)
34- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
34+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, resolver)
3535 require.NoError(t, err)
3636 require.Len(t, targets, 2)
3737
@@ -43,10 +43,25 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
4343 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
4444 }
4545
46+func TestDeriveTargets_IncludesAdditionalHosts(t *testing.T) {
47+ targets, err := DeriveCertificateTargets(
48+ context.Background(),
49+ domain.ACMEChallengeHTTP01,
50+ nil,
51+ nil,
52+ []string{"gordon.example.com"},
53+ nil,
54+ )
55+ require.NoError(t, err)
56+ require.Len(t, targets, 1)
57+ assert.Equal(t, "http01-gordon.example.com", targets[0].ID)
58+ assert.Equal(t, []string{"gordon.example.com"}, targets[0].Names)
59+}
60+
4661 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
4762 routes := []domain.Route{{Domain: "app.example.com"}}
4863 external := map[string]string{"external.example.com": "127.0.0.1:8080"}
49- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil)
64+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil, nil)
5065 require.NoError(t, err)
5166 require.Len(t, targets, 2)
5267
@@ -57,7 +72,7 @@ func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
5772
5873 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
5974 routes := []domain.Route{{Domain: "app.example.com"}}
60- _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil)
75+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, nil)
6176 require.Error(t, err)
6277 assert.Contains(t, err.Error(), "resolver is nil")
6378 }
@@ -66,14 +81,14 @@ func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) {
6681 routes := []domain.Route{{Domain: "app.example.com"}}
6782 resolver := outmocks.NewMockCloudflareZoneResolver(t)
6883 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "other.test"}, nil).Once()
69- _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
84+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, resolver)
7085 require.Error(t, err)
7186 assert.Contains(t, err.Error(), "does not match host")
7287 }
7388
7489 func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
7590 routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}
76- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
91+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil, nil)
7792 require.NoError(t, err)
7893 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
7994 assert.Equal(t, "http01-app.example.com", targets[0].ID)
@@ -15,7 +15,7 @@ import (
15 15
16 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {16 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
17 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}17 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}
18- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)18+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil, nil)
19 require.NoError(t, err)19 require.NoError(t, err)
20 require.Len(t, targets, 2)20 require.Len(t, targets, 2)
21 21
@@ -31,7 +31,7 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
31 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}31 routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}
32 resolver := outmocks.NewMockCloudflareZoneResolver(t)32 resolver := outmocks.NewMockCloudflareZoneResolver(t)
33 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)33 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3)
34- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)34+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, resolver)
35 require.NoError(t, err)35 require.NoError(t, err)
36 require.Len(t, targets, 2)36 require.Len(t, targets, 2)
37 37
@@ -43,10 +43,25 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
43 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)43 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
44 }44 }
45 45
46+func TestDeriveTargets_IncludesAdditionalHosts(t *testing.T) {
47+ targets, err := DeriveCertificateTargets(
48+ context.Background(),
49+ domain.ACMEChallengeHTTP01,
50+ nil,
51+ nil,
52+ []string{"gordon.example.com"},
53+ nil,
54+ )
55+ require.NoError(t, err)
56+ require.Len(t, targets, 1)
57+ assert.Equal(t, "http01-gordon.example.com", targets[0].ID)
58+ assert.Equal(t, []string{"gordon.example.com"}, targets[0].Names)
59+}
60+
46 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {61 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
47 routes := []domain.Route{{Domain: "app.example.com"}}62 routes := []domain.Route{{Domain: "app.example.com"}}
48 external := map[string]string{"external.example.com": "127.0.0.1:8080"}63 external := map[string]string{"external.example.com": "127.0.0.1:8080"}
49- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil)64+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil, nil)
50 require.NoError(t, err)65 require.NoError(t, err)
51 require.Len(t, targets, 2)66 require.Len(t, targets, 2)
52 67
@@ -57,7 +72,7 @@ func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
57 72
58 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {73 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
59 routes := []domain.Route{{Domain: "app.example.com"}}74 routes := []domain.Route{{Domain: "app.example.com"}}
60- _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil)75+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, nil)
61 require.Error(t, err)76 require.Error(t, err)
62 assert.Contains(t, err.Error(), "resolver is nil")77 assert.Contains(t, err.Error(), "resolver is nil")
63 }78 }
@@ -66,14 +81,14 @@ func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) {
66 routes := []domain.Route{{Domain: "app.example.com"}}81 routes := []domain.Route{{Domain: "app.example.com"}}
67 resolver := outmocks.NewMockCloudflareZoneResolver(t)82 resolver := outmocks.NewMockCloudflareZoneResolver(t)
68 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "other.test"}, nil).Once()83 resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "other.test"}, nil).Once()
69- _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)84+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil, resolver)
70 require.Error(t, err)85 require.Error(t, err)
71 assert.Contains(t, err.Error(), "does not match host")86 assert.Contains(t, err.Error(), "does not match host")
72 }87 }
73 88
74 func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {89 func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
75 routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}90 routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}
76- targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)91+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil, nil)
77 require.NoError(t, err)92 require.NoError(t, err)
78 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")93 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
79 assert.Equal(t, "http01-app.example.com", targets[0].ID)94 assert.Equal(t, "http01-app.example.com", targets[0].ID)

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