Update routing syntax

Co-authored-by: Tom Moulard <tom.moulard@traefik.io>
This commit is contained in:
Antoine 2022-11-28 15:48:05 +01:00 committed by GitHub
parent b93141992e
commit 4d86668af3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 2484 additions and 2085 deletions

View file

@ -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. - `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 `forceSlash` option of the StripPrefix middleware has been removed.
- the `preferServerCipherSuites` option 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.

View file

@ -93,12 +93,12 @@ rule = "Host(`traefik.example.com`)"
```bash tab="Path Prefix Rule" ```bash tab="Path Prefix Rule"
# The dashboard can be accessed on http://example.com/dashboard/ or http://traefik.example.com/dashboard/ # 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" ```bash tab="Combination of Rules"
# The dashboard can be accessed on http://traefik.example.com/dashboard/ # 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" ??? example "Dashboard Dynamic Configuration Examples"

View file

@ -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. 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. 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: The table below lists all the available matchers:
| Rule | Description | | Rule | Description |
|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| |-----------------------------------------------------------------|:-------------------------------------------------------------------------------|
| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` | | [```Header(`key`, `value`)```](#header-and-headerregexp) | Matches requests containing a header named `key` set to `value`. |
| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` | | [```HeaderRegexp(`key`, `regexp`)```](#header-and-headerregexp) | Matches requests containing a header named `key` matching `regexp`. |
| ```Host(`example.com`, ...)``` | Check if the request domain (host header value) targets one of the given `domains`. | | [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. |
| ```HostHeader(`example.com`, ...)``` | Same as `Host`, only exists for historical reasons. | | [```HostRegexp(`regexp`)```](#host-and-hostregexp) | Matches requests host matching `regexp`. |
| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Match the request domain. See "Regexp Syntax" below. | | [```Method(`method`)```](#method) | Matches requests method set to `method`. |
| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`) | | [```Path(`path`)```](#path-pathprefix-and-pathregexp) | Matches requests path set to `path`. |
| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. See "Regexp Syntax" below. | | [```PathPrefix(`prefix`)```](#path-pathprefix-and-pathregexp) | Matches requests path prefix set to `prefix`. |
| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. See "Regexp Syntax" below. | | [```PathRegexp(`regexp`)```](#path-pathprefix-and-pathregexp) | Matches request path using `regexp`. |
| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | | [```Query(`key`, `value`)```](#query-and-queryregexp) | Matches requests query parameters named `key` set to `value`. |
| ```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. | | [```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. To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`.
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. Single quotes `'` are not accepted since the values are [Go's String Literals](https://golang.org/ref/spec#String_literals).
!!! important "Regexp Syntax" !!! important "Regexp Syntax"
`HostRegexp`, `PathPrefix`, and `Path` accept an expression with zero or more groups enclosed by curly braces, which are called named regexps. Matchers that accept a regexp as their value use a [Go](https://golang.org/pkg/regexp/) flavored syntax.
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.
Any `regexp` supported by [Go's regexp package](https://golang.org/pkg/regexp/) may be used. !!! info "Expressing Complex Rules Using Operators and Parenthesis"
For example, here is a case insensitive path matcher syntax: ```Path(`/{path:(?i:Products)}`)```.
!!! info "Combining Matchers Using Operators and Parenthesis"
The usual AND (`&&`) and OR (`||`) logical operators can be used, with the expected precedence rules, The usual AND (`&&`) and OR (`||`) logical operators can be used, with the expected precedence rules,
as well as parentheses. 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. If no Host is set in the request URL (e.g., it's an IP address), these matchers will look at the `Host` header.
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`.
!!! 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 ### Priority
@ -300,7 +446,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul
http: http:
routers: routers:
Router-1: Router-1:
rule: "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" rule: "HostRegexp(`[a-z]+\.traefik\.com`)"
# ... # ...
Router-2: Router-2:
rule: "Host(`foobar.traefik.com`)" 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 ## Dynamic configuration
[http.routers] [http.routers]
[http.routers.Router-1] [http.routers.Router-1]
rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)"
# ... # ...
[http.routers.Router-2] [http.routers.Router-2]
rule = "Host(`foobar.traefik.com`)" 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`. In this case, all requests with host `foobar.traefik.com` will be routed through `Router-1` instead of `Router-2`.
| Name | Rule | Priority | | Name | Rule | Priority |
|----------|----------------------------------------------------|----------| |----------|------------------------------------------|----------|
| Router-1 | ```HostRegexp(`{subdomain:[a-z]+}.traefik.com`)``` | 44 | | Router-1 | ```HostRegexp(`[a-z]+\.traefik\.com`)``` | 44 |
| Router-2 | ```Host(`foobar.traefik.com`)``` | 26 | | Router-2 | ```Host(`foobar.traefik.com`)``` | 26 |
The previous table shows that `Router-1` has a higher priority than `Router-2`. 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: http:
routers: routers:
Router-1: Router-1:
rule: "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" rule: "HostRegexp(`[a-z]+\\.traefik\\.com`)"
entryPoints: entryPoints:
- "web" - "web"
service: service-1 service: service-1
@ -353,7 +499,7 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul
## Dynamic configuration ## Dynamic configuration
[http.routers] [http.routers]
[http.routers.Router-1] [http.routers.Router-1]
rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)"
entryPoints = ["web"] entryPoints = ["web"]
service = "service-1" service = "service-1"
priority = 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 ### 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. 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: The table below lists all the available matchers:
| Rule | Description | | Rule | Description |
|---------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| |-------------------------------------------------------------|:-------------------------------------------------------------------------------------------------|
| ```HostSNI(`domain-1`, ...)``` | Checks if the Server Name Indication corresponds to the given `domains`. | | [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`. |
| ```HostSNIRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Checks if the Server Name Indication matches the given regular expressions. See "Regexp Syntax" below. | | [```HostSNIRegexp(`regexp`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication matches `regexp`. |
| ```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. | | [```ClientIP(`ip`)```](#clientip_1) | Checks if the connection's client IP correspond to `ip`. It accepts IPv4, IPv6 and CIDR formats. |
| ```ALPN(`mqtt`, `h2c`)``` | Checks if any of the connection ALPN protocols is one of the given protocols. | | [```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. To set the value of a rule, use [backticks](https://en.wiktionary.org/wiki/backtick) ``` ` ``` or escaped double-quotes `\"`.
Domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)).
Single quotes `'` are not accepted since the values are [Go's String Literals](https://golang.org/ref/spec#String_literals).
!!! important "Regexp Syntax" !!! important "Regexp Syntax"
`HostSNIRegexp` accepts an expression with zero or more groups enclosed by curly braces, which are called named regexps. Matchers that accept a regexp as their value use a [Go](https://golang.org/pkg/regexp/) flavored syntax.
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.
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" !!! 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, when one wants a non-TLS router that matches all (non-TLS) requests,
one should use the specific ```HostSNI(`*`)``` syntax. 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, Match all connections:
as well as parentheses.
```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 Match connections opened by a given IP:
an ACME TLS challenge previously initiated by Traefik.
For this reason, the `ALPN` matcher is not allowed to match the `ACME-TLS/1` ```yaml tab="IPv4"
protocol, and Traefik returns an error if this is attempted. 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 ### Priority

7
go.mod
View file

@ -77,11 +77,12 @@ require (
github.com/vulcand/predicate v1.2.0 github.com/vulcand/predicate v1.2.0
go.elastic.co/apm v1.13.1 go.elastic.co/apm v1.13.1
go.elastic.co/apm/module/apmot 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/net v0.1.0
golang.org/x/text v0.4.0 golang.org/x/text v0.4.0
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 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 google.golang.org/grpc v1.46.0
gopkg.in/DataDog/dd-trace-go.v1 v1.43.1 gopkg.in/DataDog/dd-trace-go.v1 v1.43.1
gopkg.in/fsnotify.v1 v1.4.7 gopkg.in/fsnotify.v1 v1.4.7
@ -330,7 +331,7 @@ require (
go.uber.org/zap v1.18.1 // indirect go.uber.org/zap v1.18.1 // indirect
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // 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/lint v0.0.0-20210508222113-6edffad5e616 // indirect
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect

15
go.sum
View file

@ -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-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-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-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.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 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-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-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/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-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-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-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-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-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/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.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 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-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-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180811021610-c39426892332/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.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/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.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -20,12 +20,12 @@
[http.routers] [http.routers]
[http.routers.router1] [http.routers.router1]
rule = "HostRegexp(`{subdomain:[a-z1-9-]+}.snitest.com`)" rule = "HostRegexp(`[a-z1-9-]+\\.snitest\\.com`)"
service = "service1" service = "service1"
[http.routers.router1.tls] [http.routers.router1.tls]
[http.routers.router2] [http.routers.router2]
rule = "HostRegexp(`{subdomain:[a-z1-9-]+}.www.snitest.com`)" rule = "HostRegexp(`[a-z1-9-]+\\.www\\.snitest\\.com`)"
service = "service1" service = "service1"
[http.routers.router2.tls] [http.routers.router2.tls]

View file

@ -4,6 +4,7 @@
[log] [log]
level = "DEBUG" level = "DEBUG"
noColor = true
[entryPoints] [entryPoints]
[entryPoints.webHost] [entryPoints.webHost]
@ -30,12 +31,12 @@
[http.routers.router2] [http.routers.router2]
entryPoints = ["webHostRegexp"] entryPoints = ["webHostRegexp"]
service = "service1" service = "service1"
rule = "!HostRegexp(`test.localhost`)" rule = "!HostRegexp(`test\\.localhost`)"
[http.routers.router3] [http.routers.router3]
entryPoints = ["webQuery"] entryPoints = ["webQuery"]
service = "service1" service = "service1"
rule = "!Query(`foo=`)" rule = "!QueryRegexp(`foo`, `.*`)"
[http.services] [http.services]

View file

@ -24,7 +24,7 @@
[http.routers] [http.routers]
[http.routers.router1] [http.routers.router1]
service = "service1" service = "service1"
rule = "Path(`/echo`,`/ws`)" rule = "Path(`/echo`) || Path(`/ws`)"
[http.routers.router1.tls] [http.routers.router1.tls]
[http.services] [http.services]

View file

@ -1092,7 +1092,7 @@ func (s *HTTPSSuite) TestWithSNIDynamicCaseInsensitive(c *check.C) {
defer s.killCmd(cmd) defer s.killCmd(cmd)
// wait for Traefik // 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) c.Assert(err, checker.IsNil)
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{

View file

@ -1337,7 +1337,7 @@ func (s *SimpleSuite) TestMuxer(c *check.C) {
expected: http.StatusOK, 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", request: "GET /?foo=; HTTP/1.1\r\nHost: other.localhost\r\n\r\n",
target: "127.0.0.1:8002", target: "127.0.0.1:8002",
expected: http.StatusNotFound, expected: http.StatusNotFound,
@ -1367,9 +1367,7 @@ func (s *SimpleSuite) TestMuxer(c *check.C) {
resp, err := http.ReadResponse(bufio.NewReader(conn), nil) resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
c.Assert(err, checker.IsNil) c.Assert(err, checker.IsNil)
if resp.StatusCode != test.expected { c.Assert(resp.StatusCode, checker.Equals, test.expected, check.Commentf(test.desc))
c.Errorf("%s failed with %d instead of %d", test.desc, resp.StatusCode, test.expected)
}
if test.body != "" { if test.body != "" {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

253
pkg/muxer/http/matcher.go Normal file
View file

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

View file

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

View file

@ -3,32 +3,12 @@ package http
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"unicode/utf8"
"github.com/gorilla/mux" "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/traefik/traefik/v2/pkg/rules"
"github.com/vulcand/predicate" "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. // Muxer handles routing with rules.
type Muxer struct { type Muxer struct {
*mux.Router *mux.Router
@ -80,171 +60,6 @@ func (r *Muxer) AddRoute(rule string, priority int, handler http.Handler) error
return nil 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 { func addRuleOnRouter(router *mux.Router, rule *rules.Tree) error {
switch rule.Matcher { switch rule.Matcher {
case "and": 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 { func addRuleOnRoute(route *mux.Route, rule *rules.Tree) error {
switch rule.Matcher { switch rule.Matcher {
case "and": 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 not(m func(*mux.Route, ...string) error) func(*mux.Route, ...string) error {
func IsASCII(s string) bool { return func(r *mux.Route, v ...string) error {
for i := 0; i < len(s); i++ { router := mux.NewRouter()
if s[i] >= utf8.RuneSelf {
return false 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
} }

View file

@ -7,14 +7,13 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
"github.com/traefik/traefik/v2/pkg/testhelpers" "github.com/traefik/traefik/v2/pkg/testhelpers"
) )
func Test_addRoute(t *testing.T) { func TestMuxer(t *testing.T) {
testCases := []struct { testCases := []struct {
desc string desc string
rule string rule string
@ -33,607 +32,177 @@ func Test_addRoute(t *testing.T) {
expectedError: true, expectedError: true,
}, },
{ {
desc: "Host empty", desc: "Rule without quote",
rule: "Host(``)", rule: "Host(example.com)",
expectedError: true, 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", desc: "Host and PathPrefix",
rule: "Host(`localhost`) && PathPrefix(`/foo`)", rule: "Host(`localhost`) && PathPrefix(`/css`)",
expected: map[string]int{ expected: map[string]int{
"http://localhost/foo": http.StatusOK, "https://localhost/css": http.StatusOK,
}, "https://localhost/js": http.StatusNotFound,
},
{
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,
}, },
}, },
{ {
desc: "Rule with Host OR Host", desc: "Rule with Host OR Host",
rule: `Host("tchouk") || Host("pouet")`, rule: "Host(`example.com`) || Host(`example.org`)",
expected: map[string]int{ expected: map[string]int{
"http://tchouk/toto": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://pouet/a": http.StatusOK, "https://example.org/js": http.StatusOK,
"http://plopi/a": http.StatusNotFound, "https://example.eu/html": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with host OR (host AND path)", 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{ expected: map[string]int{
"http://tchouk/toto": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusOK, "https://example.com/js": http.StatusOK,
"http://pouet/powpow": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://pouet/toto": http.StatusNotFound, "https://example.org/js": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound, "https://example.eu/css": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with host OR host AND path", 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{ expected: map[string]int{
"http://tchouk/toto": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusOK, "https://example.com/js": http.StatusOK,
"http://pouet/powpow": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://pouet/toto": http.StatusNotFound, "https://example.org/js": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound, "https://example.eu/css": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with (host OR host) AND path", 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{ expected: map[string]int{
"http://tchouk/toto": http.StatusNotFound, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusOK, "https://example.com/js": http.StatusNotFound,
"http://pouet/powpow": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://pouet/toto": http.StatusNotFound, "https://example.org/js": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound, "https://example.eu/css": 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,
}, },
}, },
{ {
desc: "Rule with (host AND path) OR (host AND path)", 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{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusNotFound,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/js": http.StatusOK,
"http://pouet/powpow": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://pouet/toto": http.StatusNotFound, "https://example.org/js": http.StatusNotFound,
"http://plopi/a": http.StatusNotFound, "https://example.eu/css": http.StatusNotFound,
}, },
}, },
{
desc: "Rule without quote",
rule: `Host(tchouk)`,
expectedError: true,
},
{ {
desc: "Rule case UPPER", desc: "Rule case UPPER",
rule: `(HOST("tchouk") && PATHPREFIX("/titi"))`, rule: `PATHPREFIX("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule case lower", desc: "Rule case lower",
rule: `(host("tchouk") && pathprefix("/titi"))`, rule: `pathprefix("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule case CamelCase", desc: "Rule case CamelCase",
rule: `(Host("tchouk") && PathPrefix("/titi"))`, rule: `PathPrefix("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule case Title", desc: "Rule case Title",
rule: `(Host("tchouk") && Pathprefix("/titi"))`, rule: `Pathprefix("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "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", desc: "Rule with not",
rule: `!Host("tchouk")`, rule: `!Host("example.com")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound, "https://example.org": http.StatusOK,
"http://test/powpow": http.StatusOK, "https://example.com": http.StatusNotFound,
},
},
{
desc: "Rule with not on Path",
rule: `!Path("/titi")`,
expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound,
"http://tchouk/powpow": http.StatusOK,
}, },
}, },
{ {
desc: "Rule with not on multiple route with or", 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{ expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound, "https://example.eu/js": http.StatusOK,
"http://toto/powpow": http.StatusNotFound, "https://example.com/css": http.StatusNotFound,
"http://test/powpow": http.StatusOK, "https://example.org/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with not on multiple route with and", desc: "Rule with not on multiple route with and",
rule: `!(Host("tchouk") && Path("/titi"))`, rule: `!(Host("example.com") && Path("/css"))`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound, "https://example.com/js": http.StatusOK,
"http://tchouk/toto": http.StatusOK, "https://example.eu/css": http.StatusOK,
"http://test/titi": http.StatusOK, "https://example.com/css": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with not on multiple route with and another not", 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{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://toto/titi": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://tchouk/toto": http.StatusNotFound, "https://example.com/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with not on two rule", desc: "Rule with not on two rule",
rule: `!Host("tchouk") || !Path("/titi")`, rule: `!Host("example.com") || !Path("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound, "https://example.com/js": http.StatusOK,
"http://tchouk/toto": http.StatusOK, "https://example.org/css": http.StatusOK,
"http://test/titi": http.StatusOK, "https://example.com/css": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule case with double not", desc: "Rule case with double not",
rule: `!(!(Host("tchouk") && Pathprefix("/titi")))`, rule: `!(!(Host("example.com") && Pathprefix("/css")))`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusOK, "https://example.com/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/js": http.StatusNotFound,
"http://test/titi": http.StatusNotFound, "https://example.org/css": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule case with not domain", desc: "Rule case with not domain",
rule: `!Host("tchouk") && Pathprefix("/titi")`, rule: `!Host("example.com") && Pathprefix("/css")`,
expected: map[string]int{ expected: map[string]int{
"http://tchouk/titi": http.StatusNotFound, "https://example.org/css": http.StatusOK,
"http://tchouk/powpow": http.StatusNotFound, "https://example.org/js": http.StatusNotFound,
"http://toto/powpow": http.StatusNotFound, "https://example.com/css": http.StatusNotFound,
"http://toto/titi": http.StatusOK, "https://example.com/js": http.StatusNotFound,
}, },
}, },
{ {
desc: "Rule with multiple host AND multiple path AND not", 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{ expected: map[string]int{
"http://tchouk/toto": http.StatusOK, "https://example.com/js": http.StatusNotFound,
"http://tchouk/powpow": http.StatusNotFound, "https://example.com/html": http.StatusOK,
"http://pouet/powpow": http.StatusNotFound, "https://example.org/js": http.StatusOK,
"http://tchouk/titi": http.StatusNotFound, "https://example.com/css": http.StatusOK,
"http://pouet/titi": http.StatusNotFound, "https://example.org/css": http.StatusOK,
"http://pouet/toto": http.StatusOK, "https://example.org/html": http.StatusOK,
"http://plopi/a": http.StatusOK, "https://example.eu/images": 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,
}, },
}, },
} }
@ -644,36 +213,37 @@ func Test_addRoute(t *testing.T) {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
t.Parallel() t.Parallel()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
muxer, err := NewMuxer() muxer, err := NewMuxer()
require.NoError(t, err) require.NoError(t, err)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
err = muxer.AddRoute(test.rule, 0, handler) err = muxer.AddRoute(test.rule, 0, handler)
if test.expectedError { if test.expectedError {
require.Error(t, err) require.Error(t, err)
} else { 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 := 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)
} }
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() muxer.SortRoutes()
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := testhelpers.MustNewRequest(http.MethodGet, test.path, nil) req := testhelpers.MustNewRequest(http.MethodGet, test.path, http.NoBody)
muxer.ServeHTTP(w, req) 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) { func TestParseDomains(t *testing.T) {
testCases := []struct { testCases := []struct {
description string description string
@ -914,21 +404,6 @@ func TestParseDomains(t *testing.T) {
expression: "Foobar(`foo.bar`,`test.bar`)", expression: "Foobar(`foo.bar`,`test.bar`)",
errorExpected: true, 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", description: "No host rule",
expression: "Path(`/test`)", expression: "Path(`/test`)",
@ -938,6 +413,11 @@ func TestParseDomains(t *testing.T) {
expression: "Host(`foo.bar`) && Path(`/test`)", expression: "Host(`foo.bar`) && Path(`/test`)",
domain: []string{"foo.bar"}, 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", description: "Host rule to trim and another rule",
expression: "Host(`Foo.Bar`) && Path(`/test`)", 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 { testCases := []struct {
desc string desc string
request string request string
@ -975,41 +457,41 @@ func TestAbsoluteFormURL(t *testing.T) {
expected int expected int
}{ }{
{ {
desc: "!HostRegexp with absolute-form URL with empty host with non-matching host header", 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", request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n",
rule: "!HostRegexp(`test.localhost`)", rule: "HostRegexp(`example.com`)",
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`)",
expected: http.StatusOK, expected: http.StatusOK,
}, },
{ {
desc: "!Host with absolute-form URL with non-matching host header", desc: "Host with absolute-form URL with empty host with non-matching host header",
request: "GET http://test.localhost/ HTTP/1.1\r\nHost: toto.localhost\r\n\r\n", request: "GET http://@/ HTTP/1.1\r\nHost: example.com\r\n\r\n",
rule: "!Host(`toto.localhost`)", rule: "Host(`example.com`)",
expected: http.StatusOK, 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 { for _, test := range testCases {

134
pkg/muxer/tcp/matcher.go Normal file
View file

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

View file

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

View file

@ -1,31 +1,18 @@
package tcp package tcp
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"net" "net"
"regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/rs/zerolog/log" "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/rules"
"github.com/traefik/traefik/v2/pkg/tcp" "github.com/traefik/traefik/v2/pkg/tcp"
"github.com/traefik/traefik/v2/pkg/types" "github.com/traefik/traefik/v2/pkg/types"
"github.com/vulcand/predicate" "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. // ParseHostSNI extracts the HostSNIs declared in a rule.
// This is a first naive implementation used in TCP routing. // This is a first naive implementation used in TCP routing.
func ParseHostSNI(rule string) ([]string, error) { 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) return m.left.match(meta) && m.right.match(meta)
default: default:
// This should never happen as it should have been detected during parsing. // 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 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
}

File diff suppressed because it is too large Load diff

View file

@ -101,7 +101,7 @@ func patchDynamicConfiguration(cfg *dynamic.Configuration, ep string, port int,
cfg.HTTP.Routers["traefik-hub-agent-service"] = &dynamic.Router{ cfg.HTTP.Routers["traefik-hub-agent-service"] = &dynamic.Router{
EntryPoints: []string{ep}, EntryPoints: []string{ep},
Service: "traefik-hub-agent-service", 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{ cfg.HTTP.Services["traefik-hub-agent-service"] = &dynamic.Service{

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -1163,8 +1164,7 @@ func getRouteBindingSelectorNamespace(client Client, gatewayNamespace string, ro
} }
func hostRule(hostnames []v1alpha2.Hostname) (string, error) { func hostRule(hostnames []v1alpha2.Hostname) (string, error) {
var hostNames []string var rules []string
var hostRegexNames []string
for _, hostname := range hostnames { for _, hostname := range hostnames {
host := string(hostname) host := string(hostname)
@ -1177,7 +1177,7 @@ func hostRule(hostnames []v1alpha2.Hostname) (string, error) {
wildcard := strings.Count(host, "*") wildcard := strings.Count(host, "*")
if wildcard == 0 { if wildcard == 0 {
hostNames = append(hostNames, host) rules = append(rules, fmt.Sprintf("Host(`%s`)", host))
continue continue
} }
@ -1186,25 +1186,18 @@ func hostRule(hostnames []v1alpha2.Hostname) (string, error) {
return "", fmt.Errorf("invalid rule: %q", host) 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 switch len(rules) {
if len(hostNames) > 0 { case 0:
res = "Host(`" + strings.Join(hostNames, "`, `") + "`)" 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) { 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) 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{}{} uniqHostnames[hostname] = struct{}{}
} }
if len(matchers) == 0 { switch len(matchers) {
case 0:
return "HostSNI(`*`)", nil 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) { func extractRule(routeRule v1alpha2.HTTPRouteRule, hostRule string) (string, error) {

View file

@ -744,15 +744,15 @@ func TestLoadHTTPRoutes(t *testing.T) {
}, },
HTTP: &dynamic.HTTPConfiguration{ HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{ Routers: map[string]*dynamic.Router{
"default-http-app-1-my-gateway-web-75dd1ad561e42725558a": { "default-http-app-1-my-gateway-web-66e726cd8903b49727ae": {
EntryPoints: []string{"web"}, EntryPoints: []string{"web"},
Service: "default-http-app-1-my-gateway-web-75dd1ad561e42725558a-wrr", Service: "default-http-app-1-my-gateway-web-66e726cd8903b49727ae-wrr",
Rule: "Host(`foo.com`, `bar.com`) && PathPrefix(`/`)", Rule: "(Host(`foo.com`) || Host(`bar.com`)) && PathPrefix(`/`)",
}, },
}, },
Middlewares: map[string]*dynamic.Middleware{}, Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{ 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{ Weighted: &dynamic.WeightedRoundRobin{
Services: []dynamic.WRRService{ Services: []dynamic.WRRService{
{ {
@ -802,15 +802,15 @@ func TestLoadHTTPRoutes(t *testing.T) {
}, },
HTTP: &dynamic.HTTPConfiguration{ HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{ Routers: map[string]*dynamic.Router{
"default-http-app-1-my-gateway-web-2dbd7883f5537db39bca": { "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0": {
EntryPoints: []string{"web"}, EntryPoints: []string{"web"},
Service: "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca-wrr", Service: "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr",
Rule: "(Host(`foo.com`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.com`)) && PathPrefix(`/`)", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.com$`)) && PathPrefix(`/`)",
}, },
}, },
Middlewares: map[string]*dynamic.Middleware{}, Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{ 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{ Weighted: &dynamic.WeightedRoundRobin{
Services: []dynamic.WRRService{ Services: []dynamic.WRRService{
{ {
@ -860,15 +860,15 @@ func TestLoadHTTPRoutes(t *testing.T) {
}, },
HTTP: &dynamic.HTTPConfiguration{ HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{ Routers: map[string]*dynamic.Router{
"default-http-app-1-my-gateway-web-eb1490f180299bf5ed29": { "default-http-app-1-my-gateway-web-b0521a61fb43068694b4": {
EntryPoints: []string{"web"}, EntryPoints: []string{"web"},
Service: "default-http-app-1-my-gateway-web-eb1490f180299bf5ed29-wrr", Service: "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr",
Rule: "(Host(`foo.com`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.foo.com`)) && PathPrefix(`/`)", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.foo\\.com$`)) && PathPrefix(`/`)",
}, },
}, },
Middlewares: map[string]*dynamic.Middleware{}, Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{ 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{ Weighted: &dynamic.WeightedRoundRobin{
Services: []dynamic.WRRService{ Services: []dynamic.WRRService{
{ {
@ -3011,10 +3011,10 @@ func TestLoadTLSRoutes(t *testing.T) {
}, },
TCP: &dynamic.TCPConfiguration{ TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{ Routers: map[string]*dynamic.TCPRouter{
"default-tls-app-1-my-gateway-tls-339184c3296a9c2c39fa": { "default-tls-app-1-my-gateway-tls-dfc5c7506ac1b172c8b7": {
EntryPoints: []string{"tls"}, EntryPoints: []string{"tls"},
Service: "default-tls-app-1-my-gateway-tls-339184c3296a9c2c39fa-wrr-0", Service: "default-tls-app-1-my-gateway-tls-dfc5c7506ac1b172c8b7-wrr-0",
Rule: "HostSNI(`foo.example.com`,`bar.example.com`)", Rule: "(HostSNI(`foo.example.com`) || HostSNI(`bar.example.com`))",
TLS: &dynamic.RouterTCPTLSConfig{ TLS: &dynamic.RouterTCPTLSConfig{
Passthrough: true, Passthrough: true,
}, },
@ -3022,7 +3022,7 @@ func TestLoadTLSRoutes(t *testing.T) {
}, },
Middlewares: map[string]*dynamic.TCPMiddleware{}, Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{ 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{ Weighted: &dynamic.TCPWeightedRoundRobin{
Services: []dynamic.TCPWRRService{ Services: []dynamic.TCPWRRService{
{ {
@ -4362,7 +4362,7 @@ func Test_hostRule(t *testing.T) {
"Bar", "Bar",
"Bir", "Bir",
}, },
expectedRule: "Host(`Foo`, `Bar`, `Bir`)", expectedRule: "(Host(`Foo`) || Host(`Bar`) || Host(`Bir`))",
}, },
{ {
desc: "Multiple Hosts with empty one", desc: "Multiple Hosts with empty one",
@ -4389,14 +4389,14 @@ func Test_hostRule(t *testing.T) {
"bar.foo", "bar.foo",
"foo.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", desc: "Host with wildcard",
hostnames: []v1alpha2.Hostname{ hostnames: []v1alpha2.Hostname{
"*.bar.foo", "*.bar.foo",
}, },
expectedRule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.foo`)", expectedRule: "HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.foo$`)",
}, },
{ {
desc: "Alone wildcard", desc: "Alone wildcard",
@ -4708,7 +4708,7 @@ func Test_hostSNIRule(t *testing.T) {
{ {
desc: "Some empty hostnames", desc: "Some empty hostnames",
hostnames: []v1alpha2.Hostname{"foo", "", "bar"}, hostnames: []v1alpha2.Hostname{"foo", "", "bar"},
expectedRule: "HostSNI(`foo`,`bar`)", expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`))",
}, },
{ {
desc: "Valid hostname", desc: "Valid hostname",
@ -4718,12 +4718,12 @@ func Test_hostSNIRule(t *testing.T) {
{ {
desc: "Multiple valid hostnames", desc: "Multiple valid hostnames",
hostnames: []v1alpha2.Hostname{"foo", "bar"}, hostnames: []v1alpha2.Hostname{"foo", "bar"},
expectedRule: "HostSNI(`foo`,`bar`)", expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`))",
}, },
{ {
desc: "Multiple overlapping hostnames", desc: "Multiple overlapping hostnames",
hostnames: []v1alpha2.Hostname{"foo", "bar", "foo", "baz"}, hostnames: []v1alpha2.Hostname{"foo", "bar", "foo", "baz"},
expectedRule: "HostSNI(`foo`,`bar`,`baz`)", expectedRule: "(HostSNI(`foo`) || HostSNI(`bar`) || HostSNI(`baz`))",
}, },
} }

View file

@ -8,6 +8,7 @@ import (
"math" "math"
"net" "net"
"os" "os"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -400,10 +401,11 @@ func (p *Provider) shouldProcessIngress(ingress *networkingv1.Ingress, ingressCl
func buildHostRule(host string) string { func buildHostRule(host string) string {
if strings.HasPrefix(host, "*.") { 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 { func getCertificates(ctx context.Context, ingress *networkingv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error {

View file

@ -189,8 +189,8 @@ func TestLoadConfigurationFromIngresses(t *testing.T) {
HTTP: &dynamic.HTTPConfiguration{ HTTP: &dynamic.HTTPConfiguration{
Middlewares: map[string]*dynamic.Middleware{}, Middlewares: map[string]*dynamic.Middleware{},
Routers: map[string]*dynamic.Router{ Routers: map[string]*dynamic.Router{
"testing-bar-bar-3be6cfd7daba66cf2fdd": { "testing-bar-bar-aba9a7d00e9b06a78e16": {
Rule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar`) && PathPrefix(`/bar`)", Rule: "HostRegexp(`^[a-zA-Z0-9-]+\\.bar$`) && PathPrefix(`/bar`)",
Service: "testing-service1-80", Service: "testing-service1-80",
}, },
"testing-bar-bar-636bf36c00fedaab3d44": { "testing-bar-bar-636bf36c00fedaab3d44": {
@ -1104,7 +1104,7 @@ func TestLoadConfigurationFromIngresses(t *testing.T) {
Middlewares: map[string]*dynamic.Middleware{}, Middlewares: map[string]*dynamic.Middleware{},
Routers: map[string]*dynamic.Router{ Routers: map[string]*dynamic.Router{
"testing-foobar-com-bar": { "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", Service: "testing-service1-80",
}, },
}, },

View file

@ -9,7 +9,7 @@
"redirect-web-to-websecure" "redirect-web-to-websecure"
], ],
"service": "noop@internal", "service": "noop@internal",
"rule": "HostRegexp(`{host:.+}`)" "rule": "HostRegexp(`^.+$`)"
} }
}, },
"middlewares": { "middlewares": {
@ -27,4 +27,4 @@
}, },
"tcp": {}, "tcp": {},
"tls": {} "tls": {}
} }

View file

@ -9,7 +9,7 @@
"redirect-web-to-443" "redirect-web-to-443"
], ],
"service": "noop@internal", "service": "noop@internal",
"rule": "HostRegexp(`{host:.+}`)" "rule": "HostRegexp(`^.+$`)"
} }
}, },
"middlewares": { "middlewares": {
@ -27,4 +27,4 @@
}, },
"tcp": {}, "tcp": {},
"tls": {} "tls": {}
} }

View file

@ -9,7 +9,7 @@
"redirect-web-to-websecure" "redirect-web-to-websecure"
], ],
"service": "noop@internal", "service": "noop@internal",
"rule": "HostRegexp(`{host:.+}`)" "rule": "HostRegexp(`^.+$`)"
} }
}, },
"middlewares": { "middlewares": {
@ -27,4 +27,4 @@
}, },
"tcp": {}, "tcp": {},
"tls": {} "tls": {}
} }

View file

@ -137,7 +137,7 @@ func (i *Provider) redirection(ctx context.Context, cfg *dynamic.Configuration)
mdName := "redirect-" + rtName mdName := "redirect-" + rtName
rt := &dynamic.Router{ rt := &dynamic.Router{
Rule: "HostRegexp(`{host:.+}`)", Rule: "HostRegexp(`^.+$`)",
EntryPoints: []string{name}, EntryPoints: []string{name},
Middlewares: []string{mdName}, Middlewares: []string{mdName},
Service: "noop@internal", Service: "noop@internal",