From 15ecff2bbd344e5a8c4e5ebb64a6a31179daf69b Mon Sep 17 00:00:00 2001 From: "Gina A." <70909035+gndz07@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:08:05 +0200 Subject: [PATCH 1/9] Skip ingress when auth-secret resolution fails --- .../kubernetes/ingress-nginx/build.go | 10 +++--- .../ingress-with-basicauth-secret-missing.yml | 25 +++++++++++++++ .../ingress-nginx/kubernetes_test.go | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-basicauth-secret-missing.yml diff --git a/pkg/provider/kubernetes/ingress-nginx/build.go b/pkg/provider/kubernetes/ingress-nginx/build.go index e39e243b9..c319f5cd8 100644 --- a/pkg/provider/kubernetes/ingress-nginx/build.go +++ b/pkg/provider/kubernetes/ingress-nginx/build.go @@ -415,11 +415,13 @@ func (p *Provider) build(ctx context.Context, ingressClasses []*netv1.IngressCla logger.Error(). Err(err). Str("ingress", fmt.Sprintf("%s/%s rule-%d path-%d", ing.Namespace, ing.Name, ri, pi)). - Msg("Cannot resolve auth secret, skipping auth middleware") - } else { - loc.BasicAuth = basic - loc.DigestAuth = digest + Msg("Cannot resolve auth secret, skipping ingress") + // Skipping the ingress entirely when auth secret resolution fails, + // to match ingress-nginx behavior. + continue } + loc.BasicAuth = basic + loc.DigestAuth = digest } // Pre-resolve custom headers ConfigMap. diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-basicauth-secret-missing.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-basicauth-secret-missing.yml new file mode 100644 index 000000000..2c3f630a0 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-basicauth-secret-missing.yml @@ -0,0 +1,25 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-basicauth-secret-missing + namespace: default + annotations: + nginx.ingress.kubernetes.io/auth-type: "basic" + nginx.ingress.kubernetes.io/auth-secret-type: "auth-file" + nginx.ingress.kubernetes.io/auth-secret: "default/missing-basic-auth" + nginx.ingress.kubernetes.io/auth-realm: "Authentication Required" + +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: /basicauth + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 981246150..a594a8406 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -1329,6 +1329,37 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Basic Auth with missing secret — ingress is skipped entirely", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-basicauth-secret-missing.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "unavailable-service": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Forward Auth", paths: []string{ From 8447bfc71e971ed150d610d67ca261d64253695c Mon Sep 17 00:00:00 2001 From: Baptiste Mayelle Date: Tue, 9 Jun 2026 16:24:05 +0200 Subject: [PATCH 2/9] Reject cross-provider references with backendRefs.namespace --- .../httproute/invalid_cross_provider.yml | 55 +++++++++++++++ .../tcproute/invalid_cross_provider.yml | 51 ++++++++++++++ .../tlsroute/invalid_cross_provider.yml | 67 +++++++++++++++++++ pkg/provider/kubernetes/gateway/httproute.go | 11 +++ .../kubernetes/gateway/kubernetes_test.go | 36 +++++----- pkg/provider/kubernetes/gateway/tcproute.go | 11 +++ pkg/provider/kubernetes/gateway/tlsroute.go | 11 +++ 7 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 pkg/provider/kubernetes/gateway/fixtures/httproute/invalid_cross_provider.yml create mode 100644 pkg/provider/kubernetes/gateway/fixtures/tcproute/invalid_cross_provider.yml create mode 100644 pkg/provider/kubernetes/gateway/fixtures/tlsroute/invalid_cross_provider.yml diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/invalid_cross_provider.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/invalid_cross_provider.yml new file mode 100644 index 000000000..55bb251d3 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/invalid_cross_provider.yml @@ -0,0 +1,55 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "foo.com" + rules: + - matches: + - path: + type: Exact + value: /bar + backendRefs: + - weight: 1 + group: traefik.io + kind: TraefikService + name: service@file + namespace: bar + port: 80 + + - name: whoami + port: 80 + weight: 1 + group: "" + kind: Service diff --git a/pkg/provider/kubernetes/gateway/fixtures/tcproute/invalid_cross_provider.yml b/pkg/provider/kubernetes/gateway/fixtures/tcproute/invalid_cross_provider.yml new file mode 100644 index 000000000..b04ab7f1e --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/tcproute/invalid_cross_provider.yml @@ -0,0 +1,51 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: tcp + protocol: TCP + port: 9000 + allowedRoutes: + kinds: + - kind: TCPRoute + group: gateway.networking.k8s.io + namespaces: + from: Same + +--- +kind: TCPRoute +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: tcp-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + rules: + - backendRefs: + - weight: 1 + group: traefik.io + kind: TraefikService + name: service@file + namespace: bar + port: 9000 + - name: whoamitcp + port: 9000 + weight: 1 + group: "" + kind: Service diff --git a/pkg/provider/kubernetes/gateway/fixtures/tlsroute/invalid_cross_provider.yml b/pkg/provider/kubernetes/gateway/fixtures/tlsroute/invalid_cross_provider.yml new file mode 100644 index 000000000..5e79dca73 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/tlsroute/invalid_cross_provider.yml @@ -0,0 +1,67 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: supersecret + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJxRENDQVU2Z0F3SUJBZ0lVWU9zcjBRZ0hPQnE0a1lSQ0w1K1REZFZ0NmJRd0NnWUlLb1pJemowRUF3SXcKRmpFVU1CSUdBMVVFQXd3TFpYaGhiWEJzWlM1amIyMHdIaGNOTWpVeE1ERXdNRGN4TnpNd1doY05NelV4TURBNApNRGN4TnpNd1dqQVdNUlF3RWdZRFZRUUREQXRsZUdGdGNHeGxMbU52YlRCWk1CTUdCeXFHU000OUFnRUdDQ3FHClNNNDlBd0VIQTBJQUJET3JpdzNaUTd3SWhXcmJQUzZKRlFUM2JUb05DRjAwdlNWNWZhYjZUYlh5TDh0bHNHcmUKVFJJRjJFd2dzdGVNT2t4R0tLU2xEdnVhRHdxOHAvcVYrMHVqZWpCNE1CMEdBMVVkRGdRV0JCUk1Fa3VleFhRaApVdERnUmcxS0J2NzJDRHErRXpBZkJnTlZIU01FR0RBV2dCUk1Fa3VleFhRaFV0RGdSZzFLQnY3MkNEcStFekFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUNVR0ExVWRFUVFlTUJ5Q0MyVjRZVzF3YkdVdVkyOXRnZzBxTG1WNFlXMXcKYkdVdVkyOXRNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUURzODdWazBzd0E2SGdPSmpST3llMW14RDgzcWNHeQpwZUZnb3hWOTNEeStjd0lnVjBNTUVKSmJWc1R5WkszRVErK1hjNXJFTDc4bnJKK1lJRVYrckNVV2o1VT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ253Z0w1RFk0VUIxNHNNNmYKRGlrUWR0cWgyUVcxQXJmRjRmYzFVRnppZmRHaFJBTkNBQVF6cTRzTjJVTzhDSVZxMnowdWlSVUU5MjA2RFFoZApOTDBsZVgybStrMjE4aS9MWmJCcTNrMFNCZGhNSUxMWGpEcE1SaWlrcFE3N21nOEt2S2Y2bGZ0TAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t + +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: tls + protocol: TLS + port: 9000 + tls: + certificateRefs: + - kind: Secret + name: supersecret + group: "" + allowedRoutes: + kinds: + - kind: TLSRoute + group: gateway.networking.k8s.io + namespaces: + from: Same + +--- +kind: TLSRoute +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: tls-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + rules: + - backendRefs: + - weight: 1 + group: traefik.io + kind: TraefikService + name: service@file + namespace: bar + port: 9000 + - name: whoamitcp + port: 9000 + weight: 1 + kind: Service + group: "" diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go index 088c3dc8e..af6c0f614 100644 --- a/pkg/provider/kubernetes/gateway/httproute.go +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -237,6 +237,17 @@ func (p *Provider) loadService(ctx context.Context, listener gatewayListener, co namespace := route.Namespace if backendRef.Namespace != nil && *backendRef.Namespace != "" { namespace = string(*backendRef.Namespace) + + if strings.Contains(string(backendRef.Name), "@") { + return provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http"), &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonRefNotPermitted), + Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: namespace is not allowed with a cross-provider reference", group, kind, namespace, backendRef.Name), + } + } } serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http") diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 3e6d08a88..3fdad7bff 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -8366,20 +8366,22 @@ func Test_isCrossProviderNamespaceAllowed(t *testing.T) { func TestCrossProviderNamespaces_HTTPRoute(t *testing.T) { testCases := []struct { desc string + fixture string crossProviderNamespaces []string wantError bool }{ - {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", crossProviderNamespaces: nil, wantError: false}, - {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", crossProviderNamespaces: []string{}, wantError: true}, - {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", crossProviderNamespaces: []string{"default"}, wantError: false}, - {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", fixture: "httproute/simple_cross_provider.yml", crossProviderNamespaces: nil, wantError: false}, + {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", fixture: "httproute/simple_cross_provider.yml", crossProviderNamespaces: []string{}, wantError: true}, + {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", fixture: "httproute/simple_cross_provider.yml", crossProviderNamespaces: []string{"default"}, wantError: false}, + {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", fixture: "httproute/simple_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "namespace provided with cross-provider backendRef, route dropped", fixture: "httproute/invalid_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - k8sObjects, gwObjects := readResources(t, []string{"services.yml", "httproute/simple_cross_provider.yml"}) + k8sObjects, gwObjects := readResources(t, []string{"services.yml", test.fixture}) kubeClient := kubefake.NewClientset(k8sObjects...) gwClient := newGatewaySimpleClientSet(t, gwObjects...) @@ -8429,20 +8431,22 @@ func TestCrossProviderNamespaces_HTTPRoute(t *testing.T) { func TestCrossProviderNamespaces_TCPRoute(t *testing.T) { testCases := []struct { desc string + fixture string crossProviderNamespaces []string wantError bool }{ - {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", crossProviderNamespaces: nil, wantError: false}, - {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", crossProviderNamespaces: []string{}, wantError: true}, - {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", crossProviderNamespaces: []string{"default"}, wantError: false}, - {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", fixture: "tcproute/simple_cross_provider.yml", crossProviderNamespaces: nil, wantError: false}, + {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", fixture: "tcproute/simple_cross_provider.yml", crossProviderNamespaces: []string{}, wantError: true}, + {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", fixture: "tcproute/simple_cross_provider.yml", crossProviderNamespaces: []string{"default"}, wantError: false}, + {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", fixture: "tcproute/simple_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "namespace provided with cross-provider backendRef, route dropped", fixture: "tcproute/invalid_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - k8sObjects, gwObjects := readResources(t, []string{"services.yml", "tcproute/simple_cross_provider.yml"}) + k8sObjects, gwObjects := readResources(t, []string{"services.yml", test.fixture}) kubeClient := kubefake.NewClientset(k8sObjects...) gwClient := newGatewaySimpleClientSet(t, gwObjects...) @@ -8501,20 +8505,22 @@ func TestCrossProviderNamespaces_TCPRoute(t *testing.T) { func TestCrossProviderNamespaces_TLSRoute(t *testing.T) { testCases := []struct { desc string + fixture string crossProviderNamespaces []string wantError bool }{ - {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", crossProviderNamespaces: nil, wantError: false}, - {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", crossProviderNamespaces: []string{}, wantError: true}, - {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", crossProviderNamespaces: []string{"default"}, wantError: false}, - {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "nil: cross-provider TraefikService backendRefs accepted (backward compatible)", fixture: "tlsroute/simple_cross_provider.yml", crossProviderNamespaces: nil, wantError: false}, + {desc: "empty list: cross-provider TraefikService backendRefs are rejected, route dropped", fixture: "tlsroute/simple_cross_provider.yml", crossProviderNamespaces: []string{}, wantError: true}, + {desc: "namespace allowed: cross-provider TraefikService backendRefs accepted", fixture: "tlsroute/simple_cross_provider.yml", crossProviderNamespaces: []string{"default"}, wantError: false}, + {desc: "namespace not allowed: cross-provider TraefikService backendRefs rejected, route dropped", fixture: "tlsroute/simple_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, + {desc: "namespace provided with cross-provider backendRef, route dropped", fixture: "tlsroute/invalid_cross_provider.yml", crossProviderNamespaces: []string{"other"}, wantError: true}, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - k8sObjects, gwObjects := readResources(t, []string{"services.yml", "tlsroute/simple_cross_provider.yml"}) + k8sObjects, gwObjects := readResources(t, []string{"services.yml", test.fixture}) kubeClient := kubefake.NewClientset(k8sObjects...) gwClient := newGatewaySimpleClientSet(t, gwObjects...) diff --git a/pkg/provider/kubernetes/gateway/tcproute.go b/pkg/provider/kubernetes/gateway/tcproute.go index 3a9632a7d..2ea0a962b 100644 --- a/pkg/provider/kubernetes/gateway/tcproute.go +++ b/pkg/provider/kubernetes/gateway/tcproute.go @@ -221,6 +221,17 @@ func (p *Provider) loadTCPService(route *gatev1alpha2.TCPRoute, backendRef gatev namespace := route.Namespace if backendRef.Namespace != nil && *backendRef.Namespace != "" { namespace = string(*backendRef.Namespace) + + if strings.Contains(string(backendRef.Name), "@") { + return provider.Normalize(namespace + "-" + string(backendRef.Name)), nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonRefNotPermitted), + Message: fmt.Sprintf("Cannot load TCPRoute BackendRef %s/%s/%s/%s: namespace is not allowed with a cross-provider reference", group, kind, namespace, backendRef.Name), + } + } } serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name)) diff --git a/pkg/provider/kubernetes/gateway/tlsroute.go b/pkg/provider/kubernetes/gateway/tlsroute.go index 56bfae939..fae27acec 100644 --- a/pkg/provider/kubernetes/gateway/tlsroute.go +++ b/pkg/provider/kubernetes/gateway/tlsroute.go @@ -224,6 +224,17 @@ func (p *Provider) loadTLSService(route *gatev1alpha2.TLSRoute, backendRef gatev namespace := route.Namespace if backendRef.Namespace != nil && *backendRef.Namespace != "" { namespace = string(*backendRef.Namespace) + + if strings.Contains(string(backendRef.Name), "@") { + return provider.Normalize(namespace + "-" + string(backendRef.Name)), nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonRefNotPermitted), + Message: fmt.Sprintf("Cannot load TLSRoute BackendRef %s/%s/%s/%s: namespace is not allowed with a cross-provider reference", group, kind, namespace, backendRef.Name), + } + } } serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name)) From d5ad3eb63bd1f539eb5c41d9d760b1eaf614c550 Mon Sep 17 00:00:00 2001 From: Learloj Date: Tue, 9 Jun 2026 16:28:05 +0200 Subject: [PATCH 3/9] Pass endpointslice fencing on ingress-nginx provider --- .../ingress-with-endpoint-conditions.yml | 69 +++++++++++++ .../ingress-nginx/kubernetes_test.go | 96 +++++++++++++++++++ .../kubernetes/ingress-nginx/translator.go | 6 +- 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-endpoint-conditions.yml diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-endpoint-conditions.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-endpoint-conditions.yml new file mode 100644 index 000000000..4def723e7 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-endpoint-conditions.yml @@ -0,0 +1,69 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-endpoint-conditions + namespace: default + annotations: + kubernetes.io/ingress.class: nginx + +spec: + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 + +--- +kind: Service +apiVersion: v1 +metadata: + name: whoami + namespace: default + +spec: + clusterIP: 10.10.10.1 + ports: + - name: web + protocol: TCP + port: 80 + targetPort: web + +--- +kind: EndpointSlice +apiVersion: discovery.k8s.io/v1 +metadata: + name: whoami-abc + namespace: default + labels: + kubernetes.io/service-name: whoami + +addressType: IPv4 +ports: + - name: web + port: 80 +endpoints: + - addresses: + - 10.10.0.1 + conditions: + ready: true + serving: true + terminating: false + - addresses: + - 10.10.0.2 + conditions: + ready: false + serving: true + terminating: true + - addresses: + - 10.10.0.3 + conditions: + ready: false + serving: false + terminating: true diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index a594a8406..ed7acf395 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -15687,6 +15687,102 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Ingress with endpoint conditions", + paths: []string{ + "ingressclasses.yml", + "ingresses/ingress-with-endpoint-conditions.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-endpoint-conditions-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Service: "default-ingress-with-endpoint-conditions-whoami-80", + Middlewares: []string{"default-ingress-with-endpoint-conditions-rule-0-path-0-retry"}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-endpoint-conditions", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-endpoint-conditions-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Service: "default-ingress-with-endpoint-conditions-whoami-80", + Middlewares: []string{"default-ingress-with-endpoint-conditions-rule-0-path-0-tls-retry"}, + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-endpoint-conditions", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-endpoint-conditions-rule-0-path-0-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + "default-ingress-with-endpoint-conditions-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-endpoint-conditions-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80", Fenced: true}, + }, + Strategy: dynamic.BalancerStrategyWRR, + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-endpoint-conditions", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "unavailable-service": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: dynamic.BalancerStrategyWRR, + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-endpoint-conditions": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Auth TLS secret missing — ingress is skipped entirely", paths: []string{ diff --git a/pkg/provider/kubernetes/ingress-nginx/translator.go b/pkg/provider/kubernetes/ingress-nginx/translator.go index c70a4e718..80b66e927 100644 --- a/pkg/provider/kubernetes/ingress-nginx/translator.go +++ b/pkg/provider/kubernetes/ingress-nginx/translator.go @@ -294,7 +294,8 @@ func buildService(backend *backend, serversTransportName string) *dynamic.Servic svc := &dynamic.Service{LoadBalancer: lb} for _, ep := range backend.Endpoints { svc.LoadBalancer.Servers = append(svc.LoadBalancer.Servers, dynamic.Server{ - URL: fmt.Sprintf("http://%s", ep.Address), + URL: fmt.Sprintf("http://%s", ep.Address), + Fenced: ep.Fenced, }) } @@ -320,7 +321,8 @@ func buildServiceWithLocConfig(backend *backend, serversTransportName string, lo svc := &dynamic.Service{LoadBalancer: lb} for _, ep := range backend.Endpoints { svc.LoadBalancer.Servers = append(svc.LoadBalancer.Servers, dynamic.Server{ - URL: fmt.Sprintf("%s://%s", scheme, ep.Address), + URL: fmt.Sprintf("%s://%s", scheme, ep.Address), + Fenced: ep.Fenced, }) } From 4ef4c09300f56fff7004f2962f6227e75360c4e2 Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Tue, 9 Jun 2026 17:08:07 +0200 Subject: [PATCH 4/9] 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) From 149e62d6db59d10ea22a698e0823abd7acb1ff11 Mon Sep 17 00:00:00 2001 From: Tim Schumacher Date: Tue, 9 Jun 2026 17:24:05 +0200 Subject: [PATCH 5/9] Bump to github.com/pires/go-proxyproto v0.12.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3b3ad05d8..e2184ad48 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/moby/moby/api v1.54.1 github.com/moby/moby/client v0.4.0 github.com/patrickmn/go-cache v2.1.0+incompatible - github.com/pires/go-proxyproto v0.8.1 + github.com/pires/go-proxyproto v0.12.0 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // No tag on the repo. github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_model v0.6.2 diff --git a/go.sum b/go.sum index ae928f81f..2f2aef27e 100644 --- a/go.sum +++ b/go.sum @@ -1754,8 +1754,8 @@ github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= +github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= From 0209f984eb950861e10fc9bf0af993cd54b494b5 Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 10 Jun 2026 15:16:06 +0200 Subject: [PATCH 6/9] Fix snicheck for routers with no hosts Co-authored-by: Gina A. <70909035+gndz07@users.noreply.github.com> --- integration/https_test.go | 2 +- integration/simple_test.go | 6 +++--- pkg/server/aggregator.go | 25 +++++++++++++--------- pkg/server/aggregator_test.go | 36 +++++++++++++++++++++++++------- pkg/server/router/tcp/manager.go | 3 +-- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/integration/https_test.go b/integration/https_test.go index 66560a3a8..2a9075c17 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -315,7 +315,7 @@ func (s *HTTPSSuite) TestWithConflictingTLSOptions() { assert.ErrorContains(s.T(), err, "tls: no supported versions satisfy MinVersion and MaxVersion") // with unknown tls option - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("found different TLS options for routers on the same host, so using the default TLS options instead")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("router's TLSOptions configuration is conflicting with other routers on the same entrypoint and host, default TLS options will be used instead")) require.NoError(s.T(), err) } diff --git a/integration/simple_test.go b/integration/simple_test.go index 0d0423560..10cdbeaaf 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -667,7 +667,7 @@ func (s *SimpleSuite) TestRouterConfigErrors() { s.traefikCmd(withConfigFile(file)) // All errors - err := try.GetRequest("http://127.0.0.1:8080/api/http/routers", 1000*time.Millisecond, try.BodyContains(`["middleware \"unknown@file\" does not exist","found different TLS options for routers on the same host, so using the default TLS options instead"]`)) + err := try.GetRequest("http://127.0.0.1:8080/api/http/routers", 1000*time.Millisecond, try.BodyContains(`["middleware \"unknown@file\" does not exist","router's TLSOptions configuration is conflicting with other routers on the same entrypoint and host, default TLS options will be used instead"]`)) require.NoError(s.T(), err) // router3 has an error because it uses an unknown entrypoint @@ -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/websecure-router4@file", 1000*time.Millisecond, try.BodyContains(`"status":"warning"`)) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/websecure-conflicted-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/websecure-router5@file", 1000*time.Millisecond, try.BodyContains()) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/websecure-conflicted-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 939771041..ba88f8bfc 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -150,9 +150,8 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint // // 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. +// entryPoint is removed from the router and a dedicated copy is emitted, with its +// TLSOptions reset to the default one, named following the "ep-conflicted-name@provider" pattern. func resolveHTTPTLSOptions(routers map[string]*dynamic.Router) map[string]*dynamic.Router { if len(routers) == 0 { return routers @@ -187,7 +186,7 @@ func resolveHTTPTLSOptions(routers map[string]*dynamic.Router) map[string]*dynam // Resolve the TLS options independently for each entryPoint. conflictingRouters := make(map[string][]string, len(routersByEntryPoint)) for ep, epRouters := range routersByEntryPoint { - conflictingRouters[ep] = findConflictingRouters(epRouters) + conflictingRouters[ep] = findConflictingRouters(ep, epRouters) } for name, router := range routers { @@ -197,7 +196,9 @@ func resolveHTTPTLSOptions(routers map[string]*dynamic.Router) map[string]*dynam rt := router.DeepCopy() rt.TLS.ResolvedOptions = traefiktls.DefaultTLSConfigName rt.EntryPoints = []string{ep} - newRouters[ep+"-"+name] = rt + // The new name is not collision free but has very small possibility to collide. + // TODO: rework this naming whenever we'll introduce a resource reference mechanism not based on a string. + newRouters[ep+"-conflicted-"+name] = rt } return deleted @@ -215,7 +216,7 @@ func resolveHTTPTLSOptions(routers map[string]*dynamic.Router) map[string]*dynam // 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 { +func findConflictingRouters(ep string, routers map[string]*dynamic.Router) []string { var conflicting []string // For each host (SNI, already lower-cased by the domain parsing), the routers @@ -233,9 +234,9 @@ func findConflictingRouters(routers map[string]*dynamic.Router) []string { 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 { + // The configured TLSOptions on a router without a domain in its rule cannot be selected when evaluating the SNI, + // so if it is not the default one, it is a conflict. + if len(domains) == 0 && router.TLS.ResolvedOptions != traefiktls.DefaultTLSConfigName { conflicting = append(conflicting, name) continue } @@ -249,14 +250,18 @@ func findConflictingRouters(routers map[string]*dynamic.Router) []string { } } - for _, routersByOption := range routersByHostAndOption { + for domain, routersByOption := range routersByHostAndOption { if len(routersByOption) == 1 { continue } + var routersInConflict []string for _, names := range routersByOption { conflicting = append(conflicting, names...) + routersInConflict = append(routersInConflict, names...) } + + log.WithoutContext().Errorf("On EntryPoint %q, Host %q is served by multiple routers with different TLS options, default TLSOptions will be applied for the following routers: %v", ep, domain, routersInConflict) } return conflicting diff --git a/pkg/server/aggregator_test.go b/pkg/server/aggregator_test.go index c0c24e171..02ee3495c 100644 --- a/pkg/server/aggregator_test.go +++ b/pkg/server/aggregator_test.go @@ -693,8 +693,8 @@ func Test_resolveHTTPTLSOptions(t *testing.T) { "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", + "ep-a-conflicted-router-a@file": "default", + "ep-a-conflicted-router-b@file": "default", }, unexpectedRouters: []string{"router-a@file", "router-b@file"}, }, @@ -716,22 +716,44 @@ func Test_resolveHTTPTLSOptions(t *testing.T) { "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", + "ep-a-conflicted-shared@file": "default", // conflicts with other@file on ep-a + "shared@file": "optsX@file", // alone on ep-b + "ep-a-conflicted-other@file": "default", }, unexpectedRouters: []string{"other@file"}, }, { - desc: "no domain in rule: depends on SNI, resolves to default", + desc: "no domain in rule, non-default options: forced to default and renamed", 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", + "ep-a-conflicted-router-a@file": "default", }, unexpectedRouters: []string{"router-a@file"}, }, + { + desc: "no domain in rule, implicit default options: not conflicting, keeps its name", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "PathPrefix(`/foo`)", TLS: &dynamic.RouterTLSConfig{}}, + }, + expected: map[string]string{ + "router-a@file": "default", + }, + unexpectedRouters: []string{"ep-a-conflicted-router-a@file"}, + }, + { + desc: "no domain in rule, explicit default options: not conflicting, keeps its name", + routers: map[string]*dynamic.Router{ + "router-a@file": {EntryPoints: []string{"ep-a"}, Rule: "PathPrefix(`/foo`)", TLS: &dynamic.RouterTLSConfig{ + Options: "default", + }}, + }, + expected: map[string]string{ + "router-a@file": "default", + }, + unexpectedRouters: []string{"ep-a-conflicted-router-a@file"}, + }, } for _, test := range testCases { diff --git a/pkg/server/router/tcp/manager.go b/pkg/server/router/tcp/manager.go index de9e9e276..db21ab9f1 100644 --- a/pkg/server/router/tcp/manager.go +++ b/pkg/server/router/tcp/manager.go @@ -167,8 +167,7 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string } if len(domains) > 0 && routerHTTPConfig.TLS.ResolvedOptions != tlsOptionsName { - logger.Warn("Found different TLS options for routers on the same host, so using the default TLS options instead.") - routerHTTPConfig.AddError(errors.New("found different TLS options for routers on the same host, so using the default TLS options instead"), false) + routerHTTPConfig.AddError(errors.New("router's TLSOptions configuration is conflicting with other routers on the same entrypoint and host, default TLS options will be used instead"), false) } // Even though the error is seemingly ignored (aside from logging it), From ad1c1fc2f20c0dcb24df59685afedbf83c8232ec Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Wed, 10 Jun 2026 15:28:05 +0200 Subject: [PATCH 7/9] Prepare release v2.11.50 --- CHANGELOG.md | 7 +++++++ script/gcg/traefik-bugfix.toml | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b6e5402..6f94d7dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [v2.11.50](https://github.com/traefik/traefik/tree/v2.11.50) (2026-06-10) +[All Commits](https://github.com/traefik/traefik/compare/v2.11.49...v2.11.50) + +**Bug fixes:** +- **[tls]** Fix routers with same host, different tlsoptions on different entryPoint ([#13329](https://github.com/traefik/traefik/pull/13329) @juliens) +- **[tls]** Fix snicheck for routers with no hosts ([#13333](https://github.com/traefik/traefik/pull/13333) @rtribotte) + ## [v2.11.49](https://github.com/traefik/traefik/tree/v2.11.49) (2026-06-05) [All Commits](https://github.com/traefik/traefik/compare/v2.11.48...v2.11.49) diff --git a/script/gcg/traefik-bugfix.toml b/script/gcg/traefik-bugfix.toml index 4bc6c9e47..8bb7e2980 100644 --- a/script/gcg/traefik-bugfix.toml +++ b/script/gcg/traefik-bugfix.toml @@ -4,11 +4,11 @@ RepositoryName = "traefik" OutputType = "file" FileName = "traefik_changelog.md" -# example new bugfix v2.11.49 +# example new bugfix v2.11.50 CurrentRef = "v2.11" -PreviousRef = "v2.11.48" +PreviousRef = "v2.11.49" BaseBranch = "v2.11" -FutureCurrentRefName = "v2.11.49" +FutureCurrentRefName = "v2.11.50" ThresholdPreviousRef = 10000 ThresholdCurrentRef = 10000 From b46e795f41e9aa02b25f1ab4eef4907a766c15c1 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Wed, 10 Jun 2026 16:02:11 +0200 Subject: [PATCH 8/9] Prepare release v3.6.21 --- CHANGELOG.md | 9 +++++++++ script/gcg/traefik-bugfix.toml | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e9f2b3d..a2fd39f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [v3.6.21](https://github.com/traefik/traefik/tree/v3.6.21) (2026-06-10) +[All Commits](https://github.com/traefik/traefik/compare/v3.6.20...v3.6.21) + +**Bug fixes:** +- **[k8s/gatewayapi]** Reject cross-provider references with backendRefs.namespace ([#13322](https://github.com/traefik/traefik/pull/13322) @youkoulayley) +- **[server]** Bump to github.com/pires/go-proxyproto v0.12.0 ([#13313](https://github.com/traefik/traefik/pull/13313) @timschumi) +- **[tls]** Fix routers with same host, different tlsoptions on different entryPoint ([#13329](https://github.com/traefik/traefik/pull/13329) @juliens) +- **[tls]** Fix snicheck for routers with no hosts ([#13333](https://github.com/traefik/traefik/pull/13333) @rtribotte) + ## [v2.11.50](https://github.com/traefik/traefik/tree/v2.11.50) (2026-06-10) [All Commits](https://github.com/traefik/traefik/compare/v2.11.49...v2.11.50) diff --git a/script/gcg/traefik-bugfix.toml b/script/gcg/traefik-bugfix.toml index c5bf100f8..d1ea03d13 100644 --- a/script/gcg/traefik-bugfix.toml +++ b/script/gcg/traefik-bugfix.toml @@ -4,11 +4,11 @@ RepositoryName = "traefik" OutputType = "file" FileName = "traefik_changelog.md" -# example new bugfix v3.6.20 +# example new bugfix v3.6.21 CurrentRef = "v3.6" -PreviousRef = "v3.6.19" +PreviousRef = "v3.6.20" BaseBranch = "v3.6" -FutureCurrentRefName = "v3.6.20" +FutureCurrentRefName = "v3.6.21" ThresholdPreviousRef = 10000 ThresholdCurrentRef = 10000 From 26c96a3935cafb473f4a5bae1886560d9aa4e4f0 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Wed, 10 Jun 2026 16:46:07 +0200 Subject: [PATCH 9/9] Prepare release v3.7.5 --- CHANGELOG.md | 11 +++++++++++ script/gcg/traefik-bugfix.toml | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d134b8fa..c57cd5eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [v3.7.5](https://github.com/traefik/traefik/tree/v3.7.5) (2026-06-10) +[All Commits](https://github.com/traefik/traefik/compare/v3.7.4...v3.7.5) + +**Bug fixes:** +- **[k8s/ingress-nginx]** Skip ingress when auth-secret resolution fails ([#13323](https://github.com/traefik/traefik/pull/13323) @gndz07) +- **[k8s/ingress-nginx]** Pass endpointslice fencing on ingress-nginx provider ([#13290](https://github.com/traefik/traefik/pull/13290) @Learloj) +- **[k8s/gatewayapi]** Reject cross-provider references with backendRefs.namespace ([#13322](https://github.com/traefik/traefik/pull/13322) @youkoulayley) +- **[server]** Bump to github.com/pires/go-proxyproto v0.12.0 ([#13313](https://github.com/traefik/traefik/pull/13313) @timschumi) +- **[tls]** Fix routers with same host, different tlsoptions on different entryPoint ([#13329](https://github.com/traefik/traefik/pull/13329) @juliens) +- **[tls]** Fix snicheck for routers with no hosts ([#13333](https://github.com/traefik/traefik/pull/13333) @rtribotte) + ## [v3.6.21](https://github.com/traefik/traefik/tree/v3.6.21) (2026-06-10) [All Commits](https://github.com/traefik/traefik/compare/v3.6.20...v3.6.21) diff --git a/script/gcg/traefik-bugfix.toml b/script/gcg/traefik-bugfix.toml index 80f7df1e5..10914249e 100644 --- a/script/gcg/traefik-bugfix.toml +++ b/script/gcg/traefik-bugfix.toml @@ -4,11 +4,11 @@ RepositoryName = "traefik" OutputType = "file" FileName = "traefik_changelog.md" -# example new bugfix v3.7.4 +# example new bugfix v3.7.5 CurrentRef = "v3.7" -PreviousRef = "v3.7.3" +PreviousRef = "v3.7.4" BaseBranch = "v3.7" -FutureCurrentRefName = "v3.7.4" +FutureCurrentRefName = "v3.7.5" ThresholdPreviousRef = 10000 ThresholdCurrentRef = 10000