From a664812e9c30f8c9705ce16f0d2f2d96e017bccd Mon Sep 17 00:00:00 2001 From: Romain Date: Thu, 4 Jun 2026 10:16:05 +0200 Subject: [PATCH] Compute resolved tlsOptions after applying models Co-authored-by: Gina A. <70909035+gndz07@users.noreply.github.com> --- .../fixtures/https/https_entrypoint_tls.toml | 53 ++++++++++++++++ integration/https_test.go | 63 ++++++++++++++++++- pkg/server/aggregator.go | 2 +- pkg/server/configurationwatcher.go | 1 + pkg/server/configurationwatcher_test.go | 49 +++++++++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 integration/fixtures/https/https_entrypoint_tls.toml diff --git a/integration/fixtures/https/https_entrypoint_tls.toml b/integration/fixtures/https/https_entrypoint_tls.toml new file mode 100644 index 0000000000..26a85c8128 --- /dev/null +++ b/integration/fixtures/https/https_entrypoint_tls.toml @@ -0,0 +1,53 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + +[entryPoints] + [entryPoints.websecure] + address = ":4443" + [entryPoints.websecure.http.tls] + + [entryPoints.websecure-options] + address = ":4444" + [entryPoints.websecure-options.http.tls] + options = "foo" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + entryPoints = ["websecure"] + service = "service1" + rule = "Host(`snitest.com`)" + + [http.routers.router2] + entryPoints = ["websecure-options"] + service = "service1" + rule = "Host(`snitest.org`)" + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "http://127.0.0.1:9010" + +[[tls.certificates]] + certFile = "fixtures/https/snitest.com.cert" + keyFile = "fixtures/https/snitest.com.key" + +[[tls.certificates]] + certFile = "fixtures/https/snitest.org.cert" + keyFile = "fixtures/https/snitest.org.key" + +[tls.options] + [tls.options.foo] + maxVersion = "VersionTLS12" diff --git a/integration/https_test.go b/integration/https_test.go index 8bdf5c1c45..2a79da18b3 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -114,8 +114,69 @@ func (s *HTTPSSuite) TestWithSNIConfigRoute() { require.NoError(s.T(), err) } -// TestWithTLSOptions verifies that traefik routes the requests with the associated tls options. +// TestWithEntryPointTLSConfig verifies that a router relying on the entry point +// TLS configuration (without an explicit router TLS section) is served over HTTPS, +// including when the entry point references user-defined TLS options. +// Regression test for https://github.com/traefik/traefik/issues/13289. +func (s *HTTPSSuite) TestWithEntryPointTLSConfig() { + file := s.adaptFile("fixtures/https/https_entrypoint_tls.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(`snitest.com`)")) + require.NoError(s.T(), err) + + backend := startTestServer("9010", http.StatusNoContent, "") + defer backend.Close() + + err = try.GetRequest(backend.URL, 1*time.Second, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: "snitest.com", + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr.TLSClientConfig.ServerName + req.Header.Set("Host", tr.TLSClientConfig.ServerName) + req.Header.Set("Accept", "*/*") + + err = try.RequestWithTransport(req, 30*time.Second, tr, try.HasCn(tr.TLSClientConfig.ServerName), try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + // The websecure-options entry point references the user-defined "foo" TLS options (maxVersion VersionTLS12). + // A request with no router-level TLS must still have these options resolved and applied. + trOptions := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: "snitest.org", + }, + } + + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4444/", nil) + require.NoError(s.T(), err) + req.Host = trOptions.TLSClientConfig.ServerName + req.Header.Set("Host", trOptions.TLSClientConfig.ServerName) + req.Header.Set("Accept", "*/*") + + err = try.RequestWithTransport(req, 30*time.Second, trOptions, try.HasCn(trOptions.TLSClientConfig.ServerName), try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + // A TLS 1.3-only client must fail the handshake, proving the "foo" options + // (resolved from the entry point) are effectively enforced. + _, err = tls.Dial("tcp", "127.0.0.1:4444", &tls.Config{ + InsecureSkipVerify: true, + ServerName: "snitest.org", + MinVersion: tls.VersionTLS13, + }) + assert.Error(s.T(), err) +} + +// TestWithTLSOptions verifies that traefik routes the requests with the associated tls options. func (s *HTTPSSuite) TestWithTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go index 2dcabdae2b..1d8f5ea337 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -138,7 +138,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint delete(conf.TLS.Options, traefiktls.DefaultTLSConfigName) } - return resolveHTTPTLSOptions(conf) + return conf } func resolveHTTPTLSOptions(cfg dynamic.Configuration) dynamic.Configuration { diff --git a/pkg/server/configurationwatcher.go b/pkg/server/configurationwatcher.go index 4d6798694e..5c1e8241ec 100644 --- a/pkg/server/configurationwatcher.go +++ b/pkg/server/configurationwatcher.go @@ -167,6 +167,7 @@ func (c *ConfigurationWatcher) applyConfigurations(ctx context.Context) { conf := mergeConfiguration(newConfigs.DeepCopy(), c.defaultEntryPoints) conf = applyModel(conf) + conf = resolveHTTPTLSOptions(conf) for _, listener := range c.configurationListeners { listener(conf) diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go index 3e6c32fd76..08fd497e0d 100644 --- a/pkg/server/configurationwatcher_test.go +++ b/pkg/server/configurationwatcher_test.go @@ -893,3 +893,52 @@ func TestPublishConfigUpdatedByConfigWatcherListener(t *testing.T) { assert.Equal(t, 1, publishedConfigCount) } + +// TestEntryPointTLSResolvedOptions is a regression test for +// https://github.com/traefik/traefik/issues/13289: a router whose TLS +// configuration comes from the entry point (and not from an explicit router TLS +// section) must still have its TLS options resolved in the published configuration. +func TestEntryPointTLSResolvedOptions(t *testing.T) { + routinesPool := safe.NewPool(t.Context()) + t.Cleanup(routinesPool.Stop) + + pvd := &mockProvider{ + messages: []dynamic.Message{{ + ProviderName: "internal", + Configuration: &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "foo": { + EntryPoints: []string{"websecure"}, + Rule: "Host(`foo.example.com`)", + Service: "service", + }, + }, + Models: map[string]*dynamic.Model{ + "websecure": { + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + }, + }, + }}, + } + + watcher := NewConfigurationWatcher(routinesPool, pvd, []string{}, "") + + run := make(chan struct{}) + watcher.AddListener(func(conf dynamic.Configuration) { + router := conf.HTTP.Routers["foo@internal"] + if router == nil || router.TLS == nil { + return + } + + assert.Equal(t, "default", router.TLS.ResolvedOptions) + close(run) + }) + + watcher.Start() + t.Cleanup(watcher.Stop) + + <-run +}