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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +545 -162
M docs/config/server.md +3 -1
| @@ -102,7 +102,9 @@ Challenge behavior: | |||
| 102 | 102 | ||
| 103 | 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 | 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 | ||
| @@ -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) { | |||
| 40 | 40 | ctx, cancel := context.WithCancel(context.Background()) | |
| 41 | 41 | defer cancel() | |
| 42 | 42 | ||
| 43 | - | svc := pkiusecase.NewService(ctx, ca, routes, log) | |
| 43 | + | svc := pkiusecase.NewService(ctx, ca, routes, nil, log) | |
| 44 | 44 | defer svc.Stop() | |
| 45 | 45 | ||
| 46 | 46 | // Start a TLS server using the PKI service | |
| @@ -93,7 +93,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) { | |||
| 93 | 93 | ctx, cancel := context.WithCancel(context.Background()) | |
| 94 | 94 | defer cancel() | |
| 95 | 95 | ||
| 96 | - | svc := pkiusecase.NewService(ctx, ca, routes, log) | |
| 96 | + | svc := pkiusecase.NewService(ctx, ca, routes, nil, log) | |
| 97 | 97 | defer svc.Stop() | |
| 98 | 98 | ||
| 99 | 99 | 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 service | 46 | // 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) { | |||
| 160 | 160 | svc: &services{configSvc: cfgServiceForPKITest(t)}, | |
| 161 | 161 | } | |
| 162 | 162 | si.cfg.Server.DataDir = t.TempDir() | |
| 163 | + | si.cfg.Server.GordonDomain = "gordon.example.com" | |
| 163 | 164 | si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 164 | 165 | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | require.NoError(t, si.initPKI()) | |
| 168 | 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 | 177 | func cfgServiceForPKITest(t *testing.T) *config.Service { | |
| @@ -244,7 +249,7 @@ func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) | |||
| 244 | 249 | routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |
| 245 | 250 | ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default()) | |
| 246 | 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 | 253 | defer pkiSvc.Stop() | |
| 249 | 254 | ||
| 250 | 255 | 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 { | |||
| 596 | 596 | return si.log.WrapErr(err, "failed to initialize internal CA") | |
| 597 | 597 | } | |
| 598 | 598 | 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 | 600 | return nil | |
| 601 | 601 | } | |
| 602 | 602 | ||
| @@ -666,12 +666,13 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 666 | 666 | } | |
| 667 | 667 | ||
| 668 | 668 | 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}, | |
| 675 | 676 | }) | |
| 676 | 677 | ||
| 677 | 678 | if err := svc.Load(ctx); err != nil { | |
| @@ -779,6 +780,13 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 779 | 780 | if err != nil { | |
| 780 | 781 | 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 | 790 | var tlsConfig *tls.Config | |
| 783 | 791 | if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil { | |
| 784 | 792 | var tlsErr error | |
| @@ -1843,6 +1851,15 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time | |||
| 1843 | 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 | 1863 | if c.eventBus != nil { | |
| 1847 | 1864 | if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil { | |
| 1848 | 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 | 1870 | 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 | 1872 | c.log.Debug().Msg("config hot reload complete") | |
| 1864 | 1873 | return nil | |
| 1865 | 1874 | } | |
| @@ -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 = caAdapter | 598 | 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 nil | 600 | 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 err | 781 | 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.Config | 790 | var tlsConfig *tls.Config | ||
| 783 | if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil { | 791 | if hasTLSCapableEntrypoint(reloadCfg) && si.svc.httpsProxyHandler != nil { | ||
| 784 | var tlsErr error | 792 | 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 = now | 1870 | 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 nil | 1873 | return nil | ||
| 1865 | } | 1874 | } | ||
M internal/app/run_reload_test.go +56 -1
| @@ -2,6 +2,7 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/tls" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "net" | |
| 7 | 8 | "net/http" | |
| @@ -21,6 +22,7 @@ import ( | |||
| 21 | 22 | ||
| 22 | 23 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 23 | 24 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 25 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 24 | 26 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 25 | 27 | "github.com/bnema/gordon/internal/domain" | |
| 26 | 28 | cfgusecase "github.com/bnema/gordon/internal/usecase/config" | |
| @@ -169,6 +171,15 @@ func (r *standaloneServiceRecorder) Calls() int { | |||
| 169 | 171 | 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 | 183 | type eventBusRecorder struct { | |
| 173 | 184 | mu sync.Mutex | |
| 174 | 185 | calls int | |
| @@ -312,6 +323,10 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) { | |||
| 312 | 323 | } | |
| 313 | 324 | ||
| 314 | 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 | 330 | _, portValue, err := net.SplitHostPort(customAddress) | |
| 316 | 331 | require.NoError(t, err) | |
| 317 | 332 | port, err := strconv.Atoi(portValue) | |
| @@ -320,6 +335,44 @@ func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) { | |||
| 320 | 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 | 376 | func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) { | |
| 324 | 377 | ctx := context.Background() | |
| 325 | 378 | v := viper.New() | |
| @@ -538,8 +591,9 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||
| 538 | 591 | reloadSvc := &reloadRecorder{} | |
| 539 | 592 | proxySvc := &proxyRecorder{} | |
| 540 | 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 | 598 | err := coord.Trigger(ctx) | |
| 545 | 599 | require.ErrorIs(t, err, publishErr) | |
| @@ -550,6 +604,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||
| 550 | 604 | require.Equal(t, 2, reloadSvc.Calls()) | |
| 551 | 605 | require.Equal(t, 2, proxySvc.calls) | |
| 552 | 606 | require.Equal(t, 2, events.Calls()) | |
| 607 | + | require.Equal(t, 2, publicTLS.calls) | |
| 553 | 608 | require.Equal(t, domain.EventConfigReload, events.eventType) | |
| 554 | 609 | } | |
| 555 | 610 | ||
| @@ -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.calls | 171 | 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.Mutex | 184 | mu sync.Mutex | ||
| 174 | calls int | 185 | 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 { | |||
| 287 | 287 | routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |
| 288 | 288 | ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default()) | |
| 289 | 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 | 291 | t.Cleanup(pkiSvc.Stop) | |
| 292 | 292 | return pkiSvc | |
| 293 | 293 | } | |
| @@ -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 pkiSvc | 292 | 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 | |||
| 218 | 218 | 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 | 267 | // Status provides a mock function for the type MockPublicTLSService | |
| 222 | 268 | func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus { | |
| 223 | 269 | ret := _mock.Called(ctx) | |
| @@ -218,6 +218,52 @@ func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context | |||||
| 218 | return _c | 218 | 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 MockPublicTLSService | 267 | // 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 { | |||
| 21 | 21 | // Status returns the current public TLS status. | |
| 22 | 22 | 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 | 27 | // Reconcile ensures the desired certificate state matches the actual state. | |
| 25 | 28 | Reconcile(ctx context.Context) error | |
| 26 | 29 | ||
| @@ -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.PublicTLSStatus | 22 | 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) error | 28 | Reconcile(ctx context.Context) error | ||
| 26 | 29 | ||||
M internal/usecase/pki/service.go +116 -56
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | 6 | "crypto/x509" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -11,6 +12,7 @@ import ( | |||
| 11 | 12 | "golang.org/x/sync/singleflight" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | const ( | |
| @@ -29,9 +31,11 @@ type cachedCert struct { | |||
| 29 | 31 | // TODO: consider adding CRL/OCSP support for certificate revocation, | |
| 30 | 32 | // especially given the 10-year root CA lifetime. | |
| 31 | 33 | 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 | |
| 35 | 39 | ||
| 36 | 40 | cache sync.Map // map[string]*cachedCert | |
| 37 | 41 | flight singleflight.Group | |
| @@ -42,14 +46,16 @@ type Service struct { | |||
| 42 | 46 | // NewService creates a PKI service and starts background maintenance goroutines. | |
| 43 | 47 | // It performs an initial intermediate renewal check synchronously so the first | |
| 44 | 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 | 50 | ctx, cancel := context.WithCancel(ctx) | |
| 51 | + | allowed := canonicalDomainSet(allowedDomains) | |
| 47 | 52 | 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{}), | |
| 53 | 59 | } | |
| 54 | 60 | svc.renewIntermediateIfNeeded() | |
| 55 | 61 | go svc.maintenanceLoop(ctx) | |
| @@ -76,80 +82,129 @@ func (s *Service) CachedCertCount() int { | |||
| 76 | 82 | // It returns (nil, nil) for unknown domains so Go's TLS stack | |
| 77 | 83 | // falls back to tls.Config.Certificates. | |
| 78 | 84 | 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 == "" { | |
| 81 | 87 | return nil, nil | |
| 82 | 88 | } | |
| 83 | 89 | ||
| 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 | |
| 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(), domain) { | |
| 97 | + | if !s.isDomainAllowed(hello.Context(), domainName) { | |
| 98 | 98 | return nil, nil | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | - | result, err, _ := s.flight.Do(domain, func() (any, error) { | |
| 102 | - | // Re-check cache; another goroutine in the same flight may have populated it. | |
| 103 | - | if entry, ok := s.cache.Load(domain); ok { | |
| 104 | - | if cached, ok := entry.(*cachedCert); ok { | |
| 105 | - | if time.Now().Before(cached.expiresAt) { | |
| 106 | - | return cached.cert, nil | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | s.cache.Delete(domain) | |
| 110 | - | } | |
| 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 | + | } | |
| 111 | 113 | ||
| 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 | + | } | |
| 117 | 117 | ||
| 118 | - | expiresAt := time.Now().Add(s.leafLifetime()) | |
| 119 | - | if len(cert.Certificate) > 0 { | |
| 120 | - | if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { | |
| 121 | - | expiresAt = leaf.NotAfter | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | s.cache.Store(domain, &cachedCert{ | |
| 125 | - | cert: cert, | |
| 126 | - | expiresAt: expiresAt, | |
| 127 | - | }) | |
| 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 | + | } | |
| 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 | 136 | return cert, nil | |
| 131 | - | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | cert, err := s.ca.IssueCertificate(domainName) | |
| 132 | 140 | if err != nil { | |
| 141 | + | s.log.Error().Err(err).Str("domain", domainName).Msg("failed to issue leaf cert") | |
| 133 | 142 | return nil, err | |
| 134 | 143 | } | |
| 135 | - | if result == nil { | |
| 144 | + | if !s.isDomainAllowed(ctx, domainName) { | |
| 136 | 145 | return nil, nil | |
| 137 | 146 | } | |
| 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 | + | } | |
| 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 | 186 | for _, r := range s.routes.GetRoutes(ctx) { | |
| 143 | - | if r.Domain == domain { | |
| 187 | + | if r.Domain == domainName { | |
| 144 | 188 | return true | |
| 145 | 189 | } | |
| 146 | 190 | } | |
| 147 | - | if _, ok := s.routes.GetExternalRoutes()[domain]; ok { | |
| 191 | + | if _, ok := s.routes.GetExternalRoutes()[domainName]; ok { | |
| 148 | 192 | return true | |
| 149 | 193 | } | |
| 150 | 194 | 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 | 208 | func (s *Service) leafLifetime() time.Duration { | |
| 154 | 209 | return s.ca.LeafLifetime() | |
| 155 | 210 | } | |
| @@ -190,7 +245,12 @@ func (s *Service) sweepExpiredCerts(ctx context.Context) { | |||
| 190 | 245 | routes := s.routes.GetRoutes(ctx) | |
| 191 | 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 | 254 | for _, r := range routes { | |
| 195 | 255 | allowed[r.Domain] = struct{}{} | |
| 196 | 256 | } | |
| @@ -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.CertificateAuthority | 34 | + | ca out.CertificateAuthority |
| 33 | - | routes out.RouteChecker | 35 | + | routes out.RouteChecker |
| 34 | - | log zerowrap.Logger | 36 | + | allowedMu sync.RWMutex |
| 37 | + | allowedDomains map[string]struct{} | |||
| 38 | + | log zerowrap.Logger | |||
| 35 | 39 | ||||
| 36 | cache sync.Map // map[string]*cachedCert | 40 | cache sync.Map // map[string]*cachedCert | ||
| 37 | flight singleflight.Group | 41 | 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 first | 47 | // 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 stack | 82 | // 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.ServerName | 85 | + | domainName := canonicalServerName(hello.ServerName) |
| 80 | - | if domain == "" { | 86 | + | if domainName == "" { |
| 81 | return nil, nil | 87 | 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, nil | 98 | 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, nil | 106 | + | } |
| 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.NotAfter | 121 | + | 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, nil | 136 | 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, err | 142 | return nil, err | ||
| 134 | } | 143 | } | ||
| 135 | - | if result == nil { | 144 | + | if !s.isDomainAllowed(ctx, domainName) { |
| 136 | return nil, nil | 145 | return nil, nil | ||
| 137 | } | 146 | } | ||
| 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 | + | } | |||
| 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 true | 188 | return true | ||
| 145 | } | 189 | } | ||
| 146 | } | 190 | } | ||
| 147 | - | if _, ok := s.routes.GetExternalRoutes()[domain]; ok { | 191 | + | if _, ok := s.routes.GetExternalRoutes()[domainName]; ok { |
| 148 | return true | 192 | return true | ||
| 149 | } | 193 | } | ||
| 150 | return false | 194 | 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 ( | |||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 15 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 16 | 17 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| @@ -18,6 +19,18 @@ import ( | |||
| 18 | 19 | ||
| 19 | 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 | 34 | func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |
| 22 | 35 | m := mocks.NewMockRouteChecker(t) | |
| 23 | 36 | routes := make([]domain.Route, len(domains)) | |
| @@ -36,10 +49,10 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) { | |||
| 36 | 49 | ||
| 37 | 50 | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 38 | 51 | ||
| 39 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 52 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 40 | 53 | defer cancel() | |
| 41 | 54 | ||
| 42 | - | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 55 | + | svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger()) | |
| 43 | 56 | defer svc.Stop() | |
| 44 | 57 | ||
| 45 | 58 | hello := &tls.ClientHelloInfo{ServerName: "app.example.com"} | |
| @@ -48,6 +61,87 @@ func TestService_GetCertificate_KnownDomain(t *testing.T) { | |||
| 48 | 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 | 145 | func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |
| 52 | 146 | dir := t.TempDir() | |
| 53 | 147 | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| @@ -55,10 +149,10 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |||
| 55 | 149 | ||
| 56 | 150 | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 57 | 151 | ||
| 58 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 152 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 59 | 153 | defer cancel() | |
| 60 | 154 | ||
| 61 | - | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 155 | + | svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger()) | |
| 62 | 156 | defer svc.Stop() | |
| 63 | 157 | ||
| 64 | 158 | hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"} | |
| @@ -74,10 +168,10 @@ func TestService_GetCertificate_Caching(t *testing.T) { | |||
| 74 | 168 | ||
| 75 | 169 | cfg := newRouteCheckerMock(t, "cached.example.com") | |
| 76 | 170 | ||
| 77 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 171 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 78 | 172 | defer cancel() | |
| 79 | 173 | ||
| 80 | - | svc := pkiusecase.NewService(ctx, ca, cfg, testLogger()) | |
| 174 | + | svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger()) | |
| 81 | 175 | defer svc.Stop() | |
| 82 | 176 | ||
| 83 | 177 | 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 | |||
| 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 | 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 | 36 | // Mock issuer: returns valid stored cert for any order. | |
| 37 | 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 | 50 | 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"}, | |
| 57 | 58 | Effective: EffectiveChallenge{ | |
| 58 | 59 | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |
| 59 | 60 | Mode: domain.ACMEChallengeCloudflareDNS01, | |
| @@ -141,8 +142,21 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing | |||
| 141 | 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 | 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 | 160 | t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) { | |
| 147 | 161 | cert, err := svc.GetCertificateForHost("unrelated.com") | |
| 148 | 162 | 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 { | |||
| 23 | 23 | ||
| 24 | 24 | // ServiceDeps contains the dependencies for the Service. | |
| 25 | 25 | 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 | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | const defaultObtainBatchSize = 1 | |
| 37 | 38 | ||
| 38 | 39 | // Service manages public TLS certificates via ACME. | |
| 39 | 40 | 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 | |
| 49 | 51 | ||
| 50 | 52 | // requiredHosts is the set of hosts that must be covered by ACME certs. | |
| 51 | 53 | requiredHosts map[string]struct{} | |
| @@ -58,6 +60,7 @@ type Service struct { | |||
| 58 | 60 | ||
| 59 | 61 | // NewService creates a new public TLS Service. | |
| 60 | 62 | func NewService(cfg Config, deps ServiceDeps) *Service { | |
| 63 | + | deps.AdditionalHosts = append([]string(nil), deps.AdditionalHosts...) | |
| 61 | 64 | if deps.Challenges == nil { | |
| 62 | 65 | deps.Challenges = NewHTTP01Challenges() | |
| 63 | 66 | } | |
| @@ -65,13 +68,14 @@ func NewService(cfg Config, deps ServiceDeps) *Service { | |||
| 65 | 68 | deps.Log = zerowrap.Default() | |
| 66 | 69 | } | |
| 67 | 70 | 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{}), | |
| 75 | 79 | } | |
| 76 | 80 | } | |
| 77 | 81 | ||
| @@ -82,6 +86,9 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 82 | 86 | return nil | |
| 83 | 87 | } | |
| 84 | 88 | ||
| 89 | + | s.reconcileMu.Lock() | |
| 90 | + | defer s.reconcileMu.Unlock() | |
| 91 | + | ||
| 85 | 92 | stored, err := s.deps.Store.LoadAll(ctx) | |
| 86 | 93 | if err != nil { | |
| 87 | 94 | return fmt.Errorf("load stored certificates: %w", err) | |
| @@ -95,6 +102,13 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 95 | 102 | 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 | 112 | s.mu.Lock() | |
| 99 | 113 | defer s.mu.Unlock() | |
| 100 | 114 | ||
| @@ -110,15 +124,36 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 110 | 124 | continue | |
| 111 | 125 | } | |
| 112 | 126 | s.certs[cert.ID] = cert | |
| 113 | - | addNamesToRequiredHosts(s.requiredHosts, cert.Names) | |
| 114 | 127 | if cert.LastError != "" { | |
| 115 | 128 | s.lastErr[cert.ID] = cert.LastError | |
| 116 | 129 | } | |
| 117 | 130 | } | |
| 118 | 131 | ||
| 132 | + | s.requiredHosts = required | |
| 133 | + | ||
| 119 | 134 | 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 | 157 | // Reconcile ensures all desired certificates are obtained and cached. | |
| 123 | 158 | // If ACME is disabled, it is a no-op. | |
| 124 | 159 | func (s *Service) Reconcile(ctx context.Context) error { | |
| @@ -166,7 +201,7 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 166 | 201 | // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver). | |
| 167 | 202 | routes := s.deps.Routes.GetRoutes(ctx) | |
| 168 | 203 | external := s.deps.Routes.GetExternalRoutes() | |
| 169 | - | hosts := routeHosts(routes, external) | |
| 204 | + | hosts := routeHosts(routes, external, s.additionalHosts) | |
| 170 | 205 | ||
| 171 | 206 | // Build required hosts set from route hosts (before target derivation). | |
| 172 | 207 | required := canonicalHostSet(hosts) | |
| @@ -181,6 +216,7 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 181 | 216 | // Derive desired targets. | |
| 182 | 217 | targets, err := DeriveCertificateTargets(ctx, effective.Mode, | |
| 183 | 218 | routes, external, | |
| 219 | + | s.additionalHosts, | |
| 184 | 220 | s.deps.ZoneResolver, | |
| 185 | 221 | ) | |
| 186 | 222 | if err != nil { | |
| @@ -190,12 +226,10 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 190 | 226 | return fmt.Errorf("derive certificate targets: %w", err) | |
| 191 | 227 | } | |
| 192 | 228 | ||
| 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. | |
| 197 | 231 | ||
| 198 | - | // Under mu: update requiredHosts (with target names) and compute missing targets. | |
| 232 | + | // Under mu: update requiredHosts and compute missing targets. | |
| 199 | 233 | s.mu.Lock() | |
| 200 | 234 | s.requiredHosts = required | |
| 201 | 235 | ||
| @@ -313,21 +347,6 @@ func canonicalHostSet(hosts []string) map[string]struct{} { | |||
| 313 | 347 | 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 | 350 | func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) { | |
| 332 | 351 | for host := range hosts { | |
| 333 | 352 | delete(routeErr, host) | |
| @@ -493,20 +512,12 @@ func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) { | |||
| 493 | 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 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. | |
| 498 | 518 | 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 | |
| 510 | 521 | } | |
| 511 | 522 | ||
| 512 | 523 | // 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 Config | 26 | + | Config Config |
| 27 | - | Routes RouteSource | 27 | + | Routes RouteSource |
| 28 | - | Issuer out.PublicCertificateIssuer | 28 | + | Issuer out.PublicCertificateIssuer |
| 29 | - | Store out.CertificateStore | 29 | + | Store out.CertificateStore |
| 30 | - | ZoneResolver out.CloudflareZoneResolver | 30 | + | ZoneResolver out.CloudflareZoneResolver |
| 31 | - | Challenges *HTTP01Challenges | 31 | + | Challenges *HTTP01Challenges |
| 32 | - | Effective EffectiveChallenge | 32 | + | Effective EffectiveChallenge |
| 33 | - | Log zerowrap.Logger | 33 | + | AdditionalHosts []string |
| 34 | + | Log zerowrap.Logger | |||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | const defaultObtainBatchSize = 1 | 37 | 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.RWMutex | 41 | + | mu sync.RWMutex |
| 41 | - | reconcileMu sync.Mutex | 42 | + | reconcileMu sync.Mutex |
| 42 | - | cfg Config | 43 | + | cfg Config |
| 43 | - | deps ServiceDeps | 44 | + | deps ServiceDeps |
| 44 | - | log zerowrap.Logger | 45 | + | log zerowrap.Logger |
| 45 | - | certs map[string]*out.StoredCertificate // indexed by cert ID | 46 | + | certs map[string]*out.StoredCertificate // indexed by cert ID |
| 46 | - | lastErr map[string]string // indexed by cert ID | 47 | + | lastErr map[string]string // indexed by cert ID |
| 47 | - | routeErr map[string]string // indexed by host | 48 | + | routeErr map[string]string // indexed by host |
| 48 | - | obtainCursor int // next missing target index for batched obtains | 49 | + | 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 nil | 86 | 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 = 0 | 102 | 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 | continue | 124 | continue | ||
| 111 | } | 125 | } | ||
| 112 | s.certs[cert.ID] = cert | 126 | 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.LastError | 128 | s.lastErr[cert.ID] = cert.LastError | ||
| 116 | } | 129 | } | ||
| 117 | } | 130 | } | ||
| 118 | 131 | ||||
| 132 | + | s.requiredHosts = required | |||
| 133 | + | ||||
| 119 | return nil | 134 | 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 = required | 234 | s.requiredHosts = required | ||
| 201 | 235 | ||||
| @@ -313,21 +347,6 @@ func canonicalHostSet(hosts []string) map[string]struct{} { | |||||
| 313 | return required | 347 | 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 in | 515 | + | // 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 true | 520 | + | 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) { | |||
| 348 | 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 | 416 | func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |
| 352 | 417 | ctx := context.Background() | |
| 353 | 418 | ||
| @@ -895,7 +960,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) { | |||
| 895 | 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 | 964 | cfg := Config{Enabled: true} | |
| 900 | 965 | ||
| 901 | 966 | svc := NewService(cfg, ServiceDeps{ | |
| @@ -921,7 +986,7 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) { | |||
| 921 | 986 | ||
| 922 | 987 | served, err := svc.GetCertificateForHost("test.example.com") | |
| 923 | 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 | 992 | 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( | |||
| 24 | 24 | mode domain.ACMEChallengeMode, | |
| 25 | 25 | routes []domain.Route, | |
| 26 | 26 | external map[string]string, | |
| 27 | + | additionalHosts []string, | |
| 27 | 28 | resolver out.CloudflareZoneResolver, | |
| 28 | 29 | ) ([]CertificateTarget, error) { | |
| 29 | - | hosts := routeHosts(routes, external) | |
| 30 | + | hosts := routeHosts(routes, external, additionalHosts) | |
| 30 | 31 | ||
| 31 | 32 | switch mode { | |
| 32 | 33 | case domain.ACMEChallengeHTTP01: | |
| @@ -40,7 +41,7 @@ func DeriveCertificateTargets( | |||
| 40 | 41 | ||
| 41 | 42 | // routeHosts collects all unique canonical hosts from routes and external keys, | |
| 42 | 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 | 45 | seen := make(map[string]struct{}) | |
| 45 | 46 | var hosts []string | |
| 46 | 47 | ||
| @@ -62,6 +63,9 @@ func routeHosts(routes []domain.Route, external map[string]string) []string { | |||
| 62 | 63 | for h := range external { | |
| 63 | 64 | addHost(h) | |
| 64 | 65 | } | |
| 66 | + | for _, h := range additionalHosts { | |
| 67 | + | addHost(h) | |
| 68 | + | } | |
| 65 | 69 | ||
| 66 | 70 | sort.Strings(hosts) | |
| 67 | 71 | 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 []string | 46 | 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 hosts | 71 | return hosts | ||
M internal/usecase/publictls/targets_test.go +21 -6
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | func TestDeriveTargets_HTTP01PerRoute(t *testing.T) { | |
| 17 | 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 | 19 | require.NoError(t, err) | |
| 20 | 20 | require.Len(t, targets, 2) | |
| 21 | 21 | ||
| @@ -31,7 +31,7 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) { | |||
| 31 | 31 | routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}} | |
| 32 | 32 | resolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 33 | 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 | 35 | require.NoError(t, err) | |
| 36 | 36 | require.Len(t, targets, 2) | |
| 37 | 37 | ||
| @@ -43,10 +43,25 @@ func TestDeriveTargets_DNS01WildcardBases(t *testing.T) { | |||
| 43 | 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 | 61 | func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) { | |
| 47 | 62 | routes := []domain.Route{{Domain: "app.example.com"}} | |
| 48 | 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 | 65 | require.NoError(t, err) | |
| 51 | 66 | require.Len(t, targets, 2) | |
| 52 | 67 | ||
| @@ -57,7 +72,7 @@ func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) { | |||
| 57 | 72 | ||
| 58 | 73 | func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) { | |
| 59 | 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 | 76 | require.Error(t, err) | |
| 62 | 77 | assert.Contains(t, err.Error(), "resolver is nil") | |
| 63 | 78 | } | |
| @@ -66,14 +81,14 @@ func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) { | |||
| 66 | 81 | routes := []domain.Route{{Domain: "app.example.com"}} | |
| 67 | 82 | resolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 68 | 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 | 85 | require.Error(t, err) | |
| 71 | 86 | assert.Contains(t, err.Error(), "does not match host") | |
| 72 | 87 | } | |
| 73 | 88 | ||
| 74 | 89 | func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) { | |
| 75 | 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 | 92 | require.NoError(t, err) | |
| 78 | 93 | require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target") | |
| 79 | 94 | 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) | ||