diff --git a/docs/content/providers/kubernetes-crd.md b/docs/content/providers/kubernetes-crd.md index 41a6c5b89..8f2e6cfe5 100644 --- a/docs/content/providers/kubernetes-crd.md +++ b/docs/content/providers/kubernetes-crd.md @@ -250,6 +250,34 @@ providers: --providers.kubernetescrd.throttleDuration=10s ``` +### `allowCrossNamespace` + +_Optional, Default: true_ + +```toml tab="File (TOML)" +[providers.kubernetesCRD] + allowCrossNamespace = false + # ... +``` + +```yaml tab="File (YAML)" +providers: + kubernetesCRD: + allowCrossNamespace: false + # ... +``` + +```bash tab="CLI" +--providers.kubernetescrd.allowCrossNamespace=false +``` + +If the parameter is set to `false`, an IngressRoute will not be able to reference any resources +in another namespace than the IngressRoute namespace. + +!!! warning "Deprecation" + + Please notice that the default value for this option will be set to `false` in a future version. + ## Further Also see the [full example](../user-guides/crd-acme/index.md) with Let's Encrypt. diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 8ecd20175..f5ad7077b 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -540,6 +540,9 @@ TLS key `--providers.kubernetescrd`: Enable Kubernetes backend with default settings. (Default: ```false```) +`--providers.kubernetescrd.allowcrossnamespace`: +Allow cross namespace resource reference. (Default: ```true```) + `--providers.kubernetescrd.certauthfilepath`: Kubernetes certificate authority file path (not needed for in-cluster client). diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 83c5e8480..9abff68d3 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -540,6 +540,9 @@ TLS key `TRAEFIK_PROVIDERS_KUBERNETESCRD`: Enable Kubernetes backend with default settings. (Default: ```false```) +`TRAEFIK_PROVIDERS_KUBERNETESCRD_ALLOWCROSSNAMESPACE`: +Allow cross namespace resource reference. (Default: ```true```) + `TRAEFIK_PROVIDERS_KUBERNETESCRD_CERTAUTHFILEPATH`: Kubernetes certificate authority file path (not needed for in-cluster client). diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 8bef7d6ac..1d5985d19 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -113,6 +113,7 @@ certAuthFilePath = "foobar" disablePassHostHeaders = true namespaces = ["foobar", "foobar"] + allowCrossNamespace = true labelSelector = "foobar" ingressClass = "foobar" throttleDuration = 42 diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index 9da38a70a..26332ef18 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -123,6 +123,7 @@ providers: namespaces: - foobar - foobar + allowCrossNamespace: true labelSelector: foobar ingressClass: foobar throttleDuration: 42s diff --git a/integration/fixtures/k8s/07-ingressroute-cross-namespace.yml b/integration/fixtures/k8s/07-ingressroute-cross-namespace.yml new file mode 100644 index 000000000..fbdeb88a4 --- /dev/null +++ b/integration/fixtures/k8s/07-ingressroute-cross-namespace.yml @@ -0,0 +1,120 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: other-ns + +--- +apiVersion: v1 +kind: Service +metadata: + name: whoami + namespace: other-ns + +spec: + ports: + - name: http + port: 80 + selector: + app: traefiklabs + task: whoami + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: test6.route + namespace: other-ns + +spec: + entryPoints: + - web + routes: + - match: Host(`foo.com`) && PathPrefix(`/a`) + kind: Rule + services: + - name: whoami + namespace: default + port: 80 + - match: Host(`foo.com`) && PathPrefix(`/b`) + kind: Rule + services: + - name: wrr2 + namespace: default + kind: TraefikService + - match: Host(`foo.com`) && PathPrefix(`/c`) + kind: Rule + services: + - name: wrr3 + kind: TraefikService + - match: Host(`foo.com`) && PathPrefix(`/d`) + kind: Rule + services: + - name: whoami + namespace: other-ns + port: 80 + middlewares: + - name: stripprefix2 + namespace: default + - match: Host(`foo.com`) && PathPrefix(`/e`) + kind: Rule + services: + - name: whoami + port: 80 + middlewares: + - name: test-errorpage + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: wrr2 + namespace: default + +spec: + weighted: + services: + - name: whoami + port: 80 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: wrr3 + namespace: other-ns + +spec: + weighted: + services: + - name: whoami + namespace: default + port: 80 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: stripprefix2 + namespace: default + +spec: + stripPrefix: + prefixes: + - /tobestripped + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: test-errorpage + +spec: + errors: + status: + - 500-599 + query: /{status}.html + service: + name: whoami + namespace: other-ns + port: 80 diff --git a/integration/fixtures/k8s_crd.toml b/integration/fixtures/k8s_crd.toml index 1f85c7822..3015b6093 100644 --- a/integration/fixtures/k8s_crd.toml +++ b/integration/fixtures/k8s_crd.toml @@ -16,3 +16,4 @@ address = ":8000" [providers.kubernetesCRD] + allowCrossNamespace = false diff --git a/integration/k8s_test.go b/integration/k8s_test.go index 60bd25b36..728e326e0 100644 --- a/integration/k8s_test.go +++ b/integration/k8s_test.go @@ -53,6 +53,7 @@ func (s *K8sSuite) TearDownSuite(c *check.C) { "./fixtures/k8s/coredns.yaml", "./fixtures/k8s/rolebindings.yaml", "./fixtures/k8s/traefik.yaml", + "./fixtures/k8s/ccm.yaml", } for _, filename := range generatedFiles { diff --git a/integration/testdata/rawdata-crd.json b/integration/testdata/rawdata-crd.json index 570bee4d4..193f2ed13 100644 --- a/integration/testdata/rawdata-crd.json +++ b/integration/testdata/rawdata-crd.json @@ -50,6 +50,20 @@ "using": [ "web" ] + }, + "other-ns-test6-route-482e4988e134701d8cc8@kubernetescrd": { + "entryPoints": [ + "web" + ], + "service": "other-ns-wrr3", + "rule": "Host(`foo.com`) \u0026\u0026 PathPrefix(`/c`)", + "error": [ + "the service \"other-ns-wrr3@kubernetescrd\" does not exist" + ], + "status": "disabled", + "using": [ + "web" + ] } }, "middlewares": { @@ -64,6 +78,14 @@ "default-test2-route-23c7f4c450289ee29016@kubernetescrd" ] }, + "default-stripprefix2@kubernetescrd": { + "stripPrefix": { + "prefixes": [ + "/tobestripped" + ] + }, + "status": "enabled" + }, "default-stripprefix@kubernetescrd": { "stripPrefix": { "prefixes": [ @@ -172,6 +194,17 @@ "default-test3-route-7d0ac22d3d8db4b82618@kubernetescrd" ] }, + "default-wrr2@kubernetescrd": { + "weighted": { + "services": [ + { + "name": "default-whoami-80", + "weight": 1 + } + ] + }, + "status": "enabled" + }, "noop@internal": { "status": "enabled" } diff --git a/pkg/provider/kubernetes/crd/fixtures/services.yml b/pkg/provider/kubernetes/crd/fixtures/services.yml index 50d6d65c6..7d39877c4 100644 --- a/pkg/provider/kubernetes/crd/fixtures/services.yml +++ b/pkg/provider/kubernetes/crd/fixtures/services.yml @@ -199,3 +199,33 @@ spec: - name: http protocol: TCP port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: whoami-svc + namespace: cross-ns + +spec: + ports: + - name: web + port: 80 + selector: + app: traefiklabs + task: whoami + +--- +kind: Endpoints +apiVersion: v1 +metadata: + name: whoami-svc + namespace: cross-ns + +subsets: + - addresses: + - ip: 10.10.0.1 + - ip: 10.10.0.2 + ports: + - name: web + port: 80 diff --git a/pkg/provider/kubernetes/crd/fixtures/tcp/services.yml b/pkg/provider/kubernetes/crd/fixtures/tcp/services.yml index 32a854c8a..1f1377343 100644 --- a/pkg/provider/kubernetes/crd/fixtures/tcp/services.yml +++ b/pkg/provider/kubernetes/crd/fixtures/tcp/services.yml @@ -208,3 +208,33 @@ metadata: spec: externalName: "fe80::200:5aee:feaa:20a2" type: ExternalName + +--- +apiVersion: v1 +kind: Service +metadata: + name: whoamitcp-cross-ns + namespace: cross-ns + +spec: + ports: + - name: myapp + port: 8000 + selector: + app: traefiklabs + task: whoamitcp + +--- +kind: Endpoints +apiVersion: v1 +metadata: + name: whoamitcp-cross-ns + namespace: cross-ns + +subsets: + - addresses: + - ip: 10.10.0.1 + - ip: 10.10.0.2 + ports: + - name: myapp + port: 8000 diff --git a/pkg/provider/kubernetes/crd/fixtures/tcp/with_cross_namespace.yml b/pkg/provider/kubernetes/crd/fixtures/tcp/with_cross_namespace.yml new file mode 100644 index 000000000..c7ba22f19 --- /dev/null +++ b/pkg/provider/kubernetes/crd/fixtures/tcp/with_cross_namespace.yml @@ -0,0 +1,16 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRouteTCP +metadata: + name: test.route + namespace: default + +spec: + entryPoints: + - foo + + routes: + - match: HostSNI(`foo.com`) + services: + - name: whoamitcp-cross-ns + namespace: cross-ns + port: 8000 diff --git a/pkg/provider/kubernetes/crd/fixtures/udp/services.yml b/pkg/provider/kubernetes/crd/fixtures/udp/services.yml index f1c0abe18..887ddc385 100644 --- a/pkg/provider/kubernetes/crd/fixtures/udp/services.yml +++ b/pkg/provider/kubernetes/crd/fixtures/udp/services.yml @@ -130,3 +130,33 @@ subsets: ports: - name: myapp-ipv6 port: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: whoamiudp-cross-ns + namespace: cross-ns + +spec: + ports: + - name: myapp + port: 8000 + selector: + app: traefiklabs + task: whoamiudp + +--- +kind: Endpoints +apiVersion: v1 +metadata: + name: whoamiudp-cross-ns + namespace: cross-ns + +subsets: + - addresses: + - ip: 10.10.0.1 + - ip: 10.10.0.2 + ports: + - name: myapp + port: 8000 diff --git a/pkg/provider/kubernetes/crd/fixtures/udp/with_cross_namespace.yml b/pkg/provider/kubernetes/crd/fixtures/udp/with_cross_namespace.yml new file mode 100644 index 000000000..8df091d70 --- /dev/null +++ b/pkg/provider/kubernetes/crd/fixtures/udp/with_cross_namespace.yml @@ -0,0 +1,15 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRouteUDP +metadata: + name: test.route + namespace: default + +spec: + entryPoints: + - foo + + routes: + - services: + - name: whoamiudp-cross-ns + namespace: cross-ns + port: 8000 diff --git a/pkg/provider/kubernetes/crd/fixtures/with_cross_namespace.yml b/pkg/provider/kubernetes/crd/fixtures/with_cross_namespace.yml new file mode 100644 index 000000000..bdc98ca1e --- /dev/null +++ b/pkg/provider/kubernetes/crd/fixtures/with_cross_namespace.yml @@ -0,0 +1,91 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: cross-ns-route + namespace: default + +spec: + entryPoints: + - foo + + routes: + - match: Host(`foo.com`) && PathPrefix(`/bar`) + kind: Rule + priority: 12 + services: + - name: whoami-svc + namespace: cross-ns + port: 80 + - name: tr-svc-wrr1 + kind: TraefikService + - name: tr-svc-wrr2 + namespace: cross-ns + kind: TraefikService + - name: tr-svc-mirror1 + kind: TraefikService + - name: tr-svc-mirror2 + namespace: cross-ns + kind: TraefikService + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: tr-svc-wrr1 + namespace: default + +spec: + weighted: + services: + - name: whoami-svc + namespace: cross-ns + weight: 1 + port: 80 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: tr-svc-wrr2 + namespace: cross-ns + +spec: + weighted: + services: + - name: whoami-svc + weight: 1 + port: 80 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: tr-svc-mirror1 + namespace: default + +spec: + mirroring: + name: whoami + port: 80 + mirrors: + - name: whoami-svc + namespace: cross-ns + percent: 20 + port: 80 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: TraefikService +metadata: + name: tr-svc-mirror2 + namespace: cross-ns + +spec: + mirroring: + name: whoami-svc + port: 80 + mirrors: + - name: whoami-svc + namespace: cross-ns + percent: 20 + port: 80 diff --git a/pkg/provider/kubernetes/crd/fixtures/with_middleware_cross_namespace.yml b/pkg/provider/kubernetes/crd/fixtures/with_middleware_cross_namespace.yml new file mode 100644 index 000000000..aaed4a1d8 --- /dev/null +++ b/pkg/provider/kubernetes/crd/fixtures/with_middleware_cross_namespace.yml @@ -0,0 +1,58 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: test-crossnamespace.route + namespace: default + +spec: + entryPoints: + - foo + + routes: + - match: Host(`foo.com`) && PathPrefix(`/bar`) + kind: Rule + priority: 12 + services: + - name: whoami + namespace: default + port: 80 + middlewares: + - name: stripprefix + namespace: cross-ns + - match: Host(`foo.com`) && PathPrefix(`/bir`) + kind: Rule + priority: 12 + services: + - name: whoami + namespace: default + port: 80 + middlewares: + - name: test-errorpage + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: stripprefix + namespace: cross-ns + +spec: + stripPrefix: + prefixes: + - /stripit + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: test-errorpage + namespace: default +spec: + errors: + status: + - 500-599 + query: /{status}.html + service: + name: whoami-svc + namespace: cross-ns + port: 80 diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index 3d3678a83..2a440cfc1 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -43,6 +43,7 @@ type Provider struct { CertAuthFilePath string `description:"Kubernetes certificate authority file path (not needed for in-cluster client)." json:"certAuthFilePath,omitempty" toml:"certAuthFilePath,omitempty" yaml:"certAuthFilePath,omitempty"` DisablePassHostHeaders bool `description:"Kubernetes disable PassHost Headers." json:"disablePassHostHeaders,omitempty" toml:"disablePassHostHeaders,omitempty" yaml:"disablePassHostHeaders,omitempty" export:"true"` 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"` LabelSelector string `description:"Kubernetes label selector to use." json:"labelSelector,omitempty" toml:"labelSelector,omitempty" yaml:"labelSelector,omitempty" export:"true"` IngressClass string `description:"Value of 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"` @@ -82,6 +83,11 @@ func (p *Provider) newK8sClient(ctx context.Context) (*clientWrapper, error) { return client, nil } +// SetDefaults sets the default values. +func (p *Provider) SetDefaults() { + p.AllowCrossNamespace = func(b bool) *bool { return &b }(true) +} + // Init the provider. func (p *Provider) Init() error { return nil @@ -98,6 +104,10 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. return err } + if p.AllowCrossNamespace == nil || *p.AllowCrossNamespace { + logger.Warn("Cross-namespace reference between IngressRoutes and resources is enabled, please ensure that this is expected (see AllowCrossNamespace option)") + } + pool.GoCtx(func(ctxPool context.Context) { operation := func() error { eventsChan, err := k8sClient.WatchAll(p.Namespaces, ctxPool.Done()) @@ -197,7 +207,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client) continue } - errorPage, errorPageService, err := createErrorPageMiddleware(client, middleware.Namespace, middleware.Spec.Errors) + errorPage, errorPageService, err := p.createErrorPageMiddleware(client, middleware.Namespace, middleware.Spec.Errors) if err != nil { log.FromContext(ctxMid).Errorf("Error while reading error page middleware: %v", err) continue @@ -236,7 +246,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client) } } - cb := configBuilder{client} + cb := configBuilder{client, p.AllowCrossNamespace} for _, service := range client.GetTraefikServices() { err := cb.buildTraefikService(ctx, service, conf.HTTP.Services) if err != nil { @@ -281,7 +291,7 @@ func getServicePort(svc *corev1.Service, port int32) (*corev1.ServicePort, error return &corev1.ServicePort{Port: port}, nil } -func createErrorPageMiddleware(client Client, namespace string, errorPage *v1alpha1.ErrorPage) (*dynamic.ErrorPage, *dynamic.Service, error) { +func (p *Provider) createErrorPageMiddleware(client Client, namespace string, errorPage *v1alpha1.ErrorPage) (*dynamic.ErrorPage, *dynamic.Service, error) { if errorPage == nil { return nil, nil, nil } @@ -291,7 +301,7 @@ func createErrorPageMiddleware(client Client, namespace string, errorPage *v1alp Query: errorPage.Query, } - balancerServerHTTP, err := configBuilder{client}.buildServersLB(namespace, errorPage.Service.LoadBalancerSpec) + balancerServerHTTP, err := configBuilder{client, p.AllowCrossNamespace}.buildServersLB(namespace, errorPage.Service.LoadBalancerSpec) if err != nil { return nil, nil, err } @@ -749,3 +759,8 @@ func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *s return eventsChanBuffered } + +func isNamespaceAllowed(allowCrossNamespace *bool, parentNamespace, namespace string) bool { + // If allowCrossNamespace option is not defined the default behavior is to allow cross namespace references. + return allowCrossNamespace == nil || *allowCrossNamespace || parentNamespace == namespace +} diff --git a/pkg/provider/kubernetes/crd/kubernetes_http.go b/pkg/provider/kubernetes/crd/kubernetes_http.go index 1a40c561c..b66b65b61 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_http.go +++ b/pkg/provider/kubernetes/crd/kubernetes_http.go @@ -48,7 +48,8 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli ingressName = ingressRoute.GenerateName } - cb := configBuilder{client} + cb := configBuilder{client, p.AllowCrossNamespace} + for _, route := range ingressRoute.Spec.Routes { if route.Kind != "Rule" { logger.Errorf("Unsupported match kind: %s. Only \"Rule\" is supported for now.", route.Kind) @@ -66,23 +67,10 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli continue } - var mds []string - for _, mi := range route.Middlewares { - if strings.Contains(mi.Name, providerNamespaceSeparator) { - if len(mi.Namespace) > 0 { - logger. - WithField(log.MiddlewareName, mi.Name). - Warnf("namespace %q is ignored in cross-provider context", mi.Namespace) - } - mds = append(mds, mi.Name) - continue - } - - ns := mi.Namespace - if len(ns) == 0 { - ns = ingressRoute.Namespace - } - mds = append(mds, makeID(ns, mi.Name)) + mds, err := p.makeMiddlewareKeys(ctx, ingressRoute.Namespace, route.Middlewares) + if err != nil { + logger.Errorf("Failed to create middleware keys: %v", err) + continue } normalized := provider.Normalize(makeID(ingressRoute.Namespace, serviceKey)) @@ -153,8 +141,38 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli return conf } +func (p *Provider) makeMiddlewareKeys(ctx context.Context, ingRouteNamespace string, middlewares []v1alpha1.MiddlewareRef) ([]string, error) { + var mds []string + + for _, mi := range middlewares { + if strings.Contains(mi.Name, providerNamespaceSeparator) { + if len(mi.Namespace) > 0 { + log.FromContext(ctx). + WithField(log.MiddlewareName, mi.Name). + Warnf("namespace %q is ignored in cross-provider context", mi.Namespace) + } + mds = append(mds, mi.Name) + continue + } + + ns := ingRouteNamespace + if len(mi.Namespace) > 0 { + if !isNamespaceAllowed(p.AllowCrossNamespace, ingRouteNamespace, mi.Namespace) { + return nil, fmt.Errorf("middleware %s/%s is not in the IngressRoute namespace %s", mi.Namespace, mi.Name, ingRouteNamespace) + } + + ns = mi.Namespace + } + + mds = append(mds, makeID(ns, mi.Name)) + } + + return mds, nil +} + type configBuilder struct { - client Client + client Client + allowCrossNamespace *bool } // buildTraefikService creates the configuration for the traefik service defined in tService, @@ -270,7 +288,7 @@ func (c configBuilder) buildServersLB(namespace string, svc v1alpha1.LoadBalance return &dynamic.Service{LoadBalancer: lb}, nil } -func (c configBuilder) loadServers(fallbackNamespace string, svc v1alpha1.LoadBalancerSpec) ([]dynamic.Server, error) { +func (c configBuilder) loadServers(parentNamespace string, svc v1alpha1.LoadBalancerSpec) ([]dynamic.Server, error) { strategy := svc.Strategy if strategy == "" { strategy = roundRobinStrategy @@ -279,7 +297,11 @@ func (c configBuilder) loadServers(fallbackNamespace string, svc v1alpha1.LoadBa return nil, fmt.Errorf("load balancing strategy %s is not supported", strategy) } - namespace := namespaceOrFallback(svc, fallbackNamespace) + namespace := namespaceOrFallback(svc, parentNamespace) + + if !isNamespaceAllowed(c.allowCrossNamespace, parentNamespace, namespace) { + return nil, fmt.Errorf("load balancer service %s/%s is not in the parent resource namespace %s", svc.Namespace, svc.Name, parentNamespace) + } // If the service uses explicitly the provider suffix sanitizedName := strings.TrimSuffix(svc.Name, providerNamespaceSeparator+providerName) @@ -355,10 +377,14 @@ func (c configBuilder) loadServers(fallbackNamespace string, svc v1alpha1.LoadBa // In addition, if the service is a Kubernetes one, // it generates and returns the configuration part for such a service, // so that the caller can add it to the global config map. -func (c configBuilder) nameAndService(ctx context.Context, namespaceService string, service v1alpha1.LoadBalancerSpec) (string, *dynamic.Service, error) { +func (c configBuilder) nameAndService(ctx context.Context, parentNamespace string, service v1alpha1.LoadBalancerSpec) (string, *dynamic.Service, error) { svcCtx := log.With(ctx, log.Str(log.ServiceName, service.Name)) - namespace := namespaceOrFallback(service, namespaceService) + namespace := namespaceOrFallback(service, parentNamespace) + + if !isNamespaceAllowed(c.allowCrossNamespace, parentNamespace, namespace) { + return "", nil, fmt.Errorf("service %s/%s not in the parent resource namespace %s", service.Namespace, service.Name, parentNamespace) + } switch { case service.Kind == "" || service.Kind == "Service": diff --git a/pkg/provider/kubernetes/crd/kubernetes_tcp.go b/pkg/provider/kubernetes/crd/kubernetes_tcp.go index abf29bb8e..915ed4cd6 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_tcp.go +++ b/pkg/provider/kubernetes/crd/kubernetes_tcp.go @@ -55,7 +55,7 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client serviceName := makeID(ingressRouteTCP.Namespace, key) for _, service := range route.Services { - balancerServerTCP, err := createLoadBalancerServerTCP(client, ingressRouteTCP.Namespace, service) + balancerServerTCP, err := p.createLoadBalancerServerTCP(client, ingressRouteTCP.Namespace, service) if err != nil { logger. WithField("serviceName", service.Name). @@ -125,9 +125,13 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client return conf } -func createLoadBalancerServerTCP(client Client, namespace string, service v1alpha1.ServiceTCP) (*dynamic.TCPService, error) { - ns := namespace +func (p *Provider) createLoadBalancerServerTCP(client Client, parentNamespace string, service v1alpha1.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 = service.Namespace } diff --git a/pkg/provider/kubernetes/crd/kubernetes_test.go b/pkg/provider/kubernetes/crd/kubernetes_test.go index b866f20cb..aff6eb964 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_test.go +++ b/pkg/provider/kubernetes/crd/kubernetes_test.go @@ -2,13 +2,21 @@ package crd import ( "context" + "io/ioutil" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/traefik/traefik/v2/pkg/config/dynamic" "github.com/traefik/traefik/v2/pkg/provider" + crdfake "github.com/traefik/traefik/v2/pkg/provider/kubernetes/crd/generated/clientset/versioned/fake" + "github.com/traefik/traefik/v2/pkg/provider/kubernetes/crd/traefik/v1alpha1" + "github.com/traefik/traefik/v2/pkg/provider/kubernetes/k8s" "github.com/traefik/traefik/v2/pkg/tls" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + kubefake "k8s.io/client-go/kubernetes/fake" ) var _ provider.Provider = (*Provider)(nil) @@ -1122,7 +1130,10 @@ func TestLoadIngressRouteTCPs(t *testing.T) { } p := Provider{IngressClass: test.ingressClass} - conf := p.loadConfigurationFromCRD(context.Background(), newClientMock(test.paths...)) + p.SetDefaults() + + clientMock := newClientMock(test.paths...) + conf := p.loadConfigurationFromCRD(context.Background(), clientMock) assert.Equal(t, test.expected, conf) }) } @@ -3187,7 +3198,10 @@ func TestLoadIngressRoutes(t *testing.T) { } p := Provider{IngressClass: test.ingressClass} - conf := p.loadConfigurationFromCRD(context.Background(), newClientMock(test.paths...)) + p.SetDefaults() + + clientMock := newClientMock(test.paths...) + conf := p.loadConfigurationFromCRD(context.Background(), clientMock) assert.Equal(t, test.expected, conf) }) } @@ -3495,7 +3509,10 @@ func TestLoadIngressRouteUDPs(t *testing.T) { } p := Provider{IngressClass: test.ingressClass} - conf := p.loadConfigurationFromCRD(context.Background(), newClientMock(test.paths...)) + p.SetDefaults() + + clientMock := newClientMock(test.paths...) + conf := p.loadConfigurationFromCRD(context.Background(), clientMock) assert.Equal(t, test.expected, conf) }) } @@ -3714,3 +3731,563 @@ func TestGetServicePort(t *testing.T) { }) } } + +func TestCrossNamespace(t *testing.T) { + testCases := []struct { + desc string + allowCrossNamespace bool + ingressClass string + paths []string + expected *dynamic.Configuration + }{ + { + desc: "Empty", + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + 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{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "HTTP middleware cross namespace disallowed", + paths: []string{"services.yml", "with_middleware_cross_namespace.yml"}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-test-crossnamespace-route-9313b71dbe6a649d5049": { + EntryPoints: []string{"foo"}, + Service: "default-test-crossnamespace-route-9313b71dbe6a649d5049", + Rule: "Host(`foo.com`) && PathPrefix(`/bir`)", + Priority: 12, + Middlewares: []string{"default-test-errorpage"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "cross-ns-stripprefix": { + StripPrefix: &dynamic.StripPrefix{ + Prefixes: []string{"/stripit"}, + ForceSlash: false, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-test-crossnamespace-route-9313b71dbe6a649d5049": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "HTTP middleware cross namespace allowed", + paths: []string{"services.yml", "with_middleware_cross_namespace.yml"}, + allowCrossNamespace: true, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-test-crossnamespace-route-6b204d94623b3df4370c": { + EntryPoints: []string{"foo"}, + Service: "default-test-crossnamespace-route-6b204d94623b3df4370c", + Rule: "Host(`foo.com`) && PathPrefix(`/bar`)", + Priority: 12, + Middlewares: []string{ + "cross-ns-stripprefix", + }, + }, + "default-test-crossnamespace-route-9313b71dbe6a649d5049": { + EntryPoints: []string{"foo"}, + Service: "default-test-crossnamespace-route-9313b71dbe6a649d5049", + Rule: "Host(`foo.com`) && PathPrefix(`/bir`)", + Priority: 12, + Middlewares: []string{"default-test-errorpage"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "cross-ns-stripprefix": { + StripPrefix: &dynamic.StripPrefix{ + Prefixes: []string{"/stripit"}, + ForceSlash: false, + }, + }, + "default-test-errorpage": { + Errors: &dynamic.ErrorPage{ + Status: []string{"500-599"}, + Service: "default-test-errorpage-errorpage-service", + Query: "/{status}.html", + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-test-crossnamespace-route-6b204d94623b3df4370c": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + "default-test-crossnamespace-route-9313b71dbe6a649d5049": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + "default-test-errorpage-errorpage-service": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "HTTP cross namespace allowed", + paths: []string{"services.yml", "with_cross_namespace.yml"}, + allowCrossNamespace: true, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-cross-ns-route-6b204d94623b3df4370c": { + EntryPoints: []string{"foo"}, + Service: "default-cross-ns-route-6b204d94623b3df4370c", + Rule: "Host(`foo.com`) && PathPrefix(`/bar`)", + Priority: 12, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-cross-ns-route-6b204d94623b3df4370c": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "cross-ns-whoami-svc-80", + Weight: func(i int) *int { return &i }(1), + }, + { + Name: "default-tr-svc-wrr1", + Weight: func(i int) *int { return &i }(1), + }, + { + Name: "cross-ns-tr-svc-wrr2", + Weight: func(i int) *int { return &i }(1), + }, + { + Name: "default-tr-svc-mirror1", + Weight: func(i int) *int { return &i }(1), + }, + { + Name: "cross-ns-tr-svc-mirror2", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "cross-ns-whoami-svc-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + "default-tr-svc-wrr1": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "cross-ns-whoami-svc-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "cross-ns-tr-svc-wrr2": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "cross-ns-whoami-svc-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "default-tr-svc-mirror1": { + Mirroring: &dynamic.Mirroring{ + Service: "default-whoami-80", + Mirrors: []dynamic.MirrorService{ + { + Name: "cross-ns-whoami-svc-80", + Percent: 20, + }, + }, + }, + }, + "cross-ns-tr-svc-mirror2": { + Mirroring: &dynamic.Mirroring{ + Service: "cross-ns-whoami-svc-80", + Mirrors: []dynamic.MirrorService{ + { + Name: "cross-ns-whoami-svc-80", + Percent: 20, + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "HTTP cross namespace disallowed", + paths: []string{"services.yml", "with_cross_namespace.yml"}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + 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{ + "cross-ns-tr-svc-wrr2": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "cross-ns-whoami-svc-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "cross-ns-whoami-svc-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + "cross-ns-tr-svc-mirror2": { + Mirroring: &dynamic.Mirroring{ + Service: "cross-ns-whoami-svc-80", + Mirrors: []dynamic.MirrorService{ + { + Name: "cross-ns-whoami-svc-80", + Percent: 20, + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "TCP cross namespace allowed", + paths: []string{"tcp/services.yml", "tcp/with_cross_namespace.yml"}, + allowCrossNamespace: true, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{ + "default-test.route-fdd3e9338e47a45efefc": { + EntryPoints: []string{"foo"}, + Service: "default-test.route-fdd3e9338e47a45efefc", + Rule: "HostSNI(`foo.com`)", + }, + }, + Services: map[string]*dynamic.TCPService{ + "default-test.route-fdd3e9338e47a45efefc": { + LoadBalancer: &dynamic.TCPServersLoadBalancer{ + Servers: []dynamic.TCPServer{ + { + Address: "10.10.0.1:8000", + Port: "", + }, + { + Address: "10.10.0.2:8000", + Port: "", + }, + }, + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "TCP cross namespace disallowed", + paths: []string{"tcp/services.yml", "tcp/with_cross_namespace.yml"}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + // The router that references the invalid service will be discarded. + Routers: map[string]*dynamic.TCPRouter{ + "default-test.route-fdd3e9338e47a45efefc": { + EntryPoints: []string{"foo"}, + Service: "default-test.route-fdd3e9338e47a45efefc", + Rule: "HostSNI(`foo.com`)", + }, + }, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "UDP cross namespace allowed", + paths: []string{"udp/services.yml", "udp/with_cross_namespace.yml"}, + allowCrossNamespace: true, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{ + "default-test.route-0": { + EntryPoints: []string{"foo"}, + Service: "default-test.route-0", + }, + }, + Services: map[string]*dynamic.UDPService{ + "default-test.route-0": { + LoadBalancer: &dynamic.UDPServersLoadBalancer{ + Servers: []dynamic.UDPServer{ + { + Address: "10.10.0.1:8000", + Port: "", + }, + { + Address: "10.10.0.2:8000", + Port: "", + }, + }, + }, + }, + }, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "UDP cross namespace disallowed", + paths: []string{"udp/services.yml", "udp/with_cross_namespace.yml"}, + expected: &dynamic.Configuration{ + // The router that references the invalid service will be discarded. + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{ + "default-test.route-0": { + EntryPoints: []string{"foo"}, + Service: "default-test.route-0", + }, + }, + Services: map[string]*dynamic.UDPService{}, + }, + 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{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var k8sObjects []runtime.Object + var crdObjects []runtime.Object + for _, path := range test.paths { + yamlContent, err := ioutil.ReadFile(filepath.FromSlash("./fixtures/" + path)) + if err != nil { + panic(err) + } + + objects := k8s.MustParseYaml(yamlContent) + for _, obj := range objects { + switch o := obj.(type) { + case *corev1.Service, *corev1.Endpoints, *corev1.Secret: + k8sObjects = append(k8sObjects, o) + case *v1alpha1.IngressRoute: + crdObjects = append(crdObjects, o) + case *v1alpha1.IngressRouteTCP: + crdObjects = append(crdObjects, o) + case *v1alpha1.IngressRouteUDP: + crdObjects = append(crdObjects, o) + case *v1alpha1.Middleware: + crdObjects = append(crdObjects, o) + case *v1alpha1.TraefikService: + crdObjects = append(crdObjects, o) + case *v1alpha1.TLSOption: + crdObjects = append(crdObjects, o) + case *v1alpha1.TLSStore: + crdObjects = append(crdObjects, o) + default: + } + } + } + + kubeClient := kubefake.NewSimpleClientset(k8sObjects...) + crdClient := crdfake.NewSimpleClientset(crdObjects...) + + client := newClientImpl(kubeClient, crdClient) + + stopCh := make(chan struct{}) + + eventCh, err := client.WatchAll([]string{"default", "cross-ns"}, stopCh) + require.NoError(t, err) + + if k8sObjects != nil || crdObjects != nil { + // just wait for the first event + <-eventCh + } + + p := Provider{} + p.SetDefaults() + + p.AllowCrossNamespace = func(b bool) *bool { return &b }(test.allowCrossNamespace) + conf := p.loadConfigurationFromCRD(context.Background(), client) + assert.Equal(t, test.expected, conf) + }) + } +} diff --git a/pkg/provider/kubernetes/crd/kubernetes_udp.go b/pkg/provider/kubernetes/crd/kubernetes_udp.go index 2d02c8b5f..0346084a2 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_udp.go +++ b/pkg/provider/kubernetes/crd/kubernetes_udp.go @@ -36,7 +36,7 @@ func (p *Provider) loadIngressRouteUDPConfiguration(ctx context.Context, client serviceName := makeID(ingressRouteUDP.Namespace, key) for _, service := range route.Services { - balancerServerUDP, err := createLoadBalancerServerUDP(client, ingressRouteUDP.Namespace, service) + balancerServerUDP, err := p.createLoadBalancerServerUDP(client, ingressRouteUDP.Namespace, service) if err != nil { logger. WithField("serviceName", service.Name). @@ -77,9 +77,13 @@ func (p *Provider) loadIngressRouteUDPConfiguration(ctx context.Context, client return conf } -func createLoadBalancerServerUDP(client Client, namespace string, service v1alpha1.ServiceUDP) (*dynamic.UDPService, error) { - ns := namespace +func (p *Provider) createLoadBalancerServerUDP(client Client, parentNamespace string, service v1alpha1.ServiceUDP) (*dynamic.UDPService, error) { + ns := parentNamespace if len(service.Namespace) > 0 { + if !isNamespaceAllowed(p.AllowCrossNamespace, parentNamespace, service.Namespace) { + return nil, fmt.Errorf("udp service %s/%s is not in the parent resource namespace %s", service.Namespace, service.Name, ns) + } + ns = service.Namespace }