From 8a0c1e614fad3b9735814002f26fcb04c2314a06 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Wed, 20 Nov 2024 17:04:04 +0100 Subject: [PATCH] Fix HostRegexp config for rule syntax v2 Co-authored-by: Romain --- pkg/config/static/static_config.go | 6 + .../Ingress-with-wildcard-host-syntax-v2.yml | 49 +++ pkg/provider/kubernetes/ingress/kubernetes.go | 284 +++++++++--------- .../kubernetes/ingress/kubernetes_test.go | 34 +++ 4 files changed, 239 insertions(+), 134 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress/fixtures/Ingress-with-wildcard-host-syntax-v2.yml diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 36a47f4dd..7f26e053a 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -297,6 +297,12 @@ func (c *Configuration) SetEffectiveConfiguration() { c.Providers.KubernetesGateway.EntryPoints = entryPoints } + // Defines the default rule syntax for the Kubernetes Ingress Provider. + // This allows the provider to adapt the matcher syntax to the desired rule syntax version. + if c.Core != nil && c.Providers.KubernetesIngress != nil { + c.Providers.KubernetesIngress.DefaultRuleSyntax = c.Core.DefaultRuleSyntax + } + c.initACMEProvider() } diff --git a/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-wildcard-host-syntax-v2.yml b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-wildcard-host-syntax-v2.yml new file mode 100644 index 000000000..e0df96260 --- /dev/null +++ b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-wildcard-host-syntax-v2.yml @@ -0,0 +1,49 @@ +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: "" + namespace: testing + +spec: + rules: + - host: "*.foobar.com" + http: + paths: + - path: /bar + backend: + service: + name: service1 + port: + number: 80 + pathType: Prefix + +--- +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 + conditions: + ready: true diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index c6b38e5ea..08f945511 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -56,6 +56,9 @@ type Provider struct { 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"` NativeLBByDefault bool `description:"Defines whether to use Native Kubernetes load-balancing mode by default." json:"nativeLBByDefault,omitempty" toml:"nativeLBByDefault,omitempty" yaml:"nativeLBByDefault,omitempty" export:"true"` + // The default rule syntax is initialized with the configuration defined by the user with the core.DefaultRuleSyntax option. + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` + lastConfiguration safe.Safe routerTransform k8s.RouterTransform @@ -336,7 +339,7 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl serviceName := provider.Normalize(ingress.Namespace + "-" + pa.Backend.Service.Name + "-" + portString) conf.HTTP.Services[serviceName] = service - rt := loadRouter(rule, pa, rtConfig, serviceName) + rt := p.loadRouter(rule, pa, rtConfig, serviceName) p.applyRouterTransform(ctxIngress, rt, ingress) @@ -432,100 +435,6 @@ func (p *Provider) shouldProcessIngress(ingress *netv1.Ingress, ingressClasses [ len(p.IngressClass) == 0 && ingress.Annotations[annotationKubernetesIngressClass] == traefikDefaultIngressClass } -func buildHostRule(host string) string { - if strings.HasPrefix(host, "*.") { - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - return fmt.Sprintf("HostRegexp(`^%s$`)", host) - } - - return fmt.Sprintf("Host(`%s`)", host) -} - -func getCertificates(ctx context.Context, ingress *netv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error { - for _, t := range ingress.Spec.TLS { - if t.SecretName == "" { - log.Ctx(ctx).Debug().Msg("Skipping TLS sub-section: No secret name provided") - continue - } - - configKey := ingress.Namespace + "-" + t.SecretName - if _, tlsExists := tlsConfigs[configKey]; !tlsExists { - secret, exists, err := k8sClient.GetSecret(ingress.Namespace, t.SecretName) - if err != nil { - return fmt.Errorf("failed to fetch secret %s/%s: %w", ingress.Namespace, t.SecretName, err) - } - if !exists { - return fmt.Errorf("secret %s/%s does not exist", ingress.Namespace, t.SecretName) - } - - cert, key, err := getCertificateBlocks(secret, ingress.Namespace, t.SecretName) - if err != nil { - return err - } - - tlsConfigs[configKey] = &tls.CertAndStores{ - Certificate: tls.Certificate{ - CertFile: types.FileOrContent(cert), - KeyFile: types.FileOrContent(key), - }, - } - } - } - - return nil -} - -func getCertificateBlocks(secret *corev1.Secret, namespace, secretName string) (string, string, error) { - var missingEntries []string - - tlsCrtData, tlsCrtExists := secret.Data["tls.crt"] - if !tlsCrtExists { - missingEntries = append(missingEntries, "tls.crt") - } - - tlsKeyData, tlsKeyExists := secret.Data["tls.key"] - if !tlsKeyExists { - missingEntries = append(missingEntries, "tls.key") - } - - if len(missingEntries) > 0 { - return "", "", fmt.Errorf("secret %s/%s is missing the following TLS data entries: %s", - namespace, secretName, strings.Join(missingEntries, ", ")) - } - - cert := string(tlsCrtData) - if cert == "" { - missingEntries = append(missingEntries, "tls.crt") - } - - key := string(tlsKeyData) - if key == "" { - missingEntries = append(missingEntries, "tls.key") - } - - if len(missingEntries) > 0 { - return "", "", fmt.Errorf("secret %s/%s contains the following empty TLS data entries: %s", - namespace, secretName, strings.Join(missingEntries, ", ")) - } - - return cert, key, nil -} - -func getTLSConfig(tlsConfigs map[string]*tls.CertAndStores) []*tls.CertAndStores { - var secretNames []string - for secretName := range tlsConfigs { - secretNames = append(secretNames, secretName) - } - sort.Strings(secretNames) - - var configs []*tls.CertAndStores - for _, secretName := range secretNames { - configs = append(configs, tlsConfigs[secretName]) - } - - return configs -} - func (p *Provider) loadService(client Client, namespace string, backend netv1.IngressBackend) (*dynamic.Service, error) { if backend.Resource != nil { // https://kubernetes.io/docs/concepts/services-networking/ingress/#resource-backend @@ -698,6 +607,152 @@ func (p *Provider) loadService(client Client, namespace string, backend netv1.In return svc, nil } +func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, rtConfig *RouterConfig, serviceName string) *dynamic.Router { + rt := &dynamic.Router{ + Service: serviceName, + } + + if rtConfig != nil && rtConfig.Router != nil { + rt.RuleSyntax = rtConfig.Router.RuleSyntax + rt.Priority = rtConfig.Router.Priority + rt.EntryPoints = rtConfig.Router.EntryPoints + rt.Middlewares = rtConfig.Router.Middlewares + + if rtConfig.Router.TLS != nil { + rt.TLS = rtConfig.Router.TLS + } + } + + var rules []string + if len(rule.Host) > 0 { + if rt.RuleSyntax == "v2" || (rt.RuleSyntax == "" && p.DefaultRuleSyntax == "v2") { + rules = append(rules, buildHostRuleV2(rule.Host)) + } else { + rules = append(rules, buildHostRule(rule.Host)) + } + } + + if len(pa.Path) > 0 { + matcher := defaultPathMatcher + + if pa.PathType == nil || *pa.PathType == "" || *pa.PathType == netv1.PathTypeImplementationSpecific { + if rtConfig != nil && rtConfig.Router != nil && rtConfig.Router.PathMatcher != "" { + matcher = rtConfig.Router.PathMatcher + } + } else if *pa.PathType == netv1.PathTypeExact { + matcher = "Path" + } + + rules = append(rules, fmt.Sprintf("%s(`%s`)", matcher, pa.Path)) + } + + rt.Rule = strings.Join(rules, " && ") + return rt +} + +func buildHostRuleV2(host string) string { + if strings.HasPrefix(host, "*.") { + host = strings.Replace(host, "*.", "{subdomain:[a-zA-Z0-9-]+}.", 1) + return fmt.Sprintf("HostRegexp(`%s`)", host) + } + + return fmt.Sprintf("Host(`%s`)", host) +} + +func buildHostRule(host string) string { + if strings.HasPrefix(host, "*.") { + host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) + return fmt.Sprintf("HostRegexp(`^%s$`)", host) + } + + return fmt.Sprintf("Host(`%s`)", host) +} + +func getCertificates(ctx context.Context, ingress *netv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error { + for _, t := range ingress.Spec.TLS { + if t.SecretName == "" { + log.Ctx(ctx).Debug().Msg("Skipping TLS sub-section: No secret name provided") + continue + } + + configKey := ingress.Namespace + "-" + t.SecretName + if _, tlsExists := tlsConfigs[configKey]; !tlsExists { + secret, exists, err := k8sClient.GetSecret(ingress.Namespace, t.SecretName) + if err != nil { + return fmt.Errorf("failed to fetch secret %s/%s: %w", ingress.Namespace, t.SecretName, err) + } + if !exists { + return fmt.Errorf("secret %s/%s does not exist", ingress.Namespace, t.SecretName) + } + + cert, key, err := getCertificateBlocks(secret, ingress.Namespace, t.SecretName) + if err != nil { + return err + } + + tlsConfigs[configKey] = &tls.CertAndStores{ + Certificate: tls.Certificate{ + CertFile: types.FileOrContent(cert), + KeyFile: types.FileOrContent(key), + }, + } + } + } + + return nil +} + +func getCertificateBlocks(secret *corev1.Secret, namespace, secretName string) (string, string, error) { + var missingEntries []string + + tlsCrtData, tlsCrtExists := secret.Data["tls.crt"] + if !tlsCrtExists { + missingEntries = append(missingEntries, "tls.crt") + } + + tlsKeyData, tlsKeyExists := secret.Data["tls.key"] + if !tlsKeyExists { + missingEntries = append(missingEntries, "tls.key") + } + + if len(missingEntries) > 0 { + return "", "", fmt.Errorf("secret %s/%s is missing the following TLS data entries: %s", + namespace, secretName, strings.Join(missingEntries, ", ")) + } + + cert := string(tlsCrtData) + if cert == "" { + missingEntries = append(missingEntries, "tls.crt") + } + + key := string(tlsKeyData) + if key == "" { + missingEntries = append(missingEntries, "tls.key") + } + + if len(missingEntries) > 0 { + return "", "", fmt.Errorf("secret %s/%s contains the following empty TLS data entries: %s", + namespace, secretName, strings.Join(missingEntries, ", ")) + } + + return cert, key, nil +} + +func getTLSConfig(tlsConfigs map[string]*tls.CertAndStores) []*tls.CertAndStores { + var secretNames []string + for secretName := range tlsConfigs { + secretNames = append(secretNames, secretName) + } + sort.Strings(secretNames) + + var configs []*tls.CertAndStores + for _, secretName := range secretNames { + configs = append(configs, tlsConfigs[secretName]) + } + + return configs +} + func getNativeServiceAddress(service corev1.Service, svcPort corev1.ServicePort) (string, error) { if service.Spec.ClusterIP == "None" { return "", fmt.Errorf("no clusterIP on headless service: %s/%s", service.Namespace, service.Name) @@ -734,45 +789,6 @@ func makeRouterKeyWithHash(key, rule string) (string, error) { return dupKey, nil } -func loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, rtConfig *RouterConfig, serviceName string) *dynamic.Router { - var rules []string - if len(rule.Host) > 0 { - rules = []string{buildHostRule(rule.Host)} - } - - if len(pa.Path) > 0 { - matcher := defaultPathMatcher - - if pa.PathType == nil || *pa.PathType == "" || *pa.PathType == netv1.PathTypeImplementationSpecific { - if rtConfig != nil && rtConfig.Router != nil && rtConfig.Router.PathMatcher != "" { - matcher = rtConfig.Router.PathMatcher - } - } else if *pa.PathType == netv1.PathTypeExact { - matcher = "Path" - } - - rules = append(rules, fmt.Sprintf("%s(`%s`)", matcher, pa.Path)) - } - - rt := &dynamic.Router{ - Rule: strings.Join(rules, " && "), - Service: serviceName, - } - - if rtConfig != nil && rtConfig.Router != nil { - rt.RuleSyntax = rtConfig.Router.RuleSyntax - rt.Priority = rtConfig.Router.Priority - rt.EntryPoints = rtConfig.Router.EntryPoints - rt.Middlewares = rtConfig.Router.Middlewares - - if rtConfig.Router.TLS != nil { - rt.TLS = rtConfig.Router.TLS - } - } - - return rt -} - func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *safe.Pool, eventsChan <-chan interface{}) chan interface{} { if throttleDuration == 0 { return nil diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index a47d76def..e389a67fa 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -32,6 +32,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { allowEmptyServices bool disableIngressClassLookup bool disableClusterScopeResources bool + defaultRuleSyntax string }{ { desc: "Empty ingresses", @@ -1096,6 +1097,38 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { }, }, }, + { + desc: "Ingress with wildcard host syntax v2", + defaultRuleSyntax: "v2", + expected: &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Middlewares: map[string]*dynamic.Middleware{}, + Routers: map[string]*dynamic.Router{ + "testing-foobar-com-bar": { + Rule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.foobar.com`) && PathPrefix(`/bar`)", + Service: "testing-service1-80", + }, + }, + Services: map[string]*dynamic.Service{ + "testing-service1-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + PassHostHeader: pointer(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:8080", + Scheme: "", + Port: "", + }, + }, + }, + }, + }, + }, + }, + }, { desc: "Ingress with multiple ingressClasses", expected: &dynamic.Configuration{ @@ -1505,6 +1538,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { AllowEmptyServices: test.allowEmptyServices, DisableIngressClassLookup: test.disableIngressClassLookup, DisableClusterScopeResources: test.disableClusterScopeResources, + DefaultRuleSyntax: test.defaultRuleSyntax, } conf := p.loadConfigurationFromIngresses(context.Background(), clientMock)