Fix routers with same host, different tlsoptions on different entryPoint

Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
Julien Salleyron
2026-06-09 17:08:07 +02:00
committed by GitHub
parent ba8830fdef
commit 4ef4c09300
6 changed files with 438 additions and 56 deletions
@@ -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"
+147 -1
View File
@@ -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")
+2 -2
View File
@@ -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)
}
+96 -52
View File
@@ -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 {
+89
View File
@@ -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)
}
})
}
}
+3 -1
View File
@@ -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)