diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 07ba27e38..f06d3c772 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -320,6 +320,7 @@ The following annotations are organized by category for easier navigation. | Annotation | Limitations / Notes | |-------------------------------------------------------|--------------------------------------------------------------------------------------------| | `nginx.ingress.kubernetes.io/app-root` | | +| `nginx.ingress.kubernetes.io/from-to-www-redirect` | Doesn't support wildcard hosts. | | `nginx.ingress.kubernetes.io/use-regex` | | | `nginx.ingress.kubernetes.io/rewrite-target` | | | `nginx.ingress.kubernetes.io/permanent-redirect` | Defaults to a 301 Moved Permanently status code. | diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index 4425db6c4..4abb00e22 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -36,6 +36,8 @@ type ingressConfig struct { TemporalRedirect *string `annotation:"nginx.ingress.kubernetes.io/temporal-redirect"` TemporalRedirectCode *int `annotation:"nginx.ingress.kubernetes.io/temporal-redirect-code"` + FromToWwwRedirect *bool `annotation:"nginx.ingress.kubernetes.io/from-to-www-redirect"` + Affinity *string `annotation:"nginx.ingress.kubernetes.io/affinity"` SessionCookieName *string `annotation:"nginx.ingress.kubernetes.io/session-cookie-name"` SessionCookieSecure *bool `annotation:"nginx.ingress.kubernetes.io/session-cookie-secure"` diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-host.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-host.yml new file mode 100644 index 000000000..7f7803946 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-host.yml @@ -0,0 +1,22 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-host + namespace: default + annotations: + nginx.ingress.kubernetes.io/from-to-www-redirect: "true" + +spec: + ingressClassName: nginx + rules: + - host: host.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-www-host.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-www-host.yml new file mode 100644 index 000000000..c02df3650 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-www-host.yml @@ -0,0 +1,22 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-www-host + namespace: default + annotations: + nginx.ingress.kubernetes.io/from-to-www-redirect: "true" + +spec: + ingressClassName: nginx + rules: + - host: www.host.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingresses-with-www-redirect.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingresses-with-www-redirect.yml new file mode 100644 index 000000000..03fcb9758 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingresses-with-www-redirect.yml @@ -0,0 +1,42 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-www-host + namespace: default + annotations: + nginx.ingress.kubernetes.io/from-to-www-redirect: "true" + +spec: + ingressClassName: nginx + rules: + - host: www.host.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-host + namespace: default + +spec: + ingressClassName: nginx + rules: + - host: host.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index e2e7cebe2..a59707a19 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -266,6 +266,19 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration ingresses := p.k8sClient.ListIngresses() + hosts := make(map[string]bool) + for _, ing := range ingresses { + if !p.shouldProcessIngress(ing, ingressClasses) { + continue + } + + for _, rule := range ing.Spec.Rules { + if !hosts[rule.Host] { + hosts[rule.Host] = true + } + } + } + uniqCerts := make(map[string]*tls.CertAndStores) tlsOptions := make(map[string]tls.Options) for _, ingress := range ingresses { @@ -340,7 +353,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration Service: defaultBackendName, } - if err := p.applyMiddlewares(ingress.Namespace, defaultBackendName, "", ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, defaultBackendName, "", "", hosts, ingressConfig, hasTLS, rt, conf); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -358,7 +371,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS.TLS.Options = clientAuthTLSOptionName } - if err := p.applyMiddlewares(ingress.Namespace, defaultBackendTLSName, "", ingressConfig, false, rtTLS, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, defaultBackendTLSName, "", "", hosts, ingressConfig, false, rtTLS, conf); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -435,7 +448,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration Service: key, } - if err := p.applyMiddlewares(ingress.Namespace, key, "", ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, key, "", "", hosts, ingressConfig, hasTLS, rt, conf); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -452,7 +465,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS.TLS.Options = clientAuthTLSOptionName } - if err := p.applyMiddlewares(ingress.Namespace, key+"-tls", "", ingressConfig, false, rtTLS, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, key+"-tls", "", "", hosts, ingressConfig, false, rtTLS, conf); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } @@ -521,7 +534,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport } - if err := p.applyMiddlewares(ingress.Namespace, routerKey, pa.Path, ingressConfig, hasTLS, rt, conf); err != nil { + if err := p.applyMiddlewares(ingress.Namespace, routerKey, pa.Path, rule.Host, hosts, ingressConfig, hasTLS, rt, conf); err != nil { logger.Error().Err(err).Msg("Error applying middlewares") } } @@ -830,8 +843,10 @@ func (p *Provider) loadCertificates(ctx context.Context, ingress *netv1.Ingress, return nil } -func (p *Provider) applyMiddlewares(namespace, routerKey, rulePath string, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) error { +func (p *Provider) applyMiddlewares(namespace, routerKey, rulePath, ruleHost string, hosts map[string]bool, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) error { applyAppRootConfiguration(routerKey, ingressConfig, rt, conf) + applyFromToWwwRedirect(hosts, ruleHost, routerKey, ingressConfig, rt, conf) + applyRedirect(routerKey, ingressConfig, rt, conf) // Apply SSL redirect is mandatory to be applied after all other middlewares. // TODO: check how to remove this, and create the HTTP router elsewhere. @@ -851,8 +866,6 @@ func (p *Provider) applyMiddlewares(namespace, routerKey, rulePath string, ingre applyRewriteTargetConfiguration(rulePath, routerKey, ingressConfig, rt, conf) - applyRedirect(routerKey, ingressConfig, rt, conf) - applyUpstreamVhost(routerKey, ingressConfig, rt, conf) if err := p.applyCustomHeaders(routerKey, ingressConfig, rt, conf); err != nil { @@ -966,6 +979,49 @@ func applyAppRootConfiguration(routerName string, ingressConfig ingressConfig, r rt.Middlewares = append(rt.Middlewares, appRootMiddlewareName) } +func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { + if ingressConfig.FromToWwwRedirect == nil || !*ingressConfig.FromToWwwRedirect { + return + } + + wwwType := strings.HasPrefix(ruleHost, "www.") + wildcardType := strings.HasPrefix(ruleHost, "*.") + bypass := wwwType && hosts[strings.TrimPrefix(ruleHost, "www.")] || !wwwType && hosts["www."+ruleHost] || wildcardType + + if bypass { + // Wildcard host not compatible with this annotation. (limitation) + // hosts already configured for www. and normal hosts. + return + } + + newRule := fmt.Sprintf("Host(`www.%s`)", ruleHost) + if wwwType { + // if current ingress host is www.example.com, redirect from example.com => www.example.com + host := strings.TrimPrefix(ruleHost, "www.") + newRule = fmt.Sprintf("Host(`%s`)", host) + } + + fromToWwwRedirectMiddlewareName := routerName + "-from-to-www-redirect" + conf.HTTP.Middlewares[fromToWwwRedirectMiddlewareName] = &dynamic.Middleware{ + RedirectRegex: &dynamic.RedirectRegex{ + Regex: `(https?)://[^/]+:([0-9]+)/(.*)`, + Replacement: fmt.Sprintf("$1://%s:$2/$3", ruleHost), + Permanent: true, + }, + } + + wwwRedirectRouter := &dynamic.Router{ + Rule: newRule, + EntryPoints: rt.EntryPoints, + Priority: rt.Priority, + // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. + RuleSyntax: "default", + Middlewares: []string{fromToWwwRedirectMiddlewareName}, + Service: rt.Service, + } + conf.HTTP.Routers[routerName+"-from-to-www-redirect"] = wwwRedirectRouter +} + func (p *Provider) applyBasicAuthConfiguration(namespace, routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) error { if ingressConfig.AuthType == nil { return nil diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 68b497cc6..24c799a7a 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -1047,6 +1047,224 @@ func TestLoadIngresses(t *testing.T) { }, }, }, + { + desc: "From To WWW Redirect - www host", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-www-host.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-www-host-rule-0-path-0": { + Rule: "Host(`www.host.localhost`) && PathPrefix(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-www-host-whoami-80", + }, + "default-ingress-with-www-host-rule-0-path-0-from-to-www-redirect": { + Rule: "Host(`host.localhost`)", + RuleSyntax: "default", + Service: "default-ingress-with-www-host-whoami-80", + Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-from-to-www-redirect"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-www-host-rule-0-path-0-from-to-www-redirect": { + RedirectRegex: &dynamic.RedirectRegex{ + Regex: `(https?)://[^/]+:([0-9]+)/(.*)`, + Replacement: "$1://www.host.localhost:$2/$3", + Permanent: true, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-www-host-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), + ServersTransport: "default-ingress-with-www-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-www-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{ + Options: map[string]tls.Options{}, + }, + }, + }, + { + desc: "From To WWW Redirect - host", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-host.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-host-rule-0-path-0": { + Rule: "Host(`host.localhost`) && PathPrefix(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-host-whoami-80", + }, + "default-ingress-with-host-rule-0-path-0-from-to-www-redirect": { + Rule: "Host(`www.host.localhost`)", + RuleSyntax: "default", + Service: "default-ingress-with-host-whoami-80", + Middlewares: []string{"default-ingress-with-host-rule-0-path-0-from-to-www-redirect"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-host-rule-0-path-0-from-to-www-redirect": { + RedirectRegex: &dynamic.RedirectRegex{ + Regex: `(https?)://[^/]+:([0-9]+)/(.*)`, + Replacement: "$1://host.localhost:$2/$3", + Permanent: true, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-host-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), + ServersTransport: "default-ingress-with-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{ + Options: map[string]tls.Options{}, + }, + }, + }, + { + desc: "From To WWW Redirect - multiple ingresses", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingresses-with-www-redirect.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-host-rule-0-path-0": { + Rule: "Host(`host.localhost`) && PathPrefix(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-host-whoami-80", + }, + "default-ingress-with-www-host-rule-0-path-0": { + Rule: "Host(`www.host.localhost`) && PathPrefix(`/`)", + RuleSyntax: "default", + Service: "default-ingress-with-www-host-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-ingress-with-host-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), + ServersTransport: "default-ingress-with-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-ingress-with-www-host-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), + ServersTransport: "default-ingress-with-www-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-www-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-ingress-with-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{ + Options: map[string]tls.Options{}, + }, + }, + }, { desc: "Default Backend", defaultBackendServiceName: "whoami",