diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 185d843a5..ee055c64c 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -287,12 +287,13 @@ The following annotations are organized by category for easier navigation. ### Load Balancing & Backend -| Annotation | Limitations / Notes | -|-------------------------------------------------------|--------------------------------------------------------------------------------------------| -| `nginx.ingress.kubernetes.io/load-balance` | Only round_robin supported; ewma and IP hash not supported. | -| `nginx.ingress.kubernetes.io/backend-protocol` | FCGI and AUTO_HTTP not supported. | -| `nginx.ingress.kubernetes.io/service-upstream` | | -| `nginx.ingress.kubernetes.io/upstream-vhost` | | +| Annotation | Limitations / Notes | +|-------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| `nginx.ingress.kubernetes.io/load-balance` | Only round_robin supported; ewma and IP hash not supported. | +| `nginx.ingress.kubernetes.io/backend-protocol` | FCGI and AUTO_HTTP not supported. | +| `nginx.ingress.kubernetes.io/service-upstream` | | +| `nginx.ingress.kubernetes.io/upstream-vhost` | | +| `nginx.ingress.kubernetes.io/custom-headers` | Header whitelisting, similar to `global-allowed-response-headers` NGINX config is not supported. | ### CORS diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index df183f014..06edd65d9 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -52,6 +52,7 @@ type ingressConfig struct { WhitelistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/whitelist-source-range"` + CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"` } diff --git a/pkg/provider/kubernetes/ingress-nginx/client.go b/pkg/provider/kubernetes/ingress-nginx/client.go index 93eae4a09..f1819ad11 100644 --- a/pkg/provider/kubernetes/ingress-nginx/client.go +++ b/pkg/provider/kubernetes/ingress-nginx/client.go @@ -37,6 +37,7 @@ type clientWrapper struct { clusterScopeFactory kinformers.SharedInformerFactory factoriesKube map[string]kinformers.SharedInformerFactory factoriesSecret map[string]kinformers.SharedInformerFactory + factoriesConfigMap map[string]kinformers.SharedInformerFactory factoriesIngress map[string]kinformers.SharedInformerFactory isNamespaceAll bool watchedNamespaces []string @@ -115,10 +116,11 @@ func createClientFromConfig(c *rest.Config) (*clientWrapper, error) { func newClient(clientSet kclientset.Interface) *clientWrapper { return &clientWrapper{ - clientset: clientSet, - factoriesSecret: make(map[string]kinformers.SharedInformerFactory), - factoriesIngress: make(map[string]kinformers.SharedInformerFactory), - factoriesKube: make(map[string]kinformers.SharedInformerFactory), + clientset: clientSet, + factoriesSecret: make(map[string]kinformers.SharedInformerFactory), + factoriesConfigMap: make(map[string]kinformers.SharedInformerFactory), + factoriesIngress: make(map[string]kinformers.SharedInformerFactory), + factoriesKube: make(map[string]kinformers.SharedInformerFactory), } } @@ -182,12 +184,20 @@ func (c *clientWrapper) WatchAll(ctx context.Context, namespace, namespaceSelect return nil, err } c.factoriesSecret[ns] = factorySecret + + factoryConfigMap := kinformers.NewSharedInformerFactoryWithOptions(c.clientset, resyncPeriod, kinformers.WithNamespace(ns), kinformers.WithTweakListOptions(notOwnedByHelm)) + _, err = factoryConfigMap.Core().V1().ConfigMaps().Informer().AddEventHandler(eventHandler) + if err != nil { + return nil, err + } + c.factoriesConfigMap[ns] = factoryConfigMap } for _, ns := range c.watchedNamespaces { c.factoriesIngress[ns].Start(stopCh) c.factoriesKube[ns].Start(stopCh) c.factoriesSecret[ns].Start(stopCh) + c.factoriesConfigMap[ns].Start(stopCh) } for _, ns := range c.watchedNamespaces { @@ -208,6 +218,12 @@ func (c *clientWrapper) WatchAll(ctx context.Context, namespace, namespaceSelect return nil, fmt.Errorf("timed out waiting for controller caches to sync %s in namespace %q", t.String(), ns) } } + + for t, ok := range c.factoriesConfigMap[ns].WaitForCacheSync(stopCh) { + if !ok { + return nil, fmt.Errorf("timed out waiting for controller caches to sync %s in namespace %q", t.String(), ns) + } + } } c.clusterScopeFactory = kinformers.NewSharedInformerFactory(c.clientset, resyncPeriod) @@ -314,6 +330,15 @@ func (c *clientWrapper) GetEndpointSlicesForService(namespace, serviceName strin return c.factoriesKube[c.lookupNamespace(namespace)].Discovery().V1().EndpointSlices().Lister().EndpointSlices(namespace).List(serviceSelector) } +// GetConfigMap returns the named configMap from the given namespace. +func (c *clientWrapper) GetConfigMap(namespace, name string) (*corev1.ConfigMap, error) { + if !c.isWatchedNamespace(namespace) { + return nil, fmt.Errorf("failed to get configmap %s/%s: namespace is not within watched namespaces", namespace, name) + } + + return c.factoriesConfigMap[c.lookupNamespace(namespace)].Core().V1().ConfigMaps().Lister().ConfigMaps(namespace).Get(name) +} + // GetSecret returns the named secret from the given namespace. func (c *clientWrapper) GetSecret(namespace, name string) (*corev1.Secret, error) { if !c.isWatchedNamespace(namespace) { diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/11-ingress-with-custom-headers.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/11-ingress-with-custom-headers.yml new file mode 100644 index 000000000..19803a1e8 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/11-ingress-with-custom-headers.yml @@ -0,0 +1,31 @@ +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: ingress-with-custom-headers + namespace: default + annotations: + nginx.ingress.kubernetes.io/custom-headers: default/custom-headers-configmap + +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 + +--- +kind: ConfigMap +apiVersion: v1 +metadata: + name: custom-headers-configmap + namespace: default +data: + X-Custom-Header: "some-random-string" diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index 7495400fb..307daac2d 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -804,6 +804,40 @@ func (p *Provider) applyMiddlewares(namespace, routerKey string, ingressConfig i applyUpstreamVhost(routerKey, ingressConfig, rt, conf) + if err := p.applyCustomHeaders(routerKey, ingressConfig, rt, conf); err != nil { + return fmt.Errorf("applying custom headers: %w", err) + } + + return nil +} + +func (p *Provider) applyCustomHeaders(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) error { + customHeaders := ptr.Deref(ingressConfig.CustomHeaders, "") + if customHeaders == "" { + return nil + } + + customHeadersParts := strings.Split(customHeaders, "/") + if len(customHeadersParts) != 2 { + return fmt.Errorf("invalid custom headers config map %q", customHeaders) + } + + configMapNamespace := customHeadersParts[0] + configMapName := customHeadersParts[1] + + configMap, err := p.k8sClient.GetConfigMap(configMapNamespace, configMapName) + if err != nil { + return fmt.Errorf("getting configMap %s: %w", customHeaders, err) + } + + customHeadersMiddlewareName := routerName + "-custom-headers" + conf.HTTP.Middlewares[customHeadersMiddlewareName] = &dynamic.Middleware{ + Headers: &dynamic.Headers{ + CustomResponseHeaders: configMap.Data, + }, + } + rt.Middlewares = append(rt.Middlewares, customHeadersMiddlewareName) + return nil } diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 0492cdf85..b2f1d3de4 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -46,6 +46,58 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Custom Headers", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/11-ingress-with-custom-headers.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-custom-headers-rule-0-path-0": { + Rule: "Host(`whoami.localhost`) && Path(`/`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-custom-headers"}, + Service: "default-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-custom-headers-rule-0-path-0-custom-headers": { + Headers: &dynamic.Headers{ + CustomResponseHeaders: map[string]string{"X-Custom-Header": "some-random-string"}, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Basic Auth", paths: []string{