Merge branch v3.6 into v3.7

This commit is contained in:
kevinpollet
2026-05-11 11:58:39 +02:00
25 changed files with 1365 additions and 194 deletions
@@ -0,0 +1,19 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: test.route
namespace: default
spec:
entryPoints:
- foo
routes:
- match: HostSNI(`foo.com`)
services:
- name: whoamitcp
port: 8000
tls:
options:
name: foo@file
@@ -0,0 +1,18 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: test.route
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`foo.com`) && PathPrefix(`/bar`)
kind: Rule
priority: 12
services:
- name: whoami
port: 80
serversTransport: foo@file
@@ -0,0 +1,70 @@
---
apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
name: mirror-cp
namespace: foo
spec:
mirroring:
name: external-main@file
kind: TraefikService
mirrors:
- name: external-mirror@file
kind: TraefikService
percent: 50
---
apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
name: weighted-cp
namespace: bar
spec:
weighted:
services:
- name: external-a@file
kind: TraefikService
weight: 1
- name: external-b@file
kind: TraefikService
weight: 1
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ir-mirror
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`mirror.example.com`)
kind: Rule
services:
- name: mirror-cp
namespace: foo
kind: TraefikService
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ir-weighted
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`weighted.example.com`)
kind: Rule
services:
- name: weighted-cp
namespace: bar
kind: TraefikService
@@ -0,0 +1,21 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: test.route
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`foo.com`) && PathPrefix(`/bar`)
kind: Rule
priority: 12
services:
- name: whoami
port: 80
tls:
options:
name: foo@file
+64 -38
View File
@@ -57,6 +57,7 @@ type Provider struct {
Namespaces []string `description:"Kubernetes namespaces." json:"namespaces,omitempty" toml:"namespaces,omitempty" yaml:"namespaces,omitempty" export:"true"`
AllowCrossNamespace bool `description:"Allow cross namespace resource reference." json:"allowCrossNamespace,omitempty" toml:"allowCrossNamespace,omitempty" yaml:"allowCrossNamespace,omitempty" export:"true"`
AllowExternalNameServices bool `description:"Allow ExternalName services." json:"allowExternalNameServices,omitempty" toml:"allowExternalNameServices,omitempty" yaml:"allowExternalNameServices,omitempty" export:"true"`
CrossProviderNamespaces []string `description:"List of namespaces from which IngressRoute, IngressRouteTCP, IngressRouteUDP, and TraefikService are allowed to declare cross-provider references." json:"crossProviderNamespaces,omitempty" toml:"crossProviderNamespaces,omitempty" yaml:"crossProviderNamespaces,omitempty" export:"true"`
LabelSelector string `description:"Kubernetes label selector to use." json:"labelSelector,omitempty" toml:"labelSelector,omitempty" yaml:"labelSelector,omitempty" export:"true"`
IngressClass string `description:"Value of ingressClassName field or kubernetes.io/ingress.class annotation to watch for." json:"ingressClass,omitempty" toml:"ingressClass,omitempty" yaml:"ingressClass,omitempty" export:"true"`
ThrottleDuration ptypes.Duration `description:"Ingress refresh throttle duration" json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"`
@@ -93,6 +94,10 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
logger.Info().Msg("ExternalName service loading is enabled, please ensure that this is expected (see AllowExternalNameServices option)")
}
if p.CrossProviderNamespaces != nil {
logger.Warn().Msgf("Cross-provider references are restricted to namespaces %v (see CrossProviderNamespaces option)", p.CrossProviderNamespaces)
}
pool.GoCtx(func(ctxPool context.Context) {
operation := func() error {
eventsChan, err := k8sClient.WatchAll(p.Namespaces, ctxPool.Done())
@@ -307,7 +312,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client)
continue
}
chain, err := createChainMiddleware(ctxMid, middleware.Namespace, middleware.Spec.Chain, p.AllowCrossNamespace)
chain, err := p.createChainMiddleware(ctxMid, middleware.Namespace, middleware.Spec.Chain)
if err != nil {
logger.Error().Err(err).Msg("Error while reading chain middleware")
continue
@@ -358,6 +363,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client)
allowCrossNamespace: p.AllowCrossNamespace,
allowExternalNameServices: p.AllowExternalNameServices,
allowEmptyServices: p.AllowEmptyServices,
crossProviderNamespaces: p.CrossProviderNamespaces,
}
for _, service := range client.GetTraefikServices() {
@@ -666,6 +672,7 @@ func (p *Provider) createErrorPageMiddleware(ctx context.Context, client Client,
allowCrossNamespace: p.AllowCrossNamespace,
allowExternalNameServices: p.AllowExternalNameServices,
allowEmptyServices: p.AllowEmptyServices,
crossProviderNamespaces: p.CrossProviderNamespaces,
}
balancerName, balancerServerHTTP, err := cb.nameAndService(ctx, namespace, errorPage.Service.LoadBalancerSpec)
@@ -680,6 +687,26 @@ func (p *Provider) createErrorPageMiddleware(ctx context.Context, client Client,
}, balancerServerHTTP, nil
}
func (p *Provider) createChainMiddleware(ctx context.Context, parentNamespace string, chain *traefikv1alpha1.Chain) (*dynamic.Chain, error) {
if chain == nil {
return nil, nil
}
var mds []string
for _, mi := range chain.Middlewares {
ctxMid := log.Ctx(ctx).With().Str("middlewareRef", mi.Namespace+"/"+mi.Name).Logger().WithContext(ctx)
middlewareRef, err := resolveReference(ctxMid, parentNamespace, mi.Namespace, mi.Name, p.CrossProviderNamespaces, p.AllowCrossNamespace)
if err != nil {
return nil, fmt.Errorf("invalid reference to middleware %s: %w", mi.Name, err)
}
mds = append(mds, middlewareRef)
}
return &dynamic.Chain{Middlewares: mds}, nil
}
// getServicePort always returns a valid port, an error otherwise.
func getServicePort(svc *corev1.Service, port intstr.IntOrString) (*corev1.ServicePort, error) {
if svc == nil {
@@ -1280,43 +1307,6 @@ func loadAuthCredentials(secret *corev1.Secret) ([]string, error) {
return credentials, nil
}
func createChainMiddleware(ctx context.Context, parentNamespace string, chain *traefikv1alpha1.Chain, allowCrossNamespace bool) (*dynamic.Chain, error) {
if chain == nil {
return nil, nil
}
var mds []string
for _, mi := range chain.Middlewares {
if !allowCrossNamespace && strings.HasSuffix(mi.Name, providerNamespaceSeparator+ProviderName) {
// Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd),
// if the provider namespace kubernetescrd is used,
// we don't allow this format to avoid cross-namespace references.
return nil, fmt.Errorf("invalid reference to middleware %s: when allowCrossNamespace is disabled @kubernetescrd provider references are disallowed", mi.Name)
}
if strings.Contains(mi.Name, providerNamespaceSeparator) {
if len(mi.Namespace) > 0 {
log.Ctx(ctx).Warn().Msgf("namespace %q is ignored in cross-provider context", mi.Namespace)
}
mds = append(mds, mi.Name)
continue
}
ns := parentNamespace
if len(mi.Namespace) > 0 {
if !isNamespaceAllowed(allowCrossNamespace, parentNamespace, mi.Namespace) {
return nil, fmt.Errorf("middleware %s/%s is not in the chain namespace %s", mi.Namespace, mi.Name, parentNamespace)
}
ns = mi.Namespace
}
mds = append(mds, makeID(ns, mi.Name))
}
return &dynamic.Chain{Middlewares: mds}, nil
}
func buildTLSOptions(ctx context.Context, client Client) map[string]tls.Options {
tlsOptionsCRDs := client.GetTLSOptions()
var tlsOptions map[string]tls.Options
@@ -1659,3 +1649,39 @@ func isNamespaceAllowed(allowCrossNamespace bool, parentNamespace, namespace str
// If allowCrossNamespace option is not defined the default behavior is to allow cross namespace references.
return allowCrossNamespace || parentNamespace == namespace
}
// isCrossProviderNamespaceAllowed reports whether the given namespace is allowed to declare direct references to Traefik resources.
// A nil allowList means references are unrestricted, and an empty allowList disables them entirely.
func isCrossProviderNamespaceAllowed(allowList []string, namespace string) bool {
if allowList == nil {
return true
}
return slices.Contains(allowList, namespace)
}
func resolveReference(ctx context.Context, parentNs, ns, name string, crossProviderNamespaces []string, allowCrossNamespace bool) (string, error) {
if strings.Contains(name, providerNamespaceSeparator) {
if !allowCrossNamespace && strings.HasSuffix(name, providerNamespaceSeparator+ProviderName) {
return "", errors.New("when allowCrossNamespace is disabled, @kubernetescrd references are disallowed")
}
if !isCrossProviderNamespaceAllowed(crossProviderNamespaces, parentNs) {
return "", fmt.Errorf("namespace %q is not in crossProviderNamespaces", parentNs)
}
if ns != "" {
log.Ctx(ctx).Warn().Msgf("Namespace %q is ignored in cross-provider context", ns)
}
return name, nil
}
ns = namespaceOrParentNamespace(ns, parentNs)
if !isNamespaceAllowed(allowCrossNamespace, parentNs, ns) {
return "", errors.New("allowCrossNamespace is disabled, cross-namespace are disallowed")
}
return provider.Normalize(ns + "-" + name), nil
}
+42 -63
View File
@@ -61,6 +61,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
allowEmptyServices: p.AllowEmptyServices,
nativeLBByDefault: p.NativeLBByDefault,
disableClusterScopeResources: p.DisableClusterScopeResources,
crossProviderNamespaces: p.CrossProviderNamespaces,
}
parentRouterNames, err := resolveParentRouterNames(client, ingressRoute, p.AllowCrossNamespace)
@@ -82,7 +83,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
serviceKey := makeServiceKey(route.Match, ingressName)
mds, err := makeMiddlewareKeys(ctx, ingressRoute.Namespace, route.Middlewares, p.AllowCrossNamespace)
mds, err := makeMiddlewareKeys(ctx, ingressRoute.Namespace, route.Middlewares, p.CrossProviderNamespaces, p.AllowCrossNamespace)
if err != nil {
logger.Error().Err(err).Msg("Failed to create middleware keys")
continue
@@ -147,27 +148,14 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
}
if ingressRoute.Spec.TLS.Options != nil && len(ingressRoute.Spec.TLS.Options.Name) > 0 {
tlsOptionsName := ingressRoute.Spec.TLS.Options.Name
// Is a Kubernetes CRD reference, (i.e. not a cross-provider reference)
ns := ingressRoute.Spec.TLS.Options.Namespace
if !strings.Contains(tlsOptionsName, providerNamespaceSeparator) {
if len(ns) == 0 {
ns = ingressRoute.Namespace
}
tlsOptionsName = makeID(ns, tlsOptionsName)
} else if len(ns) > 0 {
logger.
Warn().Str("TLSOption", ingressRoute.Spec.TLS.Options.Name).
Msgf("Namespace %q is ignored in cross-provider context", ns)
}
tlsOptions := ingressRoute.Spec.TLS.Options
ctxTLSOption := log.Ctx(ctx).With().Str("TLSOption", tlsOptions.Name).Logger().WithContext(ctx)
if !isNamespaceAllowed(p.AllowCrossNamespace, ingressRoute.Namespace, ns) {
logger.Error().Msgf("TLSOption %s/%s is not in the IngressRoute namespace %s",
ns, ingressRoute.Spec.TLS.Options.Name, ingressRoute.Namespace)
r.TLS.Options, err = resolveReference(ctxTLSOption, ingressRoute.Namespace, tlsOptions.Namespace, tlsOptions.Name, p.CrossProviderNamespaces, p.AllowCrossNamespace)
if err != nil {
logger.Error().Err(err).Msgf("Invalid reference to TLSOption %q", ingressRoute.Spec.TLS.Options.Name)
continue
}
r.TLS.Options = tlsOptionsName
}
}
@@ -180,40 +168,18 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli
return conf
}
func makeMiddlewareKeys(ctx context.Context, namespace string, middlewares []traefikv1alpha1.MiddlewareRef, allowCrossNamespace bool) ([]string, error) {
func makeMiddlewareKeys(ctx context.Context, ingRouteNamespace string, middlewares []traefikv1alpha1.MiddlewareRef, crossProviderNamespaces []string, allowCrossNamespace bool) ([]string, error) {
var mds []string
for _, mi := range middlewares {
name := mi.Name
ctxMid := log.Ctx(ctx).With().Str(logs.MiddlewareName, mi.Name).Logger().WithContext(ctx)
if !allowCrossNamespace && strings.HasSuffix(mi.Name, providerNamespaceSeparator+ProviderName) {
// Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd),
// if the provider namespace kubernetescrd is used,
// we don't allow this format to avoid cross-namespace references.
return nil, fmt.Errorf("invalid reference to middleware %s: when allowCrossNamespace is disabled @kubernetescrd provider references are disallowed", mi.Name)
middlewareRef, err := resolveReference(ctxMid, ingRouteNamespace, mi.Namespace, mi.Name, crossProviderNamespaces, allowCrossNamespace)
if err != nil {
return nil, fmt.Errorf("invalid reference to middleware %s: %w", mi.Name, err)
}
if strings.Contains(name, providerNamespaceSeparator) {
if len(mi.Namespace) > 0 {
log.Ctx(ctx).
Warn().Str(logs.MiddlewareName, mi.Name).
Msgf("namespace %q is ignored in cross-provider context", mi.Namespace)
}
mds = append(mds, name)
continue
}
ns := namespace
if len(mi.Namespace) > 0 {
if !isNamespaceAllowed(allowCrossNamespace, namespace, mi.Namespace) {
return nil, fmt.Errorf("middleware %s/%s is not in the parent namespace %s", mi.Namespace, mi.Name, namespace)
}
ns = mi.Namespace
}
mds = append(mds, provider.Normalize(makeID(ns, name)))
mds = append(mds, middlewareRef)
}
return mds, nil
@@ -270,6 +236,7 @@ type configBuilder struct {
allowEmptyServices bool
nativeLBByDefault bool
disableClusterScopeResources bool
crossProviderNamespaces []string
}
// buildTraefikService creates the configuration for the traefik service defined in tService,
@@ -514,7 +481,7 @@ func (c configBuilder) buildServersLB(ctx context.Context, svc traefikv1alpha1.L
service := &dynamic.Service{LoadBalancer: lb}
if len(svc.Middlewares) > 0 {
mds, err := makeMiddlewareKeys(ctx, svc.Namespace, svc.Middlewares, c.allowCrossNamespace)
mds, err := makeMiddlewareKeys(ctx, svc.Namespace, svc.Middlewares, c.crossProviderNamespaces, c.allowCrossNamespace)
if err != nil {
return nil, fmt.Errorf("could not create middleware keys: %w", err)
}
@@ -529,14 +496,18 @@ func (c configBuilder) makeServersTransportKey(parentNamespace string, serversTr
return "", nil
}
if !c.allowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+ProviderName) {
// Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd),
// if the provider namespace kubernetescrd is used,
// we don't allow this format to avoid cross namespace references.
return "", fmt.Errorf("invalid reference to serversTransport %s: namespace-name@kubernetescrd format is not allowed when crossnamespace is disallowed", serversTransportName)
}
if strings.Contains(serversTransportName, providerNamespaceSeparator) {
if !c.allowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+ProviderName) {
// Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd),
// if the provider namespace kubernetescrd is used,
// we don't allow this format to avoid cross namespace references.
return "", fmt.Errorf("invalid reference to serversTransport %s: namespace-name@kubernetescrd format is not allowed when crossnamespace is disallowed", serversTransportName)
}
if !isCrossProviderNamespaceAllowed(c.crossProviderNamespaces, parentNamespace) {
return "", fmt.Errorf("serversTransport %q reference is not allowed: namespace %q is not in crossProviderNamespaces", serversTransportName, parentNamespace)
}
return serversTransportName, nil
}
@@ -691,11 +662,17 @@ func (c configBuilder) loadServers(svc traefikv1alpha1.LoadBalancerSpec) ([]dyna
func (c configBuilder) nameAndService(ctx context.Context, parentNamespace string, service traefikv1alpha1.LoadBalancerSpec) (string, *dynamic.Service, error) {
svcCtx := log.Ctx(ctx).With().Str(logs.ServiceName, service.Name).Logger().WithContext(ctx)
service = *service.DeepCopy()
service.Namespace = namespaceOrFallback(service, parentNamespace)
if !strings.Contains(service.Name, providerNamespaceSeparator) {
service = *service.DeepCopy()
service.Namespace = namespaceOrParentNamespace(service.Namespace, parentNamespace)
if !isNamespaceAllowed(c.allowCrossNamespace, parentNamespace, service.Namespace) {
return "", nil, fmt.Errorf("service %s/%s not in the parent resource namespace %s", service.Namespace, service.Name, parentNamespace)
if !isNamespaceAllowed(c.allowCrossNamespace, parentNamespace, service.Namespace) {
return "", nil, fmt.Errorf("service %s/%s not in the parent resource namespace %s", service.Namespace, service.Name, parentNamespace)
}
}
if !isCrossProviderNamespaceAllowed(c.crossProviderNamespaces, parentNamespace) && strings.Contains(service.Name, providerNamespaceSeparator) {
return "", nil, fmt.Errorf("service %q reference is not allowed: namespace %q is not in crossProviderNamespaces", service.Name, parentNamespace)
}
switch service.Kind {
@@ -811,11 +788,12 @@ func fullServiceName(ctx context.Context, service traefikv1alpha1.LoadBalancerSp
return provider.Normalize(name) + providerNamespaceSeparator + pName
}
func namespaceOrFallback(lb traefikv1alpha1.LoadBalancerSpec, fallback string) string {
if lb.Namespace != "" {
return lb.Namespace
func namespaceOrParentNamespace(namespace, parentNamespace string) string {
if namespace != "" {
return namespace
}
return fallback
return parentNamespace
}
// getTLSHTTP mutates tlsConfigs.
@@ -823,6 +801,7 @@ func getTLSHTTP(ctx context.Context, ingressRoute *traefikv1alpha1.IngressRoute,
if ingressRoute.Spec.TLS == nil {
return nil
}
if ingressRoute.Spec.TLS.SecretName == "" {
log.Ctx(ctx).Debug().Msg("No secret name provided")
return nil
+14 -42
View File
@@ -114,27 +114,14 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client
}
if ingressRouteTCP.Spec.TLS.Options != nil && len(ingressRouteTCP.Spec.TLS.Options.Name) > 0 {
tlsOptionsName := ingressRouteTCP.Spec.TLS.Options.Name
// Is a Kubernetes CRD reference (i.e. not a cross-provider reference)
ns := ingressRouteTCP.Spec.TLS.Options.Namespace
if !strings.Contains(tlsOptionsName, providerNamespaceSeparator) {
if len(ns) == 0 {
ns = ingressRouteTCP.Namespace
}
tlsOptionsName = makeID(ns, tlsOptionsName)
} else if len(ns) > 0 {
logger.Warn().
Str("TLSOption", ingressRouteTCP.Spec.TLS.Options.Name).
Msgf("Namespace %q is ignored in cross-provider context", ns)
}
tlsOptions := ingressRouteTCP.Spec.TLS.Options
ctxTLSOption := log.Ctx(ctx).With().Str("TLSOption", tlsOptions.Name).Logger().WithContext(ctx)
if !isNamespaceAllowed(p.AllowCrossNamespace, ingressRouteTCP.Namespace, ns) {
logger.Error().Msgf("TLSOption %s/%s is not in the IngressRouteTCP namespace %s",
ns, ingressRouteTCP.Spec.TLS.Options.Name, ingressRouteTCP.Namespace)
r.TLS.Options, err = resolveReference(ctxTLSOption, ingressRouteTCP.Namespace, tlsOptions.Namespace, tlsOptions.Name, p.CrossProviderNamespaces, p.AllowCrossNamespace)
if err != nil {
logger.Error().Err(err).Msgf("Invalid reference to TLSOption %q", ingressRouteTCP.Spec.TLS.Options.Name)
continue
}
r.TLS.Options = tlsOptionsName
}
}
@@ -149,39 +136,24 @@ func (p *Provider) makeMiddlewareTCPKeys(ctx context.Context, ingRouteTCPNamespa
var mds []string
for _, mi := range middlewares {
if strings.Contains(mi.Name, providerNamespaceSeparator) {
if len(mi.Namespace) > 0 {
log.Ctx(ctx).Warn().
Str(logs.MiddlewareName, mi.Name).
Msgf("Namespace %q is ignored in cross-provider context", mi.Namespace)
}
mds = append(mds, mi.Name)
continue
ctxMid := log.Ctx(ctx).With().Str(logs.MiddlewareName, mi.Name).Logger().WithContext(ctx)
middlewareRef, err := resolveReference(ctxMid, ingRouteTCPNamespace, mi.Namespace, mi.Name, p.CrossProviderNamespaces, p.AllowCrossNamespace)
if err != nil {
return nil, fmt.Errorf("invalid reference to middleware %s: %w", mi.Name, err)
}
ns := ingRouteTCPNamespace
if len(mi.Namespace) > 0 {
if !isNamespaceAllowed(p.AllowCrossNamespace, ingRouteTCPNamespace, mi.Namespace) {
return nil, fmt.Errorf("middleware %s/%s is not in the IngressRouteTCP namespace %s", mi.Namespace, mi.Name, ingRouteTCPNamespace)
}
ns = mi.Namespace
}
mds = append(mds, provider.Normalize(makeID(ns, mi.Name)))
mds = append(mds, middlewareRef)
}
return mds, nil
}
func (p *Provider) createLoadBalancerServerTCP(client Client, parentNamespace string, service traefikv1alpha1.ServiceTCP) (*dynamic.TCPService, error) {
ns := parentNamespace
if len(service.Namespace) > 0 {
if !isNamespaceAllowed(p.AllowCrossNamespace, parentNamespace, service.Namespace) {
return nil, fmt.Errorf("tcp service %s/%s is not in the parent resource namespace %s", service.Namespace, service.Name, parentNamespace)
}
ns := namespaceOrParentNamespace(service.Namespace, parentNamespace)
ns = service.Namespace
if !isNamespaceAllowed(p.AllowCrossNamespace, parentNamespace, ns) {
return nil, fmt.Errorf("tcp service %s/%s is not in the parent resource namespace %s", ns, service.Name, parentNamespace)
}
servers, err := p.loadTCPServers(client, ns, service)
+399 -10
View File
@@ -259,7 +259,7 @@ func TestLoadIngressRouteTCPs(t *testing.T) {
},
{
desc: "Simple Ingress Route, with foo entrypoint and crossprovider middleware",
paths: []string{"tcp/services.yml", "tcp/with_middleware_crossprovider.yml"},
paths: []string{"tcp/services.yml", "tcp/with_middleware_cross_provider.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
@@ -1813,12 +1813,13 @@ func TestLoadIngressRouteTCPs(t *testing.T) {
func TestLoadIngressRoutes(t *testing.T) {
testCases := []struct {
desc string
ingressClass string
paths []string
expected *dynamic.Configuration
allowCrossNamespace bool
allowEmptyServices bool
desc string
ingressClass string
paths []string
expected *dynamic.Configuration
allowCrossNamespace bool
allowEmptyServices bool
crossProviderNamespaces []string
}{
{
desc: "Empty",
@@ -2104,9 +2105,10 @@ func TestLoadIngressRoutes(t *testing.T) {
},
},
{
desc: "Simple Ingress Route with middleware crossprovider",
allowCrossNamespace: true,
paths: []string{"services.yml", "with_middleware_crossprovider.yml"},
desc: "Simple Ingress Route with middleware crossprovider",
crossProviderNamespaces: []string{"default"},
allowCrossNamespace: true,
paths: []string{"services.yml", "with_middleware_cross_provider.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
@@ -6389,6 +6391,7 @@ func TestLoadIngressRoutes(t *testing.T) {
AllowCrossNamespace: test.allowCrossNamespace,
AllowExternalNameServices: true,
AllowEmptyServices: test.allowEmptyServices,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
@@ -8676,6 +8679,392 @@ func TestCrossNamespace(t *testing.T) {
}
}
func Test_isCrossProviderNamespaceAllowed(t *testing.T) {
testCases := []struct {
desc string
allowList []string
namespace string
want bool
}{
{desc: "nil allowList allows any namespace", allowList: nil, namespace: "ns-a", want: true},
{desc: "empty allowList denies every namespace", allowList: []string{}, namespace: "ns-a", want: false},
{desc: "namespace in allowList is accepted", allowList: []string{"ns-a"}, namespace: "ns-a", want: true},
{desc: "namespace not in allowList is rejected", allowList: []string{"ns-b"}, namespace: "ns-a", want: false},
{desc: "namespace among multiple allowed entries is accepted", allowList: []string{"ns-a", "ns-b"}, namespace: "ns-b", want: true},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
got := isCrossProviderNamespaceAllowed(test.allowList, test.namespace)
assert.Equal(t, test.want, got)
})
}
}
// TestCrossProviderNamespaces_HTTPMiddleware verifies that the
// CrossProviderNamespaces option gates middleware references.
// Plain in-namespace middleware references are not affected.
func TestCrossProviderNamespaces_HTTPMiddleware(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantMiddlewares []string
wantRouterDropped bool
}{
{
desc: "nil: cross-provider middleware refs are accepted (backward compatible)",
crossProviderNamespaces: nil,
wantMiddlewares: []string{"default-stripprefix", "foo-addprefix", "basicauth@file", "redirect@file"},
},
{
desc: "empty list: cross-provider middleware refs are rejected, IngressRoute is dropped",
crossProviderNamespaces: []string{},
wantRouterDropped: true,
},
{
desc: "namespace allowed: cross-provider middleware refs are accepted",
crossProviderNamespaces: []string{"default"},
wantMiddlewares: []string{"default-stripprefix", "foo-addprefix", "basicauth@file", "redirect@file"},
},
{
desc: "namespace not allowed: cross-provider middleware refs are rejected, IngressRoute is dropped",
crossProviderNamespaces: []string{"other"},
wantRouterDropped: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects, crdObjects := readResources(t, []string{"services.yml", "with_middleware_cross_provider.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...)
crdClient := traefikcrdfake.NewClientset(crdObjects...)
client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{})
eventCh, err := client.WatchAll(nil, stopCh)
require.NoError(t, err)
if k8sObjects != nil || crdObjects != nil {
// just wait for the first event
<-eventCh
}
p := Provider{
AllowCrossNamespace: true,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
router, ok := conf.HTTP.Routers["default-test2-route-23c7f4c450289ee29016"]
if test.wantRouterDropped {
assert.False(t, ok)
return
}
assert.True(t, ok)
assert.Equal(t, test.wantMiddlewares, router.Middlewares)
})
}
}
// TestCrossProviderNamespaces_HTTPServiceTransitivity verifies that the option for a TraefikService chain
// (here: IngressRoute -> Mirror / Weighted TraefikService -> @file service).
func TestCrossProviderNamespaces_HTTPServiceTransitivity(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantMirrorService bool
wantWeightedService bool
}{
{
desc: "nil: cross-provider service refs accepted (backward compatible)",
crossProviderNamespaces: nil,
wantMirrorService: true,
wantWeightedService: true,
},
{
desc: "empty list: both Mirror and Weighted TraefikServices are rejected",
crossProviderNamespaces: []string{},
wantMirrorService: false,
wantWeightedService: false,
},
{
desc: "only the Mirror's namespace is allowed: Weighted is still rejected",
crossProviderNamespaces: []string{"foo"},
wantMirrorService: true,
wantWeightedService: false,
},
{
desc: "only the Weighted's namespace is allowed: Mirror is still rejected",
crossProviderNamespaces: []string{"bar"},
wantMirrorService: false,
wantWeightedService: true,
},
{
desc: "both namespaces allowed: both TraefikServices are accepted",
crossProviderNamespaces: []string{"foo", "bar"},
wantMirrorService: true,
wantWeightedService: true,
},
{
desc: "originating IngressRoute namespace alone is not enough: TraefikService namespace must also be allowed",
crossProviderNamespaces: []string{"default"},
wantMirrorService: false,
wantWeightedService: false,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects, crdObjects := readResources(t, []string{"services.yml", "with_service_cross_provider.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...)
crdClient := traefikcrdfake.NewClientset(crdObjects...)
client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{})
eventCh, err := client.WatchAll(nil, stopCh)
require.NoError(t, err)
if k8sObjects != nil || crdObjects != nil {
// just wait for the first event
<-eventCh
}
p := Provider{
AllowCrossNamespace: true,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
_, mirrorOK := conf.HTTP.Services["foo-mirror-cp"]
_, weightedOK := conf.HTTP.Services["bar-weighted-cp"]
assert.Equal(t, test.wantMirrorService, mirrorOK)
assert.Equal(t, test.wantWeightedService, weightedOK)
})
}
}
// TestCrossProviderNamespaces_HTTPTLSOption verifies that the
// CrossProviderNamespaces option gates @file references in IngressRoute tls.options.
func TestCrossProviderNamespaces_HTTPTLSOption(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantRouterDropped bool
}{
{
desc: "nil: cross-provider TLSOption ref is accepted (backward compatible)",
crossProviderNamespaces: nil,
},
{
desc: "empty list: cross-provider TLSOption ref is rejected, IngressRoute is dropped",
crossProviderNamespaces: []string{},
wantRouterDropped: true,
},
{
desc: "namespace allowed: cross-provider TLSOption ref is accepted",
crossProviderNamespaces: []string{"default"},
},
{
desc: "namespace not allowed: cross-provider TLSOption ref is rejected, IngressRoute is dropped",
crossProviderNamespaces: []string{"other"},
wantRouterDropped: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects, crdObjects := readResources(t, []string{"services.yml", "with_tls_option_cross_provider.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...)
crdClient := traefikcrdfake.NewClientset(crdObjects...)
client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{})
eventCh, err := client.WatchAll(nil, stopCh)
require.NoError(t, err)
if k8sObjects != nil || crdObjects != nil {
// just wait for the first event
<-eventCh
}
p := Provider{
AllowCrossNamespace: true,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
router, ok := conf.HTTP.Routers["default-test-route-6b204d94623b3df4370c"]
if test.wantRouterDropped {
assert.False(t, ok)
return
}
require.True(t, ok)
require.NotNil(t, router.TLS)
assert.Equal(t, "foo@file", router.TLS.Options)
})
}
}
// TestCrossProviderNamespaces_TCPTLSOption verifies that the
// CrossProviderNamespaces option gates @file references in IngressRouteTCP tls.options.
func TestCrossProviderNamespaces_TCPTLSOption(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantRouterDropped bool
}{
{
desc: "nil: cross-provider TLSOption ref is accepted (backward compatible)",
crossProviderNamespaces: nil,
},
{
desc: "empty list: cross-provider TLSOption ref is rejected, IngressRouteTCP is dropped",
crossProviderNamespaces: []string{},
wantRouterDropped: true,
},
{
desc: "namespace allowed: cross-provider TLSOption ref is accepted",
crossProviderNamespaces: []string{"default"},
},
{
desc: "namespace not allowed: cross-provider TLSOption ref is rejected, IngressRouteTCP is dropped",
crossProviderNamespaces: []string{"other"},
wantRouterDropped: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects, crdObjects := readResources(t, []string{"tcp/services.yml", "tcp/with_tls_options_cross_provider.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...)
crdClient := traefikcrdfake.NewClientset(crdObjects...)
client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{})
eventCh, err := client.WatchAll(nil, stopCh)
require.NoError(t, err)
if k8sObjects != nil || crdObjects != nil {
// just wait for the first event
<-eventCh
}
p := Provider{
AllowCrossNamespace: true,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
router, ok := conf.TCP.Routers["default-test.route-fdd3e9338e47a45efefc"]
if test.wantRouterDropped {
assert.False(t, ok)
return
}
require.True(t, ok)
require.NotNil(t, router.TLS)
assert.Equal(t, "foo@file", router.TLS.Options)
})
}
}
// TestCrossProviderNamespaces_HTTPServersTransport verifies that the
// CrossProviderNamespaces option gates @file references in service.serversTransport.
func TestCrossProviderNamespaces_HTTPServersTransport(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantServiceDropped bool
}{
{
desc: "nil: cross-provider ServersTransport ref is accepted (backward compatible)",
crossProviderNamespaces: nil,
},
{
desc: "empty list: cross-provider ServersTransport ref is rejected, service is dropped",
crossProviderNamespaces: []string{},
wantServiceDropped: true,
},
{
desc: "namespace allowed: cross-provider ServersTransport ref is accepted",
crossProviderNamespaces: []string{"default"},
},
{
desc: "namespace not allowed: cross-provider ServersTransport ref is rejected, service is dropped",
crossProviderNamespaces: []string{"other"},
wantServiceDropped: true,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects, crdObjects := readResources(t, []string{"services.yml", "with_servers_transport_cross_provider.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...)
crdClient := traefikcrdfake.NewClientset(crdObjects...)
client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{})
eventCh, err := client.WatchAll(nil, stopCh)
require.NoError(t, err)
if k8sObjects != nil || crdObjects != nil {
// just wait for the first event
<-eventCh
}
p := Provider{
AllowCrossNamespace: true,
CrossProviderNamespaces: test.crossProviderNamespaces,
}
conf := p.loadConfigurationFromCRD(t.Context(), client)
service, ok := conf.HTTP.Services["default-test-route-6b204d94623b3df4370c"]
if test.wantServiceDropped {
assert.False(t, ok)
return
}
require.True(t, ok)
require.NotNil(t, service.LoadBalancer)
assert.Equal(t, "foo@file", service.LoadBalancer.ServersTransport)
})
}
}
func TestExternalNameService(t *testing.T) {
testCases := []struct {
desc string
@@ -36,16 +36,16 @@ spec:
group: ""
allowedRoutes:
kinds:
- kind: TCPRoute
- kind: TLSRoute
group: gateway.networking.k8s.io
namespaces:
from: Same
---
kind: TCPRoute
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TLSRoute
apiVersion: gateway.networking.k8s.io/v1
metadata:
name: tcp-app-1
name: tls-app-1
namespace: default
spec:
parentRefs:
+16 -1
View File
@@ -161,7 +161,18 @@ func (p *Provider) loadHTTPRoute(ctx context.Context, listener gatewayListener,
router.Service = errWrrName
case len(routeRule.BackendRefs) == 1 && isInternalService(routeRule.BackendRefs[0].BackendRef):
router.Service = string(routeRule.BackendRefs[0].Name)
if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, route.Namespace) {
condition = metav1.Condition{
Type: string(gatev1.RouteConditionResolvedRefs),
Status: metav1.ConditionFalse,
ObservedGeneration: route.Generation,
LastTransitionTime: metav1.Now(),
Reason: string(gatev1.RouteReasonRefNotPermitted),
Message: fmt.Sprintf("Cannot load HTTPRoute BackendRef %s: internal service reference is not allowed: HTTPRoute namespace %q is not in crossProviderNamespaces", routeRule.BackendRefs[0].Name, route.Namespace),
}
} else {
router.Service = string(routeRule.BackendRefs[0].Name)
}
default:
var serviceCondition *metav1.Condition
@@ -312,6 +323,10 @@ func (p *Provider) loadHTTPBackendRef(namespace string, backendRef gatev1.HTTPBa
// Support for cross-provider references (e.g: api@internal).
// This provides the same behavior as for IngressRoutes.
if *backendRef.Kind == "TraefikService" && strings.Contains(string(backendRef.Name), "@") {
if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, namespace) {
return "", nil, fmt.Errorf("TraefikService %q reference is not allowed: namespace %q is not in crossProviderNamespaces", string(backendRef.Name), namespace)
}
return string(backendRef.Name), nil, nil
}
+24 -11
View File
@@ -63,17 +63,17 @@ const (
// Provider holds configurations of the provider.
type Provider struct {
Endpoint string `description:"Kubernetes server endpoint (required for external cluster client)." json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty"`
Token types.FileOrContent `description:"Kubernetes bearer token (not needed for in-cluster client). It accepts either a token value or a file path to the token." json:"token,omitempty" toml:"token,omitempty" yaml:"token,omitempty" loggable:"false"`
CertAuthFilePath string `description:"Kubernetes certificate authority file path (not needed for in-cluster client)." json:"certAuthFilePath,omitempty" toml:"certAuthFilePath,omitempty" yaml:"certAuthFilePath,omitempty"`
Namespaces []string `description:"Kubernetes namespaces." json:"namespaces,omitempty" toml:"namespaces,omitempty" yaml:"namespaces,omitempty" export:"true"`
LabelSelector string `description:"Kubernetes label selector to select specific GatewayClasses." json:"labelSelector,omitempty" toml:"labelSelector,omitempty" yaml:"labelSelector,omitempty" export:"true"`
ThrottleDuration ptypes.Duration `description:"Kubernetes refresh throttle duration" json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"`
ExperimentalChannel bool `description:"Toggles Experimental Channel resources support (TCPRoute, TLSRoute...)." json:"experimentalChannel,omitempty" toml:"experimentalChannel,omitempty" yaml:"experimentalChannel,omitempty" export:"true"`
StatusAddress *StatusAddress `description:"Defines the Kubernetes Gateway status address." json:"statusAddress,omitempty" toml:"statusAddress,omitempty" yaml:"statusAddress,omitempty" export:"true"`
NativeLBByDefault bool `description:"Defines whether to use Native Kubernetes load-balancing by default." json:"nativeLBByDefault,omitempty" toml:"nativeLBByDefault,omitempty" yaml:"nativeLBByDefault,omitempty" export:"true"`
EntryPoints map[string]Entrypoint `json:"-" toml:"-" yaml:"-" label:"-" file:"-"`
Endpoint string `description:"Kubernetes server endpoint (required for external cluster client)." json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty"`
Token types.FileOrContent `description:"Kubernetes bearer token (not needed for in-cluster client). It accepts either a token value or a file path to the token." json:"token,omitempty" toml:"token,omitempty" yaml:"token,omitempty" loggable:"false"`
CertAuthFilePath string `description:"Kubernetes certificate authority file path (not needed for in-cluster client)." json:"certAuthFilePath,omitempty" toml:"certAuthFilePath,omitempty" yaml:"certAuthFilePath,omitempty"`
Namespaces []string `description:"Kubernetes namespaces." json:"namespaces,omitempty" toml:"namespaces,omitempty" yaml:"namespaces,omitempty" export:"true"`
LabelSelector string `description:"Kubernetes label selector to select specific GatewayClasses." json:"labelSelector,omitempty" toml:"labelSelector,omitempty" yaml:"labelSelector,omitempty" export:"true"`
ThrottleDuration ptypes.Duration `description:"Kubernetes refresh throttle duration" json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"`
ExperimentalChannel bool `description:"Toggles Experimental Channel resources support (TCPRoute, TLSRoute...)." json:"experimentalChannel,omitempty" toml:"experimentalChannel,omitempty" yaml:"experimentalChannel,omitempty" export:"true"`
StatusAddress *StatusAddress `description:"Defines the Kubernetes Gateway status address." json:"statusAddress,omitempty" toml:"statusAddress,omitempty" yaml:"statusAddress,omitempty" export:"true"`
NativeLBByDefault bool `description:"Defines whether to use Native Kubernetes load-balancing by default." json:"nativeLBByDefault,omitempty" toml:"nativeLBByDefault,omitempty" yaml:"nativeLBByDefault,omitempty" export:"true"`
CrossProviderNamespaces []string `description:"List of namespaces from which Gateway API routes are allowed to declare TraefikService backendRef references." json:"crossProviderNamespaces,omitempty" toml:"crossProviderNamespaces,omitempty" yaml:"crossProviderNamespaces,omitempty" export:"true"`
EntryPoints map[string]Entrypoint `json:"-" toml:"-" yaml:"-" label:"-" file:"-"`
// groupKindFilterFuncs is the list of allowed Group and Kinds for the Filter ExtensionRef objects.
groupKindFilterFuncs map[string]map[string]BuildFilterFunc
@@ -183,6 +183,10 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
logger := log.With().Str(logs.ProviderName, ProviderName).Logger()
ctxLog := logger.WithContext(context.Background())
if p.CrossProviderNamespaces != nil {
logger.Warn().Msgf("Cross-provider references are restricted to namespaces %v (see CrossProviderNamespaces option)", p.CrossProviderNamespaces)
}
pool.GoCtx(func(ctxPool context.Context) {
operation := func() error {
eventsChan, err := p.client.WatchAll(p.Namespaces, ctxPool.Done())
@@ -1238,6 +1242,15 @@ func isInternalService(ref gatev1.BackendRef) bool {
return isTraefikService(ref) && strings.HasSuffix(string(ref.Name), "@internal")
}
// isCrossProviderNamespaceAllowed reports whether the given namespace is allowed to use cross-provider references.
func isCrossProviderNamespaceAllowed(allowList []string, namespace string) bool {
if allowList == nil {
return true
}
return slices.Contains(allowList, namespace)
}
// makeListenerKey joins protocol, hostname, and port of a listener into a string key.
func makeListenerKey(l gatev1.Listener) string {
var hostname gatev1.Hostname
@@ -2,9 +2,11 @@ package gateway
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -5158,9 +5160,53 @@ func TestLoadTLSRoutes(t *testing.T) {
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
Routers: map[string]*dynamic.TCPRouter{
"deny-unknown-host": {
Rule: "HostSNI(`*`) && !ALPN(`h2`) && !ALPN(`http/1.1`)",
Priority: 1,
Service: "deny-unknown-host",
TLS: &dynamic.RouterTCPTLSConfig{},
},
"tlsroute-default-tls-app-1-gw-default-my-gateway-ep-tls-0-e3b0c44298fc1c149afb": {
EntryPoints: []string{"tls"},
Service: "tlsroute-default-tls-app-1-gw-default-my-gateway-ep-tls-0-e3b0c44298fc1c149afb-wrr",
Rule: `HostSNI("*")`,
RuleSyntax: "default",
TLS: &dynamic.RouterTCPTLSConfig{},
},
},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{
"deny-unknown-host": {
LoadBalancer: &dynamic.TCPServersLoadBalancer{},
},
"tlsroute-default-tls-app-1-gw-default-my-gateway-ep-tls-0-e3b0c44298fc1c149afb-wrr": {
Weighted: &dynamic.TCPWeightedRoundRobin{
Services: []dynamic.TCPWRRService{
{
Name: "service@file",
Weight: ptr.To(1),
},
{
Name: "default-whoamitcp-9000",
Weight: ptr.To(1),
},
},
},
},
"default-whoamitcp-9000": {
LoadBalancer: &dynamic.TCPServersLoadBalancer{
Servers: []dynamic.TCPServer{
{
Address: "10.10.0.9:9000",
},
{
Address: "10.10.0.10:9000",
},
},
},
},
},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
@@ -5169,7 +5215,16 @@ func TestLoadTLSRoutes(t *testing.T) {
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
TLS: &dynamic.TLSConfiguration{
Certificates: []*tls.CertAndStores{
{
Certificate: tls.Certificate{
CertFile: types.FileOrContent(listenerCert),
KeyFile: types.FileOrContent(listenerKey),
},
},
},
},
},
},
{
@@ -8339,3 +8394,236 @@ func readResources(t *testing.T, paths []string) ([]runtime.Object, []runtime.Ob
return k8sObjects, gwObjects
}
func Test_isCrossProviderNamespaceAllowed(t *testing.T) {
testCases := []struct {
desc string
allowList []string
namespace string
want bool
}{
{desc: "nil allowList allows any namespace", allowList: nil, namespace: "ns-a", want: true},
{desc: "empty allowList denies every namespace", allowList: []string{}, namespace: "ns-a", want: false},
{desc: "namespace in allowList is accepted", allowList: []string{"ns-a"}, namespace: "ns-a", want: true},
{desc: "namespace not in allowList is rejected", allowList: []string{"ns-b"}, namespace: "ns-a", want: false},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
got := isCrossProviderNamespaceAllowed(test.allowList, test.namespace)
assert.Equal(t, test.want, got)
})
}
}
// TestCrossProviderNamespaces_HTTPRoute verifies that the
// CrossProviderNamespaces option gates `@otherProvider` TraefikService
// backendRefs declared on a Gateway HTTPRoute. The check is anchored on the
// HTTPRoute's namespace; when the route is rejected, the whole router is
// dropped from the dynamic configuration.
func TestCrossProviderNamespaces_HTTPRoute(t *testing.T) {
testCases := []struct {
desc 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},
}
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"})
kubeClient := kubefake.NewClientset(k8sObjects...)
gwClient := newGatewaySimpleClientSet(t, gwObjects...)
client := newClientImpl(kubeClient, gwClient)
eventCh, err := client.WatchAll(nil, make(chan struct{}))
require.NoError(t, err)
if len(k8sObjects) > 0 || len(gwObjects) > 0 {
// just wait for the first event
<-eventCh
}
p := Provider{
EntryPoints: map[string]Entrypoint{"web": {Address: ":80"}},
CrossProviderNamespaces: test.crossProviderNamespaces,
client: client,
}
conf := p.loadConfigurationFromGateways(t.Context())
router, ok := conf.HTTP.Routers["httproute-default-http-app-1-gw-default-my-gateway-ep-web-0-af329269dd38031b03e3"]
require.True(t, ok)
service, ok := conf.HTTP.Services[router.Service]
require.True(t, ok)
require.NotNil(t, service.Weighted)
require.Len(t, service.Weighted.Services, 2)
var hasError bool
for _, wrrService := range service.Weighted.Services {
// Whenever a service fails to be loaded, a placeholder service is added to the WRR to server a 500 status code.
if wrrService.Status != nil && *wrrService.Status == http.StatusInternalServerError {
hasError = true
break
}
}
assert.Equal(t, test.wantError, hasError)
})
}
}
// TestCrossProviderNamespaces_TCPRoute verifies that the option also gates
// cross-provider TraefikService backendRefs declared on a Gateway TCPRoute.
func TestCrossProviderNamespaces_TCPRoute(t *testing.T) {
testCases := []struct {
desc 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},
}
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"})
kubeClient := kubefake.NewClientset(k8sObjects...)
gwClient := newGatewaySimpleClientSet(t, gwObjects...)
client := newClientImpl(kubeClient, gwClient)
client.experimentalChannel = true
eventCh, err := client.WatchAll(nil, make(chan struct{}))
require.NoError(t, err)
if len(k8sObjects) > 0 || len(gwObjects) > 0 {
// just wait for the first event
<-eventCh
}
p := Provider{
EntryPoints: map[string]Entrypoint{"tcp": {Address: ":9000"}},
CrossProviderNamespaces: test.crossProviderNamespaces,
client: client,
ExperimentalChannel: true,
}
conf := p.loadConfigurationFromGateways(t.Context())
router, ok := conf.TCP.Routers["tcproute-default-tcp-app-1-gw-default-my-gateway-ep-tcp-0-e3b0c44298fc1c149afb"]
require.True(t, ok)
service, ok := conf.TCP.Services[router.Service]
require.True(t, ok)
require.NotNil(t, service.Weighted)
require.Len(t, service.Weighted.Services, 2)
var hasError bool
for _, wrrService := range service.Weighted.Services {
if strings.Contains(wrrService.Name, "@") {
continue
}
lbService, ok := conf.TCP.Services[wrrService.Name]
require.True(t, ok)
require.NotNil(t, lbService)
require.NotNil(t, lbService.LoadBalancer)
if len(lbService.LoadBalancer.Servers) == 0 {
hasError = true
}
}
assert.Equal(t, test.wantError, hasError)
})
}
}
// TestCrossProviderNamespaces_TLSRoute verifies that the option also gates
// cross-provider TraefikService backendRefs declared on a Gateway TLSRoute.
func TestCrossProviderNamespaces_TLSRoute(t *testing.T) {
testCases := []struct {
desc 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},
}
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"})
kubeClient := kubefake.NewClientset(k8sObjects...)
gwClient := newGatewaySimpleClientSet(t, gwObjects...)
client := newClientImpl(kubeClient, gwClient)
client.experimentalChannel = true
eventCh, err := client.WatchAll(nil, make(chan struct{}))
require.NoError(t, err)
if len(k8sObjects) > 0 || len(gwObjects) > 0 {
// just wait for the first event
<-eventCh
}
p := Provider{
EntryPoints: map[string]Entrypoint{"tls": {Address: ":9000"}},
CrossProviderNamespaces: test.crossProviderNamespaces,
client: client,
}
conf := p.loadConfigurationFromGateways(t.Context())
fmt.Println(conf.TCP.Routers)
router, ok := conf.TCP.Routers["tlsroute-default-tls-app-1-gw-default-my-gateway-ep-tls-0-e3b0c44298fc1c149afb"]
require.True(t, ok)
service, ok := conf.TCP.Services[router.Service]
require.True(t, ok)
require.NotNil(t, service.Weighted)
require.Len(t, service.Weighted.Services, 2)
var hasError bool
for _, wrrService := range service.Weighted.Services {
if strings.Contains(wrrService.Name, "@") {
continue
}
lbService, ok := conf.TCP.Services[wrrService.Name]
require.True(t, ok)
require.NotNil(t, lbService)
require.NotNil(t, lbService.LoadBalancer)
if len(lbService.LoadBalancer.Servers) == 0 {
hasError = true
}
}
assert.Equal(t, test.wantError, hasError)
})
}
}
+19 -2
View File
@@ -136,6 +136,19 @@ func (p *Provider) loadTCPRoute(listener gatewayListener, route *gatev1alpha2.TC
routerName := makeRouterName("", routeKey)
if len(rule.BackendRefs) == 1 && isInternalService(rule.BackendRefs[0]) {
if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, route.Namespace) {
condition = 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: internal service reference is not allowed: TCPRoute namespace %q is not in crossProviderNamespaces", rule.BackendRefs[0].Name, route.Namespace),
}
continue
}
router.Service = string(rule.BackendRefs[0].Name)
conf.TCP.Routers[routerName] = &router
continue
@@ -224,7 +237,7 @@ func (p *Provider) loadTCPService(route *gatev1alpha2.TCPRoute, backendRef gatev
}
if group != groupCore || kind != kindService {
name, err := p.loadTCPBackendRef(backendRef)
name, err := p.loadTCPBackendRef(route.Namespace, backendRef)
if err != nil {
return serviceName, nil, &metav1.Condition{
Type: string(gatev1.RouteConditionResolvedRefs),
@@ -296,10 +309,14 @@ func (p *Provider) loadTCPServers(namespace string, route *gatev1alpha2.TCPRoute
return lb, nil
}
func (p *Provider) loadTCPBackendRef(backendRef gatev1.BackendRef) (string, error) {
func (p *Provider) loadTCPBackendRef(routeNamespace string, backendRef gatev1.BackendRef) (string, error) {
// Support for cross-provider references (e.g: api@internal).
// This provides the same behavior as for IngressRoutes.
if *backendRef.Kind == "TraefikService" && strings.Contains(string(backendRef.Name), "@") {
if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, routeNamespace) {
return "", fmt.Errorf("TraefikService %q reference is not allowed: route namespace %q is not in crossProviderNamespaces", string(backendRef.Name), routeNamespace)
}
return string(backendRef.Name), nil
}
+14 -1
View File
@@ -152,6 +152,19 @@ func (p *Provider) loadTLSRoute(listener gatewayListener, route *gatev1.TLSRoute
routerName := makeRouterName("", routeKey)
if len(routeRule.BackendRefs) == 1 && isInternalService(routeRule.BackendRefs[0]) {
if !isCrossProviderNamespaceAllowed(p.CrossProviderNamespaces, route.Namespace) {
condition = 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: internal service reference is not allowed: TLSRoute namespace %q is not in crossProviderNamespaces", routeRule.BackendRefs[0].Name, route.Namespace),
}
continue
}
router.Service = string(routeRule.BackendRefs[0].Name)
conf.TCP.Routers[routerName] = &router
continue
@@ -240,7 +253,7 @@ func (p *Provider) loadTLSService(route *gatev1.TLSRoute, backendRef gatev1.Back
}
if group != groupCore || kind != kindService {
name, err := p.loadTCPBackendRef(backendRef)
name, err := p.loadTCPBackendRef(route.Namespace, backendRef)
if err != nil {
return serviceName, nil, &metav1.Condition{
Type: string(gatev1.RouteConditionResolvedRefs),
@@ -0,0 +1,51 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ""
namespace: testing
spec:
rules:
- http:
paths:
- path: /bar
backend:
service:
name: service1
port:
number: 80
---
kind: Service
apiVersion: v1
metadata:
name: service1
namespace: testing
annotations:
traefik.ingress.kubernetes.io/service.serverstransport: foobar@file
spec:
ports:
- port: 80
clusterIP: 10.0.0.1
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: service1-abc
namespace: testing
labels:
kubernetes.io/service-name: service1
addressType: IPv4
ports:
- port: 8080
name: ""
endpoints:
- addresses:
- 10.10.0.1
- 10.21.0.1
conditions:
ready: true
@@ -0,0 +1,53 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ""
namespace: testing
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.options: foobar@file
spec:
rules:
- http:
paths:
- path: /bar
backend:
service:
name: service1
port:
number: 80
---
kind: Service
apiVersion: v1
metadata:
name: service1
namespace: testing
spec:
ports:
- port: 80
clusterIP: 10.0.0.1
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: service1-abc
namespace: testing
labels:
kubernetes.io/service-name: service1
addressType: IPv4
ports:
- port: 8080
name: ""
endpoints:
- addresses:
- 10.10.0.1
- 10.21.0.1
conditions:
ready: true
@@ -51,6 +51,7 @@ type Provider struct {
ThrottleDuration ptypes.Duration `description:"Ingress refresh throttle duration" json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"`
AllowEmptyServices bool `description:"Allow creation of services without endpoints." json:"allowEmptyServices,omitempty" toml:"allowEmptyServices,omitempty" yaml:"allowEmptyServices,omitempty" export:"true"`
AllowExternalNameServices bool `description:"Allow ExternalName services." json:"allowExternalNameServices,omitempty" toml:"allowExternalNameServices,omitempty" yaml:"allowExternalNameServices,omitempty" export:"true"`
CrossProviderNamespaces []string `description:"List of namespaces from which Ingresses or Services are allowed to declare Middlewares, TLSOptions, or ServersTransport references." json:"crossProviderNamespaces,omitempty" toml:"crossProviderNamespaces,omitempty" yaml:"crossProviderNamespaces,omitempty" export:"true"`
// Deprecated: please use DisableClusterScopeResources.
DisableIngressClassLookup bool `description:"Disables the lookup of IngressClasses (Deprecated, please use DisableClusterScopeResources)." json:"disableIngressClassLookup,omitempty" toml:"disableIngressClassLookup,omitempty" yaml:"disableIngressClassLookup,omitempty" export:"true"`
DisableClusterScopeResources bool `description:"Disables the lookup of cluster scope resources (incompatible with IngressClasses and NodePortLB enabled services)." json:"disableClusterScopeResources,omitempty" toml:"disableClusterScopeResources,omitempty" yaml:"disableClusterScopeResources,omitempty" export:"true"`
@@ -92,6 +93,10 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
logger.Info().Msg("ExternalName service loading is enabled, please ensure that this is expected (see AllowExternalNameServices option)")
}
if p.CrossProviderNamespaces != nil {
logger.Warn().Msgf("Cross-provider Middleware, TLSOption and ServersTransport references are restricted to namespaces %v (see CrossProviderNamespaces option)", p.CrossProviderNamespaces)
}
pool.GoCtx(func(ctxPool context.Context) {
operation := func() error {
eventsChan, err := k8sClient.WatchAll(p.Namespaces, ctxPool.Done())
@@ -260,6 +265,19 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl
continue
}
// Middlewares and TLS options always contain cross-provider references.
if rtConfig != nil && rtConfig.Router != nil && p.CrossProviderNamespaces != nil && !slices.Contains(p.CrossProviderNamespaces, ingress.Namespace) {
if len(rtConfig.Router.Middlewares) > 0 {
logger.Error().Msgf("Skipping Ingress: cross-provider middleware reference is not allowed from namespace %q", ingress.Namespace)
continue
}
if rtConfig.Router.TLS != nil && rtConfig.Router.TLS.Options != "" {
logger.Error().Msgf("Skipping Ingress: cross-provider TLS option reference is not allowed from namespace %q", ingress.Namespace)
continue
}
}
err = getCertificates(ctxIngress, ingress, client, certConfigs)
if err != nil {
logger.Error().Err(err).Msg("Error configuring TLS")
@@ -582,6 +600,10 @@ func (p *Provider) loadService(client Client, namespace string, backend netv1.In
}
if svcConfig.Service.ServersTransport != "" {
if p.CrossProviderNamespaces != nil && !slices.Contains(p.CrossProviderNamespaces, namespace) {
return nil, fmt.Errorf("cross-provider serversTransport reference is not allowed from namespace %q", namespace)
}
svc.LoadBalancer.ServersTransport = svcConfig.Service.ServersTransport
}
@@ -2480,6 +2480,153 @@ func generateTestFilename(desc string) string {
return filepath.Join("fixtures", strings.ReplaceAll(desc, " ", "-")+".yml")
}
// TestLoadConfigurationFromIngressesWithCrossProviderNamespaces verifies that an Ingress,
// declaring a `traefik.ingress.kubernetes.io/router.middlewares` annotation,
// is dropped from the dynamic configuration when its namespace is not in `crossProviderNamespaces`.
func TestLoadConfigurationFromIngressesWithCrossProviderNamespaces(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
path string
wantRouter string
}{
{
desc: "Ingress with middleware annotation is kept when option is unset (backward compatible)",
crossProviderNamespaces: nil,
path: "fixtures/Ingress-with-annotations.yml",
wantRouter: "testing-bar",
},
{
desc: "Ingress with middleware annotation is dropped when option is empty",
crossProviderNamespaces: []string{},
path: "fixtures/Ingress-with-annotations.yml",
},
{
desc: "Ingress with middleware annotation is kept when its namespace is allow-listed",
crossProviderNamespaces: []string{"testing"},
path: "fixtures/Ingress-with-annotations.yml",
wantRouter: "testing-bar",
},
{
desc: "Ingress with middleware annotation is dropped when its namespace is not allow-listed",
crossProviderNamespaces: []string{"other"},
path: "fixtures/Ingress-with-annotations.yml",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
p := Provider{CrossProviderNamespaces: test.crossProviderNamespaces}
conf := p.loadConfigurationFromIngresses(t.Context(), newClientMock(test.path))
if test.wantRouter == "" {
assert.Empty(t, conf.HTTP.Routers)
return
}
assert.Contains(t, conf.HTTP.Routers, test.wantRouter)
})
}
}
// TestLoadConfigurationFromIngressesWithCrossProviderNamespaces_TLSOptions verifies that an Ingress,
// declaring a `traefik.ingress.kubernetes.io/router.tls.options` annotation,
// is dropped from the dynamic configuration when its namespace is not in `crossProviderNamespaces`.
func TestLoadConfigurationFromIngressesWithCrossProviderNamespaces_TLSOptions(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantRouter string
}{
{
desc: "Ingress with TLS options annotation is kept when option is unset (backward compatible)",
crossProviderNamespaces: nil,
wantRouter: "testing-bar",
},
{
desc: "Ingress with TLS options annotation is dropped when option is empty",
crossProviderNamespaces: []string{},
},
{
desc: "Ingress with TLS options annotation is kept when its namespace is allow-listed",
crossProviderNamespaces: []string{"testing"},
wantRouter: "testing-bar",
},
{
desc: "Ingress with TLS options annotation is dropped when its namespace is not allow-listed",
crossProviderNamespaces: []string{"other"},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
p := Provider{CrossProviderNamespaces: test.crossProviderNamespaces}
conf := p.loadConfigurationFromIngresses(t.Context(), newClientMock("fixtures/Ingress-with-tls-options-annotation.yml"))
if test.wantRouter == "" {
assert.Empty(t, conf.HTTP.Routers)
return
}
assert.Contains(t, conf.HTTP.Routers, test.wantRouter)
assert.NotNil(t, conf.HTTP.Routers[test.wantRouter].TLS)
assert.Equal(t, "foobar@file", conf.HTTP.Routers[test.wantRouter].TLS.Options)
})
}
}
// TestLoadConfigurationFromIngressesWithCrossProviderNamespaces_ServersTransport verifies that a Service referencing a cross-provider ServersTransport,
// via the `traefik.ingress.kubernetes.io/service.serverstransport` annotation,
// is dropped from the dynamic configuration when its namespace is not in `crossProviderNamespaces`.
func TestLoadConfigurationFromIngressesWithCrossProviderNamespaces_ServersTransport(t *testing.T) {
testCases := []struct {
desc string
crossProviderNamespaces []string
wantService string
}{
{
desc: "Service with serversTransport annotation is kept when option is unset (backward compatible)",
crossProviderNamespaces: nil,
wantService: "testing-service1-80",
},
{
desc: "Service with serversTransport annotation is dropped when option is empty",
crossProviderNamespaces: []string{},
},
{
desc: "Service with serversTransport annotation is kept when its namespace is allow-listed",
crossProviderNamespaces: []string{"testing"},
wantService: "testing-service1-80",
},
{
desc: "Service with serversTransport annotation is dropped when its namespace is not allow-listed",
crossProviderNamespaces: []string{"other"},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
p := Provider{CrossProviderNamespaces: test.crossProviderNamespaces}
conf := p.loadConfigurationFromIngresses(t.Context(), newClientMock("fixtures/Ingress-with-servers-transport-annotation.yml"))
if test.wantService == "" {
assert.Empty(t, conf.HTTP.Services)
assert.Empty(t, conf.HTTP.Routers)
return
}
assert.Contains(t, conf.HTTP.Services, test.wantService)
assert.Equal(t, "foobar@file", conf.HTTP.Services[test.wantService].LoadBalancer.ServersTransport)
})
}
}
func TestGetCertificates(t *testing.T) {
testIngressWithoutHostname := buildIngress(
iNamespace("testing"),