Support NGINX custom-headers annotation

This commit is contained in:
Nándor Kollár
2025-12-22 10:44:08 +01:00
committed by GitHub
parent f71b941995
commit b4abd8dc2c
6 changed files with 154 additions and 10 deletions
@@ -287,12 +287,13 @@ The following annotations are organized by category for easier navigation.
### Load Balancing & Backend
| Annotation | Limitations / Notes |
|-------------------------------------------------------|--------------------------------------------------------------------------------------------|
| <a id="opt-nginx-ingress-kubernetes-ioload-balance" href="#opt-nginx-ingress-kubernetes-ioload-balance" title="#opt-nginx-ingress-kubernetes-ioload-balance">`nginx.ingress.kubernetes.io/load-balance`</a> | Only round_robin supported; ewma and IP hash not supported. |
| <a id="opt-nginx-ingress-kubernetes-iobackend-protocol" href="#opt-nginx-ingress-kubernetes-iobackend-protocol" title="#opt-nginx-ingress-kubernetes-iobackend-protocol">`nginx.ingress.kubernetes.io/backend-protocol`</a> | FCGI and AUTO_HTTP not supported. |
| <a id="opt-nginx-ingress-kubernetes-ioservice-upstream" href="#opt-nginx-ingress-kubernetes-ioservice-upstream" title="#opt-nginx-ingress-kubernetes-ioservice-upstream">`nginx.ingress.kubernetes.io/service-upstream`</a> | |
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | |
| Annotation | Limitations / Notes |
|-------------------------------------------------------|--------------------------------------------------------------------------------------------------|
| <a id="opt-nginx-ingress-kubernetes-ioload-balance" href="#opt-nginx-ingress-kubernetes-ioload-balance" title="#opt-nginx-ingress-kubernetes-ioload-balance">`nginx.ingress.kubernetes.io/load-balance`</a> | Only round_robin supported; ewma and IP hash not supported. |
| <a id="opt-nginx-ingress-kubernetes-iobackend-protocol" href="#opt-nginx-ingress-kubernetes-iobackend-protocol" title="#opt-nginx-ingress-kubernetes-iobackend-protocol">`nginx.ingress.kubernetes.io/backend-protocol`</a> | FCGI and AUTO_HTTP not supported. |
| <a id="opt-nginx-ingress-kubernetes-ioservice-upstream" href="#opt-nginx-ingress-kubernetes-ioservice-upstream" title="#opt-nginx-ingress-kubernetes-ioservice-upstream">`nginx.ingress.kubernetes.io/service-upstream`</a> | |
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | |
| <a id="opt-nginx-ingress-kubernetes-iocustom-headers" href="#opt-nginx-ingress-kubernetes-iocustom-headers" title="#opt-nginx-ingress-kubernetes-iocustom-headers">`nginx.ingress.kubernetes.io/custom-headers`</a> | Header whitelisting, similar to `global-allowed-response-headers` NGINX config is not supported. |
### CORS
@@ -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"`
}
@@ -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) {
@@ -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"
@@ -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
}
@@ -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{