From 4d86668af3bdee54da63664f45615eb2397ddd78 Mon Sep 17 00:00:00 2001 From: Antoine <13622487+skwair@users.noreply.github.com> Date: Mon, 28 Nov 2022 15:48:05 +0100 Subject: [PATCH] Update routing syntax Co-authored-by: Tom Moulard --- docs/content/migration/v2-to-v3.md | 15 + docs/content/operations/dashboard.md | 4 +- docs/content/routing/routers/index.md | 402 ++++++-- go.mod | 7 +- go.sum | 15 +- .../https_sni_case_insensitive_dynamic.toml | 4 +- integration/fixtures/simple_muxer.toml | 5 +- .../fixtures/websocket/config_https.toml | 2 +- integration/https_test.go | 2 +- integration/simple_test.go | 6 +- pkg/muxer/http/matcher.go | 253 +++++ pkg/muxer/http/matcher_test.go | 974 ++++++++++++++++++ pkg/muxer/http/mux.go | 242 +---- pkg/muxer/http/mux_test.go | 808 +++------------ pkg/muxer/tcp/matcher.go | 134 +++ pkg/muxer/tcp/matcher_test.go | 383 +++++++ pkg/muxer/tcp/mux.go | 241 +---- pkg/muxer/tcp/mux_test.go | 958 +++-------------- pkg/provider/hub/hub.go | 2 +- pkg/provider/kubernetes/gateway/kubernetes.go | 42 +- .../kubernetes/gateway/kubernetes_test.go | 44 +- pkg/provider/kubernetes/ingress/kubernetes.go | 6 +- .../kubernetes/ingress/kubernetes_test.go | 6 +- .../traefik/fixtures/redirection.json | 4 +- .../traefik/fixtures/redirection_port.json | 4 +- .../fixtures/redirection_with_protocol.json | 4 +- pkg/provider/traefik/internal.go | 2 +- 27 files changed, 2484 insertions(+), 2085 deletions(-) create mode 100644 pkg/muxer/http/matcher.go create mode 100644 pkg/muxer/http/matcher_test.go create mode 100644 pkg/muxer/tcp/matcher.go create mode 100644 pkg/muxer/tcp/matcher_test.go diff --git a/docs/content/migration/v2-to-v3.md b/docs/content/migration/v2-to-v3.md index 31218c466..4a8a9bf24 100644 --- a/docs/content/migration/v2-to-v3.md +++ b/docs/content/migration/v2-to-v3.md @@ -30,3 +30,18 @@ In v3, the reported status code for gRPC requests is now the value of the `Grpc- - `sslRedirect`, `sslTemporaryRedirect`, `sslHost`, `sslForceHost` and `featurePolicy` options of the Headers middleware have been removed. - The `forceSlash` option of the StripPrefix middleware has been removed. - the `preferServerCipherSuites` option has been removed. + +## Matchers + +In v3, the `Headers` and `HeadersRegexp` matchers have been renamed to `Header` and `HeaderRegexp` respectively. + +`QueryRegexp` has been introduced to match query values using a regular expression. + +`HeaderRegexp`, `HostRegexp`, `PathRegexp`, `QueryRegexp`, and `HostSNIRegexp` matchers now uses the [Go regexp syntax](https://golang.org/pkg/regexp/syntax/). + +All matchers now take a single value (except `Headers`, `HeaderRegexp`, `Query`, and `QueryRegexp` which take two) +and should be explicitly combined using logical operators to mimic previous behavior. + +`Query` can take a single value to match is the query value that has no value (e.g. `/search?mobile`). + +`HostHeader` has been removed, use `Host` instead. diff --git a/docs/content/operations/dashboard.md b/docs/content/operations/dashboard.md index 61e17e742..5b79dfc98 100644 --- a/docs/content/operations/dashboard.md +++ b/docs/content/operations/dashboard.md @@ -93,12 +93,12 @@ rule = "Host(`traefik.example.com`)" ```bash tab="Path Prefix Rule" # The dashboard can be accessed on http://example.com/dashboard/ or http://traefik.example.com/dashboard/ -rule = "PathPrefix(`/api`, `/dashboard`)" +rule = "PathPrefix(`/api`) || PathPrefix(`/dashboard`)" ``` ```bash tab="Combination of Rules" # The dashboard can be accessed on http://traefik.example.com/dashboard/ -rule = "Host(`traefik.example.com`) && PathPrefix(`/api`, `/dashboard`)" +rule = "Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" ``` ??? example "Dashboard Dynamic Configuration Examples" diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index 557dcd8ef..14acc4448 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -214,78 +214,224 @@ If you want to limit the router scope to a set of entry points, set the `entryPo Rules are a set of matchers configured with values, that determine if a particular request matches specific criteria. If the rule is verified, the router becomes active, calls middlewares, and then forwards the request to the service. -??? tip "Backticks or Quotes?" - To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`. - - Single quotes `'` are not accepted since the values are [Golang's String Literals](https://golang.org/ref/spec#String_literals). - -!!! example "Host is example.com" - - ```toml - rule = "Host(`example.com`)" - ``` - -!!! example "Host is example.com OR Host is example.org AND path is /traefik" - - ```toml - rule = "Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))" - ``` - The table below lists all the available matchers: -| Rule | Description | -|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| -| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` | -| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` | -| ```Host(`example.com`, ...)``` | Check if the request domain (host header value) targets one of the given `domains`. | -| ```HostHeader(`example.com`, ...)``` | Same as `Host`, only exists for historical reasons. | -| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Match the request domain. See "Regexp Syntax" below. | -| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`) | -| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. See "Regexp Syntax" below. | -| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. See "Regexp Syntax" below. | -| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | -| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Match if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. | +| Rule | Description | +|-----------------------------------------------------------------|:-------------------------------------------------------------------------------| +| [```Header(`key`, `value`)```](#header-and-headerregexp) | Matches requests containing a header named `key` set to `value`. | +| [```HeaderRegexp(`key`, `regexp`)```](#header-and-headerregexp) | Matches requests containing a header named `key` matching `regexp`. | +| [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. | +| [```HostRegexp(`regexp`)```](#host-and-hostregexp) | Matches requests host matching `regexp`. | +| [```Method(`method`)```](#method) | Matches requests method set to `method`. | +| [```Path(`path`)```](#path-pathprefix-and-pathregexp) | Matches requests path set to `path`. | +| [```PathPrefix(`prefix`)```](#path-pathprefix-and-pathregexp) | Matches requests path prefix set to `prefix`. | +| [```PathRegexp(`regexp`)```](#path-pathprefix-and-pathregexp) | Matches request path using `regexp`. | +| [```Query(`key`, `value`)```](#query-and-queryregexp) | Matches requests query parameters named `key` set to `value`. | +| [```QueryRegexp(`key`, `regexp`)```](#query-and-queryregexp) | Matches requests query parameters named `key` matching `regexp`. | +| [```ClientIP(`ip`)```](#clientip) | Matches requests client IP using `ip`. It accepts IPv4, IPv6 and CIDR formats. | -!!! important "Non-ASCII Domain Names" +!!! tip "Backticks or Quotes?" - Non-ASCII characters are not supported in `Host` and `HostRegexp` expressions, and by doing so the associated router will be invalid. - For the `Host` expression, domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)). - As well, when using the `HostRegexp` expressions, in order to match domain names containing non-ASCII characters, the regular expression should match a punycode encoded domain name. + To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`. + + Single quotes `'` are not accepted since the values are [Go's String Literals](https://golang.org/ref/spec#String_literals). !!! important "Regexp Syntax" - `HostRegexp`, `PathPrefix`, and `Path` accept an expression with zero or more groups enclosed by curly braces, which are called named regexps. - Named regexps, of the form `{name:regexp}`, are the only expressions considered for regexp matching. - The regexp name (`name` in the above example) is an arbitrary value, that exists only for historical reasons. + Matchers that accept a regexp as their value use a [Go](https://golang.org/pkg/regexp/) flavored syntax. - Any `regexp` supported by [Go's regexp package](https://golang.org/pkg/regexp/) may be used. - For example, here is a case insensitive path matcher syntax: ```Path(`/{path:(?i:Products)}`)```. - -!!! info "Combining Matchers Using Operators and Parenthesis" +!!! info "Expressing Complex Rules Using Operators and Parenthesis" The usual AND (`&&`) and OR (`||`) logical operators can be used, with the expected precedence rules, as well as parentheses. + + One can invert a matcher by using the NOT (`!`) operator. + + The following rule matches requests where: + + - either host is `example.com` OR, + - host is `example.org` AND path is NOT `/traefik` + + ```yaml + Host(`example.com`) || (Host(`example.org`) && !Path(`/traefik`)) + ``` -!!! info "Inverting a matcher" +#### Header and HeaderRegexp - One can invert a matcher by using the `!` operator. +The `Header` and `HeaderRegexp` matchers allow to match requests that contain specific header. -!!! important "Rule, Middleware, and Services" +!!! example "Examples" + + Match requests with a `Content-Type` header set to `application/yaml`: + + ```yaml + Header(`Content-Type`, `application/yaml`) + ``` + + Match requests with a `Content-Type` header set to either `application/json` or `application/yaml`: + + ```yaml + HeaderRegexp(`Content-Type`, `^application/(json|yaml)$`) + ``` + + To match headers [case-insensitively](https://en.wikipedia.org/wiki/Case_sensitivity), use the `(?i)` option: + + ```yaml + HeaderRegexp(`Content-Type`, `(?i)^application/(json|yaml)$`) + ``` - The rule is evaluated "before" any middleware has the opportunity to work, and "before" the request is forwarded to the service. +#### Host and HostRegexp -!!! info "Path Vs PathPrefix" +The `Host` and `HostRegexp` matchers allow to match requests that are targeted to a given host. - Use `Path` if your service listens on the exact path only. For instance, ```Path(`/products`)``` would match `/products` but not `/products/shoes`. +These matchers do not support non-ASCII characters, use punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)) to match such domains. - Use a `*Prefix*` matcher if your service listens on a particular base path but also serves requests on sub-paths. - For instance, ```PathPrefix(`/products`)``` would match `/products` and `/products/shoes`, - as well as `/productsforsale`, and `/productsforsale/shoes`. - Since the path is forwarded as-is, your service is expected to listen on `/products`. +If no Host is set in the request URL (e.g., it's an IP address), these matchers will look at the `Host` header. -!!! info "ClientIP matcher" +!!! example "Examples" - The `ClientIP` matcher will only match the request client IP and does not use the `X-Forwarded-For` header for matching. + Match requests with `Host` set to `example.com`: + + ```yaml + Host(`example.com`) + ``` + + Match requests sent to any subdomain of `example.com`: + + ```yaml + HostRegexp(`^.+\.example\.com$`) + ``` + + Match requests with `Host` set to either `example.com` or `example.org`: + + ```yaml + HostRegexp(`^example\.(com|org)$`) + ``` + + To match domains [case-insensitively](https://en.wikipedia.org/wiki/Case_sensitivity), use the `(?i)` option: + + ```yaml + HostRegexp(`(?i)^example\.(com|org)$`) + ``` + +#### Method + +The `Method` matchers allows to match requests sent with the given method. + +!!! example "Example" + + Match `OPTIONS` requests: + + ```yaml + Method(`OPTIONS`) + ``` + +#### Path, PathPrefix, and PathRegexp + +These matchers allow matching requests based on their URL path. + +For exact matches, use `Path` and its prefixed alternative `PathPrefix`, for regexp matches, use `PathRegexp`. + +Path are always starting with a `/`, except for `PathRegexp`. + +!!! example "Examples" + + Match `/products` but neither `/products/shoes` nor `/products/`: + + ```yaml + Path(`/products`) + ``` + + Match `/products` as well as everything under `/products`, + such as `/products/shoes`, `/products/` but also `/products-for-sale`: + + ```yaml + PathPrefix(`/products`) + ``` + + Match both `/products/shoes` and `/products/socks` with and ID like `/products/shoes/57`: + + ```yaml + PathRegexp(`^/products/(shoes|socks)/[0-9]+$`) + ``` + + Match requests with a path ending in either `.jpeg`, `.jpg` or `.png`: + + ```yaml + PathRegexp(`\.(jpeg|jpg|png)$`) + ``` + + Match `/products` as well as everything under `/products`, + such as `/products/shoes`, `/products/` but also `/products-for-sale`, + [case-insensitively](https://en.wikipedia.org/wiki/Case_sensitivity): + + ```yaml + HostRegexp(`(?i)^/products`) + ``` + +#### Query and QueryRegexp + +The `Query` and `QueryRegexp` matchers allow to match requests based on query parameters. + +!!! example "Examples" + + Match requests with a `mobile` query parameter set to `true`, such as in `/search?mobile=true`: + + ```yaml + Query(`mobile`, `true`) + ``` + + To match requests with a query parameter `mobile` that has no value, such as in `/search?mobile`, use: + + ```yaml + Query(`mobile`) + ``` + + Match requests with a `mobile` query parameter set to either `true` or `yes`: + + ```yaml + QueryRegexp(`mobile`, `^(true|yes)$`) + ``` + + Match requests with a `mobile` query parameter set to any value (including the empty value): + + ```yaml + QueryRegexp(`mobile`, `^.*$`) + ``` + + To match query parameters [case-insensitively](https://en.wikipedia.org/wiki/Case_sensitivity), use the `(?i)` option: + + ```yaml + QueryRegexp(`mobile`, `(?i)^(true|yes)$`) + ``` + +#### ClientIP + +The `ClientIP` matcher allows matching requests sent from the given client IP. + +It only matches the request client IP and does not use the `X-Forwarded-For` header for matching. + +!!! example "Examples" + + Match requests coming from a given IP: + + ```yaml tab="IPv4" + ClientIP(`10.76.105.11`) + ``` + + ```yaml tab="IPv6" + ClientIP(`::1`) + ``` + + Match requests coming from a given subnet: + + ```yaml tab="IPv4" + ClientIP(`192.168.1.0/24`) + ``` + + ```yaml tab="IPv6" + ClientIP(`fe80::/10`) + ``` ### Priority @@ -300,7 +446,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul http: routers: Router-1: - rule: "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + rule: "HostRegexp(`[a-z]+\.traefik\.com`)" # ... Router-2: rule: "Host(`foobar.traefik.com`)" @@ -311,7 +457,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul ## Dynamic configuration [http.routers] [http.routers.Router-1] - rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)" # ... [http.routers.Router-2] rule = "Host(`foobar.traefik.com`)" @@ -320,10 +466,10 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul In this case, all requests with host `foobar.traefik.com` will be routed through `Router-1` instead of `Router-2`. - | Name | Rule | Priority | - |----------|----------------------------------------------------|----------| - | Router-1 | ```HostRegexp(`{subdomain:[a-z]+}.traefik.com`)``` | 44 | - | Router-2 | ```Host(`foobar.traefik.com`)``` | 26 | + | Name | Rule | Priority | + |----------|------------------------------------------|----------| + | Router-1 | ```HostRegexp(`[a-z]+\.traefik\.com`)``` | 44 | + | Router-2 | ```Host(`foobar.traefik.com`)``` | 26 | The previous table shows that `Router-1` has a higher priority than `Router-2`. @@ -336,7 +482,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul http: routers: Router-1: - rule: "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + rule: "HostRegexp(`[a-z]+\\.traefik\\.com`)" entryPoints: - "web" service: service-1 @@ -353,7 +499,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul ## Dynamic configuration [http.routers] [http.routers.Router-1] - rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)" entryPoints = ["web"] service = "service-1" priority = 1 @@ -818,48 +964,49 @@ If you want to limit the router scope to a set of entry points, set the entry po ### Rule -Rules are a set of matchers configured with values, that determine if a particular request matches specific criteria. +Rules are a set of matchers configured with values, that determine if a particular connection matches specific criteria. If the rule is verified, the router becomes active, calls middlewares, and then forwards the request to the service. -??? tip "Backticks or Quotes?" - - To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`. - - Single quotes `'` are not accepted since the values are [Golang's String Literals](https://golang.org/ref/spec#String_literals). - -!!! example "HostSNI is example.com" - - ```toml - rule = "HostSNI(`example.com`)" - ``` - -!!! example "HostSNI is example.com OR HostSNI is example.org AND ClientIP is 0.0.0.0" - - ```toml - rule = "HostSNI(`example.com`) || (HostSNI(`example.org`) && ClientIP(`0.0.0.0`))" - ``` - The table below lists all the available matchers: -| Rule | Description | -|---------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| -| ```HostSNI(`domain-1`, ...)``` | Checks if the Server Name Indication corresponds to the given `domains`. | -| ```HostSNIRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Checks if the Server Name Indication matches the given regular expressions. See "Regexp Syntax" below. | -| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Checks if the connection client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. | -| ```ALPN(`mqtt`, `h2c`)``` | Checks if any of the connection ALPN protocols is one of the given protocols. | +| Rule | Description | +|-------------------------------------------------------------|:-------------------------------------------------------------------------------------------------| +| [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`. | +| [```HostSNIRegexp(`regexp`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication matches `regexp`. | +| [```ClientIP(`ip`)```](#clientip_1) | Checks if the connection's client IP correspond to `ip`. It accepts IPv4, IPv6 and CIDR formats. | +| [```ALPN(`protocol`)```](#alpn) | Checks if the connection's ALPN protocol equals `protocol`. | -!!! important "Non-ASCII Domain Names" +!!! tip "Backticks or Quotes?" - Non-ASCII characters are not supported in the `HostSNI` and `HostSNIRegexp` expressions, and so using them would invalidate the associated TCP router. - Domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)). + To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`. + + Single quotes `'` are not accepted since the values are [Go's String Literals](https://golang.org/ref/spec#String_literals). !!! important "Regexp Syntax" - `HostSNIRegexp` accepts an expression with zero or more groups enclosed by curly braces, which are called named regexps. - Named regexps, of the form `{name:regexp}`, are the only expressions considered for regexp matching. - The regexp name (`name` in the above example) is an arbitrary value, that exists only for historical reasons. + Matchers that accept a regexp as their value use a [Go](https://golang.org/pkg/regexp/) flavored syntax. - Any `regexp` supported by [Go's regexp package](https://golang.org/pkg/regexp/) may be used. +!!! info "Expressing Complex Rules Using Operators and Parenthesis" + + The usual AND (`&&`) and OR (`||`) logical operators can be used, with the expected precedence rules, + as well as parentheses. + + One can invert a matcher by using the NOT (`!`) operator. + + The following rule matches connections where: + + - either Server Name Indication is `example.com` OR, + - Server Name Indication is `example.org` AND ALPN protocol is NOT `h2` + + ```yaml + HostSNI(`example.com`) || (HostSNI(`example.org`) && !ALPN(`h2`)) + ``` + +#### HostSNI and HostSNIRegexp + +`HostSNI` and `HostSNIRegexp` matchers allow to match connections targeted to a given domain. + +These matchers do not support non-ASCII characters, use punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)) to match such domains. !!! important "HostSNI & TLS" @@ -869,25 +1016,72 @@ The table below lists all the available matchers: when one wants a non-TLS router that matches all (non-TLS) requests, one should use the specific ```HostSNI(`*`)``` syntax. -!!! info "Combining Matchers Using Operators and Parenthesis" +!!! example "Examples" - The usual AND (`&&`) and OR (`||`) logical operators can be used, with the expected precedence rules, - as well as parentheses. + Match all connections: + + ```yaml tab="HostSNI" + HostSNI(`*`) + ``` + + ```yaml tab="HostSNIRegexp" + HostSNIRegexp(`^.*$`) + ``` -!!! info "Inverting a matcher" + Match TCP connections sent to `example.com`: + + ```yaml + HostSNI(`example.com`) + ``` - One can invert a matcher by using the `!` operator. + Match TCP connections openned on any subdomain of `example.com`: + + ```yaml + HostSNIRegexp(`^.+\.example\.com$`) + ``` -!!! important "Rule, Middleware, and Services" +#### ClientIP - The rule is evaluated "before" any middleware has the opportunity to work, and "before" the request is forwarded to the service. +The `ClientIP` matcher allows matching connections opened by a client with the given IP. -!!! important "ALPN ACME-TLS/1" +!!! example "Examples" - It would be a security issue to let a user-defined router catch the response to - an ACME TLS challenge previously initiated by Traefik. - For this reason, the `ALPN` matcher is not allowed to match the `ACME-TLS/1` - protocol, and Traefik returns an error if this is attempted. + Match connections opened by a given IP: + + ```yaml tab="IPv4" + ClientIP(`10.76.105.11`) + ``` + + ```yaml tab="IPv6" + ClientIP(`::1`) + ``` + + Match connections coming from a given subnet: + + ```yaml tab="IPv4" + ClientIP(`192.168.1.0/24`) + ``` + + ```yaml tab="IPv6" + ClientIP(`fe80::/10`) + ``` + +#### ALPN + +The `ALPN` matcher allows matching connections the given protocol. + +It would be a security issue to let a user-defined router catch the response to +an ACME TLS challenge previously initiated by Traefik. +For this reason, the `ALPN` matcher is not allowed to match the `ACME-TLS/1` +protocol, and Traefik returns an error if this is attempted. + +!!! example "Example" + + Match connections using the ALPN protocol `h2`: + + ```yaml + ALPN(`h2`) + ``` ### Priority diff --git a/go.mod b/go.mod index adf17cfd4..86872dde8 100644 --- a/go.mod +++ b/go.mod @@ -77,11 +77,12 @@ require ( github.com/vulcand/predicate v1.2.0 go.elastic.co/apm v1.13.1 go.elastic.co/apm/module/apmot v1.13.1 - golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 + golang.org/x/exp v0.0.0-20221114191408-850992195362 + golang.org/x/mod v0.6.0 golang.org/x/net v0.1.0 golang.org/x/text v0.4.0 golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 - golang.org/x/tools v0.1.12 + golang.org/x/tools v0.2.0 google.golang.org/grpc v1.46.0 gopkg.in/DataDog/dd-trace-go.v1 v1.43.1 gopkg.in/fsnotify.v1 v1.4.7 @@ -330,7 +331,7 @@ require ( go.uber.org/zap v1.18.1 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect - golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect + golang.org/x/crypto v0.1.0 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect diff --git a/go.sum b/go.sum index d546c08fe..25a719ffa 100644 --- a/go.sum +++ b/go.sum @@ -2028,8 +2028,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc= -golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2044,7 +2044,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 h1:5XVKs2rlCg8EFyRcvO8/XFwYxh1oKJO1Q3X5vttIf9c= +golang.org/x/exp v0.0.0-20221114191408-850992195362 h1:NoHlPRbyl1VFI6FjwHtPQCN7wAMXI6cKcqrmXhOOfBQ= +golang.org/x/exp v0.0.0-20221114191408-850992195362/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -2074,8 +2075,8 @@ golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hM golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2417,8 +2418,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/integration/fixtures/https/https_sni_case_insensitive_dynamic.toml b/integration/fixtures/https/https_sni_case_insensitive_dynamic.toml index 718477cb3..77c87a519 100644 --- a/integration/fixtures/https/https_sni_case_insensitive_dynamic.toml +++ b/integration/fixtures/https/https_sni_case_insensitive_dynamic.toml @@ -20,12 +20,12 @@ [http.routers] [http.routers.router1] - rule = "HostRegexp(`{subdomain:[a-z1-9-]+}.snitest.com`)" + rule = "HostRegexp(`[a-z1-9-]+\\.snitest\\.com`)" service = "service1" [http.routers.router1.tls] [http.routers.router2] - rule = "HostRegexp(`{subdomain:[a-z1-9-]+}.www.snitest.com`)" + rule = "HostRegexp(`[a-z1-9-]+\\.www\\.snitest\\.com`)" service = "service1" [http.routers.router2.tls] diff --git a/integration/fixtures/simple_muxer.toml b/integration/fixtures/simple_muxer.toml index 9ec55ebdc..92a86edb4 100644 --- a/integration/fixtures/simple_muxer.toml +++ b/integration/fixtures/simple_muxer.toml @@ -4,6 +4,7 @@ [log] level = "DEBUG" + noColor = true [entryPoints] [entryPoints.webHost] @@ -30,12 +31,12 @@ [http.routers.router2] entryPoints = ["webHostRegexp"] service = "service1" - rule = "!HostRegexp(`test.localhost`)" + rule = "!HostRegexp(`test\\.localhost`)" [http.routers.router3] entryPoints = ["webQuery"] service = "service1" - rule = "!Query(`foo=`)" + rule = "!QueryRegexp(`foo`, `.*`)" [http.services] diff --git a/integration/fixtures/websocket/config_https.toml b/integration/fixtures/websocket/config_https.toml index cdc92b862..71f0f5465 100644 --- a/integration/fixtures/websocket/config_https.toml +++ b/integration/fixtures/websocket/config_https.toml @@ -24,7 +24,7 @@ [http.routers] [http.routers.router1] service = "service1" - rule = "Path(`/echo`,`/ws`)" + rule = "Path(`/echo`) || Path(`/ws`)" [http.routers.router1.tls] [http.services] diff --git a/integration/https_test.go b/integration/https_test.go index 868d31aff..ba80a25f0 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -1092,7 +1092,7 @@ func (s *HTTPSSuite) TestWithSNIDynamicCaseInsensitive(c *check.C) { defer s.killCmd(cmd) // wait for Traefik - err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 500*time.Millisecond, try.BodyContains("HostRegexp(`{subdomain:[a-z1-9-]+}.www.snitest.com`)")) + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 500*time.Millisecond, try.BodyContains("HostRegexp(`[a-z1-9-]+\\\\.www\\\\.snitest\\\\.com`)")) c.Assert(err, checker.IsNil) tlsConfig := &tls.Config{ diff --git a/integration/simple_test.go b/integration/simple_test.go index 287fe72e0..8738699a1 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -1337,7 +1337,7 @@ func (s *SimpleSuite) TestMuxer(c *check.C) { expected: http.StatusOK, }, { - desc: "!Query with semicolon, no match", + desc: "!Query with semicolon and empty query param value, no match", request: "GET /?foo=; HTTP/1.1\r\nHost: other.localhost\r\n\r\n", target: "127.0.0.1:8002", expected: http.StatusNotFound, @@ -1367,9 +1367,7 @@ func (s *SimpleSuite) TestMuxer(c *check.C) { resp, err := http.ReadResponse(bufio.NewReader(conn), nil) c.Assert(err, checker.IsNil) - if resp.StatusCode != test.expected { - c.Errorf("%s failed with %d instead of %d", test.desc, resp.StatusCode, test.expected) - } + c.Assert(resp.StatusCode, checker.Equals, test.expected, check.Commentf(test.desc)) if test.body != "" { body, err := io.ReadAll(resp.Body) diff --git a/pkg/muxer/http/matcher.go b/pkg/muxer/http/matcher.go new file mode 100644 index 000000000..ceafbd6b4 --- /dev/null +++ b/pkg/muxer/http/matcher.go @@ -0,0 +1,253 @@ +package http + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v2/pkg/ip" + "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" + "golang.org/x/exp/slices" +) + +var httpFuncs = map[string]func(*mux.Route, ...string) error{ + "ClientIP": expectNParameters(clientIP, 1), + "Method": expectNParameters(method, 1), + "Host": expectNParameters(host, 1), + "HostRegexp": expectNParameters(hostRegexp, 1), + "Path": expectNParameters(path, 1), + "PathRegexp": expectNParameters(pathRegexp, 1), + "PathPrefix": expectNParameters(pathPrefix, 1), + "Header": expectNParameters(header, 2), + "HeaderRegexp": expectNParameters(headerRegexp, 2), + "Query": expectNParameters(query, 1, 2), + "QueryRegexp": expectNParameters(queryRegexp, 1, 2), +} + +func expectNParameters(fn func(*mux.Route, ...string) error, n ...int) func(*mux.Route, ...string) error { + return func(route *mux.Route, s ...string) error { + if !slices.Contains(n, len(s)) { + return fmt.Errorf("unexpected number of parameters; got %d, expected one of %v", len(s), n) + } + + return fn(route, s...) + } +} + +func clientIP(route *mux.Route, clientIP ...string) error { + checker, err := ip.NewChecker(clientIP) + if err != nil { + return fmt.Errorf("initializing IP checker for ClientIP matcher: %w", err) + } + + strategy := ip.RemoteAddrStrategy{} + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + ok, err := checker.Contains(strategy.GetIP(req)) + if err != nil { + log.Ctx(req.Context()).Warn().Err(err).Msg("ClientIP matcher: could not match remote address") + return false + } + + return ok + }) + + return nil +} + +func method(route *mux.Route, methods ...string) error { + return route.Methods(methods...).GetError() +} + +func host(route *mux.Route, hosts ...string) error { + host := hosts[0] + + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", host) + } + + host = strings.ToLower(host) + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + reqHost := requestdecorator.GetCanonizedHost(req.Context()) + if len(reqHost) == 0 { + // If the request is an HTTP/1.0 request, then a Host may not be defined. + if req.ProtoAtLeast(1, 1) { + log.Ctx(req.Context()).Warn().Str("host", req.Host).Msg("Could not retrieve CanonizedHost, rejecting") + } + + return false + } + + flatH := requestdecorator.GetCNAMEFlatten(req.Context()) + if len(flatH) > 0 { + if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) { + return true + } + + log.Ctx(req.Context()).Debug(). + Str("host", reqHost). + Str("flattenHost", flatH). + Str("matcher", host). + Msg("CNAMEFlattening: resolved Host does not match") + return false + } + + if reqHost == host { + return true + } + + // Check for match on trailing period on host + if last := len(host) - 1; last >= 0 && host[last] == '.' { + h := host[:last] + if reqHost == h { + return true + } + } + + // Check for match on trailing period on request + if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { + h := reqHost[:last] + if h == host { + return true + } + } + + return false + }) + + return nil +} + +func hostRegexp(route *mux.Route, hosts ...string) error { + host := hosts[0] + + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) + } + + re, err := regexp.Compile(host) + if err != nil { + return fmt.Errorf("compiling HostRegexp matcher: %w", err) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + return re.MatchString(req.Host) + }) + + return nil +} + +func path(route *mux.Route, paths ...string) error { + path := paths[0] + + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + return req.URL.Path == path + }) + + return nil +} + +func pathRegexp(route *mux.Route, paths ...string) error { + path := paths[0] + + re, err := regexp.Compile(path) + if err != nil { + return fmt.Errorf("compiling PathPrefix matcher: %w", err) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + return re.MatchString(req.URL.Path) + }) + + return nil +} + +func pathPrefix(route *mux.Route, paths ...string) error { + path := paths[0] + + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + return strings.HasPrefix(req.URL.Path, path) + }) + + return nil +} + +func header(route *mux.Route, headers ...string) error { + return route.Headers(headers...).GetError() +} + +func headerRegexp(route *mux.Route, headers ...string) error { + return route.HeadersRegexp(headers...).GetError() +} + +func query(route *mux.Route, queries ...string) error { + key := queries[0] + + var value string + if len(queries) == 2 { + value = queries[1] + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + values, ok := req.URL.Query()[key] + if !ok { + return false + } + + return slices.Contains(values, value) + }) + + return nil +} + +func queryRegexp(route *mux.Route, queries ...string) error { + if len(queries) == 1 { + return query(route, queries...) + } + + key, value := queries[0], queries[1] + + re, err := regexp.Compile(value) + if err != nil { + return fmt.Errorf("compiling QueryRegexp matcher: %w", err) + } + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + values, ok := req.URL.Query()[key] + if !ok { + return false + } + + idx := slices.IndexFunc(values, func(value string) bool { + return re.MatchString(value) + }) + + return idx >= 0 + }) + + return nil +} + +// IsASCII checks if the given string contains only ASCII characters. +func IsASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + return false + } + } + + return true +} diff --git a/pkg/muxer/http/matcher_test.go b/pkg/muxer/http/matcher_test.go new file mode 100644 index 000000000..231d34f91 --- /dev/null +++ b/pkg/muxer/http/matcher_test.go @@ -0,0 +1,974 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" +) + +func TestClientIPMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid ClientIP matcher", + rule: "ClientIP(`1`)", + expectedError: true, + }, + { + desc: "invalid ClientIP matcher (no parameter)", + rule: "ClientIP()", + expectedError: true, + }, + { + desc: "invalid ClientIP matcher (empty parameter)", + rule: "ClientIP(``)", + expectedError: true, + }, + { + desc: "invalid ClientIP matcher (too many parameters)", + rule: "ClientIP(`127.0.0.1`, `192.168.1.0/24`)", + expectedError: true, + }, + { + desc: "valid ClientIP matcher", + rule: "ClientIP(`127.0.0.1`)", + expected: map[string]int{ + "127.0.0.1": http.StatusOK, + "192.168.1.1": http.StatusNotFound, + }, + }, + { + desc: "valid ClientIP matcher but invalid remote address", + rule: "ClientIP(`127.0.0.1`)", + expected: map[string]int{ + "1": http.StatusNotFound, + }, + }, + { + desc: "valid ClientIP matcher using CIDR", + rule: "ClientIP(`192.168.1.0/24`)", + expected: map[string]int{ + "192.168.1.1": http.StatusOK, + "192.168.1.100": http.StatusOK, + "192.168.2.1": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for remoteAddr := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.RemoteAddr = remoteAddr + + muxer.ServeHTTP(w, req) + results[remoteAddr] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestMethodMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Method matcher (no parameter)", + rule: "Method()", + expectedError: true, + }, + { + desc: "invalid Method matcher (empty parameter)", + rule: "Method(``)", + expectedError: true, + }, + { + desc: "invalid Method matcher (too many parameters)", + rule: "Method(`GET`, `POST`)", + expectedError: true, + }, + { + desc: "valid Method matcher", + rule: "Method(`GET`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusMethodNotAllowed, + }, + }, + { + desc: "valid Method matcher (lower case)", + rule: "Method(`get`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusMethodNotAllowed, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for method := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(method, "https://example.com", http.NoBody) + + muxer.ServeHTTP(w, req) + results[method] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHostMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Host matcher (no parameter)", + rule: "Host()", + expectedError: true, + }, + { + desc: "invalid Host matcher (empty parameter)", + rule: "Host(``)", + expectedError: true, + }, + { + desc: "invalid Host matcher (non-ASCII)", + rule: "Host(`🦭.com`)", + expectedError: true, + }, + { + desc: "invalid Host matcher (too many parameters)", + rule: "Host(`example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid Host matcher", + rule: "Host(`example.com`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - matcher ending with a dot", + rule: "Host(`example.com.`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + "https://example.com.": http.StatusOK, + "https://example.com./path": http.StatusOK, + "https://example.org.": http.StatusNotFound, + "https://example.org./path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - URL ending with a dot", + rule: "Host(`example.com`)", + expected: map[string]int{ + "https://example.com.": http.StatusOK, + "https://example.com./path": http.StatusOK, + "https://example.org.": http.StatusNotFound, + "https://example.org./path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - puny-coded emoji", + rule: "Host(`xn--9t9h.com`)", + expected: map[string]int{ + "https://xn--9t9h.com": http.StatusOK, + "https://xn--9t9h.com/path": http.StatusOK, + "https://example.com": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + // The request's sender must use puny-code. + "https://🦭.com": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + // RequestDecorator is necessary for the host rule + reqHost := requestdecorator.New(nil) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + reqHost.ServeHTTP(w, req, muxer.ServeHTTP) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHostRegexpMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid HostRegexp matcher (no parameter)", + rule: "HostRegexp()", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (empty parameter)", + rule: "HostRegexp(``)", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (non-ASCII)", + rule: "HostRegexp(`🦭.com`)", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (invalid regexp)", + rule: "HostRegexp(`(example.com`)", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (too many parameters)", + rule: "HostRegexp(`example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid HostRegexp matcher", + rule: "HostRegexp(`^[a-zA-Z-]+\\.com$`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid HostRegexp matcher with Traefik v2 syntax", + rule: "HostRegexp(`{domain:[a-zA-Z-]+\\.com}`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestPathMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Path matcher (no parameter)", + rule: "Path()", + expectedError: true, + }, + { + desc: "invalid Path matcher (empty parameter)", + rule: "Path(``)", + expectedError: true, + }, + { + desc: "invalid Path matcher (no leading /)", + rule: "Path(`css`)", + expectedError: true, + }, + { + desc: "invalid Path matcher (too many parameters)", + rule: "Path(`/css`, `/js`)", + expectedError: true, + }, + { + desc: "valid Path matcher", + rule: "Path(`/css`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusNotFound, + "https://example.com/css/main.css": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestPathRegexpMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid PathRegexp matcher (no parameter)", + rule: "PathRegexp()", + expectedError: true, + }, + { + desc: "invalid PathRegexp matcher (empty parameter)", + rule: "PathRegexp(``)", + expectedError: true, + }, + { + desc: "invalid PathRegexp matcher (invalid regexp)", + rule: "PathRegexp(`/(css`)", + expectedError: true, + }, + { + desc: "invalid PathRegexp matcher (too many parameters)", + rule: "PathRegexp(`/css`, `/js`)", + expectedError: true, + }, + { + desc: "valid PathRegexp matcher", + rule: "PathRegexp(`^/(css|js)`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/CSS": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusOK, + "https://example.com/css/main.css": http.StatusOK, + "https://example.com/js": http.StatusOK, + "https://example.com/js/": http.StatusOK, + "https://example.com/js/main.js": http.StatusOK, + }, + }, + { + desc: "valid PathRegexp matcher with Traefik v2 syntax", + rule: `PathRegexp("/{path:(css|js)}")`, + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusNotFound, + "https://example.com/{path:css}": http.StatusOK, + "https://example.com/{path:css}/": http.StatusOK, + "https://example.com/%7Bpath:css%7D": http.StatusOK, + "https://example.com/%7Bpath:css%7D/": http.StatusOK, + "https://example.com/{path:js}": http.StatusOK, + "https://example.com/{path:js}/": http.StatusOK, + "https://example.com/%7Bpath:js%7D": http.StatusOK, + "https://example.com/%7Bpath:js%7D/": http.StatusOK, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestPathPrefixMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid PathPrefix matcher (no parameter)", + rule: "PathPrefix()", + expectedError: true, + }, + { + desc: "invalid PathPrefix matcher (empty parameter)", + rule: "PathPrefix(``)", + expectedError: true, + }, + { + desc: "invalid PathPrefix matcher (no leading /)", + rule: "PathPrefix(`css`)", + expectedError: true, + }, + { + desc: "invalid PathPrefix matcher (too many parameters)", + rule: "PathPrefix(`/css`, `/js`)", + expectedError: true, + }, + { + desc: "valid PathPrefix matcher", + rule: `PathPrefix("/css")`, + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusOK, + "https://example.com/css/main.css": http.StatusOK, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHeaderMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[*http.Header]int + expectedError bool + }{ + { + desc: "invalid Header matcher (no parameter)", + rule: "Header()", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Header(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Header(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing key parameter)", + rule: "Header(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (too many parameters)", + rule: "Header(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid Header matcher", + rule: "Header(`X-Forwarded-Proto`, `https`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"x-forwarded-proto": []string{"https"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http", "https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"https", "http"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + for headers := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.Header = *headers + + muxer.ServeHTTP(w, req) + assert.Equal(t, test.expected[headers], w.Code, headers) + } + }) + } +} + +func TestHeaderRegexpMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[*http.Header]int + expectedError bool + }{ + { + desc: "invalid HeaderRegexp matcher (no parameter)", + rule: "HeaderRegexp()", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing value parameter)", + rule: "HeaderRegexp(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing value parameter)", + rule: "HeaderRegexp(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing key parameter)", + rule: "HeaderRegexp(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (invalid regexp)", + rule: "HeaderRegexp(`X-Forwarded-Host`,`(example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (too many parameters)", + rule: "HeaderRegexp(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid HeaderRegexp matcher", + rule: "HeaderRegexp(`X-Forwarded-Proto`, `^https?$`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"http"}}: http.StatusOK, + {"x-forwarded-proto": []string{"http"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"ws", "https"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + { + desc: "valid HeaderRegexp matcher with Traefik v2 syntax", + rule: "HeaderRegexp(`X-Forwarded-Proto`, `http{secure:s?}`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"http"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"https"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http{secure:}"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"HTTP{secure:}"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http{secure:s}"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"http{secure:S}"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"ws", "http{secure:s}"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + for headers := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.Header = *headers + + muxer.ServeHTTP(w, req) + assert.Equal(t, test.expected[headers], w.Code, *headers) + } + }) + } +} + +func TestQueryMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Query matcher (no parameter)", + rule: "Query()", + expectedError: true, + }, + { + desc: "invalid Query matcher (empty key, one parameter)", + rule: "Query(``)", + expectedError: true, + }, + { + desc: "invalid Query matcher (empty key)", + rule: "Query(``, `traefik`)", + expectedError: true, + }, + { + desc: "invalid Query matcher (empty value)", + rule: "Query(`q`, ``)", + expectedError: true, + }, + { + desc: "invalid Query matcher (too many parameters)", + rule: "Query(`q`, `traefik`, `proxy`)", + expectedError: true, + }, + { + desc: "valid Query matcher", + rule: "Query(`q`, `traefik`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com?q=traefik": http.StatusOK, + "https://example.com?rel=ddg&q=traefik": http.StatusOK, + "https://example.com?q=traefik&q=proxy": http.StatusOK, + "https://example.com?q=awesome&q=traefik": http.StatusOK, + "https://example.com?q=nginx": http.StatusNotFound, + "https://example.com?rel=ddg": http.StatusNotFound, + "https://example.com?q=TRAEFIK": http.StatusNotFound, + "https://example.com?Q=traefik": http.StatusNotFound, + "https://example.com?rel=traefik": http.StatusNotFound, + }, + }, + { + desc: "valid Query matcher with empty value", + rule: "Query(`mobile`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com?mobile": http.StatusOK, + "https://example.com?mobile=true": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestQueryRegexpMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid QueryRegexp matcher (no parameter)", + rule: "QueryRegexp()", + expectedError: true, + }, + { + desc: "invalid QueryRegexp matcher (empty parameter)", + rule: "QueryRegexp(``)", + expectedError: true, + }, + { + desc: "invalid QueryRegexp matcher (invalid regexp)", + rule: "QueryRegexp(`q`, `(traefik`)", + expectedError: true, + }, + { + desc: "invalid QueryRegexp matcher (too many parameters)", + rule: "QueryRegexp(`q`, `traefik`, `proxy`)", + expectedError: true, + }, + { + desc: "valid QueryRegexp matcher", + rule: "QueryRegexp(`q`, `^(traefik|nginx)$`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com?q=traefik": http.StatusOK, + "https://example.com?rel=ddg&q=traefik": http.StatusOK, + "https://example.com?q=traefik&q=proxy": http.StatusOK, + "https://example.com?q=awesome&q=traefik": http.StatusOK, + "https://example.com?q=TRAEFIK": http.StatusNotFound, + "https://example.com?Q=traefik": http.StatusNotFound, + "https://example.com?rel=traefik": http.StatusNotFound, + "https://example.com?q=nginx": http.StatusOK, + "https://example.com?rel=ddg&q=nginx": http.StatusOK, + "https://example.com?q=nginx&q=proxy": http.StatusOK, + "https://example.com?q=awesome&q=nginx": http.StatusOK, + "https://example.com?q=NGINX": http.StatusNotFound, + "https://example.com?Q=nginx": http.StatusNotFound, + "https://example.com?rel=nginx": http.StatusNotFound, + "https://example.com?q=haproxy": http.StatusNotFound, + "https://example.com?rel=ddg": http.StatusNotFound, + }, + }, + { + desc: "valid QueryRegexp matcher", + rule: "QueryRegexp(`q`, `^.*$`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com?q=traefik": http.StatusOK, + "https://example.com?rel=ddg&q=traefik": http.StatusOK, + "https://example.com?q=traefik&q=proxy": http.StatusOK, + "https://example.com?q=awesome&q=traefik": http.StatusOK, + "https://example.com?q=TRAEFIK": http.StatusOK, + "https://example.com?Q=traefik": http.StatusNotFound, + "https://example.com?rel=traefik": http.StatusNotFound, + "https://example.com?q=nginx": http.StatusOK, + "https://example.com?rel=ddg&q=nginx": http.StatusOK, + "https://example.com?q=nginx&q=proxy": http.StatusOK, + "https://example.com?q=awesome&q=nginx": http.StatusOK, + "https://example.com?q=NGINX": http.StatusOK, + "https://example.com?Q=nginx": http.StatusNotFound, + "https://example.com?rel=nginx": http.StatusNotFound, + "https://example.com?q=haproxy": http.StatusOK, + "https://example.com?rel=ddg": http.StatusNotFound, + }, + }, + { + desc: "valid QueryRegexp matcher with Traefik v2 syntax", + rule: "QueryRegexp(`q`, `{value:(traefik|nginx)}`)", + expected: map[string]int{ + "https://example.com?q=traefik": http.StatusNotFound, + "https://example.com?q={value:traefik}": http.StatusOK, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} diff --git a/pkg/muxer/http/mux.go b/pkg/muxer/http/mux.go index 16f6e69fe..237977044 100644 --- a/pkg/muxer/http/mux.go +++ b/pkg/muxer/http/mux.go @@ -3,32 +3,12 @@ package http import ( "fmt" "net/http" - "strings" - "unicode/utf8" "github.com/gorilla/mux" - "github.com/rs/zerolog/log" - "github.com/traefik/traefik/v2/pkg/ip" - "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" "github.com/traefik/traefik/v2/pkg/rules" "github.com/vulcand/predicate" ) -const hostMatcher = "Host" - -var httpFuncs = map[string]func(*mux.Route, ...string) error{ - hostMatcher: host, - "HostHeader": host, - "HostRegexp": hostRegexp, - "ClientIP": clientIP, - "Path": path, - "PathPrefix": pathPrefix, - "Method": methods, - "Headers": headers, - "HeadersRegexp": headersRegexp, - "Query": query, -} - // Muxer handles routing with rules. type Muxer struct { *mux.Router @@ -80,171 +60,6 @@ func (r *Muxer) AddRoute(rule string, priority int, handler http.Handler) error return nil } -// ParseDomains extract domains from rule. -func ParseDomains(rule string) ([]string, error) { - var matchers []string - for matcher := range httpFuncs { - matchers = append(matchers, matcher) - } - - parser, err := rules.NewParser(matchers) - if err != nil { - return nil, err - } - - parse, err := parser.Parse(rule) - if err != nil { - return nil, err - } - - buildTree, ok := parse.(rules.TreeBuilder) - if !ok { - return nil, fmt.Errorf("error while parsing rule %s", rule) - } - - return buildTree().ParseMatchers([]string{hostMatcher}), nil -} - -func path(route *mux.Route, paths ...string) error { - rt := route.Subrouter() - - for _, path := range paths { - if err := rt.Path(path).GetError(); err != nil { - return err - } - } - - return nil -} - -func pathPrefix(route *mux.Route, paths ...string) error { - rt := route.Subrouter() - - for _, path := range paths { - if err := rt.PathPrefix(path).GetError(); err != nil { - return err - } - } - - return nil -} - -func host(route *mux.Route, hosts ...string) error { - for i, host := range hosts { - if !IsASCII(host) { - return fmt.Errorf("invalid value %q for \"Host\" matcher, non-ASCII characters are not allowed", host) - } - - hosts[i] = strings.ToLower(host) - } - - route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) - if len(reqHost) == 0 { - // If the request is an HTTP/1.0 request, then a Host may not be defined. - if req.ProtoAtLeast(1, 1) { - log.Ctx(req.Context()).Warn().Msgf("Could not retrieve CanonizedHost, rejecting %s", req.Host) - } - - return false - } - - flatH := requestdecorator.GetCNAMEFlatten(req.Context()) - if len(flatH) > 0 { - for _, host := range hosts { - if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) { - return true - } - log.Ctx(req.Context()).Debug().Msgf("CNAMEFlattening: request %s which resolved to %s, is not matched to route %s", reqHost, flatH, host) - } - return false - } - - for _, host := range hosts { - if reqHost == host { - return true - } - - // Check for match on trailing period on host - if last := len(host) - 1; last >= 0 && host[last] == '.' { - h := host[:last] - if reqHost == h { - return true - } - } - - // Check for match on trailing period on request - if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { - h := reqHost[:last] - if h == host { - return true - } - } - } - return false - }) - return nil -} - -func clientIP(route *mux.Route, clientIPs ...string) error { - checker, err := ip.NewChecker(clientIPs) - if err != nil { - return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err) - } - - strategy := ip.RemoteAddrStrategy{} - - route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { - ok, err := checker.Contains(strategy.GetIP(req)) - if err != nil { - log.Ctx(req.Context()).Warn().Err(err).Msg("\"ClientIP\" matcher: could not match remote address") - return false - } - - return ok - }) - - return nil -} - -func hostRegexp(route *mux.Route, hosts ...string) error { - router := route.Subrouter() - for _, host := range hosts { - if !IsASCII(host) { - return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) - } - - tmpRt := router.Host(host) - if tmpRt.GetError() != nil { - return tmpRt.GetError() - } - } - return nil -} - -func methods(route *mux.Route, methods ...string) error { - return route.Methods(methods...).GetError() -} - -func headers(route *mux.Route, headers ...string) error { - return route.Headers(headers...).GetError() -} - -func headersRegexp(route *mux.Route, headers ...string) error { - return route.HeadersRegexp(headers...).GetError() -} - -func query(route *mux.Route, query ...string) error { - var queries []string - for _, elem := range query { - queries = append(queries, strings.SplitN(elem, "=", 2)...) - } - - route.Queries(queries...) - // Queries can return nil so we can't chain the GetError() - return route.GetError() -} - func addRuleOnRouter(router *mux.Router, rule *rules.Tree) error { switch rule.Matcher { case "and": @@ -276,20 +91,6 @@ func addRuleOnRouter(router *mux.Router, rule *rules.Tree) error { } } -func not(m func(*mux.Route, ...string) error) func(*mux.Route, ...string) error { - return func(r *mux.Route, v ...string) error { - router := mux.NewRouter() - err := m(router.NewRoute(), v...) - if err != nil { - return err - } - r.MatcherFunc(func(req *http.Request, ma *mux.RouteMatch) bool { - return !router.Match(req, ma) - }) - return nil - } -} - func addRuleOnRoute(route *mux.Route, rule *rules.Tree) error { switch rule.Matcher { case "and": @@ -322,13 +123,44 @@ func addRuleOnRoute(route *mux.Route, rule *rules.Tree) error { } } -// IsASCII checks if the given string contains only ASCII characters. -func IsASCII(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] >= utf8.RuneSelf { - return false +func not(m func(*mux.Route, ...string) error) func(*mux.Route, ...string) error { + return func(r *mux.Route, v ...string) error { + router := mux.NewRouter() + + err := m(router.NewRoute(), v...) + if err != nil { + return err } + + r.MatcherFunc(func(req *http.Request, ma *mux.RouteMatch) bool { + return !router.Match(req, ma) + }) + + return nil + } +} + +// ParseDomains extract domains from rule. +func ParseDomains(rule string) ([]string, error) { + var matchers []string + for matcher := range httpFuncs { + matchers = append(matchers, matcher) } - return true + parser, err := rules.NewParser(matchers) + if err != nil { + return nil, err + } + + parse, err := parser.Parse(rule) + if err != nil { + return nil, err + } + + buildTree, ok := parse.(rules.TreeBuilder) + if !ok { + return nil, fmt.Errorf("error while parsing rule %s", rule) + } + + return buildTree().ParseMatchers([]string{"Host"}), nil } diff --git a/pkg/muxer/http/mux_test.go b/pkg/muxer/http/mux_test.go index be8e2de30..bdc98df37 100644 --- a/pkg/muxer/http/mux_test.go +++ b/pkg/muxer/http/mux_test.go @@ -7,14 +7,13 @@ import ( "net/http/httptest" "testing" - "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" "github.com/traefik/traefik/v2/pkg/testhelpers" ) -func Test_addRoute(t *testing.T) { +func TestMuxer(t *testing.T) { testCases := []struct { desc string rule string @@ -33,607 +32,177 @@ func Test_addRoute(t *testing.T) { expectedError: true, }, { - desc: "Host empty", - rule: "Host(``)", + desc: "Rule without quote", + rule: "Host(example.com)", expectedError: true, }, - { - desc: "PathPrefix empty", - rule: "PathPrefix(``)", - expectedError: true, - }, - { - desc: "PathPrefix", - rule: "PathPrefix(`/foo`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "wrong PathPrefix", - rule: "PathPrefix(`/bar`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "Host", - rule: "Host(`localhost`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Non-ASCII Host", - rule: "Host(`locàlhost`)", - expectedError: true, - }, - { - desc: "Non-ASCII HostRegexp", - rule: "HostRegexp(`locàlhost`)", - expectedError: true, - }, - { - desc: "HostHeader equivalent to Host", - rule: "HostHeader(`localhost`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - "http://bar/foo": http.StatusNotFound, - }, - }, - { - desc: "Host with trailing period in rule", - rule: "Host(`localhost.`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Host with trailing period in domain", - rule: "Host(`localhost`)", - expected: map[string]int{ - "http://localhost./foo": http.StatusOK, - }, - }, - { - desc: "Host with trailing period in domain and rule", - rule: "Host(`localhost.`)", - expected: map[string]int{ - "http://localhost./foo": http.StatusOK, - }, - }, - { - desc: "wrong Host", - rule: "Host(`nope`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, { desc: "Host and PathPrefix", - rule: "Host(`localhost`) && PathPrefix(`/foo`)", + rule: "Host(`localhost`) && PathPrefix(`/css`)", expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Host and PathPrefix wrong PathPrefix", - rule: "Host(`localhost`) && PathPrefix(`/bar`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "Host and PathPrefix wrong Host", - rule: "Host(`nope`) && PathPrefix(`/foo`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "Host and PathPrefix Host OR, first host", - rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Host and PathPrefix Host OR, second host", - rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", - expected: map[string]int{ - "http://nope/foo": http.StatusOK, - }, - }, - { - desc: "Host and PathPrefix Host OR, first host and wrong PathPrefix", - rule: "Host(`nope,localhost`) && PathPrefix(`/bar`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "HostRegexp with capturing group", - rule: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)", - expected: map[string]int{ - "http://foo.bar.com": http.StatusOK, - "http://bar.com": http.StatusOK, - "http://fooubar.com": http.StatusNotFound, - "http://barucom": http.StatusNotFound, - "http://barcom": http.StatusNotFound, - }, - }, - { - desc: "HostRegexp with non capturing group", - rule: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)", - expected: map[string]int{ - "http://foo.bar.com": http.StatusOK, - "http://bar.com": http.StatusOK, - "http://fooubar.com": http.StatusNotFound, - "http://barucom": http.StatusNotFound, - "http://barcom": http.StatusNotFound, - }, - }, - { - desc: "Methods with GET", - rule: "Method(`GET`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Methods with GET and POST", - rule: "Method(`GET`,`POST`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Methods with POST", - rule: "Method(`POST`)", - expected: map[string]int{ - "http://localhost/foo": http.StatusMethodNotAllowed, - }, - }, - { - desc: "Header with matching header", - rule: "Headers(`Content-Type`,`application/json`)", - headers: map[string]string{ - "Content-Type": "application/json", - }, - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Header without matching header", - rule: "Headers(`Content-Type`,`application/foo`)", - headers: map[string]string{ - "Content-Type": "application/json", - }, - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "HeaderRegExp with matching header", - rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", - headers: map[string]string{ - "Content-Type": "application/json", - }, - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "HeaderRegExp without matching header", - rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", - headers: map[string]string{ - "Content-Type": "application/foo", - }, - expected: map[string]int{ - "http://localhost/foo": http.StatusNotFound, - }, - }, - { - desc: "HeaderRegExp with matching second header", - rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", - headers: map[string]string{ - "Content-Type": "application/text", - }, - expected: map[string]int{ - "http://localhost/foo": http.StatusOK, - }, - }, - { - desc: "Query with multiple params", - rule: "Query(`foo=bar`, `bar=baz`)", - expected: map[string]int{ - "http://localhost/foo?foo=bar&bar=baz": http.StatusOK, - "http://localhost/foo?bar=baz": http.StatusNotFound, - }, - }, - { - desc: "Query with multiple equals", - rule: "Query(`foo=b=ar`)", - expected: map[string]int{ - "http://localhost/foo?foo=b=ar": http.StatusOK, - "http://localhost/foo?foo=bar": http.StatusNotFound, - }, - }, - { - desc: "Rule with simple path", - rule: `Path("/a")`, - expected: map[string]int{ - "http://plop/a": http.StatusOK, - }, - }, - { - desc: `Rule with a simple host`, - rule: `Host("plop")`, - expected: map[string]int{ - "http://plop": http.StatusOK, - }, - }, - { - desc: "Rule with Path AND Host", - rule: `Path("/a") && Host("plop")`, - expected: map[string]int{ - "http://plop/a": http.StatusOK, - "http://plopi/a": http.StatusNotFound, + "https://localhost/css": http.StatusOK, + "https://localhost/js": http.StatusNotFound, }, }, { desc: "Rule with Host OR Host", - rule: `Host("tchouk") || Host("pouet")`, + rule: "Host(`example.com`) || Host(`example.org`)", expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - "http://pouet/a": http.StatusOK, - "http://plopi/a": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.org/js": http.StatusOK, + "https://example.eu/html": http.StatusNotFound, }, }, { desc: "Rule with host OR (host AND path)", - rule: `Host("tchouk") || (Host("pouet") && Path("/powpow"))`, + rule: `Host("example.com") || (Host("example.org") && Path("/css"))`, expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - "http://tchouk/powpow": http.StatusOK, - "http://pouet/powpow": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.org/js": http.StatusNotFound, + "https://example.eu/css": http.StatusNotFound, }, }, { desc: "Rule with host OR host AND path", - rule: `Host("tchouk") || Host("pouet") && Path("/powpow")`, + rule: `Host("example.com") || Host("example.org") && Path("/css")`, expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - "http://tchouk/powpow": http.StatusOK, - "http://pouet/powpow": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.org/js": http.StatusNotFound, + "https://example.eu/css": http.StatusNotFound, }, }, { desc: "Rule with (host OR host) AND path", - rule: `(Host("tchouk") || Host("pouet")) && Path("/powpow")`, + rule: `(Host("example.com") || Host("example.org")) && Path("/css")`, expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - "http://tchouk/powpow": http.StatusOK, - "http://pouet/powpow": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, - }, - }, - { - desc: "Rule with multiple host AND path", - rule: `(Host("tchouk","pouet")) && Path("/powpow")`, - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - "http://tchouk/powpow": http.StatusOK, - "http://pouet/powpow": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, - }, - }, - { - desc: "Rule with multiple host AND multiple path", - rule: `Host("tchouk","pouet") && Path("/powpow", "/titi")`, - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - "http://tchouk/powpow": http.StatusOK, - "http://pouet/powpow": http.StatusOK, - "http://tchouk/titi": http.StatusOK, - "http://pouet/titi": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.org/js": http.StatusNotFound, + "https://example.eu/css": http.StatusNotFound, }, }, { desc: "Rule with (host AND path) OR (host AND path)", - rule: `(Host("tchouk") && Path("/titi")) || ((Host("pouet")) && Path("/powpow"))`, + rule: `(Host("example.com") && Path("/js")) || ((Host("example.org")) && Path("/css"))`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, - "http://pouet/powpow": http.StatusOK, - "http://pouet/toto": http.StatusNotFound, - "http://plopi/a": http.StatusNotFound, + "https://example.com/css": http.StatusNotFound, + "https://example.com/js": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.org/js": http.StatusNotFound, + "https://example.eu/css": http.StatusNotFound, }, }, - { - desc: "Rule without quote", - rule: `Host(tchouk)`, - expectedError: true, - }, { desc: "Rule case UPPER", - rule: `(HOST("tchouk") && PATHPREFIX("/titi"))`, + rule: `PATHPREFIX("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, }, }, { desc: "Rule case lower", - rule: `(host("tchouk") && pathprefix("/titi"))`, + rule: `pathprefix("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, }, }, { desc: "Rule case CamelCase", - rule: `(Host("tchouk") && PathPrefix("/titi"))`, + rule: `PathPrefix("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, }, }, { desc: "Rule case Title", - rule: `(Host("tchouk") && Pathprefix("/titi"))`, + rule: `Pathprefix("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, }, }, - { - desc: "Rule Path with error", - rule: `Path("titi")`, - expectedError: true, - }, - { - desc: "Rule PathPrefix with error", - rule: `PathPrefix("titi")`, - expectedError: true, - }, - { - desc: "Rule HostRegexp with error", - rule: `HostRegexp("{test")`, - expectedError: true, - }, - { - desc: "Rule Headers with error", - rule: `Headers("titi")`, - expectedError: true, - }, - { - desc: "Rule HeadersRegexp with error", - rule: `HeadersRegexp("titi")`, - expectedError: true, - }, - { - desc: "Rule Query", - rule: `Query("titi")`, - expectedError: true, - }, - { - desc: "Rule Query with bad syntax", - rule: `Query("titi={test")`, - expectedError: true, - }, - { - desc: "Rule with Path without args", - rule: `Host("tchouk") && Path()`, - expectedError: true, - }, - { - desc: "Rule with an empty path", - rule: `Host("tchouk") && Path("")`, - expectedError: true, - }, - { - desc: "Rule with an empty path", - rule: `Host("tchouk") && Path("", "/titi")`, - expectedError: true, - }, { desc: "Rule with not", - rule: `!Host("tchouk")`, + rule: `!Host("example.com")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://test/powpow": http.StatusOK, - }, - }, - { - desc: "Rule with not on Path", - rule: `!Path("/titi")`, - expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://tchouk/powpow": http.StatusOK, + "https://example.org": http.StatusOK, + "https://example.com": http.StatusNotFound, }, }, { desc: "Rule with not on multiple route with or", - rule: `!(Host("tchouk") || Host("toto"))`, + rule: `!(Host("example.com") || Host("example.org"))`, expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://toto/powpow": http.StatusNotFound, - "http://test/powpow": http.StatusOK, + "https://example.eu/js": http.StatusOK, + "https://example.com/css": http.StatusNotFound, + "https://example.org/js": http.StatusNotFound, }, }, { desc: "Rule with not on multiple route with and", - rule: `!(Host("tchouk") && Path("/titi"))`, + rule: `!(Host("example.com") && Path("/css"))`, expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://tchouk/toto": http.StatusOK, - "http://test/titi": http.StatusOK, + "https://example.com/js": http.StatusOK, + "https://example.eu/css": http.StatusOK, + "https://example.com/css": http.StatusNotFound, }, }, { desc: "Rule with not on multiple route with and another not", - rule: `!(Host("tchouk") && !Path("/titi"))`, + rule: `!(Host("example.com") && !Path("/css"))`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://toto/titi": http.StatusOK, - "http://tchouk/toto": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, }, }, { desc: "Rule with not on two rule", - rule: `!Host("tchouk") || !Path("/titi")`, + rule: `!Host("example.com") || !Path("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://tchouk/toto": http.StatusOK, - "http://test/titi": http.StatusOK, + "https://example.com/js": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusNotFound, }, }, { desc: "Rule case with double not", - rule: `!(!(Host("tchouk") && Pathprefix("/titi")))`, + rule: `!(!(Host("example.com") && Pathprefix("/css")))`, expected: map[string]int{ - "http://tchouk/titi": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, - "http://test/titi": http.StatusNotFound, + "https://example.com/css": http.StatusOK, + "https://example.com/js": http.StatusNotFound, + "https://example.org/css": http.StatusNotFound, }, }, { desc: "Rule case with not domain", - rule: `!Host("tchouk") && Pathprefix("/titi")`, + rule: `!Host("example.com") && Pathprefix("/css")`, expected: map[string]int{ - "http://tchouk/titi": http.StatusNotFound, - "http://tchouk/powpow": http.StatusNotFound, - "http://toto/powpow": http.StatusNotFound, - "http://toto/titi": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.org/js": http.StatusNotFound, + "https://example.com/css": http.StatusNotFound, + "https://example.com/js": http.StatusNotFound, }, }, { desc: "Rule with multiple host AND multiple path AND not", - rule: `!(Host("tchouk","pouet") && Path("/powpow", "/titi"))`, + rule: `!(Host("example.com") && Path("/js"))`, expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - "http://tchouk/powpow": http.StatusNotFound, - "http://pouet/powpow": http.StatusNotFound, - "http://tchouk/titi": http.StatusNotFound, - "http://pouet/titi": http.StatusNotFound, - "http://pouet/toto": http.StatusOK, - "http://plopi/a": http.StatusOK, - }, - }, - { - desc: "ClientIP empty", - rule: "ClientIP(``)", - expectedError: true, - }, - { - desc: "Invalid ClientIP", - rule: "ClientIP(`invalid`)", - expectedError: true, - }, - { - desc: "Non matching ClientIP", - rule: "ClientIP(`10.10.1.1`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - }, - }, - { - desc: "Non matching IPv6", - rule: "ClientIP(`10::10`)", - remoteAddr: "::1", - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - }, - }, - { - desc: "Matching IP", - rule: "ClientIP(`10.0.0.0`)", - remoteAddr: "10.0.0.0:8456", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IPv6", - rule: "ClientIP(`10::10`)", - remoteAddr: "10::10", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IP among several IP", - rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Non Matching IP with CIDR", - rule: "ClientIP(`11.0.0.0/24`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - }, - }, - { - desc: "Non Matching IPv6 with CIDR", - rule: "ClientIP(`11::/16`)", - remoteAddr: "10::", - expected: map[string]int{ - "http://tchouk/toto": http.StatusNotFound, - }, - }, - { - desc: "Matching IP with CIDR", - rule: "ClientIP(`10.0.0.0/16`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IPv6 with CIDR", - rule: "ClientIP(`10::/16`)", - remoteAddr: "10::10", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IP among several CIDR", - rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IP among non matching CIDR and matching IP", - rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, - }, - }, - { - desc: "Matching IP among matching CIDR and non matching IP", - rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", - remoteAddr: "10.0.0.0", - expected: map[string]int{ - "http://tchouk/toto": http.StatusOK, + "https://example.com/js": http.StatusNotFound, + "https://example.com/html": http.StatusOK, + "https://example.org/js": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.org/css": http.StatusOK, + "https://example.org/html": http.StatusOK, + "https://example.eu/images": http.StatusOK, }, }, } @@ -644,36 +213,37 @@ func Test_addRoute(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) muxer, err := NewMuxer() require.NoError(t, err) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) err = muxer.AddRoute(test.rule, 0, handler) if test.expectedError { require.Error(t, err) - } else { - require.NoError(t, err) - - // RequestDecorator is necessary for the host rule - reqHost := requestdecorator.New(nil) - - results := make(map[string]int) - for calledURL := range test.expected { - w := httptest.NewRecorder() - - req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil) - - // Useful for the ClientIP matcher - req.RemoteAddr = test.remoteAddr - - for key, value := range test.headers { - req.Header.Set(key, value) - } - reqHost.ServeHTTP(w, req, muxer.ServeHTTP) - results[calledURL] = w.Code - } - assert.Equal(t, test.expected, results) + return } + require.NoError(t, err) + + // RequestDecorator is necessary for the host rule + reqHost := requestdecorator.New(nil) + + results := make(map[string]int) + for calledURL := range test.expected { + req := testhelpers.MustNewRequest(http.MethodGet, calledURL, http.NoBody) + + // Useful for the ClientIP matcher + req.RemoteAddr = test.remoteAddr + + for key, value := range test.headers { + req.Header.Set(key, value) + } + + w := httptest.NewRecorder() + reqHost.ServeHTTP(w, req, muxer.ServeHTTP) + results[calledURL] = w.Code + } + + assert.Equal(t, test.expected, results) }) } } @@ -813,7 +383,7 @@ func Test_addRoutePriority(t *testing.T) { muxer.SortRoutes() w := httptest.NewRecorder() - req := testhelpers.MustNewRequest(http.MethodGet, test.path, nil) + req := testhelpers.MustNewRequest(http.MethodGet, test.path, http.NoBody) muxer.ServeHTTP(w, req) @@ -822,86 +392,6 @@ func Test_addRoutePriority(t *testing.T) { } } -func TestHostRegexp(t *testing.T) { - testCases := []struct { - desc string - hostExp string - urls map[string]bool - }{ - { - desc: "capturing group", - hostExp: "{subdomain:(foo\\.)?bar\\.com}", - urls: map[string]bool{ - "http://foo.bar.com": true, - "http://bar.com": true, - "http://fooubar.com": false, - "http://barucom": false, - "http://barcom": false, - }, - }, - { - desc: "non capturing group", - hostExp: "{subdomain:(?:foo\\.)?bar\\.com}", - urls: map[string]bool{ - "http://foo.bar.com": true, - "http://bar.com": true, - "http://fooubar.com": false, - "http://barucom": false, - "http://barcom": false, - }, - }, - { - desc: "regex insensitive", - hostExp: "{dummy:[A-Za-z-]+\\.bar\\.com}", - urls: map[string]bool{ - "http://FOO.bar.com": true, - "http://foo.bar.com": true, - "http://fooubar.com": false, - "http://barucom": false, - "http://barcom": false, - }, - }, - { - desc: "insensitive host", - hostExp: "{dummy:[a-z-]+\\.bar\\.com}", - urls: map[string]bool{ - "http://FOO.bar.com": true, - "http://foo.bar.com": true, - "http://fooubar.com": false, - "http://barucom": false, - "http://barcom": false, - }, - }, - { - desc: "insensitive host simple", - hostExp: "foo.bar.com", - urls: map[string]bool{ - "http://FOO.bar.com": true, - "http://foo.bar.com": true, - "http://fooubar.com": false, - "http://barucom": false, - "http://barcom": false, - }, - }, - } - - for _, test := range testCases { - test := test - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - rt := &mux.Route{} - err := hostRegexp(rt, test.hostExp) - require.NoError(t, err) - - for testURL, match := range test.urls { - req := testhelpers.MustNewRequest(http.MethodGet, testURL, nil) - assert.Equal(t, match, rt.Match(req, &mux.RouteMatch{}), testURL) - } - }) - } -} - func TestParseDomains(t *testing.T) { testCases := []struct { description string @@ -914,21 +404,6 @@ func TestParseDomains(t *testing.T) { expression: "Foobar(`foo.bar`,`test.bar`)", errorExpected: true, }, - { - description: "Several host rules", - expression: "Host(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, - }, - { - description: "Several host rules upper", - expression: "HOST(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, - }, - { - description: "Several host rules lower", - expression: "host(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, - }, { description: "No host rule", expression: "Path(`/test`)", @@ -938,6 +413,11 @@ func TestParseDomains(t *testing.T) { expression: "Host(`foo.bar`) && Path(`/test`)", domain: []string{"foo.bar"}, }, + { + description: "Host rule to trim and another rule", + expression: "Host(`Foo.Bar`) || Host(`bar.buz`) && Path(`/test`)", + domain: []string{"foo.bar", "bar.buz"}, + }, { description: "Host rule to trim and another rule", expression: "Host(`Foo.Bar`) && Path(`/test`)", @@ -967,7 +447,9 @@ func TestParseDomains(t *testing.T) { } } -func TestAbsoluteFormURL(t *testing.T) { +// TestEmptyHost is a non regression test for +// https://github.com/traefik/traefik/pull/9131 +func TestEmptyHost(t *testing.T) { testCases := []struct { desc string request string @@ -975,41 +457,41 @@ func TestAbsoluteFormURL(t *testing.T) { expected int }{ { - desc: "!HostRegexp with absolute-form URL with empty host with non-matching host header", - request: "GET http://@/ HTTP/1.1\r\nHost: test.localhost\r\n\r\n", - rule: "!HostRegexp(`test.localhost`)", - expected: http.StatusNotFound, - }, - { - desc: "!Host with absolute-form URL with empty host with non-matching host header", - request: "GET http://@/ HTTP/1.1\r\nHost: test.localhost\r\n\r\n", - rule: "!Host(`test.localhost`)", - expected: http.StatusNotFound, - }, - { - desc: "!HostRegexp with absolute-form URL with matching host header", - request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n", - rule: "!HostRegexp(`test.localhost`)", - expected: http.StatusNotFound, - }, - { - desc: "!Host with absolute-form URL with matching host header", - request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n", - rule: "!Host(`test.localhost`)", - expected: http.StatusNotFound, - }, - { - desc: "!HostRegexp with absolute-form URL with non-matching host header", - request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n", - rule: "!HostRegexp(`toto.localhost`)", + desc: "HostRegexp with absolute-form URL with empty host with non-matching host header", + request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n", + rule: "HostRegexp(`example.com`)", expected: http.StatusOK, }, { - desc: "!Host with absolute-form URL with non-matching host header", - request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n", - rule: "!Host(`toto.localhost`)", + desc: "Host with absolute-form URL with empty host with non-matching host header", + request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n", + rule: "Host(`example.com`)", expected: http.StatusOK, }, + { + desc: "HostRegexp with absolute-form URL with matching host header", + request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n", + rule: "HostRegexp(`example.com`)", + expected: http.StatusOK, + }, + { + desc: "Host with absolute-form URL with matching host header", + request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n", + rule: "Host(`example.com`)", + expected: http.StatusOK, + }, + { + desc: "HostRegexp with absolute-form URL with non-matching host header", + request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n", + rule: "HostRegexp(`example.org`)", + expected: http.StatusNotFound, + }, + { + desc: "Host with absolute-form URL with non-matching host header", + request: "GET http://example.com/ HTTP/1.1\r\nHost: example.org\r\n\r\n", + rule: "Host(`example.org`)", + expected: http.StatusNotFound, + }, } for _, test := range testCases { diff --git a/pkg/muxer/tcp/matcher.go b/pkg/muxer/tcp/matcher.go new file mode 100644 index 000000000..40694c3ea --- /dev/null +++ b/pkg/muxer/tcp/matcher.go @@ -0,0 +1,134 @@ +package tcp + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "github.com/go-acme/lego/v4/challenge/tlsalpn01" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v2/pkg/ip" +) + +var tcpFuncs = map[string]func(*matchersTree, ...string) error{ + "ALPN": expect1Parameter(alpn), + "ClientIP": expect1Parameter(clientIP), + "HostSNI": expect1Parameter(hostSNI), + "HostSNIRegexp": expect1Parameter(hostSNIRegexp), +} + +func expect1Parameter(fn func(*matchersTree, ...string) error) func(*matchersTree, ...string) error { + return func(route *matchersTree, s ...string) error { + if len(s) != 1 { + return fmt.Errorf("unexpected number of parameters; got %d, expected 1", len(s)) + } + + return fn(route, s...) + } +} + +// alpn checks if any of the connection ALPN protocols matches one of the matcher protocols. +func alpn(tree *matchersTree, protos ...string) error { + proto := protos[0] + + if proto == tlsalpn01.ACMETLS1Protocol { + return fmt.Errorf("invalid protocol value for ALPN matcher, %q is not allowed", proto) + } + + tree.matcher = func(meta ConnData) bool { + for _, alpnProto := range meta.alpnProtos { + if alpnProto == proto { + return true + } + } + + return false + } + + return nil +} + +func clientIP(tree *matchersTree, clientIP ...string) error { + checker, err := ip.NewChecker(clientIP) + if err != nil { + return fmt.Errorf("initializing IP checker for ClientIP matcher: %w", err) + } + + tree.matcher = func(meta ConnData) bool { + ok, err := checker.Contains(meta.remoteIP) + if err != nil { + log.Warn().Err(err).Msg("ClientIP matcher: could not match remote address") + return false + } + return ok + } + + return nil +} + +var almostFQDN = regexp.MustCompile(`^[[:alnum:]\.-]+$`) + +// hostSNI checks if the SNI Host of the connection match the matcher host. +func hostSNI(tree *matchersTree, hosts ...string) error { + host := hosts[0] + + if host == "*" { + // Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP, + // it allows matching with an empty serverName. + tree.matcher = func(meta ConnData) bool { return true } + return nil + } + + if !almostFQDN.MatchString(host) { + return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", host) + } + + tree.matcher = func(meta ConnData) bool { + if meta.serverName == "" { + return false + } + + if host == meta.serverName { + return true + } + + // trim trailing period in case of FQDN + host = strings.TrimSuffix(host, ".") + + return host == meta.serverName + } + + return nil +} + +// hostSNIRegexp checks if the SNI Host of the connection matches the matcher host regexp. +func hostSNIRegexp(tree *matchersTree, templates ...string) error { + template := templates[0] + + if !isASCII(template) { + return fmt.Errorf("invalid value for HostSNIRegexp matcher, %q is not a valid hostname", template) + } + + re, err := regexp.Compile(template) + if err != nil { + return fmt.Errorf("compiling HostSNIRegexp matcher: %w", err) + } + + tree.matcher = func(meta ConnData) bool { + return re.MatchString(meta.serverName) + } + + return nil +} + +// isASCII checks if the given string contains only ASCII characters. +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + return false + } + } + + return true +} diff --git a/pkg/muxer/tcp/matcher_test.go b/pkg/muxer/tcp/matcher_test.go new file mode 100644 index 000000000..06b45e186 --- /dev/null +++ b/pkg/muxer/tcp/matcher_test.go @@ -0,0 +1,383 @@ +package tcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v2/pkg/tcp" +) + +func Test_HostSNICatchAll(t *testing.T) { + testCases := []struct { + desc string + rule string + isCatchAll bool + }{ + { + desc: "HostSNI(`example.com`) is not catchAll", + rule: "HostSNI(`example.com`)", + }, + { + desc: "HostSNI(`*`) is catchAll", + rule: "HostSNI(`*`)", + isCatchAll: true, + }, + { + desc: "HostSNIRegexp(`^.*$`) is not catchAll", + rule: "HostSNIRegexp(`.*`)", + isCatchAll: false, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + require.NoError(t, err) + + handler, catchAll := muxer.Match(ConnData{ + serverName: "example.com", + }) + require.NotNil(t, handler) + assert.Equal(t, test.isCatchAll, catchAll) + }) + } +} + +func Test_HostSNI(t *testing.T) { + testCases := []struct { + desc string + rule string + serverName string + buildErr bool + match bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Invalid HostSNI matcher (empty host)", + rule: "HostSNI(``)", + buildErr: true, + }, + { + desc: "Invalid HostSNI matcher (too many parameters)", + rule: "HostSNI(`example.com`, `example.org`)", + buildErr: true, + }, + { + desc: "Invalid HostSNI matcher (globing sub domain)", + rule: "HostSNI(`*.com`)", + buildErr: true, + }, + { + desc: "Invalid HostSNI matcher (non ASCII host)", + rule: "HostSNI(`🦭.com`)", + buildErr: true, + }, + { + desc: "Valid HostSNI matcher - puny-coded emoji", + rule: "HostSNI(`xn--9t9h.com`)", + serverName: "xn--9t9h.com", + match: true, + }, + { + desc: "Valid HostSNI matcher - puny-coded emoji but emoji in server name", + rule: "HostSNI(`xn--9t9h.com`)", + serverName: "🦭.com", + }, + { + desc: "Matching hosts", + rule: "HostSNI(`example.com`)", + serverName: "example.com", + match: true, + }, + { + desc: "No matching hosts", + rule: "HostSNI(`example.com`)", + serverName: "example.org", + }, + { + desc: "Matching globing host `*`", + rule: "HostSNI(`*`)", + serverName: "example.com", + match: true, + }, + { + desc: "Matching globing host `*` and empty server name", + rule: "HostSNI(`*`)", + serverName: "", + match: true, + }, + { + desc: "Matching host with trailing dot", + rule: "HostSNI(`example.com.`)", + serverName: "example.com.", + match: true, + }, + { + desc: "Matching host with trailing dot but not in server name", + rule: "HostSNI(`example.com.`)", + serverName: "example.com", + match: true, + }, + { + desc: "Matching hosts with subdomains", + rule: "HostSNI(`foo.example.com`)", + serverName: "foo.example.com", + match: true, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + serverName: test.serverName, + } + + handler, _ := muxer.Match(meta) + require.Equal(t, test.match, handler != nil) + }) + } +} + +func Test_HostSNIRegexp(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]bool + buildErr bool + match bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Invalid HostSNIRegexp matcher (empty host)", + rule: "HostSNIRegexp(``)", + buildErr: true, + }, + { + desc: "Invalid HostSNIRegexp matcher (non ASCII host)", + rule: "HostSNIRegexp(`🦭.com`)", + buildErr: true, + }, + { + desc: "Invalid HostSNIRegexp matcher (invalid regexp)", + rule: "HostSNIRegexp(`(example.com`)", + buildErr: true, + }, + { + desc: "Invalid HostSNIRegexp matcher (too many parameters)", + rule: "HostSNIRegexp(`example.com`, `example.org`)", + buildErr: true, + }, + { + desc: "valid HostSNIRegexp matcher", + rule: "HostSNIRegexp(`^example\\.(com|org)$`)", + expected: map[string]bool{ + "example.com": true, + "example.com.": false, + "EXAMPLE.com": false, + "example.org": true, + "exampleuorg": false, + "": false, + }, + }, + { + desc: "valid HostSNIRegexp matcher with Traefik v2 syntax", + rule: "HostSNIRegexp(`example.{tld:(com|org)}`)", + expected: map[string]bool{ + "example.com": false, + "example.com.": false, + "EXAMPLE.com": false, + "example.org": false, + "exampleuorg": false, + "": false, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for serverName, match := range test.expected { + meta := ConnData{ + serverName: serverName, + } + + handler, _ := muxer.Match(meta) + assert.Equal(t, match, handler != nil, serverName) + } + }) + } +} + +func Test_ClientIP(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]bool + buildErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Invalid ClientIP matcher (empty host)", + rule: "ClientIP(``)", + buildErr: true, + }, + { + desc: "Invalid ClientIP matcher (non ASCII host)", + rule: "ClientIP(`🦭/32`)", + buildErr: true, + }, + { + desc: "Invalid ClientIP matcher (too many parameters)", + rule: "ClientIP(`127.0.0.1`, `127.0.0.2`)", + buildErr: true, + }, + { + desc: "valid ClientIP matcher", + rule: "ClientIP(`20.20.20.20`)", + expected: map[string]bool{ + "20.20.20.20": true, + "10.10.10.10": false, + }, + }, + { + desc: "valid ClientIP matcher with CIDR", + rule: "ClientIP(`20.20.20.20/24`)", + expected: map[string]bool{ + "20.20.20.20": true, + "20.20.20.40": true, + "10.10.10.10": false, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for remoteIP, match := range test.expected { + meta := ConnData{ + remoteIP: remoteIP, + } + + handler, _ := muxer.Match(meta) + assert.Equal(t, match, handler != nil, remoteIP) + } + }) + } +} + +func Test_ALPN(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]bool + buildErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Invalid ALPN matcher (TLS proto)", + rule: "ALPN(`acme-tls/1`)", + buildErr: true, + }, + { + desc: "Invalid ALPN matcher (empty parameters)", + rule: "ALPN(``)", + buildErr: true, + }, + { + desc: "Invalid ALPN matcher (too many parameters)", + rule: "ALPN(`h2`, `mqtt`)", + buildErr: true, + }, + { + desc: "Valid ALPN matcher", + rule: "ALPN(`h2`)", + expected: map[string]bool{ + "h2": true, + "mqtt": false, + "": false, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for proto, match := range test.expected { + meta := ConnData{ + alpnProtos: []string{proto}, + } + + handler, _ := muxer.Match(meta) + assert.Equal(t, match, handler != nil, proto) + } + }) + } +} diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index 6d7421119..3c5abffaf 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -1,31 +1,18 @@ package tcp import ( - "bytes" - "errors" "fmt" "net" - "regexp" "sort" - "strconv" "strings" - "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/rs/zerolog/log" - "github.com/traefik/traefik/v2/pkg/ip" "github.com/traefik/traefik/v2/pkg/rules" "github.com/traefik/traefik/v2/pkg/tcp" "github.com/traefik/traefik/v2/pkg/types" "github.com/vulcand/predicate" ) -var tcpFuncs = map[string]func(*matchersTree, ...string) error{ - "HostSNI": hostSNI, - "HostSNIRegexp": hostSNIRegexp, - "ClientIP": clientIP, - "ALPN": alpn, -} - // ParseHostSNI extracts the HostSNIs declared in a rule. // This is a first naive implementation used in TCP routing. func ParseHostSNI(rule string) ([]string, error) { @@ -261,233 +248,7 @@ func (m *matchersTree) match(meta ConnData) bool { return m.left.match(meta) && m.right.match(meta) default: // This should never happen as it should have been detected during parsing. - log.Warn().Msgf("Invalid rule operator %s", m.operator) + log.Warn().Str("operator", m.operator).Msg("Invalid rule operator") return false } } - -func clientIP(tree *matchersTree, clientIPs ...string) error { - checker, err := ip.NewChecker(clientIPs) - if err != nil { - return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err) - } - - tree.matcher = func(meta ConnData) bool { - if meta.remoteIP == "" { - return false - } - - ok, err := checker.Contains(meta.remoteIP) - if err != nil { - log.Warn().Err(err).Msg("\"ClientIP\" matcher: could not match remote address") - return false - } - return ok - } - - return nil -} - -// alpn checks if any of the connection ALPN protocols matches one of the matcher protocols. -func alpn(tree *matchersTree, protos ...string) error { - if len(protos) == 0 { - return errors.New("empty value for \"ALPN\" matcher is not allowed") - } - - for _, proto := range protos { - if proto == tlsalpn01.ACMETLS1Protocol { - return fmt.Errorf("invalid protocol value for \"ALPN\" matcher, %q is not allowed", proto) - } - } - - tree.matcher = func(meta ConnData) bool { - for _, proto := range meta.alpnProtos { - for _, filter := range protos { - if proto == filter { - return true - } - } - } - - return false - } - - return nil -} - -var almostFQDN = regexp.MustCompile(`^[[:alnum:]\.-]+$`) - -// hostSNI checks if the SNI Host of the connection match the matcher host. -func hostSNI(tree *matchersTree, hosts ...string) error { - if len(hosts) == 0 { - return errors.New("empty value for \"HostSNI\" matcher is not allowed") - } - - for i, host := range hosts { - // Special case to allow global wildcard - if host == "*" { - continue - } - - if !almostFQDN.MatchString(host) { - return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname", host) - } - - hosts[i] = strings.ToLower(host) - } - - tree.matcher = func(meta ConnData) bool { - // Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP, - // it allows matching with an empty serverName. - // Which is why we make sure to take that case into account before - // checking meta.serverName. - if hosts[0] == "*" { - return true - } - - if meta.serverName == "" { - return false - } - - for _, host := range hosts { - if host == "*" { - return true - } - - if host == meta.serverName { - return true - } - - // trim trailing period in case of FQDN - host = strings.TrimSuffix(host, ".") - if host == meta.serverName { - return true - } - } - - return false - } - - return nil -} - -// hostSNIRegexp checks if the SNI Host of the connection matches the matcher host regexp. -func hostSNIRegexp(tree *matchersTree, templates ...string) error { - if len(templates) == 0 { - return fmt.Errorf("empty value for \"HostSNIRegexp\" matcher is not allowed") - } - - var regexps []*regexp.Regexp - - for _, template := range templates { - preparedPattern, err := preparePattern(template) - if err != nil { - return fmt.Errorf("invalid pattern value for \"HostSNIRegexp\" matcher, %q is not a valid pattern: %w", template, err) - } - - regexp, err := regexp.Compile(preparedPattern) - if err != nil { - return err - } - - regexps = append(regexps, regexp) - } - - tree.matcher = func(meta ConnData) bool { - for _, regexp := range regexps { - if regexp.MatchString(meta.serverName) { - return true - } - } - - return false - } - - return nil -} - -// TODO: expose more of containous/mux fork to get rid of the following copied code (https://github.com/containous/mux/blob/8ffa4f6d063c/regexp.go). - -// preparePattern builds a regexp pattern from the initial user defined expression. -// This function reuses the code dedicated to host matching of the newRouteRegexp func from the gorilla/mux library. -// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. -func preparePattern(template string) (string, error) { - // Check if it is well-formed. - idxs, errBraces := braceIndices(template) - if errBraces != nil { - return "", errBraces - } - - defaultPattern := "[^.]+" - pattern := bytes.NewBufferString("") - - // Host SNI matching is case-insensitive - fmt.Fprint(pattern, "(?i)") - - pattern.WriteByte('^') - var end int - var err error - for i := 0; i < len(idxs); i += 2 { - // Set all values we are interested in. - raw := template[end:idxs[i]] - end = idxs[i+1] - parts := strings.SplitN(template[idxs[i]+1:end-1], ":", 2) - name := parts[0] - patt := defaultPattern - if len(parts) == 2 { - patt = parts[1] - } - // Name or pattern can't be empty. - if name == "" || patt == "" { - return "", fmt.Errorf("mux: missing name or pattern in %q", - template[idxs[i]:end]) - } - // Build the regexp pattern. - fmt.Fprintf(pattern, "%s(?P<%s>%s)", regexp.QuoteMeta(raw), varGroupName(i/2), patt) - - // Append variable name and compiled pattern. - if err != nil { - return "", err - } - } - - // Add the remaining. - raw := template[end:] - pattern.WriteString(regexp.QuoteMeta(raw)) - pattern.WriteByte('$') - - return pattern.String(), nil -} - -// varGroupName builds a capturing group name for the indexed variable. -// This function is a copy of varGroupName func from the gorilla/mux library. -// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. -func varGroupName(idx int) string { - return "v" + strconv.Itoa(idx) -} - -// braceIndices returns the first level curly brace indices from a string. -// This function is a copy of braceIndices func from the gorilla/mux library. -// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. -func braceIndices(s string) ([]int, error) { - var level, idx int - var idxs []int - for i := 0; i < len(s); i++ { - switch s[i] { - case '{': - if level++; level == 1 { - idx = i - } - case '}': - if level--; level == 0 { - idxs = append(idxs, idx, i+1) - } else if level < 0 { - return nil, fmt.Errorf("mux: unbalanced braces in %q", s) - } - } - } - if level != 0 { - return nil, fmt.Errorf("mux: unbalanced braces in %q", s) - } - return idxs, nil -} diff --git a/pkg/muxer/tcp/mux_test.go b/pkg/muxer/tcp/mux_test.go index 50b8938cf..95e84a245 100644 --- a/pkg/muxer/tcp/mux_test.go +++ b/pkg/muxer/tcp/mux_test.go @@ -1,59 +1,15 @@ package tcp import ( - "fmt" "net" "testing" "time" - "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/traefik/traefik/v2/pkg/tcp" ) -type fakeConn struct { - call map[string]int - remoteAddr net.Addr -} - -func (f *fakeConn) Read(b []byte) (n int, err error) { - panic("implement me") -} - -func (f *fakeConn) Write(b []byte) (n int, err error) { - f.call[string(b)]++ - return len(b), nil -} - -func (f *fakeConn) Close() error { - panic("implement me") -} - -func (f *fakeConn) LocalAddr() net.Addr { - panic("implement me") -} - -func (f *fakeConn) RemoteAddr() net.Addr { - return f.remoteAddr -} - -func (f *fakeConn) SetDeadline(t time.Time) error { - panic("implement me") -} - -func (f *fakeConn) SetReadDeadline(t time.Time) error { - panic("implement me") -} - -func (f *fakeConn) SetWriteDeadline(t time.Time) error { - panic("implement me") -} - -func (f *fakeConn) CloseWrite() error { - panic("implement me") -} - func Test_addTCPRoute(t *testing.T) { testCases := []struct { desc string @@ -73,430 +29,225 @@ func Test_addTCPRoute(t *testing.T) { rule: "rulewithnotmatcher", routeErr: true, }, - { - desc: "Empty HostSNI rule", - rule: "HostSNI()", - serverName: "foobar", - routeErr: true, - }, { desc: "Empty HostSNI rule", rule: "HostSNI(``)", - serverName: "foobar", + serverName: "example.org", routeErr: true, }, { desc: "Valid HostSNI rule matching", - rule: "HostSNI(`foobar`)", - serverName: "foobar", + rule: "HostSNI(`example.org`)", + serverName: "example.org", }, { desc: "Valid negative HostSNI rule matching", - rule: "!HostSNI(`bar`)", - serverName: "foobar", + rule: "!HostSNI(`example.com`)", + serverName: "example.org", }, { desc: "Valid HostSNI rule matching with alternative case", - rule: "hostsni(`foobar`)", - serverName: "foobar", + rule: "hostsni(`example.org`)", + serverName: "example.org", }, { desc: "Valid HostSNI rule matching with alternative case", - rule: "HOSTSNI(`foobar`)", - serverName: "foobar", + rule: "HOSTSNI(`example.org`)", + serverName: "example.org", }, { desc: "Valid HostSNI rule not matching", - rule: "HostSNI(`foobar`)", - serverName: "bar", - matchErr: true, - }, - { - desc: "Empty HostSNIRegexp rule", - rule: "HostSNIRegexp()", - serverName: "foobar", - routeErr: true, - }, - { - desc: "Empty HostSNIRegexp rule", - rule: "HostSNIRegexp(``)", - serverName: "foobar", - routeErr: true, - }, - { - desc: "Valid HostSNIRegexp rule matching", - rule: "HostSNIRegexp(`{subdomain:[a-z]+}.foobar`)", - serverName: "sub.foobar", - }, - { - desc: "Valid negative HostSNIRegexp rule matching", - rule: "!HostSNIRegexp(`bar`)", - serverName: "foobar", - }, - { - desc: "Valid HostSNIRegexp rule matching with alternative case", - rule: "hostsniregexp(`foobar`)", - serverName: "foobar", - }, - { - desc: "Valid HostSNIRegexp rule matching with alternative case", - rule: "HOSTSNIREGEXP(`foobar`)", - serverName: "foobar", - }, - { - desc: "Valid HostSNIRegexp rule not matching", - rule: "HostSNIRegexp(`foobar`)", - serverName: "bar", + rule: "HostSNI(`example.org`)", + serverName: "example.com", matchErr: true, }, { desc: "Valid negative HostSNI rule not matching", - rule: "!HostSNI(`bar`)", - serverName: "bar", + rule: "!HostSNI(`example.com`)", + serverName: "example.com", matchErr: true, }, - { - desc: "Valid HostSNIRegexp rule matching empty servername", - rule: "HostSNIRegexp(`{subdomain:[a-z]*}`)", - serverName: "", - }, - { - desc: "Valid HostSNIRegexp rule with one name", - rule: "HostSNIRegexp(`{dummy}`)", - serverName: "toto", - }, - { - desc: "Valid HostSNIRegexp rule with one name 2", - rule: "HostSNIRegexp(`{dummy}`)", - serverName: "toto.com", - matchErr: true, - }, - { - desc: "Empty ClientIP rule", - rule: "ClientIP()", - routeErr: true, - }, - { - desc: "Empty ClientIP rule", - rule: "ClientIP(``)", - routeErr: true, - }, - { - desc: "Invalid ClientIP", - rule: "ClientIP(`invalid`)", - routeErr: true, - }, - { - desc: "Invalid remoteAddr", - rule: "ClientIP(`10.0.0.1`)", - remoteAddr: "not.an.IP:80", - matchErr: true, - }, - { - desc: "Valid ClientIP rule matching", - rule: "ClientIP(`10.0.0.1`)", - remoteAddr: "10.0.0.1:80", - }, - { - desc: "Valid negative ClientIP rule matching", - rule: "!ClientIP(`20.0.0.1`)", - remoteAddr: "10.0.0.1:80", - }, - { - desc: "Valid ClientIP rule matching with alternative case", - rule: "clientip(`10.0.0.1`)", - remoteAddr: "10.0.0.1:80", - }, - { - desc: "Valid ClientIP rule matching with alternative case", - rule: "CLIENTIP(`10.0.0.1`)", - remoteAddr: "10.0.0.1:80", - }, - { - desc: "Valid ClientIP rule not matching", - rule: "ClientIP(`10.0.0.1`)", - remoteAddr: "10.0.0.2:80", - matchErr: true, - }, - { - desc: "Valid negative ClientIP rule not matching", - rule: "!ClientIP(`10.0.0.2`)", - remoteAddr: "10.0.0.2:80", - matchErr: true, - }, - { - desc: "Valid ClientIP rule matching IPv6", - rule: "ClientIP(`10::10`)", - remoteAddr: "[10::10]:80", - }, - { - desc: "Valid negative ClientIP rule matching IPv6", - rule: "!ClientIP(`10::10`)", - remoteAddr: "[::1]:80", - }, - { - desc: "Valid ClientIP rule not matching IPv6", - rule: "ClientIP(`10::10`)", - remoteAddr: "[::1]:80", - matchErr: true, - }, - { - desc: "Valid ClientIP rule matching multiple IPs", - rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", - remoteAddr: "10.0.0.0:80", - }, - { - desc: "Valid ClientIP rule matching CIDR", - rule: "ClientIP(`11.0.0.0/24`)", - remoteAddr: "11.0.0.0:80", - }, - { - desc: "Valid ClientIP rule not matching CIDR", - rule: "ClientIP(`11.0.0.0/24`)", - remoteAddr: "10.0.0.0:80", - matchErr: true, - }, - { - desc: "Valid ClientIP rule matching CIDR IPv6", - rule: "ClientIP(`11::/16`)", - remoteAddr: "[11::]:80", - }, - { - desc: "Valid ClientIP rule not matching CIDR IPv6", - rule: "ClientIP(`11::/16`)", - remoteAddr: "[10::]:80", - matchErr: true, - }, - { - desc: "Valid ClientIP rule matching multiple CIDR", - rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", - remoteAddr: "10.0.0.0:80", - }, - { - desc: "Valid ClientIP rule not matching CIDR and matching IP", - rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", - remoteAddr: "10.0.0.0:80", - }, - { - desc: "Valid ClientIP rule matching CIDR and not matching IP", - rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", - remoteAddr: "10.0.0.0:80", - }, { desc: "Valid HostSNI and ClientIP rule matching", - rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) && ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid negative HostSNI and ClientIP rule matching", - rule: "!HostSNI(`bar`) && ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "!HostSNI(`example.com`) && ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI and negative ClientIP rule matching", - rule: "HostSNI(`foobar`) && !ClientIP(`10.0.0.2`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) && !ClientIP(`10.0.0.2`)", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid negative HostSNI and negative ClientIP rule matching", - rule: "!HostSNI(`bar`) && !ClientIP(`10.0.0.2`)", - serverName: "foobar", + rule: "!HostSNI(`example.com`) && !ClientIP(`10.0.0.2`)", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid negative HostSNI or negative ClientIP rule matching", - rule: "!(HostSNI(`bar`) || ClientIP(`10.0.0.2`))", - serverName: "foobar", + rule: "!(HostSNI(`example.com`) || ClientIP(`10.0.0.2`))", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid negative HostSNI and negative ClientIP rule matching", - rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", - serverName: "foobar", + rule: "!(HostSNI(`example.com`) && ClientIP(`10.0.0.2`))", + serverName: "example.org", remoteAddr: "10.0.0.2:80", }, { desc: "Valid negative HostSNI and negative ClientIP rule matching", - rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", - serverName: "bar", + rule: "!(HostSNI(`example.com`) && ClientIP(`10.0.0.2`))", + serverName: "example.com", remoteAddr: "10.0.0.1:80", }, { desc: "Valid negative HostSNI and negative ClientIP rule matching", - rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", - serverName: "bar", + rule: "!(HostSNI(`example.com`) && ClientIP(`10.0.0.2`))", + serverName: "example.com", remoteAddr: "10.0.0.2:80", matchErr: true, }, { desc: "Valid negative HostSNI and negative ClientIP rule matching", - rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", - serverName: "foobar", + rule: "!(HostSNI(`example.com`) && ClientIP(`10.0.0.2`))", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI and ClientIP rule not matching", - rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", - serverName: "bar", + rule: "HostSNI(`example.org`) && ClientIP(`10.0.0.1`)", + serverName: "example.com", remoteAddr: "10.0.0.1:80", matchErr: true, }, { desc: "Valid HostSNI and ClientIP rule not matching", - rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) && ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.2:80", matchErr: true, }, { desc: "Valid HostSNI or ClientIP rule matching", - rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) || ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI or ClientIP rule matching", - rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", - serverName: "bar", + rule: "HostSNI(`example.org`) || ClientIP(`10.0.0.1`)", + serverName: "example.com", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI or ClientIP rule matching", - rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) || ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.2:80", }, { desc: "Valid HostSNI or ClientIP rule not matching", - rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", - serverName: "bar", + rule: "HostSNI(`example.org`) || ClientIP(`10.0.0.1`)", + serverName: "example.com", remoteAddr: "10.0.0.2:80", matchErr: true, }, { desc: "Valid HostSNI x 3 OR rule matching", - rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) || HostSNI(`example.eu`) || HostSNI(`example.com`)", + serverName: "example.org", }, { desc: "Valid HostSNI x 3 OR rule not matching", - rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", + rule: "HostSNI(`example.org`) || HostSNI(`example.eu`) || HostSNI(`example.com`)", serverName: "baz", matchErr: true, }, { desc: "Valid HostSNI and ClientIP Combined rule matching", - rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", - serverName: "foobar", + rule: "HostSNI(`example.org`) || HostSNI(`example.com`) && ClientIP(`10.0.0.1`)", + serverName: "example.org", remoteAddr: "10.0.0.2:80", }, { desc: "Valid HostSNI and ClientIP Combined rule matching", - rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", - serverName: "bar", + rule: "HostSNI(`example.org`) || HostSNI(`example.com`) && ClientIP(`10.0.0.1`)", + serverName: "example.com", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI and ClientIP Combined rule not matching", - rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", - serverName: "bar", + rule: "HostSNI(`example.org`) || HostSNI(`example.com`) && ClientIP(`10.0.0.1`)", + serverName: "example.com", remoteAddr: "10.0.0.2:80", matchErr: true, }, { desc: "Valid HostSNI and ClientIP Combined rule not matching", - rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + rule: "HostSNI(`example.org`) || HostSNI(`example.com`) && ClientIP(`10.0.0.1`)", serverName: "baz", remoteAddr: "10.0.0.1:80", matchErr: true, }, { desc: "Valid HostSNI and ClientIP complex combined rule matching", - rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", - serverName: "bar", + rule: "(HostSNI(`example.org`) || HostSNI(`example.com`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "example.com", remoteAddr: "10.0.0.1:80", }, { desc: "Valid HostSNI and ClientIP complex combined rule not matching", - rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + rule: "(HostSNI(`example.org`) || HostSNI(`example.com`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", serverName: "baz", remoteAddr: "10.0.0.1:80", matchErr: true, }, { desc: "Valid HostSNI and ClientIP complex combined rule not matching", - rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", - serverName: "bar", + rule: "(HostSNI(`example.org`) || HostSNI(`example.com`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "example.com", remoteAddr: "10.0.0.3:80", matchErr: true, }, { desc: "Valid HostSNI and ClientIP more complex (but absurd) combined rule matching", - rule: "(HostSNI(`foobar`) || (HostSNI(`bar`) && !HostSNI(`foobar`))) && ((ClientIP(`10.0.0.1`) && !ClientIP(`10.0.0.2`)) || ClientIP(`10.0.0.2`)) ", - serverName: "bar", + rule: "(HostSNI(`example.org`) || (HostSNI(`example.com`) && !HostSNI(`example.org`))) && ((ClientIP(`10.0.0.1`) && !ClientIP(`10.0.0.2`)) || ClientIP(`10.0.0.2`)) ", + serverName: "example.com", remoteAddr: "10.0.0.1:80", }, - { - desc: "Invalid ALPN rule matching ACME-TLS/1", - rule: fmt.Sprintf("ALPN(`%s`)", tlsalpn01.ACMETLS1Protocol), - protos: []string{"foo"}, - routeErr: true, - }, - { - desc: "Valid ALPN rule matching single protocol", - rule: "ALPN(`foo`)", - protos: []string{"foo"}, - }, - { - desc: "Valid ALPN rule matching ACME-TLS/1 protocol", - rule: "ALPN(`foo`)", - protos: []string{tlsalpn01.ACMETLS1Protocol}, - matchErr: true, - }, - { - desc: "Valid ALPN rule not matching single protocol", - rule: "ALPN(`foo`)", - protos: []string{"bar"}, - matchErr: true, - }, - { - desc: "Valid alternative case ALPN rule matching single protocol without another being supported", - rule: "ALPN(`foo`) && !alpn(`h2`)", - protos: []string{"foo", "bar"}, - }, - { - desc: "Valid alternative case ALPN rule not matching single protocol because of another being supported", - rule: "ALPN(`foo`) && !alpn(`h2`)", - protos: []string{"foo", "h2", "bar"}, - matchErr: true, - }, { desc: "Valid complex alternative case ALPN and HostSNI rule", - rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", - protos: []string{"foo", "bar"}, - serverName: "foo", + rule: "ALPN(`h2c`) && (!ALPN(`h2`) || HostSNI(`example.eu`))", + protos: []string{"h2c", "mqtt"}, + serverName: "example.eu", }, { desc: "Valid complex alternative case ALPN and HostSNI rule not matching by SNI", - rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", - protos: []string{"foo", "bar", "h2"}, - serverName: "bar", + rule: "ALPN(`h2c`) && (!ALPN(`h2`) || HostSNI(`example.eu`))", + protos: []string{"h2c", "http/1.1", "h2"}, + serverName: "example.com", matchErr: true, }, { desc: "Valid complex alternative case ALPN and HostSNI rule matching by ALPN", - rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", - protos: []string{"foo", "bar"}, - serverName: "bar", + rule: "ALPN(`h2c`) && (!ALPN(`h2`) || HostSNI(`example.eu`))", + protos: []string{"h2c", "http/1.1"}, + serverName: "example.com", }, { desc: "Valid complex alternative case ALPN and HostSNI rule not matching by protos", - rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", - protos: []string{"h2", "bar"}, - serverName: "bar", + rule: "ALPN(`h2c`) && (!ALPN(`h2`) || HostSNI(`example.eu`))", + protos: []string{"http/1.1", "mqtt"}, + serverName: "example.com", matchErr: true, }, } @@ -554,68 +305,56 @@ func Test_addTCPRoute(t *testing.T) { } } -type fakeAddr struct { - addr string -} - -func (f fakeAddr) String() string { - return f.addr -} - -func (f fakeAddr) Network() string { - panic("Implement me") -} - func TestParseHostSNI(t *testing.T) { testCases := []struct { - description string + desc string expression string domain []string errorExpected bool }{ { - description: "Unknown rule", - expression: "Foobar(`foo.bar`,`test.bar`)", + desc: "Unknown rule", + expression: "Unknown(`example.com`)", errorExpected: true, }, { - description: "Many hostSNI rules", - expression: "HostSNI(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, + desc: "HostSNI rule", + expression: "HostSNI(`example.com`)", + domain: []string{"example.com"}, }, { - description: "Many hostSNI rules upper", - expression: "HOSTSNI(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, + desc: "HostSNI rule upper", + expression: "HOSTSNI(`example.com`)", + domain: []string{"example.com"}, }, { - description: "Many hostSNI rules lower", - expression: "hostsni(`foo.bar`,`test.bar`)", - domain: []string{"foo.bar", "test.bar"}, + desc: "HostSNI rule lower", + expression: "hostsni(`example.com`)", + domain: []string{"example.com"}, }, { - description: "No hostSNI rule", - expression: "ClientIP(`10.1`)", + desc: "No hostSNI rule", + expression: "ClientIP(`10.1`)", }, { - description: "HostSNI rule and another rule", - expression: "HostSNI(`foo.bar`) && ClientIP(`10.1`)", - domain: []string{"foo.bar"}, + desc: "HostSNI rule and another rule", + expression: "HostSNI(`example.com`) && ClientIP(`10.1`)", + domain: []string{"example.com"}, }, { - description: "HostSNI rule to lower and another rule", - expression: "HostSNI(`Foo.Bar`) && ClientIP(`10.1`)", - domain: []string{"foo.bar"}, + desc: "HostSNI rule to lower and another rule", + expression: "HostSNI(`example.com`) && ClientIP(`10.1`)", + domain: []string{"example.com"}, }, { - description: "HostSNI rule with no domain", - expression: "HostSNI() && ClientIP(`10.1`)", + desc: "HostSNI rule with no domain", + expression: "HostSNI() && ClientIP(`10.1`)", }, } for _, test := range testCases { test := test - t.Run(test.expression, func(t *testing.T) { + t.Run(test.desc, func(t *testing.T) { t.Parallel() domains, err := ParseHostSNI(test.expression) @@ -631,468 +370,48 @@ func TestParseHostSNI(t *testing.T) { } } -func Test_HostSNICatchAll(t *testing.T) { - testCases := []struct { - desc string - rule string - isCatchAll bool - }{ - { - desc: "HostSNI(`foobar`) is not catchAll", - rule: "HostSNI(`foobar`)", - }, - { - desc: "HostSNI(`*`) is catchAll", - rule: "HostSNI(`*`)", - isCatchAll: true, - }, - { - desc: "HOSTSNI(`*`) is catchAll", - rule: "HOSTSNI(`*`)", - isCatchAll: true, - }, - { - desc: `HostSNI("*") is catchAll`, - rule: `HostSNI("*")`, - isCatchAll: true, - }, - } - - for _, test := range testCases { - test := test - - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - muxer, err := NewMuxer() - require.NoError(t, err) - - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) - require.NoError(t, err) - - handler, catchAll := muxer.Match(ConnData{ - serverName: "foobar", - }) - require.NotNil(t, handler) - assert.Equal(t, test.isCatchAll, catchAll) - }) - } -} - -func Test_HostSNI(t *testing.T) { - testCases := []struct { - desc string - ruleHosts []string - serverName string - buildErr bool - matchErr bool - }{ - { - desc: "Empty", - buildErr: true, - }, - { - desc: "Non ASCII host", - ruleHosts: []string{"héhé"}, - buildErr: true, - }, - { - desc: "Not Matching hosts", - ruleHosts: []string{"foobar"}, - serverName: "bar", - matchErr: true, - }, - { - desc: "Matching globing host `*`", - ruleHosts: []string{"*"}, - serverName: "foobar", - }, - { - desc: "Matching globing host `*` and empty serverName", - ruleHosts: []string{"*"}, - serverName: "", - }, - { - desc: "Matching globing host `*` and another non matching host", - ruleHosts: []string{"foo", "*"}, - serverName: "bar", - }, - { - desc: "Matching globing host `*` and another non matching host, and empty servername", - ruleHosts: []string{"foo", "*"}, - serverName: "", - matchErr: true, - }, - { - desc: "Not Matching globing host with subdomain", - ruleHosts: []string{"*.bar"}, - buildErr: true, - }, - { - desc: "Not Matching host with trailing dot with ", - ruleHosts: []string{"foobar."}, - serverName: "foobar.", - }, - { - desc: "Matching host with trailing dot", - ruleHosts: []string{"foobar."}, - serverName: "foobar", - }, - { - desc: "Matching hosts", - ruleHosts: []string{"foobar"}, - serverName: "foobar", - }, - { - desc: "Matching hosts with subdomains", - ruleHosts: []string{"foo.bar"}, - serverName: "foo.bar", - }, - } - - for _, test := range testCases { - test := test - - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - matcherTree := &matchersTree{} - err := hostSNI(matcherTree, test.ruleHosts...) - if test.buildErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - meta := ConnData{ - serverName: test.serverName, - } - - assert.Equal(t, test.matchErr, !matcherTree.match(meta)) - }) - } -} - -func Test_HostSNIRegexp(t *testing.T) { - testCases := []struct { - desc string - pattern string - serverNames map[string]bool - buildErr bool - }{ - { - desc: "unbalanced braces", - pattern: "subdomain:(foo\\.)?bar\\.com}", - buildErr: true, - }, - { - desc: "empty group name", - pattern: "{:(foo\\.)?bar\\.com}", - buildErr: true, - }, - { - desc: "empty capturing group", - pattern: "{subdomain:}", - buildErr: true, - }, - { - desc: "malformed capturing group", - pattern: "{subdomain:(foo\\.?bar\\.com}", - buildErr: true, - }, - { - desc: "not interpreted as a regexp", - pattern: "bar.com", - serverNames: map[string]bool{ - "bar.com": true, - "barucom": false, - }, - }, - { - desc: "capturing group", - pattern: "{subdomain:(foo\\.)?bar\\.com}", - serverNames: map[string]bool{ - "foo.bar.com": true, - "bar.com": true, - "fooubar.com": false, - "barucom": false, - "barcom": false, - }, - }, - { - desc: "non capturing group", - pattern: "{subdomain:(?:foo\\.)?bar\\.com}", - serverNames: map[string]bool{ - "foo.bar.com": true, - "bar.com": true, - "fooubar.com": false, - "barucom": false, - "barcom": false, - }, - }, - { - desc: "regex insensitive", - pattern: "{dummy:[A-Za-z-]+\\.bar\\.com}", - serverNames: map[string]bool{ - "FOO.bar.com": true, - "foo.bar.com": true, - "fooubar.com": false, - "barucom": false, - "barcom": false, - }, - }, - { - desc: "insensitive host", - pattern: "{dummy:[a-z-]+\\.bar\\.com}", - serverNames: map[string]bool{ - "FOO.bar.com": true, - "foo.bar.com": true, - "fooubar.com": false, - "barucom": false, - "barcom": false, - }, - }, - { - desc: "insensitive host simple", - pattern: "foo.bar.com", - serverNames: map[string]bool{ - "FOO.bar.com": true, - "foo.bar.com": true, - "fooubar.com": false, - "barucom": false, - "barcom": false, - }, - }, - } - - for _, test := range testCases { - test := test - - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - matchersTree := &matchersTree{} - err := hostSNIRegexp(matchersTree, test.pattern) - if test.buildErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - for serverName, match := range test.serverNames { - meta := ConnData{ - serverName: serverName, - } - - assert.Equal(t, match, matchersTree.match(meta)) - } - }) - } -} - -func Test_ClientIP(t *testing.T) { - testCases := []struct { - desc string - ruleCIDRs []string - remoteIP string - buildErr bool - matchErr bool - }{ - { - desc: "Empty", - buildErr: true, - }, - { - desc: "Malformed CIDR", - ruleCIDRs: []string{"héhé"}, - buildErr: true, - }, - { - desc: "Not matching empty remote IP", - ruleCIDRs: []string{"20.20.20.20"}, - matchErr: true, - }, - { - desc: "Not matching IP", - ruleCIDRs: []string{"20.20.20.20"}, - remoteIP: "10.10.10.10", - matchErr: true, - }, - { - desc: "Matching IP", - ruleCIDRs: []string{"10.10.10.10"}, - remoteIP: "10.10.10.10", - }, - { - desc: "Not matching multiple IPs", - ruleCIDRs: []string{"20.20.20.20", "30.30.30.30"}, - remoteIP: "10.10.10.10", - matchErr: true, - }, - { - desc: "Matching multiple IPs", - ruleCIDRs: []string{"10.10.10.10", "20.20.20.20", "30.30.30.30"}, - remoteIP: "20.20.20.20", - }, - { - desc: "Not matching CIDR", - ruleCIDRs: []string{"20.0.0.0/24"}, - remoteIP: "10.10.10.10", - matchErr: true, - }, - { - desc: "Matching CIDR", - ruleCIDRs: []string{"20.0.0.0/8"}, - remoteIP: "20.10.10.10", - }, - { - desc: "Not matching multiple CIDRs", - ruleCIDRs: []string{"10.0.0.0/24", "20.0.0.0/24"}, - remoteIP: "10.10.10.10", - matchErr: true, - }, - { - desc: "Matching multiple CIDRs", - ruleCIDRs: []string{"10.0.0.0/8", "20.0.0.0/8"}, - remoteIP: "20.10.10.10", - }, - } - - for _, test := range testCases { - test := test - - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - matchersTree := &matchersTree{} - err := clientIP(matchersTree, test.ruleCIDRs...) - if test.buildErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - meta := ConnData{ - remoteIP: test.remoteIP, - } - - assert.Equal(t, test.matchErr, !matchersTree.match(meta)) - }) - } -} - -func Test_ALPN(t *testing.T) { - testCases := []struct { - desc string - ruleALPNProtos []string - connProto string - buildErr bool - matchErr bool - }{ - { - desc: "Empty", - buildErr: true, - }, - { - desc: "ACME TLS proto", - ruleALPNProtos: []string{tlsalpn01.ACMETLS1Protocol}, - buildErr: true, - }, - { - desc: "Not matching empty proto", - ruleALPNProtos: []string{"h2"}, - matchErr: true, - }, - { - desc: "Not matching ALPN", - ruleALPNProtos: []string{"h2"}, - connProto: "mqtt", - matchErr: true, - }, - { - desc: "Matching ALPN", - ruleALPNProtos: []string{"h2"}, - connProto: "h2", - }, - { - desc: "Not matching multiple ALPNs", - ruleALPNProtos: []string{"h2", "mqtt"}, - connProto: "h2c", - matchErr: true, - }, - { - desc: "Matching multiple ALPNs", - ruleALPNProtos: []string{"h2", "h2c", "mqtt"}, - connProto: "h2c", - }, - } - - for _, test := range testCases { - test := test - - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - matchersTree := &matchersTree{} - err := alpn(matchersTree, test.ruleALPNProtos...) - if test.buildErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - meta := ConnData{ - alpnProtos: []string{test.connProto}, - } - - assert.Equal(t, test.matchErr, !matchersTree.match(meta)) - }) - } -} - func Test_Priority(t *testing.T) { testCases := []struct { desc string rules map[string]int serverName string - remoteIP string expectedRule string }{ { desc: "One matching rule, calculated priority", rules: map[string]int{ - "HostSNI(`bar`)": 0, - "HostSNI(`foobar`)": 0, + "HostSNI(`example.com`)": 0, + "HostSNI(`example.org`)": 0, }, - expectedRule: "HostSNI(`bar`)", - serverName: "bar", + expectedRule: "HostSNI(`example.com`)", + serverName: "example.com", }, { desc: "One matching rule, custom priority", rules: map[string]int{ - "HostSNI(`foobar`)": 0, - "HostSNI(`bar`)": 10000, + "HostSNI(`example.org`)": 0, + "HostSNI(`example.com`)": 10000, }, - expectedRule: "HostSNI(`foobar`)", - serverName: "foobar", + expectedRule: "HostSNI(`example.org`)", + serverName: "example.org", }, { desc: "Two matching rules, calculated priority", rules: map[string]int{ - "HostSNI(`foobar`)": 0, - "HostSNI(`foobar`, `bar`)": 0, + "HostSNI(`example.org`)": 0, + "HostSNI(`example.com`)": 0, }, - expectedRule: "HostSNI(`foobar`, `bar`)", - serverName: "foobar", + expectedRule: "HostSNI(`example.org`)", + serverName: "example.org", }, { desc: "Two matching rules, custom priority", rules: map[string]int{ - "HostSNI(`foobar`)": 10000, - "HostSNI(`foobar`, `bar`)": 0, + "HostSNI(`example.com`)": 10000, + "HostSNI(`example.org`)": 0, }, - expectedRule: "HostSNI(`foobar`)", - serverName: "foobar", + expectedRule: "HostSNI(`example.com`)", + serverName: "example.com", }, } @@ -1116,7 +435,6 @@ func Test_Priority(t *testing.T) { handler, _ := muxer.Match(ConnData{ serverName: test.serverName, - remoteIP: test.remoteIP, }) require.NotNil(t, handler) @@ -1125,3 +443,57 @@ func Test_Priority(t *testing.T) { }) } } + +type fakeConn struct { + call map[string]int + remoteAddr net.Addr +} + +func (f *fakeConn) Read(b []byte) (n int, err error) { + panic("implement me") +} + +func (f *fakeConn) Write(b []byte) (n int, err error) { + f.call[string(b)]++ + return len(b), nil +} + +func (f *fakeConn) Close() error { + panic("implement me") +} + +func (f *fakeConn) LocalAddr() net.Addr { + panic("implement me") +} + +func (f *fakeConn) RemoteAddr() net.Addr { + return f.remoteAddr +} + +func (f *fakeConn) SetDeadline(t time.Time) error { + panic("implement me") +} + +func (f *fakeConn) SetReadDeadline(t time.Time) error { + panic("implement me") +} + +func (f *fakeConn) SetWriteDeadline(t time.Time) error { + panic("implement me") +} + +func (f *fakeConn) CloseWrite() error { + panic("implement me") +} + +type fakeAddr struct { + addr string +} + +func (f fakeAddr) String() string { + return f.addr +} + +func (f fakeAddr) Network() string { + panic("Implement me") +} diff --git a/pkg/provider/hub/hub.go b/pkg/provider/hub/hub.go index 07fc50d30..c44cb7e97 100644 --- a/pkg/provider/hub/hub.go +++ b/pkg/provider/hub/hub.go @@ -101,7 +101,7 @@ func patchDynamicConfiguration(cfg *dynamic.Configuration, ep string, port int, cfg.HTTP.Routers["traefik-hub-agent-service"] = &dynamic.Router{ EntryPoints: []string{ep}, Service: "traefik-hub-agent-service", - Rule: "Host(`proxy.traefik`) && PathPrefix(`/config`, `/discover-ip`, `/state`)", + Rule: "Host(`proxy.traefik`) && (PathPrefix(`/config`) || PathPrefix(`/discover-ip`) || PathPrefix(`/state`))", } cfg.HTTP.Services["traefik-hub-agent-service"] = &dynamic.Service{ diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 7bc3dc754..f06bdac9b 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "os" + "regexp" "sort" "strconv" "strings" @@ -1163,8 +1164,7 @@ func getRouteBindingSelectorNamespace(client Client, gatewayNamespace string, ro } func hostRule(hostnames []v1alpha2.Hostname) (string, error) { - var hostNames []string - var hostRegexNames []string + var rules []string for _, hostname := range hostnames { host := string(hostname) @@ -1177,7 +1177,7 @@ func hostRule(hostnames []v1alpha2.Hostname) (string, error) { wildcard := strings.Count(host, "*") if wildcard == 0 { - hostNames = append(hostNames, host) + rules = append(rules, fmt.Sprintf("Host(`%s`)", host)) continue } @@ -1186,25 +1186,18 @@ func hostRule(hostnames []v1alpha2.Hostname) (string, error) { return "", fmt.Errorf("invalid rule: %q", host) } - hostRegexNames = append(hostRegexNames, strings.Replace(host, "*.", "{subdomain:[a-zA-Z0-9-]+}.", 1)) + host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) + rules = append(rules, fmt.Sprintf("HostRegexp(`^%s$`)", host)) } - var res string - if len(hostNames) > 0 { - res = "Host(`" + strings.Join(hostNames, "`, `") + "`)" + switch len(rules) { + case 0: + return "", nil + case 1: + return rules[0], nil + default: + return fmt.Sprintf("(%s)", strings.Join(rules, " || ")), nil } - - if len(hostRegexNames) == 0 { - return res, nil - } - - hostRegexp := "HostRegexp(`" + strings.Join(hostRegexNames, "`, `") + "`)" - - if len(res) > 0 { - return "(" + res + " || " + hostRegexp + ")", nil - } - - return hostRegexp, nil } func hostSNIRule(hostnames []v1alpha2.Hostname) (string, error) { @@ -1227,15 +1220,18 @@ func hostSNIRule(hostnames []v1alpha2.Hostname) (string, error) { return "", fmt.Errorf("wildcard hostname is not supported: %q", h) } - matchers = append(matchers, "`"+h+"`") + matchers = append(matchers, fmt.Sprintf("HostSNI(`%s`)", h)) uniqHostnames[hostname] = struct{}{} } - if len(matchers) == 0 { + switch len(matchers) { + case 0: return "HostSNI(`*`)", nil + case 1: + return matchers[0], nil + default: + return fmt.Sprintf("(%s)", strings.Join(matchers, " || ")), nil } - - return "HostSNI(" + strings.Join(matchers, ",") + ")", nil } func extractRule(routeRule v1alpha2.HTTPRouteRule, hostRule string) (string, error) { diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 803701476..44b3ce249 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -744,15 +744,15 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-75dd1ad561e42725558a": { + "default-http-app-1-my-gateway-web-66e726cd8903b49727ae": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-75dd1ad561e42725558a-wrr", - Rule: "Host(`foo.com`, `bar.com`) && PathPrefix(`/`)", + Service: "default-http-app-1-my-gateway-web-66e726cd8903b49727ae-wrr", + Rule: "(Host(`foo.com`) || Host(`bar.com`)) && PathPrefix(`/`)", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-75dd1ad561e42725558a-wrr": { + "default-http-app-1-my-gateway-web-66e726cd8903b49727ae-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -802,15 +802,15 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca": { + "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca-wrr", - Rule: "(Host(`foo.com`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.com`)) && PathPrefix(`/`)", + Service: "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr", + Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.com$`)) && PathPrefix(`/`)", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca-wrr": { + "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -860,15 +860,15 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-eb1490f180299bf5ed29": { + "default-http-app-1-my-gateway-web-b0521a61fb43068694b4": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-eb1490f180299bf5ed29-wrr", - Rule: "(Host(`foo.com`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.foo.com`)) && PathPrefix(`/`)", + Service: "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr", + Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.foo\\.com$`)) && PathPrefix(`/`)", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-eb1490f180299bf5ed29-wrr": { + "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -3011,10 +3011,10 @@ func TestLoadTLSRoutes(t *testing.T) { }, TCP: &dynamic.TCPConfiguration{ Routers: map[string]*dynamic.TCPRouter{ - "default-tls-app-1-my-gateway-tls-339184c3296a9c2c39fa": { + "default-tls-app-1-my-gateway-tls-dfc5c7506ac1b172c8b7": { EntryPoints: []string{"tls"}, - Service: "default-tls-app-1-my-gateway-tls-339184c3296a9c2c39fa-wrr-0", - Rule: "HostSNI(`foo.example.com`,`bar.example.com`)", + Service: "default-tls-app-1-my-gateway-tls-dfc5c7506ac1b172c8b7-wrr-0", + Rule: "(HostSNI(`foo.example.com`) || HostSNI(`bar.example.com`))", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3022,7 +3022,7 @@ func TestLoadTLSRoutes(t *testing.T) { }, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{ - "default-tls-app-1-my-gateway-tls-339184c3296a9c2c39fa-wrr-0": { + "default-tls-app-1-my-gateway-tls-dfc5c7506ac1b172c8b7-wrr-0": { Weighted: &dynamic.TCPWeightedRoundRobin{ Services: []dynamic.TCPWRRService{ { @@ -4362,7 +4362,7 @@ func Test_hostRule(t *testing.T) { "Bar", "Bir", }, - expectedRule: "Host(`Foo`, `Bar`, `Bir`)", + expectedRule: "(Host(`Foo`) || Host(`Bar`) || Host(`Bir`))", }, { desc: "Multiple Hosts with empty one", @@ -4389,14 +4389,14 @@ func Test_hostRule(t *testing.T) { "bar.foo", "foo.foo", }, - expectedRule: "(Host(`bar.foo`, `foo.foo`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.foo`))", + expectedRule: "(HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.foo$`) || Host(`bar.foo`) || Host(`foo.foo`))", }, { desc: "Host with wildcard", hostnames: []v1alpha2.Hostname{ "*.bar.foo", }, - expectedRule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.foo`)", + expectedRule: "HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.foo$`)", }, { desc: "Alone wildcard", @@ -4708,7 +4708,7 @@ func Test_hostSNIRule(t *testing.T) { { desc: "Some empty hostnames", hostnames: []v1alpha2.Hostname{"foo", "", "bar"}, - expectedRule: "HostSNI(`foo`,`bar`)", + expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`))", }, { desc: "Valid hostname", @@ -4718,12 +4718,12 @@ func Test_hostSNIRule(t *testing.T) { { desc: "Multiple valid hostnames", hostnames: []v1alpha2.Hostname{"foo", "bar"}, - expectedRule: "HostSNI(`foo`,`bar`)", + expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`))", }, { desc: "Multiple overlapping hostnames", hostnames: []v1alpha2.Hostname{"foo", "bar", "foo", "baz"}, - expectedRule: "HostSNI(`foo`,`bar`,`baz`)", + expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`) || HostSNI(`baz`))", }, } diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index 502c7cbe9..206f37d50 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -8,6 +8,7 @@ import ( "math" "net" "os" + "regexp" "sort" "strconv" "strings" @@ -400,10 +401,11 @@ func (p *Provider) shouldProcessIngress(ingress *networkingv1.Ingress, ingressCl func buildHostRule(host string) string { if strings.HasPrefix(host, "*.") { - return "HostRegexp(`" + strings.Replace(host, "*.", "{subdomain:[a-zA-Z0-9-]+}.", 1) + "`)" + host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) + return fmt.Sprintf("HostRegexp(`^%s$`)", host) } - return "Host(`" + host + "`)" + return fmt.Sprintf("Host(`%s`)", host) } func getCertificates(ctx context.Context, ingress *networkingv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error { diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index d1b76ec8a..3b032f996 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -189,8 +189,8 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Middlewares: map[string]*dynamic.Middleware{}, Routers: map[string]*dynamic.Router{ - "testing-bar-bar-3be6cfd7daba66cf2fdd": { - Rule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar`) && PathPrefix(`/bar`)", + "testing-bar-bar-aba9a7d00e9b06a78e16": { + Rule: "HostRegexp(`^[a-zA-Z0-9-]+\\.bar$`) && PathPrefix(`/bar`)", Service: "testing-service1-80", }, "testing-bar-bar-636bf36c00fedaab3d44": { @@ -1104,7 +1104,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { 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`)", + Rule: "HostRegexp(`^[a-zA-Z0-9-]+\\.foobar\\.com$`) && PathPrefix(`/bar`)", Service: "testing-service1-80", }, }, diff --git a/pkg/provider/traefik/fixtures/redirection.json b/pkg/provider/traefik/fixtures/redirection.json index 4ffbb756c..2b3b271fa 100644 --- a/pkg/provider/traefik/fixtures/redirection.json +++ b/pkg/provider/traefik/fixtures/redirection.json @@ -9,7 +9,7 @@ "redirect-web-to-websecure" ], "service": "noop@internal", - "rule": "HostRegexp(`{host:.+}`)" + "rule": "HostRegexp(`^.+$`)" } }, "middlewares": { @@ -27,4 +27,4 @@ }, "tcp": {}, "tls": {} -} \ No newline at end of file +} diff --git a/pkg/provider/traefik/fixtures/redirection_port.json b/pkg/provider/traefik/fixtures/redirection_port.json index e113d21b3..ead9bc0b1 100644 --- a/pkg/provider/traefik/fixtures/redirection_port.json +++ b/pkg/provider/traefik/fixtures/redirection_port.json @@ -9,7 +9,7 @@ "redirect-web-to-443" ], "service": "noop@internal", - "rule": "HostRegexp(`{host:.+}`)" + "rule": "HostRegexp(`^.+$`)" } }, "middlewares": { @@ -27,4 +27,4 @@ }, "tcp": {}, "tls": {} -} \ No newline at end of file +} diff --git a/pkg/provider/traefik/fixtures/redirection_with_protocol.json b/pkg/provider/traefik/fixtures/redirection_with_protocol.json index 4ffbb756c..2b3b271fa 100644 --- a/pkg/provider/traefik/fixtures/redirection_with_protocol.json +++ b/pkg/provider/traefik/fixtures/redirection_with_protocol.json @@ -9,7 +9,7 @@ "redirect-web-to-websecure" ], "service": "noop@internal", - "rule": "HostRegexp(`{host:.+}`)" + "rule": "HostRegexp(`^.+$`)" } }, "middlewares": { @@ -27,4 +27,4 @@ }, "tcp": {}, "tls": {} -} \ No newline at end of file +} diff --git a/pkg/provider/traefik/internal.go b/pkg/provider/traefik/internal.go index 175ce07b9..1e1c23bec 100644 --- a/pkg/provider/traefik/internal.go +++ b/pkg/provider/traefik/internal.go @@ -137,7 +137,7 @@ func (i *Provider) redirection(ctx context.Context, cfg *dynamic.Configuration) mdName := "redirect-" + rtName rt := &dynamic.Router{ - Rule: "HostRegexp(`{host:.+}`)", + Rule: "HostRegexp(`^.+$`)", EntryPoints: []string{name}, Middlewares: []string{mdName}, Service: "noop@internal",