Case sensitive route match (#19647)

Add case insensitive param on service route match

This commit adds in a new feature that allows service routers to specify that
paths and path prefixes should ignore upper / lower casing when matching URLs.

Co-authored-by: Derek Menteer <105233703+hashi-derek@users.noreply.github.com>
This commit is contained in:
Lord-Y 2024-01-22 16:23:24 +01:00 committed by GitHub
parent 34b343a980
commit 758ddf84e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 495 additions and 6 deletions

3
.changelog/19647.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
connect: Add `CaseInsensitive` flag to service-routers that allows paths and path prefixes to ignore URL upper and lower casing.
```

View File

@ -372,9 +372,10 @@ func (m *ServiceRouteMatch) IsEmpty() bool {
// ServiceRouteHTTPMatch is a set of http-specific match criteria.
type ServiceRouteHTTPMatch struct {
PathExact string `json:",omitempty" alias:"path_exact"`
PathPrefix string `json:",omitempty" alias:"path_prefix"`
PathRegex string `json:",omitempty" alias:"path_regex"`
PathExact string `json:",omitempty" alias:"path_exact"`
PathPrefix string `json:",omitempty" alias:"path_prefix"`
PathRegex string `json:",omitempty" alias:"path_regex"`
CaseInsensitive bool `json:",omitempty" alias:"case_insensitive"`
Header []ServiceRouteHTTPMatchHeader `json:",omitempty"`
QueryParam []ServiceRouteHTTPMatchQueryParam `json:",omitempty" alias:"query_param"`
@ -385,6 +386,7 @@ func (m *ServiceRouteHTTPMatch) IsEmpty() bool {
return m.PathExact == "" &&
m.PathPrefix == "" &&
m.PathRegex == "" &&
!m.CaseInsensitive &&
len(m.Header) == 0 &&
len(m.QueryParam) == 0 &&
len(m.Methods) == 0

View File

@ -2742,6 +2742,20 @@ func TestServiceRouterConfigEntry(t *testing.T) {
}),
validateErr: "contains an invalid retry condition: \"invalid-retry-condition\"",
},
////////////////
{
name: "default route with case insensitive match",
entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{
CaseInsensitive: true,
}))),
},
{
name: "route with path prefix and case insensitive match /apI",
entry: makerouter(routeMatch(httpMatch(&ServiceRouteHTTPMatch{
PathPrefix: "/apI",
CaseInsensitive: true,
}))),
},
}
for _, tc := range cases {

View File

@ -889,6 +889,310 @@ func TestDecodeConfigEntry(t *testing.T) {
},
},
},
{
name: "service-router: kitchen sink case insensitive",
snake: `
kind = "service-router"
name = "main"
meta {
"foo" = "bar"
"gir" = "zim"
}
routes = [
{
match {
http {
path_exact = "/foo"
case_insensitive = true
header = [
{
name = "debug1"
present = true
},
{
name = "debug2"
present = false
invert = true
},
{
name = "debug3"
exact = "1"
},
{
name = "debug4"
prefix = "aaa"
},
{
name = "debug5"
suffix = "bbb"
},
{
name = "debug6"
regex = "a.*z"
},
]
}
}
destination {
service = "carrot"
service_subset = "kale"
namespace = "leek"
prefix_rewrite = "/alternate"
request_timeout = "99s"
idle_timeout = "99s"
num_retries = 12345
retry_on_connect_failure = true
retry_on_status_codes = [401, 209]
request_headers {
add {
x-foo = "bar"
}
set {
bar = "baz"
}
remove = ["qux"]
}
response_headers {
add {
x-foo = "bar"
}
set {
bar = "baz"
}
remove = ["qux"]
}
}
},
{
match {
http {
path_prefix = "/foo"
methods = [ "GET", "DELETE" ]
query_param = [
{
name = "hack1"
present = true
},
{
name = "hack2"
exact = "1"
},
{
name = "hack3"
regex = "a.*z"
},
]
}
}
},
{
match {
http {
path_regex = "/foo"
}
}
},
]
`,
camel: `
Kind = "service-router"
Name = "main"
Meta {
"foo" = "bar"
"gir" = "zim"
}
Routes = [
{
Match {
HTTP {
PathExact = "/foo"
CaseInsensitive = true
Header = [
{
Name = "debug1"
Present = true
},
{
Name = "debug2"
Present = false
Invert = true
},
{
Name = "debug3"
Exact = "1"
},
{
Name = "debug4"
Prefix = "aaa"
},
{
Name = "debug5"
Suffix = "bbb"
},
{
Name = "debug6"
Regex = "a.*z"
},
]
}
}
Destination {
Service = "carrot"
ServiceSubset = "kale"
Namespace = "leek"
PrefixRewrite = "/alternate"
RequestTimeout = "99s"
IdleTimeout = "99s"
NumRetries = 12345
RetryOnConnectFailure = true
RetryOnStatusCodes = [401, 209]
RequestHeaders {
Add {
x-foo = "bar"
}
Set {
bar = "baz"
}
Remove = ["qux"]
}
ResponseHeaders {
Add {
x-foo = "bar"
}
Set {
bar = "baz"
}
Remove = ["qux"]
}
}
},
{
Match {
HTTP {
PathPrefix = "/foo"
Methods = [ "GET", "DELETE" ]
QueryParam = [
{
Name = "hack1"
Present = true
},
{
Name = "hack2"
Exact = "1"
},
{
Name = "hack3"
Regex = "a.*z"
},
]
}
}
},
{
Match {
HTTP {
PathRegex = "/foo"
}
}
},
]
`,
expect: &ServiceRouterConfigEntry{
Kind: "service-router",
Name: "main",
Meta: map[string]string{
"foo": "bar",
"gir": "zim",
},
Routes: []ServiceRoute{
{
Match: &ServiceRouteMatch{
HTTP: &ServiceRouteHTTPMatch{
PathExact: "/foo",
CaseInsensitive: true,
Header: []ServiceRouteHTTPMatchHeader{
{
Name: "debug1",
Present: true,
},
{
Name: "debug2",
Present: false,
Invert: true,
},
{
Name: "debug3",
Exact: "1",
},
{
Name: "debug4",
Prefix: "aaa",
},
{
Name: "debug5",
Suffix: "bbb",
},
{
Name: "debug6",
Regex: "a.*z",
},
},
},
},
Destination: &ServiceRouteDestination{
Service: "carrot",
ServiceSubset: "kale",
Namespace: "leek",
PrefixRewrite: "/alternate",
RequestTimeout: 99 * time.Second,
IdleTimeout: 99 * time.Second,
NumRetries: 12345,
RetryOnConnectFailure: true,
RetryOnStatusCodes: []uint32{401, 209},
RequestHeaders: &HTTPHeaderModifiers{
Add: map[string]string{"x-foo": "bar"},
Set: map[string]string{"bar": "baz"},
Remove: []string{"qux"},
},
ResponseHeaders: &HTTPHeaderModifiers{
Add: map[string]string{"x-foo": "bar"},
Set: map[string]string{"bar": "baz"},
Remove: []string{"qux"},
},
},
},
{
Match: &ServiceRouteMatch{
HTTP: &ServiceRouteHTTPMatch{
PathPrefix: "/foo",
Methods: []string{"GET", "DELETE"},
QueryParam: []ServiceRouteHTTPMatchQueryParam{
{
Name: "hack1",
Present: true,
},
{
Name: "hack2",
Exact: "1",
},
{
Name: "hack3",
Regex: "a.*z",
},
},
},
},
},
{
Match: &ServiceRouteMatch{
HTTP: &ServiceRouteHTTPMatch{
PathRegex: "/foo",
},
},
},
},
},
},
{
name: "service-splitter: kitchen sink",
snake: `

View File

@ -25,6 +25,7 @@ import (
"github.com/hashicorp/consul/agent/xds/response"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
// routesFromSnapshot returns the xDS API representation of the "routes" in the
@ -859,6 +860,10 @@ func makeRouteMatchForDiscoveryRoute(discoveryRoute *structs.DiscoveryRoute) *en
}
}
if match.HTTP.CaseInsensitive {
em.CaseSensitive = wrapperspb.Bool(false)
}
if len(match.HTTP.Header) > 0 {
em.Headers = make([]*envoy_route_v3.HeaderMatcher, 0, len(match.HTTP.Header))
for _, hdr := range match.HTTP.Header {

View File

@ -41,9 +41,10 @@ type ServiceRouteMatch struct {
}
type ServiceRouteHTTPMatch struct {
PathExact string `json:",omitempty" alias:"path_exact"`
PathPrefix string `json:",omitempty" alias:"path_prefix"`
PathRegex string `json:",omitempty" alias:"path_regex"`
PathExact string `json:",omitempty" alias:"path_exact"`
PathPrefix string `json:",omitempty" alias:"path_prefix"`
PathRegex string `json:",omitempty" alias:"path_regex"`
CaseInsensitive bool `json:",omitempty" alias:"case_insensitive"`
Header []ServiceRouteHTTPMatchHeader `json:",omitempty"`
QueryParam []ServiceRouteHTTPMatchQueryParam `json:",omitempty" alias:"query_param"`

View File

@ -121,6 +121,7 @@ func TestAPI_ConfigEntry_DiscoveryChain(t *testing.T) {
"alternate",
"test-split",
"test-route",
"test-route-case-insensitive",
} {
serviceDefaults := &ServiceConfigEntry{
Kind: ServiceDefaults,
@ -306,6 +307,67 @@ func TestAPI_ConfigEntry_DiscoveryChain(t *testing.T) {
},
verify: verifyRouter,
},
{
name: "mega router case insensitive", // use one mega object to avoid multiple trips
entry: &ServiceRouterConfigEntry{
Kind: ServiceRouter,
Name: "test-route-case-insensitive",
Partition: defaultPartition,
Namespace: defaultNamespace,
Routes: []ServiceRoute{
{
Match: &ServiceRouteMatch{
HTTP: &ServiceRouteHTTPMatch{
PathPrefix: "/prEfix",
CaseInsensitive: true,
Header: []ServiceRouteHTTPMatchHeader{
{Name: "x-debug", Exact: "1"},
},
QueryParam: []ServiceRouteHTTPMatchQueryParam{
{Name: "debug", Exact: "1"},
},
},
},
Destination: &ServiceRouteDestination{
Service: "test-failover",
ServiceSubset: "v2",
Namespace: defaultNamespace,
Partition: defaultPartition,
PrefixRewrite: "/",
RequestTimeout: 5 * time.Second,
NumRetries: 5,
RetryOnConnectFailure: true,
RetryOnStatusCodes: []uint32{500, 503, 401},
RetryOn: []string{
"gateway-error",
"reset",
"envoy-ratelimited",
"retriable-4xx",
"refused-stream",
"cancelled",
"deadline-exceeded",
"internal",
"resource-exhausted",
"unavailable",
},
RequestHeaders: &HTTPHeaderModifiers{
Set: map[string]string{
"x-foo": "bar",
},
},
ResponseHeaders: &HTTPHeaderModifiers{
Remove: []string{"x-foo"},
},
},
},
},
Meta: map[string]string{
"foo": "bar",
"gir": "zim",
},
},
verify: verifyRouter,
},
} {
tc := tc
name := fmt.Sprintf("%s:%s: %s", tc.entry.GetKind(), tc.entry.GetName(), tc.name)

View File

@ -59,6 +59,17 @@ routes = [
prefix_rewrite = "/"
}
},
{
match { http {
path_prefix = "/prefix-case-insensitive/"
case_insensitive = true
}
}
destination {
service_subset = "v1"
prefix_rewrite = "/"
}
},
{
match { http {
path_regex = "/deb[ug]{2}"

View File

@ -56,6 +56,11 @@ load helpers
assert_expected_fortio_name s2-v1 localhost 5000 /prefix-alt
}
@test "test prefix path case insensitive" {
assert_expected_fortio_name s2-v1 localhost 5000 /prefix-case-Insensitive
assert_expected_fortio_name s2-v1 localhost 5000 /prefix-case-INSENSITIVE
}
@test "test regex path" {
assert_expected_fortio_name s2-v2 localhost 5000 "" regex-path
}

View File

@ -30,6 +30,7 @@ The following list outlines field hierarchy, language-specific data types, and r
- [`PathExact`](#routes-match-http-pathexact): string
- [`PathPrefix`](#routes-match-http-pathprefix): string
- [`PathRegex`](#routes-match-http-pathregex): string
- [`CaseInsensitive`](#routes-match-http-caseinsensitive): boolean | `false`
- [`Methods`](#routes-match-http-methods): list
- [`Header`](#routes-match-http-header): list
- [`Name`](#routes-match-http-header-name): string
@ -456,6 +457,15 @@ Specifies the path prefix to match on the HTTP request path. When using this fie
- Default: None
- Data type: String
### `Routes[].Match{}.HTTP{}.CaseInsensitive`
Specifies the path prefix to match on the HTTP request path must be case insensitive or not.
#### Values
- Default: `false`
- Data type: Boolean
### `Routes[].Match{}.HTTP{}.PathRegex`
Specifies a regular expression to match on the HTTP request path. When using this field, do not configure `PathExact` or `PathPrefix` in the same HTTP map. The syntax for the regular expression field is proxy-specific. When [using Envoy](/consul/docs/connect/proxies/envoy), refer to [the documentation for Envoy v1.11.2 or newer](https://github.com/google/re2/wiki/Syntax) or [the documentation for Envoy v1.11.1 or older](https://en.cppreference.com/w/cpp/regex/ecmascript), depending on the version of Envoy you use.
@ -1372,6 +1382,78 @@ spec:
</Tab>
</Tabs>
### Path prefix matching with case insensitive
The following example routes HTTP requests for the `web` service to a service named `admin` when they have `/admin` or `/Admin` at the start of their path.
<Tabs>
<Tab heading="HCL" group="hcl">
```hcl
Kind = "service-router"
Name = "web"
Routes = [
{
Match {
HTTP {
PathPrefix = "/Admin"
CaseInsensitive = true
}
}
Destination {
Service = "admin"
}
},
]
```
</Tab>
<Tab heading="YAML" group="yaml">
```yaml
apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceRouter
metadata:
name: web
spec:
routes:
- match:
http:
pathPrefix: /Admin
caseInsensitive: true
destination:
service: admin
```
</Tab>
<Tab heading="JSON" group="json">
```json
{
"Kind": "service-router",
"Name": "web",
"Routes": [
{
"Match": {
"HTTP": {
"PathPrefix": "/Admin",
"CaseInsensitive": true
}
},
"Destination": {
"Service": "admin"
}
}
]
}
```
</Tab>
</Tabs>
### Match a header and query parameter
The following example routes HTTP traffic to the `web` service to a subset of `canary` instances when the requests have `x-debug` in either the header or the URL parameter.