detached 5e22cc2

fix(tls): cover Gordon management domain

commit
5e22cc2551856929efbc78e7075303f6cfaeb38e
author
brice · · 2026-07-16 06:31 UTC
committer
brice · · 2026-07-16 09:45 UTC
parent
8d342c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +545 -162
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
M internal/usecase/pki/service.go +116 -56
@@ -4,6 +4,7 @@ import (
44 "context"
55 "crypto/tls"
66 "crypto/x509"
7+ "strings"
78 "sync"
89 "time"
910
@@ -11,6 +12,7 @@ import (
1112 "golang.org/x/sync/singleflight"
1213
1314 "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 const (
@@ -29,9 +31,11 @@ type cachedCert struct {
2931 // TODO: consider adding CRL/OCSP support for certificate revocation,
3032 // especially given the 10-year root CA lifetime.
3133 type Service struct {
32- ca out.CertificateAuthority
33- routes out.RouteChecker
34- log zerowrap.Logger
34+ ca out.CertificateAuthority
35+ routes out.RouteChecker
36+ allowedMu sync.RWMutex
37+ allowedDomains map[string]struct{}
38+ log zerowrap.Logger
3539
3640 cache sync.Map // map[string]*cachedCert
3741 flight singleflight.Group
@@ -42,14 +46,16 @@ type Service struct {
4246 // NewService creates a PKI service and starts background maintenance goroutines.
4347 // It performs an initial intermediate renewal check synchronously so the first
4448 // TLS handshakes never use a nearly-expired intermediate.
45-func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {
49+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, allowedDomains []string, log zerowrap.Logger) *Service {
4650 ctx, cancel := context.WithCancel(ctx)
51+ allowed := canonicalDomainSet(allowedDomains)
4752 svc := &Service{
48- ca: ca,
49- routes: routes,
50- log: log,
51- cancel: cancel,
52- done: make(chan struct{}),
53+ ca: ca,
54+ routes: routes,
55+ allowedDomains: allowed,
56+ log: log,
57+ cancel: cancel,
58+ done: make(chan struct{}),
5359 }
5460 svc.renewIntermediateIfNeeded()
5561 go svc.maintenanceLoop(ctx)
@@ -76,80 +82,129 @@ func (s *Service) CachedCertCount() int {
7682 // It returns (nil, nil) for unknown domains so Go's TLS stack
7783 // falls back to tls.Config.Certificates.
7884 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
79- domain := hello.ServerName
80- if domain == "" {
85+ domainName := canonicalServerName(hello.ServerName)
86+ if domainName == "" {
8187 return nil, nil
8288 }
8389
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- }
90+ if cert := s.validCachedCertificate(domainName); cert != nil {
91+ if s.isDomainAllowed(hello.Context(), domainName) {
92+ return cert, nil
9393 }
94- s.cache.Delete(domain)
94+ s.cache.Delete(domainName)
95+ return nil, nil
9596 }
96-
97- if !s.isDomainAllowed(hello.Context(), domain) {
97+ if !s.isDomainAllowed(hello.Context(), domainName) {
9898 return nil, nil
9999 }
100100
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- }
101+ result, err, _ := s.flight.Do(domainName, func() (any, error) {
102+ return s.issueCertificate(hello.Context(), domainName)
103+ })
104+ if err != nil {
105+ return nil, err
106+ }
107+ if result == nil || !s.isDomainAllowed(hello.Context(), domainName) {
108+ s.cache.Delete(domainName)
109+ return nil, nil
110+ }
111+ return result.(*tls.Certificate), nil
112+}
111113
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- }
114+func canonicalServerName(serverName string) string {
115+ return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
116+}
117117
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- })
118+func (s *Service) validCachedCertificate(domainName string) *tls.Certificate {
119+ entry, ok := s.cache.Load(domainName)
120+ if !ok {
121+ return nil
122+ }
123+ cached, ok := entry.(*cachedCert)
124+ if !ok || !time.Now().Before(cached.expiresAt) {
125+ s.cache.Delete(domainName)
126+ return nil
127+ }
128+ return cached.cert
129+}
128130
129- s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")
131+func (s *Service) issueCertificate(ctx context.Context, domainName string) (*tls.Certificate, error) {
132+ if !s.isDomainAllowed(ctx, domainName) {
133+ return nil, nil
134+ }
135+ if cert := s.validCachedCertificate(domainName); cert != nil {
130136 return cert, nil
131- })
137+ }
138+
139+ cert, err := s.ca.IssueCertificate(domainName)
132140 if err != nil {
141+ s.log.Error().Err(err).Str("domain", domainName).Msg("failed to issue leaf cert")
133142 return nil, err
134143 }
135- if result == nil {
144+ if !s.isDomainAllowed(ctx, domainName) {
136145 return nil, nil
137146 }
138- return result.(*tls.Certificate), nil
147+
148+ s.cache.Store(domainName, &cachedCert{cert: cert, expiresAt: certificateExpiry(cert, s.leafLifetime())})
149+ s.log.Debug().Str("domain", domainName).Msg("issued new leaf certificate")
150+ return cert, nil
151+}
152+
153+func certificateExpiry(cert *tls.Certificate, fallbackLifetime time.Duration) time.Time {
154+ if len(cert.Certificate) > 0 {
155+ if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
156+ return leaf.NotAfter
157+ }
158+ }
159+ return time.Now().Add(fallbackLifetime)
160+}
161+
162+// SetAdditionalDomains atomically replaces non-route domains authorized for
163+// on-demand certificates and evicts certificates that are no longer allowed.
164+func (s *Service) SetAdditionalDomains(domains []string) {
165+ next := canonicalDomainSet(domains)
166+
167+ s.allowedMu.Lock()
168+ previous := s.allowedDomains
169+ s.allowedDomains = next
170+ s.allowedMu.Unlock()
171+
172+ for name := range previous {
173+ if _, ok := next[name]; !ok {
174+ s.cache.Delete(name)
175+ }
176+ }
139177 }
140178
141-func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {
179+func (s *Service) isDomainAllowed(ctx context.Context, domainName string) bool {
180+ s.allowedMu.RLock()
181+ _, additionallyAllowed := s.allowedDomains[domainName]
182+ s.allowedMu.RUnlock()
183+ if additionallyAllowed {
184+ return true
185+ }
142186 for _, r := range s.routes.GetRoutes(ctx) {
143- if r.Domain == domain {
187+ if r.Domain == domainName {
144188 return true
145189 }
146190 }
147- if _, ok := s.routes.GetExternalRoutes()[domain]; ok {
191+ if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
148192 return true
149193 }
150194 return false
151195 }
152196
197+func canonicalDomainSet(domains []string) map[string]struct{} {
198+ canonical := make(map[string]struct{}, len(domains))
199+ for _, name := range domains {
200+ name = strings.TrimSuffix(strings.TrimSpace(name), ".")
201+ if name, ok := domain.CanonicalRouteDomain(name); ok {
202+ canonical[name] = struct{}{}
203+ }
204+ }
205+ return canonical
206+}
207+
153208 func (s *Service) leafLifetime() time.Duration {
154209 return s.ca.LeafLifetime()
155210 }
@@ -190,7 +245,12 @@ func (s *Service) sweepExpiredCerts(ctx context.Context) {
190245 routes := s.routes.GetRoutes(ctx)
191246 extRoutes := s.routes.GetExternalRoutes()
192247
193- allowed := make(map[string]struct{}, len(routes)+len(extRoutes))
248+ s.allowedMu.RLock()
249+ allowed := make(map[string]struct{}, len(routes)+len(extRoutes)+len(s.allowedDomains))
250+ for domain := range s.allowedDomains {
251+ allowed[domain] = struct{}{}
252+ }
253+ s.allowedMu.RUnlock()
194254 for _, r := range routes {
195255 allowed[r.Domain] = struct{}{}
196256 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6 "crypto/x509"6 "crypto/x509"
7+ "strings"
7 "sync"8 "sync"
8 "time"9 "time"
9 10
@@ -11,6 +12,7 @@ import (
11 "golang.org/x/sync/singleflight"12 "golang.org/x/sync/singleflight"
12 13
13 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 const (18 const (
@@ -29,9 +31,11 @@ type cachedCert struct {
29 // TODO: consider adding CRL/OCSP support for certificate revocation,31 // TODO: consider adding CRL/OCSP support for certificate revocation,
30 // especially given the 10-year root CA lifetime.32 // especially given the 10-year root CA lifetime.
31 type Service struct {33 type Service struct {
32- ca out.CertificateAuthority34+ ca out.CertificateAuthority
33- routes out.RouteChecker35+ routes out.RouteChecker
34- log zerowrap.Logger36+ allowedMu sync.RWMutex
37+ allowedDomains map[string]struct{}
38+ log zerowrap.Logger
35 39
36 cache sync.Map // map[string]*cachedCert40 cache sync.Map // map[string]*cachedCert
37 flight singleflight.Group41 flight singleflight.Group
@@ -42,14 +46,16 @@ type Service struct {
42 // NewService creates a PKI service and starts background maintenance goroutines.46 // NewService creates a PKI service and starts background maintenance goroutines.
43 // It performs an initial intermediate renewal check synchronously so the first47 // It performs an initial intermediate renewal check synchronously so the first
44 // TLS handshakes never use a nearly-expired intermediate.48 // TLS handshakes never use a nearly-expired intermediate.
45-func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, log zerowrap.Logger) *Service {49+func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.RouteChecker, allowedDomains []string, log zerowrap.Logger) *Service {
46 ctx, cancel := context.WithCancel(ctx)50 ctx, cancel := context.WithCancel(ctx)
51+ allowed := canonicalDomainSet(allowedDomains)
47 svc := &Service{52 svc := &Service{
48- ca: ca,53+ ca: ca,
49- routes: routes,54+ routes: routes,
50- log: log,55+ allowedDomains: allowed,
51- cancel: cancel,56+ log: log,
52- done: make(chan struct{}),57+ cancel: cancel,
58+ done: make(chan struct{}),
53 }59 }
54 svc.renewIntermediateIfNeeded()60 svc.renewIntermediateIfNeeded()
55 go svc.maintenanceLoop(ctx)61 go svc.maintenanceLoop(ctx)
@@ -76,80 +82,129 @@ func (s *Service) CachedCertCount() int {
76 // It returns (nil, nil) for unknown domains so Go's TLS stack82 // It returns (nil, nil) for unknown domains so Go's TLS stack
77 // falls back to tls.Config.Certificates.83 // falls back to tls.Config.Certificates.
78 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {84 func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
79- domain := hello.ServerName85+ domainName := canonicalServerName(hello.ServerName)
80- if domain == "" {86+ if domainName == "" {
81 return nil, nil87 return nil, nil
82 }88 }
83 89
84- if entry, ok := s.cache.Load(domain); ok {90+ if cert := s.validCachedCertificate(domainName); cert != nil {
85- if cached, ok := entry.(*cachedCert); ok {91+ if s.isDomainAllowed(hello.Context(), domainName) {
86- if time.Now().Before(cached.expiresAt) {92+ 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 }93 }
94- s.cache.Delete(domain)94+ s.cache.Delete(domainName)
95+ return nil, nil
95 }96 }
96-97+ if !s.isDomainAllowed(hello.Context(), domainName) {
97- if !s.isDomainAllowed(hello.Context(), domain) {
98 return nil, nil98 return nil, nil
99 }99 }
100 100
101- result, err, _ := s.flight.Do(domain, func() (any, error) {101+ result, err, _ := s.flight.Do(domainName, func() (any, error) {
102- // Re-check cache; another goroutine in the same flight may have populated it.102+ return s.issueCertificate(hello.Context(), domainName)
103- if entry, ok := s.cache.Load(domain); ok {103+ })
104- if cached, ok := entry.(*cachedCert); ok {104+ if err != nil {
105- if time.Now().Before(cached.expiresAt) {105+ return nil, err
106- return cached.cert, nil106+ }
107- }107+ if result == nil || !s.isDomainAllowed(hello.Context(), domainName) {
108- }108+ s.cache.Delete(domainName)
109- s.cache.Delete(domain)109+ return nil, nil
110- }110+ }
111+ return result.(*tls.Certificate), nil
112+}
111 113
112- cert, err := s.ca.IssueCertificate(domain)114+func canonicalServerName(serverName string) string {
113- if err != nil {115+ return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
114- s.log.Error().Err(err).Str("domain", domain).Msg("failed to issue leaf cert")116+}
115- return nil, err
116- }
117 117
118- expiresAt := time.Now().Add(s.leafLifetime())118+func (s *Service) validCachedCertificate(domainName string) *tls.Certificate {
119- if len(cert.Certificate) > 0 {119+ entry, ok := s.cache.Load(domainName)
120- if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {120+ if !ok {
121- expiresAt = leaf.NotAfter121+ return nil
122- }122+ }
123- }123+ cached, ok := entry.(*cachedCert)
124- s.cache.Store(domain, &cachedCert{124+ if !ok || !time.Now().Before(cached.expiresAt) {
125- cert: cert,125+ s.cache.Delete(domainName)
126- expiresAt: expiresAt,126+ return nil
127- })127+ }
128+ return cached.cert
129+}
128 130
129- s.log.Debug().Str("domain", domain).Msg("issued new leaf certificate")131+func (s *Service) issueCertificate(ctx context.Context, domainName string) (*tls.Certificate, error) {
132+ if !s.isDomainAllowed(ctx, domainName) {
133+ return nil, nil
134+ }
135+ if cert := s.validCachedCertificate(domainName); cert != nil {
130 return cert, nil136 return cert, nil
131- })137+ }
138+
139+ cert, err := s.ca.IssueCertificate(domainName)
132 if err != nil {140 if err != nil {
141+ s.log.Error().Err(err).Str("domain", domainName).Msg("failed to issue leaf cert")
133 return nil, err142 return nil, err
134 }143 }
135- if result == nil {144+ if !s.isDomainAllowed(ctx, domainName) {
136 return nil, nil145 return nil, nil
137 }146 }
138- return result.(*tls.Certificate), nil147+
148+ s.cache.Store(domainName, &cachedCert{cert: cert, expiresAt: certificateExpiry(cert, s.leafLifetime())})
149+ s.log.Debug().Str("domain", domainName).Msg("issued new leaf certificate")
150+ return cert, nil
151+}
152+
153+func certificateExpiry(cert *tls.Certificate, fallbackLifetime time.Duration) time.Time {
154+ if len(cert.Certificate) > 0 {
155+ if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
156+ return leaf.NotAfter
157+ }
158+ }
159+ return time.Now().Add(fallbackLifetime)
160+}
161+
162+// SetAdditionalDomains atomically replaces non-route domains authorized for
163+// on-demand certificates and evicts certificates that are no longer allowed.
164+func (s *Service) SetAdditionalDomains(domains []string) {
165+ next := canonicalDomainSet(domains)
166+
167+ s.allowedMu.Lock()
168+ previous := s.allowedDomains
169+ s.allowedDomains = next
170+ s.allowedMu.Unlock()
171+
172+ for name := range previous {
173+ if _, ok := next[name]; !ok {
174+ s.cache.Delete(name)
175+ }
176+ }
139 }177 }
140 178
141-func (s *Service) isDomainAllowed(ctx context.Context, domain string) bool {179+func (s *Service) isDomainAllowed(ctx context.Context, domainName string) bool {
180+ s.allowedMu.RLock()
181+ _, additionallyAllowed := s.allowedDomains[domainName]
182+ s.allowedMu.RUnlock()
183+ if additionallyAllowed {
184+ return true
185+ }
142 for _, r := range s.routes.GetRoutes(ctx) {186 for _, r := range s.routes.GetRoutes(ctx) {
143- if r.Domain == domain {187+ if r.Domain == domainName {
144 return true188 return true
145 }189 }
146 }190 }
147- if _, ok := s.routes.GetExternalRoutes()[domain]; ok {191+ if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
148 return true192 return true
149 }193 }
150 return false194 return false
151 }195 }
152 196
197+func canonicalDomainSet(domains []string) map[string]struct{} {
198+ canonical := make(map[string]struct{}, len(domains))
199+ for _, name := range domains {
200+ name = strings.TrimSuffix(strings.TrimSpace(name), ".")
201+ if name, ok := domain.CanonicalRouteDomain(name); ok {
202+ canonical[name] = struct{}{}
203+ }
204+ }
205+ return canonical
206+}
207+
153 func (s *Service) leafLifetime() time.Duration {208 func (s *Service) leafLifetime() time.Duration {
154 return s.ca.LeafLifetime()209 return s.ca.LeafLifetime()
155 }210 }
@@ -190,7 +245,12 @@ func (s *Service) sweepExpiredCerts(ctx context.Context) {
190 routes := s.routes.GetRoutes(ctx)245 routes := s.routes.GetRoutes(ctx)
191 extRoutes := s.routes.GetExternalRoutes()246 extRoutes := s.routes.GetExternalRoutes()
192 247
193- allowed := make(map[string]struct{}, len(routes)+len(extRoutes))248+ s.allowedMu.RLock()
249+ allowed := make(map[string]struct{}, len(routes)+len(extRoutes)+len(s.allowedDomains))
250+ for domain := range s.allowedDomains {
251+ allowed[domain] = struct{}{}
252+ }
253+ s.allowedMu.RUnlock()
194 for _, r := range routes {254 for _, r := range routes {
195 allowed[r.Domain] = struct{}{}255 allowed[r.Domain] = struct{}{}
196 }256 }
M internal/usecase/pki/service_test.go +100 -6
@@ -11,6 +11,7 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
14+ "github.com/bnema/gordon/internal/boundaries/out"
1415 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1516 "github.com/bnema/gordon/internal/domain"
1617 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
@@ -18,6 +19,18 @@ import (
1819
1920 func testLogger() zerowrap.Logger { return zerowrap.Default() }
2021
22+type blockingCertificateAuthority struct {
23+ out.CertificateAuthority
24+ started chan struct{}
25+ release chan struct{}
26+}
27+
28+func (b *blockingCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
29+ close(b.started)
30+ <-b.release
31+ return b.CertificateAuthority.IssueCertificate(domain)
32+}
33+
2134 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
2235 m := mocks.NewMockRouteChecker(t)
2336 routes := make([]domain.Route, len(domains))
@@ -36,10 +49,10 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
3649
3750 cfg := newRouteCheckerMock(t, "app.example.com")
3851
39- ctx, cancel := context.WithCancel(context.Background())
52+ ctx, cancel := context.WithCancel(t.Context())
4053 defer cancel()
4154
42- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
55+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
4356 defer svc.Stop()
4457
4558 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
@@ -48,6 +61,87 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
4861 require.NotNil(t, cert)
4962 }
5063
64+func TestService_GetCertificate_AdditionalDomain(t *testing.T) {
65+ dir := t.TempDir()
66+ ca, err := pkiadapter.NewCA(dir, testLogger())
67+ require.NoError(t, err)
68+
69+ cfg := newRouteCheckerMock(t, "app.example.com")
70+
71+ ctx, cancel := context.WithCancel(t.Context())
72+ defer cancel()
73+
74+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"gordon.example.com"}, testLogger())
75+ defer svc.Stop()
76+
77+ hello := &tls.ClientHelloInfo{ServerName: "GORDON.EXAMPLE.COM."}
78+ cert, err := svc.GetCertificate(hello)
79+ require.NoError(t, err)
80+ require.NotNil(t, cert)
81+}
82+
83+func TestService_SetAdditionalDomains_ReplacesAuthorization(t *testing.T) {
84+ dir := t.TempDir()
85+ ca, err := pkiadapter.NewCA(dir, testLogger())
86+ require.NoError(t, err)
87+
88+ cfg := newRouteCheckerMock(t)
89+ ctx, cancel := context.WithCancel(t.Context())
90+ defer cancel()
91+
92+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"old.example.com"}, testLogger())
93+ defer svc.Stop()
94+
95+ oldHello := &tls.ClientHelloInfo{ServerName: "old.example.com"}
96+ cert, err := svc.GetCertificate(oldHello)
97+ require.NoError(t, err)
98+ require.NotNil(t, cert)
99+
100+ svc.SetAdditionalDomains([]string{"NEW.EXAMPLE.COM."})
101+
102+ cert, err = svc.GetCertificate(oldHello)
103+ require.NoError(t, err)
104+ assert.Nil(t, cert)
105+
106+ cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "new.example.com"})
107+ require.NoError(t, err)
108+ require.NotNil(t, cert)
109+}
110+
111+func TestService_SetAdditionalDomains_RevokesConcurrentIssuance(t *testing.T) {
112+ dir := t.TempDir()
113+ ca, err := pkiadapter.NewCA(dir, testLogger())
114+ require.NoError(t, err)
115+ blockingCA := &blockingCertificateAuthority{
116+ CertificateAuthority: ca,
117+ started: make(chan struct{}),
118+ release: make(chan struct{}),
119+ }
120+
121+ cfg := newRouteCheckerMock(t)
122+ ctx, cancel := context.WithCancel(t.Context())
123+ defer cancel()
124+
125+ svc := pkiusecase.NewService(ctx, blockingCA, cfg, []string{"old.example.com"}, testLogger())
126+ defer svc.Stop()
127+
128+ result := make(chan *tls.Certificate, 1)
129+ errs := make(chan error, 1)
130+ go func() {
131+ cert, getErr := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "old.example.com"})
132+ result <- cert
133+ errs <- getErr
134+ }()
135+
136+ <-blockingCA.started
137+ svc.SetAdditionalDomains([]string{"new.example.com"})
138+ close(blockingCA.release)
139+
140+ require.NoError(t, <-errs)
141+ assert.Nil(t, <-result)
142+ assert.Equal(t, 0, svc.CachedCertCount())
143+}
144+
51145 func TestService_GetCertificate_UnknownDomain(t *testing.T) {
52146 dir := t.TempDir()
53147 ca, err := pkiadapter.NewCA(dir, testLogger())
@@ -55,10 +149,10 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
55149
56150 cfg := newRouteCheckerMock(t, "app.example.com")
57151
58- ctx, cancel := context.WithCancel(context.Background())
152+ ctx, cancel := context.WithCancel(t.Context())
59153 defer cancel()
60154
61- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
155+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
62156 defer svc.Stop()
63157
64158 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
@@ -74,10 +168,10 @@ func TestService_GetCertificate_Caching(t *testing.T) {
74168
75169 cfg := newRouteCheckerMock(t, "cached.example.com")
76170
77- ctx, cancel := context.WithCancel(context.Background())
171+ ctx, cancel := context.WithCancel(t.Context())
78172 defer cancel()
79173
80- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())
174+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
81175 defer svc.Stop()
82176
83177 hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"13 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
14+ "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/boundaries/out/mocks"15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
15 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
16 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"17 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
@@ -18,6 +19,18 @@ import (
18 19
19 func testLogger() zerowrap.Logger { return zerowrap.Default() }20 func testLogger() zerowrap.Logger { return zerowrap.Default() }
20 21
22+type blockingCertificateAuthority struct {
23+ out.CertificateAuthority
24+ started chan struct{}
25+ release chan struct{}
26+}
27+
28+func (b *blockingCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
29+ close(b.started)
30+ <-b.release
31+ return b.CertificateAuthority.IssueCertificate(domain)
32+}
33+
21 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {34 func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
22 m := mocks.NewMockRouteChecker(t)35 m := mocks.NewMockRouteChecker(t)
23 routes := make([]domain.Route, len(domains))36 routes := make([]domain.Route, len(domains))
@@ -36,10 +49,10 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
36 49
37 cfg := newRouteCheckerMock(t, "app.example.com")50 cfg := newRouteCheckerMock(t, "app.example.com")
38 51
39- ctx, cancel := context.WithCancel(context.Background())52+ ctx, cancel := context.WithCancel(t.Context())
40 defer cancel()53 defer cancel()
41 54
42- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())55+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
43 defer svc.Stop()56 defer svc.Stop()
44 57
45 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}58 hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
@@ -48,6 +61,87 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) {
48 require.NotNil(t, cert)61 require.NotNil(t, cert)
49 }62 }
50 63
64+func TestService_GetCertificate_AdditionalDomain(t *testing.T) {
65+ dir := t.TempDir()
66+ ca, err := pkiadapter.NewCA(dir, testLogger())
67+ require.NoError(t, err)
68+
69+ cfg := newRouteCheckerMock(t, "app.example.com")
70+
71+ ctx, cancel := context.WithCancel(t.Context())
72+ defer cancel()
73+
74+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"gordon.example.com"}, testLogger())
75+ defer svc.Stop()
76+
77+ hello := &tls.ClientHelloInfo{ServerName: "GORDON.EXAMPLE.COM."}
78+ cert, err := svc.GetCertificate(hello)
79+ require.NoError(t, err)
80+ require.NotNil(t, cert)
81+}
82+
83+func TestService_SetAdditionalDomains_ReplacesAuthorization(t *testing.T) {
84+ dir := t.TempDir()
85+ ca, err := pkiadapter.NewCA(dir, testLogger())
86+ require.NoError(t, err)
87+
88+ cfg := newRouteCheckerMock(t)
89+ ctx, cancel := context.WithCancel(t.Context())
90+ defer cancel()
91+
92+ svc := pkiusecase.NewService(ctx, ca, cfg, []string{"old.example.com"}, testLogger())
93+ defer svc.Stop()
94+
95+ oldHello := &tls.ClientHelloInfo{ServerName: "old.example.com"}
96+ cert, err := svc.GetCertificate(oldHello)
97+ require.NoError(t, err)
98+ require.NotNil(t, cert)
99+
100+ svc.SetAdditionalDomains([]string{"NEW.EXAMPLE.COM."})
101+
102+ cert, err = svc.GetCertificate(oldHello)
103+ require.NoError(t, err)
104+ assert.Nil(t, cert)
105+
106+ cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "new.example.com"})
107+ require.NoError(t, err)
108+ require.NotNil(t, cert)
109+}
110+
111+func TestService_SetAdditionalDomains_RevokesConcurrentIssuance(t *testing.T) {
112+ dir := t.TempDir()
113+ ca, err := pkiadapter.NewCA(dir, testLogger())
114+ require.NoError(t, err)
115+ blockingCA := &blockingCertificateAuthority{
116+ CertificateAuthority: ca,
117+ started: make(chan struct{}),
118+ release: make(chan struct{}),
119+ }
120+
121+ cfg := newRouteCheckerMock(t)
122+ ctx, cancel := context.WithCancel(t.Context())
123+ defer cancel()
124+
125+ svc := pkiusecase.NewService(ctx, blockingCA, cfg, []string{"old.example.com"}, testLogger())
126+ defer svc.Stop()
127+
128+ result := make(chan *tls.Certificate, 1)
129+ errs := make(chan error, 1)
130+ go func() {
131+ cert, getErr := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "old.example.com"})
132+ result <- cert
133+ errs <- getErr
134+ }()
135+
136+ <-blockingCA.started
137+ svc.SetAdditionalDomains([]string{"new.example.com"})
138+ close(blockingCA.release)
139+
140+ require.NoError(t, <-errs)
141+ assert.Nil(t, <-result)
142+ assert.Equal(t, 0, svc.CachedCertCount())
143+}
144+
51 func TestService_GetCertificate_UnknownDomain(t *testing.T) {145 func TestService_GetCertificate_UnknownDomain(t *testing.T) {
52 dir := t.TempDir()146 dir := t.TempDir()
53 ca, err := pkiadapter.NewCA(dir, testLogger())147 ca, err := pkiadapter.NewCA(dir, testLogger())
@@ -55,10 +149,10 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
55 149
56 cfg := newRouteCheckerMock(t, "app.example.com")150 cfg := newRouteCheckerMock(t, "app.example.com")
57 151
58- ctx, cancel := context.WithCancel(context.Background())152+ ctx, cancel := context.WithCancel(t.Context())
59 defer cancel()153 defer cancel()
60 154
61- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())155+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
62 defer svc.Stop()156 defer svc.Stop()
63 157
64 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}158 hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
@@ -74,10 +168,10 @@ func TestService_GetCertificate_Caching(t *testing.T) {
74 168
75 cfg := newRouteCheckerMock(t, "cached.example.com")169 cfg := newRouteCheckerMock(t, "cached.example.com")
76 170
77- ctx, cancel := context.WithCancel(context.Background())171+ ctx, cancel := context.WithCancel(t.Context())
78 defer cancel()172 defer cancel()
79 173
80- svc := pkiusecase.NewService(ctx, ca, cfg, testLogger())174+ svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
81 defer svc.Stop()175 defer svc.Stop()
82 176
83 hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}177 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