diff --git a/docs/content/providers/kubernetes-ingress.md b/docs/content/providers/kubernetes-ingress.md index 08c77b888..dd9061de1 100644 --- a/docs/content/providers/kubernetes-ingress.md +++ b/docs/content/providers/kubernetes-ingress.md @@ -529,6 +529,29 @@ providers: --providers.kubernetesingress.nativeLBByDefault=true ``` +### `strictPrefixMatching` + +_Optional, Default: false_ + +Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). For example, a PathPrefix of `/foo` will match `/foo`, `/foo/`, and `/foo/bar` but not `/foobar`. + +```yaml tab="File (YAML)" +providers: + kubernetesIngress: + strictPrefixMatching: true + # ... +``` + +```toml tab="File (TOML)" +[providers.kubernetesIngress] + strictPrefixMatching = true + # ... +``` + +```bash tab="CLI" +--providers.kubernetesingress.strictPrefixMatching=true +``` + ### Further To learn more about the various aspects of the Ingress specification that Traefik supports, diff --git a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md index adf102e64..ceed9b222 100644 --- a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md +++ b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md @@ -58,6 +58,7 @@ which in turn creates the resulting routers, services, handlers, etc. | `providers.kubernetesIngress.allowExternalNameServices` | Allows the `Ingress` to reference ExternalName services. | false | No | | `providers.kubernetesIngress.nativeLBByDefault` | Allow using the Kubernetes Service load balancing between the pods instead of the one provided by Traefik for every `Ingress` by default.
It can br overridden in the [`ServerTransport`](../../../../routing/services/index.md#serverstransport). | false | No | | `providers.kubernetesIngress.disableClusterScopeResources` | Prevent from discovering cluster scope resources (`IngressClass` and `Nodes`).
By doing so, it alleviates the requirement of giving Traefik the rights to look up for cluster resources.
Furthermore, Traefik will not handle Ingresses with IngressClass references, therefore such Ingresses will be ignored (please note that annotations are not affected by this option).
This will also prevent from using the `NodePortLB` options on services. | false | No | +| `providers.kubernetesIngress.strictPrefixMatching` | Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). For example, a PathPrefix of `/foo` will match `/foo`, `/foo/`, and `/foo/bar` but not `/foobar`. | false | No | diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 6d7e180a7..cd98f99af 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -1032,6 +1032,9 @@ Kubernetes namespaces. `--providers.kubernetesingress.nativelbbydefault`: Defines whether to use Native Kubernetes load-balancing mode by default. (Default: ```false```) +`--providers.kubernetesingress.strictprefixmatching`: +Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). (Default: ```false```) + `--providers.kubernetesingress.throttleduration`: Ingress refresh throttle duration (Default: ```0```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 884fa4aaa..4eac71881 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -1032,6 +1032,9 @@ Kubernetes namespaces. `TRAEFIK_PROVIDERS_KUBERNETESINGRESS_NATIVELBBYDEFAULT`: Defines whether to use Native Kubernetes load-balancing mode by default. (Default: ```false```) +`TRAEFIK_PROVIDERS_KUBERNETESINGRESS_STRICTPREFIXMATCHING`: +Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). (Default: ```false```) + `TRAEFIK_PROVIDERS_KUBERNETESINGRESS_THROTTLEDURATION`: Ingress refresh throttle duration (Default: ```0```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 964cd8a7b..4e9e45ebe 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -138,6 +138,7 @@ disableIngressClassLookup = true disableClusterScopeResources = true nativeLBByDefault = true + strictPrefixMatching = true [providers.kubernetesIngress.ingressEndpoint] ip = "foobar" hostname = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index 691101e01..77310bd39 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -157,6 +157,7 @@ providers: disableIngressClassLookup: true disableClusterScopeResources: true nativeLBByDefault: true + strictPrefixMatching: true kubernetesCRD: endpoint: foobar token: foobar diff --git a/docs/content/routing/providers/kubernetes-ingress.md b/docs/content/routing/providers/kubernetes-ingress.md index 5a82d8433..2cd511c27 100644 --- a/docs/content/routing/providers/kubernetes-ingress.md +++ b/docs/content/routing/providers/kubernetes-ingress.md @@ -439,7 +439,7 @@ If the Kubernetes cluster version is 1.18+, the new `pathType` property can be leveraged to define the rules matchers: - `Exact`: This path type forces the rule matcher to `Path` -- `Prefix`: This path type forces the rule matcher to `PathPrefix` +- `Prefix`: This path type forces the rule matcher to `PathPrefix`. Note that if you want the matching behavior to strictly comply with Kubernetes Ingress specification (request path is matched on an element-by-element basis), consider enabling [`strictPrefixMatching`](../../providers/kubernetes-ingress.md#strictprefixmatching) in the Ingress Provider configuration. Please see [this documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types) for more information. diff --git a/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-strict-prefix-matching.yml b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-strict-prefix-matching.yml new file mode 100644 index 000000000..c52467d71 --- /dev/null +++ b/pkg/provider/kubernetes/ingress/fixtures/Ingress-with-strict-prefix-matching.yml @@ -0,0 +1,49 @@ +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: "" + namespace: testing + +spec: + rules: + - 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 + - 10.21.0.1 + conditions: + ready: true diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index d83f29cbd..e8416fb8f 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -56,6 +56,7 @@ type Provider struct { DisableIngressClassLookup bool `description:"Disables the lookup of IngressClasses (Deprecated, please use DisableClusterScopeResources)." json:"disableIngressClassLookup,omitempty" toml:"disableIngressClassLookup,omitempty" yaml:"disableIngressClassLookup,omitempty" export:"true"` 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"` + StrictPrefixMatching bool `description:"Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching)." json:"strictPrefixMatching,omitempty" toml:"strictPrefixMatching,omitempty" yaml:"strictPrefixMatching,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:"-"` @@ -698,7 +699,7 @@ func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, matcher = "Path" } - rules = append(rules, fmt.Sprintf("%s(`%s`)", matcher, pa.Path)) + rules = append(rules, buildRule(p.StrictPrefixMatching, matcher, pa.Path)) } rt.Rule = strings.Join(rules, " && ") @@ -844,6 +845,41 @@ func makeRouterKeyWithHash(key, rule string) (string, error) { return dupKey, nil } +func buildRule(strictPrefixMatching bool, matcher string, path string) string { + // When enabled, strictPrefixMatching ensures that prefix matching follows + // the Kubernetes Ingress spec (path-element-wise instead of character-wise). + if strictPrefixMatching && matcher == "PathPrefix" { + // According to + // https://kubernetes.io/docs/concepts/services-networking/ingress/#examples, + // "/v12" should not match "/v1". + // + // Traefik's default PathPrefix matcher performs a character-wise prefix match, + // unlike Kubernetes which matches path elements. To mimic Kubernetes behavior, + // we will use Path and PathPrefix to replicate element-wise behavior. + // + // "PathPrefix" in Kubernetes Gateway API is semantically equivalent to the "Prefix" path type in the + // Kubernetes Ingress API. + return buildStrictPrefixMatchingRule(path) + } + + return fmt.Sprintf("%s(`%s`)", matcher, path) +} + +// buildStrictPrefixMatchingRule is a helper function to build a path prefix rule that matches path prefix split by `/`. +// For example, the paths `/abc`, `/abc/`, and `/abc/def` would all match the prefix `/abc`, +// but the path `/abcd` would not. See TestStrictPrefixMatchingRule() for more examples. +// +// "PathPrefix" in Kubernetes Gateway API is semantically equivalent to the "Prefix" path type in the +// Kubernetes Ingress API. +func buildStrictPrefixMatchingRule(path string) string { + if path == "/" { + return "PathPrefix(`/`)" + } + + path = strings.TrimSuffix(path, "/") + return fmt.Sprintf("(Path(`%[1]s`) || PathPrefix(`%[1]s/`))", path) +} + 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 f6019b63c..79f130391 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -3,7 +3,10 @@ package ingress import ( "context" "errors" + "fmt" "math" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -14,6 +17,7 @@ import ( "github.com/stretchr/testify/require" ptypes "github.com/traefik/paerser/types" "github.com/traefik/traefik/v3/pkg/config/dynamic" + traefikhttp "github.com/traefik/traefik/v3/pkg/muxer/http" "github.com/traefik/traefik/v3/pkg/provider" "github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s" "github.com/traefik/traefik/v3/pkg/tls" @@ -38,6 +42,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { disableIngressClassLookup bool disableClusterScopeResources bool defaultRuleSyntax string + strictPrefixMatching bool }{ { desc: "Empty ingresses", @@ -1621,6 +1626,40 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { }, }, }, + { + desc: "Ingress with strict prefix matching", + expected: &dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Middlewares: map[string]*dynamic.Middleware{}, + Routers: map[string]*dynamic.Router{ + "testing-bar": { + Rule: "(Path(`/bar`) || PathPrefix(`/bar/`))", + Service: "testing-service1-80", + }, + }, + Services: map[string]*dynamic.Service{ + "testing-service1-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: dynamic.BalancerStrategyWRR, + PassHostHeader: pointer(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:8080", + }, + { + URL: "http://10.21.0.1:8080", + }, + }, + }, + }, + }, + }, + }, + strictPrefixMatching: true, + }, } for _, test := range testCases { @@ -1634,6 +1673,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { DisableIngressClassLookup: test.disableIngressClassLookup, DisableClusterScopeResources: test.disableClusterScopeResources, DefaultRuleSyntax: test.defaultRuleSyntax, + StrictPrefixMatching: test.strictPrefixMatching, } conf := p.loadConfigurationFromIngresses(context.Background(), clientMock) @@ -2256,3 +2296,106 @@ func readResources(t *testing.T, paths []string) []runtime.Object { return k8sObjects } + +func TestStrictPrefixMatchingRule(t *testing.T) { + tests := []struct { + path string + requestPath string + match bool + }{ // The tests are taken from https://kubernetes.io/docs/concepts/services-networking/ingress/#examples + { + path: "/foo", + requestPath: "/foo", + match: true, + }, + { + path: "/foo", + requestPath: "/foo/", + match: true, + }, + { + path: "/foo/", + requestPath: "/foo", + match: true, + }, + { + path: "/foo/", + requestPath: "/foo/", + match: true, + }, + { + path: "/aaa/bb", + requestPath: "/aaa/bbb", + match: false, + }, + { + path: "/aaa/bbb", + requestPath: "/aaa/bbb", + match: true, + }, + { + path: "/aaa/bbb/", + requestPath: "/aaa/bbb", + match: true, + }, + { + path: "/aaa/bbb", + requestPath: "/aaa/bbb/", + match: true, + }, + { + path: "/aaa/bbb", + requestPath: "/aaa/bbb/ccc", + match: true, + }, + { + path: "/aaa/bbb", + requestPath: "/aaa/bbbxyz", + match: false, + }, + { + path: "/", + requestPath: "/aaa/ccc", + match: true, + }, + { + path: "/aaa", + requestPath: "/aaa/ccc", + match: true, + }, + { + path: "/...", + requestPath: "/aaa", + match: false, + }, + { + path: "/...", + requestPath: "/.../", + match: true, + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("Prefix match case %s", tt.path), func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := traefikhttp.NewMuxer() + require.NoError(t, err) + + rule := buildStrictPrefixMatchingRule(tt.path) + err = muxer.AddRoute(rule, "", 0, handler) + require.NoError(t, err) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.requestPath, http.NoBody) + muxer.ServeHTTP(w, req) + + if tt.match { + assert.Equal(t, http.StatusOK, w.Code) + } else { + assert.Equal(t, http.StatusNotFound, w.Code) + } + }) + } +}