1
0
Fork 0

Make the behavior of prefix matching in Ingress consistent with Kubernetes doc

This commit is contained in:
Charlie Chiang 2025-05-20 20:40:05 +08:00 committed by GitHub
parent 8c6ed23c5f
commit 4790e4910f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 262 additions and 2 deletions

View file

@ -529,6 +529,29 @@ providers:
--providers.kubernetesingress.nativeLBByDefault=true --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 ### Further
To learn more about the various aspects of the Ingress specification that Traefik supports, To learn more about the various aspects of the Ingress specification that Traefik supports,

View file

@ -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.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.<br />It can br overridden in the [`ServerTransport`](../../../../routing/services/index.md#serverstransport). | 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.<br />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`).<br />By doing so, it alleviates the requirement of giving Traefik the rights to look up for cluster resources.<br />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).<br />This will also prevent from using the `NodePortLB` options on services. | false | No | | `providers.kubernetesIngress.disableClusterScopeResources` | Prevent from discovering cluster scope resources (`IngressClass` and `Nodes`).<br />By doing so, it alleviates the requirement of giving Traefik the rights to look up for cluster resources.<br />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).<br />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 |
<!-- markdownlint-enable MD013 --> <!-- markdownlint-enable MD013 -->

View file

@ -1032,6 +1032,9 @@ Kubernetes namespaces.
`--providers.kubernetesingress.nativelbbydefault`: `--providers.kubernetesingress.nativelbbydefault`:
Defines whether to use Native Kubernetes load-balancing mode by default. (Default: ```false```) 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`: `--providers.kubernetesingress.throttleduration`:
Ingress refresh throttle duration (Default: ```0```) Ingress refresh throttle duration (Default: ```0```)

View file

@ -1032,6 +1032,9 @@ Kubernetes namespaces.
`TRAEFIK_PROVIDERS_KUBERNETESINGRESS_NATIVELBBYDEFAULT`: `TRAEFIK_PROVIDERS_KUBERNETESINGRESS_NATIVELBBYDEFAULT`:
Defines whether to use Native Kubernetes load-balancing mode by default. (Default: ```false```) 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`: `TRAEFIK_PROVIDERS_KUBERNETESINGRESS_THROTTLEDURATION`:
Ingress refresh throttle duration (Default: ```0```) Ingress refresh throttle duration (Default: ```0```)

View file

@ -138,6 +138,7 @@
disableIngressClassLookup = true disableIngressClassLookup = true
disableClusterScopeResources = true disableClusterScopeResources = true
nativeLBByDefault = true nativeLBByDefault = true
strictPrefixMatching = true
[providers.kubernetesIngress.ingressEndpoint] [providers.kubernetesIngress.ingressEndpoint]
ip = "foobar" ip = "foobar"
hostname = "foobar" hostname = "foobar"

View file

@ -157,6 +157,7 @@ providers:
disableIngressClassLookup: true disableIngressClassLookup: true
disableClusterScopeResources: true disableClusterScopeResources: true
nativeLBByDefault: true nativeLBByDefault: true
strictPrefixMatching: true
kubernetesCRD: kubernetesCRD:
endpoint: foobar endpoint: foobar
token: foobar token: foobar

View file

@ -439,7 +439,7 @@ If the Kubernetes cluster version is 1.18+,
the new `pathType` property can be leveraged to define the rules matchers: the new `pathType` property can be leveraged to define the rules matchers:
- `Exact`: This path type forces the rule matcher to `Path` - `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. Please see [this documentation](https://kubernetes.io/docs/concepts/services-networking/ingress/#path-types) for more information.

View file

@ -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

View file

@ -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"` 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"` 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"` 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. // 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:"-"` DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-"`
@ -698,7 +699,7 @@ func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath,
matcher = "Path" 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, " && ") rt.Rule = strings.Join(rules, " && ")
@ -844,6 +845,41 @@ func makeRouterKeyWithHash(key, rule string) (string, error) {
return dupKey, nil 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{} { func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *safe.Pool, eventsChan <-chan interface{}) chan interface{} {
if throttleDuration == 0 { if throttleDuration == 0 {
return nil return nil

View file

@ -3,7 +3,10 @@ package ingress
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math" "math"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -14,6 +17,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
ptypes "github.com/traefik/paerser/types" ptypes "github.com/traefik/paerser/types"
"github.com/traefik/traefik/v3/pkg/config/dynamic" "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"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s" "github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s"
"github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/tls"
@ -38,6 +42,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) {
disableIngressClassLookup bool disableIngressClassLookup bool
disableClusterScopeResources bool disableClusterScopeResources bool
defaultRuleSyntax string defaultRuleSyntax string
strictPrefixMatching bool
}{ }{
{ {
desc: "Empty ingresses", 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 { for _, test := range testCases {
@ -1634,6 +1673,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) {
DisableIngressClassLookup: test.disableIngressClassLookup, DisableIngressClassLookup: test.disableIngressClassLookup,
DisableClusterScopeResources: test.disableClusterScopeResources, DisableClusterScopeResources: test.disableClusterScopeResources,
DefaultRuleSyntax: test.defaultRuleSyntax, DefaultRuleSyntax: test.defaultRuleSyntax,
StrictPrefixMatching: test.strictPrefixMatching,
} }
conf := p.loadConfigurationFromIngresses(context.Background(), clientMock) conf := p.loadConfigurationFromIngresses(context.Background(), clientMock)
@ -2256,3 +2296,106 @@ func readResources(t *testing.T, paths []string) []runtime.Object {
return k8sObjects 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)
}
})
}
}