From 4ef4c09300f56fff7004f2962f6227e75360c4e2 Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Tue, 9 Jun 2026 17:08:07 +0200 Subject: [PATCH] Fix routers with same host, different tlsoptions on different entryPoint Co-authored-by: Romain --- .../https/https_tls_options_conflict.toml | 101 ++++++++++++ integration/https_test.go | 148 +++++++++++++++++- integration/simple_test.go | 4 +- pkg/server/aggregator.go | 148 ++++++++++++------ pkg/server/aggregator_test.go | 89 +++++++++++ pkg/server/configurationwatcher.go | 4 +- 6 files changed, 438 insertions(+), 56 deletions(-) create mode 100644 integration/fixtures/https/https_tls_options_conflict.toml diff --git a/integration/fixtures/https/https_tls_options_conflict.toml b/integration/fixtures/https/https_tls_options_conflict.toml new file mode 100644 index 000000000..1d01b7eaa --- /dev/null +++ b/integration/fixtures/https/https_tls_options_conflict.toml @@ -0,0 +1,101 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + +[entryPoints.websecure] + address = ":4443" + +[entryPoints.websecure2] + address = ":4444" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +# --- Same host, same options, same entryPoint: no conflict, the options are applied. --- +[http.routers.same-1] + rule = "Host(`same.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.same-1.tls] + options = "tls12" + +[http.routers.same-2] + rule = "Host(`same.www.snitest.com`) && PathPrefix(`/same`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.same-2.tls] + options = "tls12" + +# --- Same host, different options, same entryPoint: conflict, fallback to default options. --- +[http.routers.conflict-1] + rule = "Host(`conflict.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.conflict-1.tls] + options = "tls12" + +[http.routers.conflict-2] + rule = "Host(`conflict.www.snitest.com`) && PathPrefix(`/conflict`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.conflict-2.tls] + options = "tls13" + +# --- Same host, different options, different entryPoints: no conflict, each entryPoint keeps its own options. --- +[http.routers.cross-ep1] + rule = "Host(`cross.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.cross-ep1.tls] + options = "tls12" + +[http.routers.cross-ep2] + rule = "Host(`cross.www.snitest.com`)" + entryPoints = ["websecure2"] + service = "service1" + [http.routers.cross-ep2.tls] + options = "tls13" + +# --- Domain fronting (Host header != SNI): same options follow the header, different options are rejected. --- +[http.routers.df-a] + rule = "Host(`df-a.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.df-a.tls] + options = "tls12" + +[http.routers.df-b] + rule = "Host(`df-b.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.df-b.tls] + options = "tls12" + +[http.routers.df-c] + rule = "Host(`df-c.www.snitest.com`)" + entryPoints = ["websecure"] + service = "service1" + [http.routers.df-c.tls] + options = "tls13" + +[http.services.service1] + [[http.services.service1.loadBalancer.servers]] + url = "http://127.0.0.1:9010" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.www.snitest.com.cert" + keyFile = "fixtures/https/wildcard.www.snitest.com.key" + +[tls.options] + [tls.options.tls12] + maxVersion = "VersionTLS12" + [tls.options.tls13] + minVersion = "VersionTLS13" diff --git a/integration/https_test.go b/integration/https_test.go index 2a79da18b..66560a3a8 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -258,7 +258,6 @@ func (s *HTTPSSuite) TestWithTLSOptions() { } // TestWithConflictingTLSOptions checks that routers with same SNI but different TLS options get fallbacked to the default TLS options. - func (s *HTTPSSuite) TestWithConflictingTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -1173,6 +1172,153 @@ func (s *HTTPSSuite) TestWithDomainFronting() { } } +// TestWithTLSOptionsConflict checks how TLS options are resolved when several routers +// target the same host (SNI), across the different conflict situations: +// - same options on the same entryPoint: no conflict, the options are applied; +// - different options on the same entryPoint: conflict, fallback to the default options; +// - different options on different entryPoints: no conflict, each entryPoint keeps its +// own options (they are selected independently on each listener); +// - domain fronting (Host header != SNI): allowed when both resolve to the same options, +// rejected with a 421 otherwise. +// +// The effective TLS options are probed through the negotiated TLS version: the "tls12" +// options cap the version to TLS 1.2, while the "tls13" options require at least TLS 1.3. +func (s *HTTPSSuite) TestWithTLSOptionsConflict() { + backend := startTestServer("9010", http.StatusOK, "server1") + defer backend.Close() + + file := s.adaptFile("fixtures/https/https_tls_options_conflict.toml", struct{}{}) + s.traefikCmd(withConfigFile(file)) + + // wait for Traefik + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`cross.www.snitest.com`)")) + require.NoError(s.T(), err) + + testCases := []struct { + desc string + addr string // entryPoint address to reach + hostHeader string + serverName string // SNI + minVersion uint16 // 0 means the crypto/tls library default + maxVersion uint16 // 0 means the crypto/tls library default + // expectHandshakeError is set when the TLS handshake itself is expected to fail + // (i.e. the probed options reject the client's TLS version). Otherwise + // expectedStatusCode is asserted on the HTTP response. + expectHandshakeError bool + expectedStatusCode int + }{ + // Same host, same options, same entryPoint: no conflict, the "tls12" options are applied. + { + desc: "same options / same entryPoint: TLS 1.2 client is accepted", + addr: "127.0.0.1:4443", + hostHeader: "same.www.snitest.com", + serverName: "same.www.snitest.com", + maxVersion: tls.VersionTLS12, + expectedStatusCode: http.StatusOK, + }, + { + desc: "same options / same entryPoint: TLS 1.3 client is rejected (maxVersion TLS1.2 enforced)", + addr: "127.0.0.1:4443", + hostHeader: "same.www.snitest.com", + serverName: "same.www.snitest.com", + minVersion: tls.VersionTLS13, + expectHandshakeError: true, + }, + + // Same host, different options, same entryPoint: conflict, both routers fall back to the default options. + { + desc: "conflicting options / same entryPoint: TLS 1.3 client is accepted (default options used)", + addr: "127.0.0.1:4443", + hostHeader: "conflict.www.snitest.com", + serverName: "conflict.www.snitest.com", + minVersion: tls.VersionTLS13, + expectedStatusCode: http.StatusOK, + }, + { + desc: "conflicting options / same entryPoint: TLS 1.2 client is accepted (default options used)", + addr: "127.0.0.1:4443", + hostHeader: "conflict.www.snitest.com", + serverName: "conflict.www.snitest.com", + maxVersion: tls.VersionTLS12, + expectedStatusCode: http.StatusOK, + }, + + // Same host, different options, different entryPoints: no conflict, each entryPoint keeps its own options. + { + desc: "different entryPoints: websecure keeps tls12, TLS 1.2 client is accepted", + addr: "127.0.0.1:4443", + hostHeader: "cross.www.snitest.com", + serverName: "cross.www.snitest.com", + maxVersion: tls.VersionTLS12, + expectedStatusCode: http.StatusOK, + }, + { + desc: "different entryPoints: websecure keeps tls12, TLS 1.3 client is rejected", + addr: "127.0.0.1:4443", + hostHeader: "cross.www.snitest.com", + serverName: "cross.www.snitest.com", + minVersion: tls.VersionTLS13, + expectHandshakeError: true, + }, + { + desc: "different entryPoints: websecure2 keeps tls13, TLS 1.3 client is accepted", + addr: "127.0.0.1:4444", + hostHeader: "cross.www.snitest.com", + serverName: "cross.www.snitest.com", + minVersion: tls.VersionTLS13, + expectedStatusCode: http.StatusOK, + }, + { + desc: "different entryPoints: websecure2 keeps tls13, TLS 1.2 client is rejected", + addr: "127.0.0.1:4444", + hostHeader: "cross.www.snitest.com", + serverName: "cross.www.snitest.com", + maxVersion: tls.VersionTLS12, + expectHandshakeError: true, + }, + + // Domain fronting (Host header != SNI) on the same entryPoint. + { + desc: "domain fronting / same options: request follows the Host header (200)", + addr: "127.0.0.1:4443", + hostHeader: "df-a.www.snitest.com", + serverName: "df-b.www.snitest.com", + maxVersion: tls.VersionTLS12, + expectedStatusCode: http.StatusOK, + }, + { + desc: "domain fronting / different options: request is misdirected (421)", + addr: "127.0.0.1:4443", + hostHeader: "df-a.www.snitest.com", + serverName: "df-c.www.snitest.com", + minVersion: tls.VersionTLS13, + expectedStatusCode: http.StatusMisdirectedRequest, + }, + } + + for _, test := range testCases { + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: test.serverName, + MinVersion: test.minVersion, + MaxVersion: test.maxVersion, + } + + req, err := http.NewRequest(http.MethodGet, "https://"+test.addr+"/", nil) + require.NoError(s.T(), err) + req.Host = test.hostHeader + + if test.expectHandshakeError { + _, err = (&http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}).Do(req) + assert.ErrorContains(s.T(), err, "tls:", "test %q should fail the TLS handshake", test.desc) + continue + } + + err = try.RequestWithTransport(req, 2*time.Second, &http.Transport{TLSClientConfig: tlsConfig}, try.StatusCodeIs(test.expectedStatusCode)) + assert.NoError(s.T(), err, "test %q failed with: %v", test.desc, err) + } +} + // TestWithInvalidTLSOption verifies the behavior when using an invalid tlsOption configuration. func (s *HTTPSSuite) TestWithInvalidTLSOption() { backend := startTestServer("9010", http.StatusOK, "server1") diff --git a/integration/simple_test.go b/integration/simple_test.go index 40620f8f9..0d0423560 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -675,11 +675,11 @@ func (s *SimpleSuite) TestRouterConfigErrors() { require.NoError(s.T(), err) // router4 is enabled, but in warning state because its tls options conf was messed up - err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router4@file", 1000*time.Millisecond, try.BodyContains(`"status":"warning"`)) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/websecure-router4@file", 1000*time.Millisecond, try.BodyContains(`"status":"warning"`)) require.NoError(s.T(), err) // router5 is disabled because its middleware conf is broken - err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router5@file", 1000*time.Millisecond, try.BodyContains()) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/websecure-router5@file", 1000*time.Millisecond, try.BodyContains()) require.NoError(s.T(), err) } diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go index 1d8f5ea33..939771041 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -2,7 +2,6 @@ package server import ( "context" - "fmt" "slices" "github.com/go-acme/lego/v4/challenge/tlsalpn01" @@ -48,7 +47,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint log.WithoutContext(). WithField(log.RouterName, routerName). Debugf("No entryPoint defined for this router, using the default one(s) instead: %+v", defaultEntryPoints) - router.EntryPoints = defaultEntryPoints + router.EntryPoints = slices.Clone(defaultEntryPoints) } conf.HTTP.Routers[provider.MakeQualifiedName(pvd, routerName)] = router @@ -73,7 +72,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint log.WithoutContext(). WithField(log.RouterName, routerName). Debugf("No entryPoint defined for this TCP router, using the default one(s) instead: %+v", defaultEntryPoints) - router.EntryPoints = defaultEntryPoints + router.EntryPoints = slices.Clone(defaultEntryPoints) } conf.TCP.Routers[provider.MakeQualifiedName(pvd, routerName)] = router } @@ -141,81 +140,126 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint return conf } -func resolveHTTPTLSOptions(cfg dynamic.Configuration) dynamic.Configuration { - if cfg.HTTP == nil || len(cfg.HTTP.Routers) == 0 { - return cfg +// resolveHTTPTLSOptions resolves the TLS options for the given routers, on a per +// entryPoint basis. +// +// TLS options conflicts (i.e. the same host served with different TLS options) can +// only be detected and arbitrated within a single TLS listener, that is to say within +// a single entryPoint. To honor that, routers are grouped per entryPoint and the +// conflict detection is run independently for each entryPoint. +// +// A router keeps its original name, and its resolved TLS options, for the entryPoints +// on which it does not conflict. For each entryPoint on which it conflicts, that +// entryPoint is removed from the router and a dedicated copy is emitted, prefixed with +// the entryPoint name the same way applyModel does (ep-name), with its TLS options reset +// to the default ones. +func resolveHTTPTLSOptions(routers map[string]*dynamic.Router) map[string]*dynamic.Router { + if len(routers) == 0 { + return routers } - rts := make(map[string]*dynamic.Router) + newRouters := make(map[string]*dynamic.Router) - // Keyed by domain, then by options reference. - // The actual source of truth for what TLS options will actually be used for the connection. - // As opposed to tlsOptionsForHost, it keeps track of all the (different) TLS - // options that occur for a given host name, so that later on we can set relevant - // errors and logging for all the routers concerned (i.e. wrongly configured). - tlsOptionsForHostSNI := map[string]map[string][]string{} - - for routerHTTPName, routerHTTPConfig := range cfg.HTTP.Routers { - rts[routerHTTPName] = routerHTTPConfig.DeepCopy() - - if routerHTTPConfig.TLS == nil { + // Split every router per entryPoint. + // Routers always have at least one entryPoint at this stage, as they are + // defaulted in mergeConfiguration before applyModel and this resolution run. + routersByEntryPoint := map[string]map[string]*dynamic.Router{} + for name, router := range routers { + if router.TLS == nil { + newRouters[name] = router continue } - ctxRouter := log.With(provider.AddInContext(context.Background(), routerHTTPName), log.Str(log.RouterName, routerHTTPName)) - logger := log.FromContext(ctxRouter) - - tlsOptionsName := traefiktls.DefaultTLSConfigName - if len(routerHTTPConfig.TLS.Options) > 0 && routerHTTPConfig.TLS.Options != traefiktls.DefaultTLSConfigName { - tlsOptionsName = provider.GetQualifiedName(ctxRouter, routerHTTPConfig.TLS.Options) + router.TLS.ResolvedOptions = traefiktls.DefaultTLSConfigName + if len(router.TLS.Options) > 0 && router.TLS.Options != traefiktls.DefaultTLSConfigName { + router.TLS.ResolvedOptions = provider.GetQualifiedName(provider.AddInContext(context.Background(), name), router.TLS.Options) } - domains, err := httpmuxer.ParseDomains(routerHTTPConfig.Rule) + for _, ep := range router.EntryPoints { + if routersByEntryPoint[ep] == nil { + routersByEntryPoint[ep] = map[string]*dynamic.Router{} + } + + routersByEntryPoint[ep][name] = router + } + } + + // Resolve the TLS options independently for each entryPoint. + conflictingRouters := make(map[string][]string, len(routersByEntryPoint)) + for ep, epRouters := range routersByEntryPoint { + conflictingRouters[ep] = findConflictingRouters(epRouters) + } + + for name, router := range routers { + router.EntryPoints = slices.DeleteFunc(router.EntryPoints, func(ep string) bool { + deleted := slices.Contains(conflictingRouters[ep], name) + if deleted { + rt := router.DeepCopy() + rt.TLS.ResolvedOptions = traefiktls.DefaultTLSConfigName + rt.EntryPoints = []string{ep} + newRouters[ep+"-"+name] = rt + } + + return deleted + }) + + if len(router.EntryPoints) > 0 { + newRouters[name] = router + } + } + + return newRouters +} + +// findConflictingRouters returns the names of the routers, among the given +// single-entryPoint routers, that serve a host (SNI) also served by another router +// with a different resolved TLS option. Such routers are arbitrated by falling back +// to the default TLS options. +func findConflictingRouters(routers map[string]*dynamic.Router) []string { + var conflicting []string + + // For each host (SNI, already lower-cased by the domain parsing), the routers + // serving it grouped by their resolved TLS option. A host with more than one + // group is served with conflicting TLS options. + routersByHostAndOption := map[string]map[string][]string{} + + for name, router := range routers { + if router.TLS == nil { + continue + } + + domains, err := httpmuxer.ParseDomains(router.Rule) if err != nil { - routerErr := fmt.Errorf("invalid rule %s, error: %w", routerHTTPConfig.Rule, err) - logger.Error(routerErr) continue } + // A router without a domain in its rule cannot be matched against an SNI, + // so it always falls back to the default TLS options. if len(domains) == 0 { - rts[routerHTTPName].TLS.ResolvedOptions = "default" - logger.Warnf("No domain found in rule %v, the TLS options applied for this router will depend on the SNI of each request", routerHTTPConfig.Rule) + conflicting = append(conflicting, name) + continue } for _, domain := range domains { - // domain is already in lower case thanks to the domain parsing - if tlsOptionsForHostSNI[domain] == nil { - tlsOptionsForHostSNI[domain] = make(map[string][]string) + if routersByHostAndOption[domain] == nil { + routersByHostAndOption[domain] = map[string][]string{} } - tlsOptionsForHostSNI[domain][tlsOptionsName] = append(tlsOptionsForHostSNI[domain][tlsOptionsName], routerHTTPName) + option := router.TLS.ResolvedOptions + routersByHostAndOption[domain][option] = append(routersByHostAndOption[domain][option], name) } } - for hostSNI, tlsConfigs := range tlsOptionsForHostSNI { - if len(tlsConfigs) == 1 { - for optionsName, v := range tlsConfigs { - log.WithoutContext().Debugf("Adding route for %s with TLS options %s", hostSNI, optionsName) - for _, s := range v { - rts[s].TLS.ResolvedOptions = optionsName - } - } + for _, routersByOption := range routersByHostAndOption { + if len(routersByOption) == 1 { continue } - // multiple tlsConfigs - routers := make([]string, 0, len(tlsConfigs)) - for _, v := range tlsConfigs { - for _, s := range v { - rts[s].TLS.ResolvedOptions = traefiktls.DefaultTLSConfigName - routers = append(routers, s) - } + for _, names := range routersByOption { + conflicting = append(conflicting, names...) } - - log.WithoutContext().Warnf("Found different TLS options for routers on the same host %v, so using the default TLS options instead for these routers: %#v", hostSNI, routers) } - cfg.HTTP.Routers = rts - return cfg + return conflicting } func applyModel(cfg dynamic.Configuration) dynamic.Configuration { diff --git a/pkg/server/aggregator_test.go b/pkg/server/aggregator_test.go index 48ba181a2..c0c24e171 100644 --- a/pkg/server/aggregator_test.go +++ b/pkg/server/aggregator_test.go @@ -5,6 +5,7 @@ import ( "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/traefik/traefik/v2/pkg/config/dynamic" "github.com/traefik/traefik/v2/pkg/tls" ) @@ -666,3 +667,91 @@ func Test_applyModel(t *testing.T) { }) } } + +func Test_resolveHTTPTLSOptions(t *testing.T) { + testCases := []struct { + desc string + routers map[string]*dynamic.Router + expected map[string]string // router name -> ResolvedOptions + unexpectedRouters []string + }{ + { + desc: "same host, different options, different entryPoints: no conflict", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsA"}}, + "router-b@file": {EntryPoints: []string{"ep-b"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsB"}}, + }, + expected: map[string]string{ + "router-a@file": "optsA@file", + "router-b@file": "optsB@file", + }, + }, + { + desc: "same host, different options, same entryPoint: conflict falls back to default", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsA"}}, + "router-b@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsB"}}, + }, + expected: map[string]string{ + "ep-a-router-a@file": "default", + "ep-a-router-b@file": "default", + }, + unexpectedRouters: []string{"router-a@file", "router-b@file"}, + }, + { + desc: "same host, same options, same entryPoint: keeps the configured options", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsA"}}, + "router-b@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`) && PathPrefix(`/foo`)", TLS: &dynamic.RouterTLSConfig{Options: "optsA"}}, + }, + expected: map[string]string{ + "router-a@file": "optsA@file", + "router-b@file": "optsA@file", + }, + }, + { + desc: "router spanning two entryPoints, conflict on one only: router is duplicated", + routers: map[string]*dynamic.Router{ + "shared@file": {EntryPoints: []string{"ep-a", "ep-b"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsX"}}, + "other@file": {EntryPoints: []string{"ep-a"}, Rule: "Host(`example.com`)", TLS: &dynamic.RouterTLSConfig{Options: "optsY"}}, + }, + expected: map[string]string{ + "ep-a-shared@file": "default", // conflicts with other@file on ep-a + "shared@file": "optsX@file", // alone on ep-b + "ep-a-other@file": "default", + }, + unexpectedRouters: []string{"other@file"}, + }, + { + desc: "no domain in rule: depends on SNI, resolves to default", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "PathPrefix(`/foo`)", TLS: &dynamic.RouterTLSConfig{Options: "optsA"}}, + }, + expected: map[string]string{ + "ep-a-router-a@file": "default", + }, + unexpectedRouters: []string{"router-a@file"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + got := resolveHTTPTLSOptions(test.routers) + + for name, want := range test.expected { + rt, ok := got[name] + + require.True(t, ok, "router %q is missing", name) + require.NotNil(t, rt.TLS, "router %q has no TLS config", name) + assert.Equal(t, want, rt.TLS.ResolvedOptions, "router %q %v", name, rt.EntryPoints) + } + + for _, name := range test.unexpectedRouters { + _, ok := got[name] + require.False(t, ok, "router %q is present", name) + } + }) + } +} diff --git a/pkg/server/configurationwatcher.go b/pkg/server/configurationwatcher.go index 5c1e8241e..29c413958 100644 --- a/pkg/server/configurationwatcher.go +++ b/pkg/server/configurationwatcher.go @@ -167,7 +167,9 @@ func (c *ConfigurationWatcher) applyConfigurations(ctx context.Context) { conf := mergeConfiguration(newConfigs.DeepCopy(), c.defaultEntryPoints) conf = applyModel(conf) - conf = resolveHTTPTLSOptions(conf) + if conf.HTTP != nil { + conf.HTTP.Routers = resolveHTTPTLSOptions(conf.HTTP.Routers) + } for _, listener := range c.configurationListeners { listener(conf)