diff --git a/.changelog/19647.txt b/.changelog/19647.txt new file mode 100644 index 0000000000..33b91ef01c --- /dev/null +++ b/.changelog/19647.txt @@ -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. +``` diff --git a/agent/structs/config_entry_discoverychain.go b/agent/structs/config_entry_discoverychain.go index 7ec0222fe4..8b43c4f924 100644 --- a/agent/structs/config_entry_discoverychain.go +++ b/agent/structs/config_entry_discoverychain.go @@ -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 diff --git a/agent/structs/config_entry_discoverychain_test.go b/agent/structs/config_entry_discoverychain_test.go index b2547a7ace..2403c62c8f 100644 --- a/agent/structs/config_entry_discoverychain_test.go +++ b/agent/structs/config_entry_discoverychain_test.go @@ -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 { diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index c0f858b119..d52c5fd3da 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -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: ` diff --git a/agent/xds/routes.go b/agent/xds/routes.go index 80e207cf41..3d05e2a21a 100644 --- a/agent/xds/routes.go +++ b/agent/xds/routes.go @@ -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 { diff --git a/api/config_entry_discoverychain.go b/api/config_entry_discoverychain.go index eeb3a1074c..572d9d2d5e 100644 --- a/api/config_entry_discoverychain.go +++ b/api/config_entry_discoverychain.go @@ -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"` diff --git a/api/config_entry_discoverychain_test.go b/api/config_entry_discoverychain_test.go index 6b4a63e97e..5d825a0db0 100644 --- a/api/config_entry_discoverychain_test.go +++ b/api/config_entry_discoverychain_test.go @@ -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) diff --git a/test/integration/connect/envoy/case-cfg-router-features/setup.sh b/test/integration/connect/envoy/case-cfg-router-features/setup.sh index 577b3512b4..91f6770c65 100644 --- a/test/integration/connect/envoy/case-cfg-router-features/setup.sh +++ b/test/integration/connect/envoy/case-cfg-router-features/setup.sh @@ -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}" diff --git a/test/integration/connect/envoy/case-cfg-router-features/verify.bats b/test/integration/connect/envoy/case-cfg-router-features/verify.bats index 7af248f3ca..484209ad12 100644 --- a/test/integration/connect/envoy/case-cfg-router-features/verify.bats +++ b/test/integration/connect/envoy/case-cfg-router-features/verify.bats @@ -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 } diff --git a/website/content/docs/connect/config-entries/service-router.mdx b/website/content/docs/connect/config-entries/service-router.mdx index 2888bc3a79..814763957c 100644 --- a/website/content/docs/connect/config-entries/service-router.mdx +++ b/website/content/docs/connect/config-entries/service-router.mdx @@ -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: +### 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. + + + + +```hcl +Kind = "service-router" +Name = "web" +Routes = [ + { + Match { + HTTP { + PathPrefix = "/Admin" + CaseInsensitive = true + } + } + + Destination { + Service = "admin" + } + }, +] +``` + + + + + +```yaml +apiVersion: consul.hashicorp.com/v1alpha1 +kind: ServiceRouter +metadata: + name: web +spec: + routes: + - match: + http: + pathPrefix: /Admin + caseInsensitive: true + destination: + service: admin +``` + + + + + +```json +{ + "Kind": "service-router", + "Name": "web", + "Routes": [ + { + "Match": { + "HTTP": { + "PathPrefix": "/Admin", + "CaseInsensitive": true + } + }, + "Destination": { + "Service": "admin" + } + } + ] +} +``` + + + + ### 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.