diff --git a/docs/content/migration/v2-to-v3.md b/docs/content/migration/v2-to-v3.md index 4396e0887..b9f7c5102 100644 --- a/docs/content/migration/v2-to-v3.md +++ b/docs/content/migration/v2-to-v3.md @@ -526,21 +526,16 @@ All Pilot related configuration should be removed from the static configuration. ## Dynamic configuration -### IPWhiteList +### Router Rule Matchers -In v3, we renamed the `IPWhiteList` middleware to `IPAllowList` without changing anything to the configuration. +In v3, a new rule matchers syntax has been introduced for HTTP and TCP routers. +The default rule matchers syntax is now the v3 one, but for backward compatibility this can be configured. +The v2 rule matchers syntax is deprecated and its support will be removed in the next major version. +For this reason, we encourage migrating to the new syntax. -### Deprecated Options Removal +#### New V3 Syntax Notable Changes -- The `tracing.datadog.globaltag` option has been removed. -- The `tls.caOptional` option has been removed from the ForwardAuth middleware, as well as from the HTTP, Consul, Etcd, Redis, ZooKeeper, Consul Catalog, and Docker providers. -- `sslRedirect`, `sslTemporaryRedirect`, `sslHost`, `sslForceHost` and `featurePolicy` options of the Headers middleware have been removed. -- The `forceSlash` option of the StripPrefix middleware has been removed. -- The `preferServerCipherSuites` option has been removed. - -### Matchers - -In v3, the `Headers` and `HeadersRegexp` matchers have been renamed to `Header` and `HeaderRegexp` respectively. +The `Headers` and `HeadersRegexp` matchers have been renamed to `Header` and `HeaderRegexp` respectively. `PathPrefix` no longer uses regular expressions to match path prefixes. @@ -555,6 +550,87 @@ and should be explicitly combined using logical operators to mimic previous beha `HostHeader` has been removed, use `Host` instead. +#### Remediation + +##### Configure the Default Syntax In Static Configuration + +The default rule matchers syntax is the expected syntax for any router that is not self opt-out from this default value. +It can be configured in the static configuration. + +??? example "An example configuration for the default rule matchers syntax" + + ```yaml tab="File (YAML)" + # static configuration + core: + defaultRuleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + # static configuration + [core] + defaultRuleSyntax="v2" + ``` + + ```bash tab="CLI" + # static configuration + --core.defaultRuleSyntax=v2 + ``` + +##### Configure the Syntax Per Router + +The rule syntax can also be configured on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "An example router with syntax configuration" + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.routers.test.ruleSyntax=v2" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: test.route + namespace: default + +spec: + routes: + - match: PathPrefix(`/foo`, `/bar`) + syntax: v2 + kind: Rule +``` + +```yaml tab="Consul Catalog" +- "traefik.http.routers.test.ruleSyntax=v2" +``` + +```yaml tab="File (YAML)" +http: + routers: + test: + ruleSyntax: v2 +``` + +```toml tab="File (TOML)" +[http.routers] + [http.routers.test] + ruleSyntax = "v2" +``` + +### IPWhiteList + +In v3, we renamed the `IPWhiteList` middleware to `IPAllowList` without changing anything to the configuration. + +### Deprecated Options Removal + +- The `tracing.datadog.globaltag` option has been removed. +- The `tls.caOptional` option has been removed from the ForwardAuth middleware, as well as from the HTTP, Consul, Etcd, Redis, ZooKeeper, Consul Catalog, and Docker providers. +- `sslRedirect`, `sslTemporaryRedirect`, `sslHost`, `sslForceHost` and `featurePolicy` options of the Headers middleware have been removed. +- The `forceSlash` option of the StripPrefix middleware has been removed. +- The `preferServerCipherSuites` option has been removed. + ### TCP LoadBalancer `terminationDelay` option The TCP LoadBalancer `terminationDelay` option has been removed. diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index 93a785ff4..df6db2ff0 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -132,6 +132,7 @@ - "traefik.http.routers.router0.middlewares=foobar, foobar" - "traefik.http.routers.router0.priority=42" - "traefik.http.routers.router0.rule=foobar" +- "traefik.http.routers.router0.rulesyntax=foobar" - "traefik.http.routers.router0.service=foobar" - "traefik.http.routers.router0.tls=true" - "traefik.http.routers.router0.tls.certresolver=foobar" @@ -144,6 +145,7 @@ - "traefik.http.routers.router1.middlewares=foobar, foobar" - "traefik.http.routers.router1.priority=42" - "traefik.http.routers.router1.rule=foobar" +- "traefik.http.routers.router1.rulesyntax=foobar" - "traefik.http.routers.router1.service=foobar" - "traefik.http.routers.router1.tls=true" - "traefik.http.routers.router1.tls.certresolver=foobar" @@ -183,6 +185,7 @@ - "traefik.tcp.routers.tcprouter0.middlewares=foobar, foobar" - "traefik.tcp.routers.tcprouter0.priority=42" - "traefik.tcp.routers.tcprouter0.rule=foobar" +- "traefik.tcp.routers.tcprouter0.rulesyntax=foobar" - "traefik.tcp.routers.tcprouter0.service=foobar" - "traefik.tcp.routers.tcprouter0.tls=true" - "traefik.tcp.routers.tcprouter0.tls.certresolver=foobar" @@ -196,6 +199,7 @@ - "traefik.tcp.routers.tcprouter1.middlewares=foobar, foobar" - "traefik.tcp.routers.tcprouter1.priority=42" - "traefik.tcp.routers.tcprouter1.rule=foobar" +- "traefik.tcp.routers.tcprouter1.rulesyntax=foobar" - "traefik.tcp.routers.tcprouter1.service=foobar" - "traefik.tcp.routers.tcprouter1.tls=true" - "traefik.tcp.routers.tcprouter1.tls.certresolver=foobar" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a8264d2ed..541affb9c 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -7,6 +7,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [http.routers.Router0.tls] options = "foobar" @@ -24,6 +25,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [http.routers.Router1.tls] options = "foobar" @@ -353,6 +355,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [tcp.routers.TCPRouter0.tls] passthrough = true @@ -371,6 +374,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [tcp.routers.TCPRouter1.tls] passthrough = true diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 7ae72ad7d..4e0019e42 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -11,6 +11,7 @@ http: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: options: foobar @@ -33,6 +34,7 @@ http: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: options: foobar @@ -409,6 +411,7 @@ tcp: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: passthrough: true @@ -432,6 +435,7 @@ tcp: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: passthrough: true diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 5a4c63c3c..966a45177 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match @@ -402,6 +406,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index f078b166a..6d80b13b8 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -158,6 +158,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/routers/Router0/middlewares/1` | `foobar` | | `traefik/http/routers/Router0/priority` | `42` | | `traefik/http/routers/Router0/rule` | `foobar` | +| `traefik/http/routers/Router0/ruleSyntax` | `foobar` | | `traefik/http/routers/Router0/service` | `foobar` | | `traefik/http/routers/Router0/tls/certResolver` | `foobar` | | `traefik/http/routers/Router0/tls/domains/0/main` | `foobar` | @@ -173,6 +174,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/routers/Router1/middlewares/1` | `foobar` | | `traefik/http/routers/Router1/priority` | `42` | | `traefik/http/routers/Router1/rule` | `foobar` | +| `traefik/http/routers/Router1/ruleSyntax` | `foobar` | | `traefik/http/routers/Router1/service` | `foobar` | | `traefik/http/routers/Router1/tls/certResolver` | `foobar` | | `traefik/http/routers/Router1/tls/domains/0/main` | `foobar` | @@ -273,6 +275,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/tcp/routers/TCPRouter0/middlewares/1` | `foobar` | | `traefik/tcp/routers/TCPRouter0/priority` | `42` | | `traefik/tcp/routers/TCPRouter0/rule` | `foobar` | +| `traefik/tcp/routers/TCPRouter0/ruleSyntax` | `foobar` | | `traefik/tcp/routers/TCPRouter0/service` | `foobar` | | `traefik/tcp/routers/TCPRouter0/tls/certResolver` | `foobar` | | `traefik/tcp/routers/TCPRouter0/tls/domains/0/main` | `foobar` | @@ -289,6 +292,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/tcp/routers/TCPRouter1/middlewares/1` | `foobar` | | `traefik/tcp/routers/TCPRouter1/priority` | `42` | | `traefik/tcp/routers/TCPRouter1/rule` | `foobar` | +| `traefik/tcp/routers/TCPRouter1/ruleSyntax` | `foobar` | | `traefik/tcp/routers/TCPRouter1/service` | `foobar` | | `traefik/tcp/routers/TCPRouter1/tls/certResolver` | `foobar` | | `traefik/tcp/routers/TCPRouter1/tls/domains/0/main` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml index 41628b58a..8399f56fb 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match diff --git a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml index 94226e14c..0f95de8d2 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml @@ -129,6 +129,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index aabed9139..2f655ccb1 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -105,6 +105,9 @@ Activate TLS-ALPN-01 Challenge. (Default: ```true```) `--certificatesresolvers..tailscale`: Enables Tailscale certificate resolution. (Default: ```true```) +`--core.defaultrulesyntax`: +Defines the rule parser default syntax (v2 or v3) (Default: ```v3```) + `--entrypoints.`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 892fb2369..fdf733783 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -105,6 +105,9 @@ Activate TLS-ALPN-01 Challenge. (Default: ```true```) `TRAEFIK_CERTIFICATESRESOLVERS__TAILSCALE`: Enables Tailscale certificate resolution. (Default: ```true```) +`TRAEFIK_CORE_DEFAULTRULESYNTAX`: +Defines the rule parser default syntax (v2 or v3) (Default: ```v3```) + `TRAEFIK_ENTRYPOINTS_`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 3edf7ebc9..8f9326416 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -453,5 +453,8 @@ [experimental.localPlugins.LocalDescriptor1] moduleName = "foobar" +[core] + defaultRuleSyntax = "foobar" + [spiffe] workloadAPIAddr = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index a9ccffe9a..1739759cb 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -486,5 +486,7 @@ experimental: LocalDescriptor1: moduleName: foobar kubernetesGateway: true +core: + defaultRuleSyntax: foobar spiffe: workloadAPIAddr: foobar diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index 55b46dc9d..9a4b8c7d0 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -515,6 +515,60 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul In this configuration, the priority is configured to allow `Router-2` to handle requests with the `foobar.traefik.com` host. +### RuleSyntax + +In Traefik v3 a new rule syntax has been introduced ([migration guide](../../migration/v2-to-v3.md#router-rule-matchers)). +`ruleSyntax` option allows to configure the rule syntax to be used for parsing the rule on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "Set rule syntax -- using the [File Provider](../../providers/file.md)" + + ```yaml tab="File (YAML)" + ## Dynamic configuration + http: + routers: + Router-v3: + rule: HostRegexp(`[a-z]+\\.traefik\\.com`) + ruleSyntax: v3 + Router-v2: + rule: HostRegexp(`{subdomain:[a-z]+}.traefik.com`) + ruleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [http.routers] + [http.routers.Router-v3] + rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)" + ruleSyntax = v3 + [http.routers.Router-v2] + rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + ruleSyntax = v2 + ``` + + ```yaml tab="Kubernetes traefik.io/v1alpha1" + apiVersion: traefik.io/v1alpha1 + kind: IngressRoute + metadata: + name: test.route + namespace: default + + spec: + routes: + # route v3 + - match: HostRegexp(`[a-z]+\\.traefik\\.com`) + syntax: v3 + kind: Rule + + # route v2 + - match: HostRegexp(`{subdomain:[a-z]+}.traefik.com`) + syntax: v2 + kind: Rule + ``` + + In this configuration, the ruleSyntax is configured to allow `Router-v2` to use v2 syntax, + while for `Router-v3` it is configured to use v3 syntax. + ### Middlewares You can attach a list of [middlewares](../../middlewares/overview.md) to each HTTP router. @@ -1161,6 +1215,60 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul In this configuration, the priority is configured so that `Router-1` will handle requests from `192.168.0.12`. +### RuleSyntax + +In Traefik v3 a new rule syntax has been introduced ([migration guide](../../migration/v2-to-v3.md#router-rule-matchers)). +`ruleSyntax` option allows to configure the rule syntax to be used for parsing the rule on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "Set rule syntax -- using the [File Provider](../../providers/file.md)" + + ```yaml tab="File (YAML)" + ## Dynamic configuration + tcp: + routers: + Router-v3: + rule: ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`) + ruleSyntax: v3 + Router-v2: + rule: ClientIP(`192.168.0.11`, `192.168.0.12`) + ruleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [tcp.routers] + [tcp.routers.Router-v3] + rule = "ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`)" + ruleSyntax = v3 + [tcp.routers.Router-v2] + rule = "ClientIP(`192.168.0.11`, `192.168.0.12`)" + ruleSyntax = v2 + ``` + + ```yaml tab="Kubernetes traefik.io/v1alpha1" + apiVersion: traefik.io/v1alpha1 + kind: IngressRouteTCP + metadata: + name: test.route + namespace: default + + spec: + routes: + # route v3 + - match: ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`) + syntax: v3 + kind: Rule + + # route v2 + - match: ClientIP(`192.168.0.11`, `192.168.0.12`) + syntax: v2 + kind: Rule + ``` + + In this configuration, the ruleSyntax is configured to allow `Router-v2` to use v2 syntax, + while for `Router-v3` it is configured to use v3 syntax. + ### Middlewares You can attach a list of [middlewares](../../middlewares/overview.md) to each TCP router. diff --git a/go.mod b/go.mod index 17fee2a7f..3685c0c5a 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.4 github.com/hashicorp/go-version v1.6.0 - github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b + github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74 github.com/http-wasm/http-wasm-host-go v0.5.2 github.com/influxdata/influxdb-client-go/v2 v2.7.0 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d diff --git a/go.sum b/go.sum index 28bc21641..c651525ee 100644 --- a/go.sum +++ b/go.sum @@ -598,8 +598,8 @@ github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= -github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b h1:R1UDhkwGltpSPY9bCBBxIMQd+NY9BkN0vFHnJo/8o8w= -github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= +github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74 h1:Q+WuGTnZkL2cJ7yNsg4Go4GNnRkcahGLiQP/WD41TTA= +github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 5a4c63c3c..966a45177 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match @@ -402,6 +406,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/integration/fixtures/with_default_rule_syntax.toml b/integration/fixtures/with_default_rule_syntax.toml new file mode 100644 index 000000000..76001eac9 --- /dev/null +++ b/integration/fixtures/with_default_rule_syntax.toml @@ -0,0 +1,41 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[core] + defaultRuleSyntax = "v2" + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + + [http.routers.router2] + service = "service1" + rule = "QueryRegexp(`foo`, `bar`)" + + [http.routers.router3] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + ruleSyntax = "v3" + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [http.services.service1.loadBalancer.servers] diff --git a/integration/fixtures/without_default_rule_syntax.toml b/integration/fixtures/without_default_rule_syntax.toml new file mode 100644 index 000000000..772ea7a85 --- /dev/null +++ b/integration/fixtures/without_default_rule_syntax.toml @@ -0,0 +1,38 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + service = "service1" + rule = "PathPrefix(`/foo`) || PathPrefix(`/bar`)" + + [http.routers.router2] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + + [http.routers.router3] + service = "service1" + rule = "QueryRegexp(`foo`, `bar`)" + ruleSyntax = "v2" + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [http.services.service1.loadBalancer.servers] diff --git a/integration/simple_test.go b/integration/simple_test.go index d8f0f45b7..94b4ec45e 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -659,6 +659,66 @@ func (s *SimpleSuite) TestSimpleConfigurationHostRequestTrailingPeriod() { } } +func (s *SimpleSuite) TestWithDefaultRuleSyntax() { + file := s.adaptFile("fixtures/with_default_rule_syntax.toml", struct{}{}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("PathPrefix")) + require.NoError(s.T(), err) + + // router1 has no error + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router1@file", 1*time.Second, try.BodyContains(`"status":"enabled"`)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/notfound", 1*time.Second, try.StatusCodeIs(http.StatusNotFound)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/foo", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/bar", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v3 instead of v2) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router2@file", 1*time.Second, try.BodyContains("error while parsing rule QueryRegexp(`foo`, `bar`): unsupported function: QueryRegexp")) + require.NoError(s.T(), err) + + // router3 has an error because it uses the wrong rule syntax (v2 instead of v3) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router3@file", 1*time.Second, try.BodyContains("error while adding rule PathPrefix: unexpected number of parameters; got 2, expected one of [1]")) + require.NoError(s.T(), err) +} + +func (s *SimpleSuite) TestWithoutDefaultRuleSyntax() { + file := s.adaptFile("fixtures/without_default_rule_syntax.toml", struct{}{}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("PathPrefix")) + require.NoError(s.T(), err) + + // router1 has no error + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router1@file", 1*time.Second, try.BodyContains(`"status":"enabled"`)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/notfound", 1*time.Second, try.StatusCodeIs(http.StatusNotFound)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/foo", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/bar", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v3 instead of v2) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router2@file", 1*time.Second, try.BodyContains("error while adding rule PathPrefix: unexpected number of parameters; got 2, expected one of [1]")) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v2 instead of v3) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router3@file", 1*time.Second, try.BodyContains("error while parsing rule QueryRegexp(`foo`, `bar`): unsupported function: QueryRegexp")) + require.NoError(s.T(), err) +} + func (s *SimpleSuite) TestRouterConfigErrors() { file := s.adaptFile("fixtures/router_errors.toml", struct{}{}) diff --git a/integration/testdata/rawdata-gateway.json b/integration/testdata/rawdata-gateway.json index f8e364e66..909ce1326 100644 --- a/integration/testdata/rawdata-gateway.json +++ b/integration/testdata/rawdata-gateway.json @@ -34,6 +34,7 @@ ], "service": "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", "rule": "Host(`foo.com`) \u0026\u0026 Path(`/bar`)", + "ruleSyntax": "v3", "priority": 31, "status": "enabled", "using": [ @@ -46,6 +47,7 @@ ], "service": "default-http-app-1-my-https-gateway-websecure-1c0cf64bde37d9d0df06-wrr", "rule": "Host(`foo.com`) \u0026\u0026 Path(`/bar`)", + "ruleSyntax": "v3", "priority": 31, "tls": {}, "status": "enabled", @@ -152,6 +154,7 @@ ], "service": "default-tcp-app-1-my-tcp-gateway-footcp-e3b0c44298fc1c149afb-wrr-0", "rule": "HostSNI(`*`)", + "ruleSyntax": "v3", "priority": -1, "status": "enabled", "using": [ @@ -164,6 +167,7 @@ ], "service": "default-tcp-app-1-my-tls-gateway-footlsterminate-e3b0c44298fc1c149afb-wrr-0", "rule": "HostSNI(`*`)", + "ruleSyntax": "v3", "priority": -1, "tls": { "passthrough": false @@ -179,6 +183,7 @@ ], "service": "default-tls-app-1-my-tls-gateway-footlspassthrough-2279fe75c5156dc5eb26-wrr-0", "rule": "HostSNI(`foo.bar`)", + "ruleSyntax": "v3", "priority": 18, "tls": { "passthrough": true diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index a447e94eb..0783d5592 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -37,8 +37,9 @@ type HTTPConfiguration struct { // Model is a set of default router's values. type Model struct { - Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` - TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -59,6 +60,7 @@ type Router struct { Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"` + RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` DefaultRule bool `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` diff --git a/pkg/config/dynamic/tcp_config.go b/pkg/config/dynamic/tcp_config.go index adedd0882..4bf82364f 100644 --- a/pkg/config/dynamic/tcp_config.go +++ b/pkg/config/dynamic/tcp_config.go @@ -16,11 +16,19 @@ type TCPConfiguration struct { Routers map[string]*TCPRouter `json:"routers,omitempty" toml:"routers,omitempty" yaml:"routers,omitempty" export:"true"` Services map[string]*TCPService `json:"services,omitempty" toml:"services,omitempty" yaml:"services,omitempty" export:"true"` Middlewares map[string]*TCPMiddleware `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + Models map[string]*TCPModel `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` ServersTransports map[string]*TCPServersTransport `json:"serversTransports,omitempty" toml:"serversTransports,omitempty" yaml:"serversTransports,omitempty" label:"-" export:"true"` } // +k8s:deepcopy-gen=true +// TCPModel is a set of default router's values. +type TCPModel struct { + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` +} + +// +k8s:deepcopy-gen=true + // TCPService holds a tcp service configuration (can only be of one type at the same time). type TCPService struct { LoadBalancer *TCPServersLoadBalancer `json:"loadBalancer,omitempty" toml:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty" export:"true"` @@ -56,6 +64,7 @@ type TCPRouter struct { Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"` + RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` TLS *RouterTCPTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` } diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 847e350ba..3969d144d 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1435,6 +1435,21 @@ func (in *TCPConfiguration) DeepCopyInto(out *TCPConfiguration) { (*out)[key] = outVal } } + if in.Models != nil { + in, out := &in.Models, &out.Models + *out = make(map[string]*TCPModel, len(*in)) + for key, val := range *in { + var outVal *TCPModel + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = new(TCPModel) + **out = **in + } + (*out)[key] = outVal + } + } if in.ServersTransports != nil { in, out := &in.ServersTransports, &out.ServersTransports *out = make(map[string]*TCPServersTransport, len(*in)) @@ -1552,6 +1567,22 @@ func (in *TCPMiddleware) DeepCopy() *TCPMiddleware { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TCPModel) DeepCopyInto(out *TCPModel) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TCPModel. +func (in *TCPModel) DeepCopy() *TCPModel { + if in == nil { + return nil + } + out := new(TCPModel) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TCPRouter) DeepCopyInto(out *TCPRouter) { *out = *in diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 9f0ce294f..bb5778671 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -71,11 +71,23 @@ type Configuration struct { CertificatesResolvers map[string]CertificateResolver `description:"Certificates resolvers configuration." json:"certificatesResolvers,omitempty" toml:"certificatesResolvers,omitempty" yaml:"certificatesResolvers,omitempty" export:"true"` - Experimental *Experimental `description:"experimental features." json:"experimental,omitempty" toml:"experimental,omitempty" yaml:"experimental,omitempty" export:"true"` + Experimental *Experimental `description:"Experimental features." json:"experimental,omitempty" toml:"experimental,omitempty" yaml:"experimental,omitempty" export:"true"` + + Core *Core `description:"Core controls." json:"core,omitempty" toml:"core,omitempty" yaml:"core,omitempty" export:"true"` Spiffe *SpiffeClientConfig `description:"SPIFFE integration configuration." json:"spiffe,omitempty" toml:"spiffe,omitempty" yaml:"spiffe,omitempty" export:"true"` } +// Core configures Traefik core behavior. +type Core struct { + DefaultRuleSyntax string `description:"Defines the rule parser default syntax (v2 or v3)" json:"defaultRuleSyntax,omitempty" toml:"defaultRuleSyntax,omitempty" yaml:"defaultRuleSyntax,omitempty"` +} + +// SetDefaults sets the default values. +func (c *Core) SetDefaults() { + c.DefaultRuleSyntax = "v3" +} + // SpiffeClientConfig defines the SPIFFE client configuration. type SpiffeClientConfig struct { WorkloadAPIAddr string `description:"Defines the workload API address." json:"workloadAPIAddr,omitempty" toml:"workloadAPIAddr,omitempty" yaml:"workloadAPIAddr,omitempty"` @@ -317,6 +329,17 @@ func (c *Configuration) ValidateConfiguration() error { acmeEmail = resolver.ACME.Email } + if c.Core != nil { + switch c.Core.DefaultRuleSyntax { + case "v3": // NOOP + case "v2": + // TODO: point to migration guide. + log.Warn().Msgf("v2 rules syntax is now deprecated, please use v3 instead...") + default: + return fmt.Errorf("unsupported default rule syntax configuration: %q", c.Core.DefaultRuleSyntax) + } + } + if c.Tracing != nil && c.Tracing.OTLP != nil { if c.Tracing.OTLP.HTTP == nil && c.Tracing.OTLP.GRPC == nil { return errors.New("tracing OTLP: at least one of HTTP and gRPC options should be defined") diff --git a/pkg/muxer/http/matcher_test.go b/pkg/muxer/http/matcher_test.go index 1b527a392..c4e601d01 100644 --- a/pkg/muxer/http/matcher_test.go +++ b/pkg/muxer/http/matcher_test.go @@ -73,7 +73,7 @@ func TestClientIPMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -147,7 +147,7 @@ func TestMethodMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -265,7 +265,7 @@ func TestHostMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -365,7 +365,7 @@ func TestHostRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -439,7 +439,7 @@ func TestPathMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -532,7 +532,7 @@ func TestPathRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -604,7 +604,7 @@ func TestPathPrefixMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -692,7 +692,7 @@ func TestHeaderMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -800,7 +800,7 @@ func TestHeaderRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -889,7 +889,7 @@ func TestQueryMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -1003,7 +1003,7 @@ func TestQueryRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return diff --git a/pkg/muxer/http/matcher_v2.go b/pkg/muxer/http/matcher_v2.go new file mode 100644 index 000000000..72d5d826a --- /dev/null +++ b/pkg/muxer/http/matcher_v2.go @@ -0,0 +1,226 @@ +package http + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/ip" + "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" +) + +var httpFuncsV2 = map[string]func(*matchersTree, ...string) error{ + "Host": hostV2, + "HostHeader": hostV2, + "HostRegexp": hostRegexpV2, + "ClientIP": clientIPV2, + "Path": pathV2, + "PathPrefix": pathPrefixV2, + "Method": methodsV2, + "Headers": headersV2, + "HeadersRegexp": headersRegexpV2, + "Query": queryV2, +} + +func pathV2(tree *matchersTree, paths ...string) error { + for _, path := range paths { + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + } + + tree.matcher = func(req *http.Request) bool { + for _, path := range paths { + if req.URL.Path == path { + return true + } + } + + return false + } + + return nil +} + +func pathPrefixV2(tree *matchersTree, paths ...string) error { + for _, path := range paths { + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + } + + tree.matcher = func(req *http.Request) bool { + for _, path := range paths { + if strings.HasPrefix(req.URL.Path, path) { + return true + } + } + + return false + } + + return nil +} + +func hostV2(tree *matchersTree, 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) + } + + tree.matcher = func(req *http.Request) 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 clientIPV2(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) + } + + strategy := ip.RemoteAddrStrategy{} + + tree.matcher = func(req *http.Request) 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 methodsV2(tree *matchersTree, methods ...string) error { + route := mux.NewRouter().NewRoute() + route.Methods(methods...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func headersV2(tree *matchersTree, headers ...string) error { + route := mux.NewRouter().NewRoute() + route.Headers(headers...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func queryV2(tree *matchersTree, query ...string) error { + var queries []string + for _, elem := range query { + queries = append(queries, strings.SplitN(elem, "=", 2)...) + } + + route := mux.NewRouter().NewRoute() + route.Queries(queries...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func hostRegexpV2(tree *matchersTree, hosts ...string) error { + router := mux.NewRouter() + + 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() + } + } + + tree.matcher = func(req *http.Request) bool { + return router.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func headersRegexpV2(tree *matchersTree, headers ...string) error { + route := mux.NewRouter().NewRoute() + route.HeadersRegexp(headers...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} diff --git a/pkg/muxer/http/matcher_v2_test.go b/pkg/muxer/http/matcher_v2_test.go new file mode 100644 index 000000000..eac62a005 --- /dev/null +++ b/pkg/muxer/http/matcher_v2_test.go @@ -0,0 +1,1535 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/testhelpers" +) + +func TestClientIPV2Matcher(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: "valid ClientIP matcher (many parameters)", + rule: "ClientIP(`127.0.0.1`, `192.168.1.0/24`)", + expected: map[string]int{ + "127.0.0.1": http.StatusOK, + "192.168.1.1": http.StatusOK, + }, + }, + { + 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, "v2", 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 TestMethodV2Matcher(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: "valid Method matcher (many parameters)", + rule: "Method(`GET`, `POST`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusOK, + }, + }, + { + desc: "valid Method matcher", + rule: "Method(`GET`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusNotFound, + strings.ToLower(http.MethodGet): http.StatusNotFound, + }, + }, + { + desc: "valid Method matcher (lower case)", + rule: "Method(`get`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusNotFound, + strings.ToLower(http.MethodGet): 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, "v2", 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 TestHostV2Matcher(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: "valid Host matcher (many parameters)", + rule: "Host(`example.com`, `example.org`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://EXAMPLE.COM/path": http.StatusOK, + "https://example.org": http.StatusOK, + "https://example.org/path": http.StatusOK, + }, + }, + { + desc: "valid Host matcher", + rule: "Host(`example.com`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": 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 - matcher with UPPER case", + 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, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + // RequestDecorator is necessary for the Host matcher + 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 TestHostRegexpV2Matcher(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: "valid HostRegexp matcher (invalid regexp)", + rule: "HostRegexp(`(example.com`)", + // This is weird. + expectedError: false, + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com:8080": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid HostRegexp matcher (many parameters)", + rule: "HostRegexp(`example.com`, `example.org`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusOK, + "https://example.org/path": http.StatusOK, + }, + }, + { + desc: "valid HostRegexp matcher with case sensitive regexp", + rule: "HostRegexp(`^[A-Z]+\\.com$`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://EXAMPLE.com": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + "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.StatusOK, + "https://example.com/path": http.StatusOK, + "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, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + + // RequestDecorator is necessary for the HostRegexp matcher + 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 TestPathV2Matcher(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: "valid Path matcher (many parameters)", + rule: "Path(`/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.StatusOK, + "https://example.com/css/": http.StatusNotFound, + "https://example.com/css/main.css": http.StatusNotFound, + "https://example.com/js": http.StatusOK, + "https://example.com/js/main.js": http.StatusNotFound, + }, + }, + { + 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, "v2", 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 TestPathPrefixV2Matcher(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: "valid PathPrefix matcher (many parameters)", + rule: "PathPrefix(`/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.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/main.js": http.StatusOK, + }, + }, + { + 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, "v2", 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 TestHeadersMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[*http.Header]int + expectedError bool + }{ + { + desc: "invalid Header matcher (no parameter)", + rule: "Headers()", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Headers(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Headers(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing key parameter)", + rule: "Headers(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (too many parameters)", + rule: "Headers(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid Header matcher", + rule: "Headers(`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, + }, + }, + { + desc: "valid Header matcher (non-canonical form)", + rule: "Headers(`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, "v2", 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 TestHeaderRegexpV2Matcher(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: "HeadersRegexp(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing value parameter)", + rule: "HeadersRegexp(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing key parameter)", + rule: "HeadersRegexp(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (invalid regexp)", + rule: "HeadersRegexp(`X-Forwarded-Host`,`(example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (too many parameters)", + rule: "HeadersRegexp(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid HeaderRegexp matcher", + rule: "HeadersRegexp(`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 (non-canonical form)", + rule: "HeadersRegexp(`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: "HeadersRegexp(`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, "v2", 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 TestHostRegexp(t *testing.T) { + testCases := []struct { + desc string + hostExp string + urls map[string]int + }{ + { + desc: "capturing group", + hostExp: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)", + urls: 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: "non capturing group", + hostExp: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)", + urls: 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: "regex insensitive", + hostExp: "HostRegexp(`{dummy:[A-Za-z-]+\\.bar\\.com}`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "insensitive host", + hostExp: "HostRegexp(`{dummy:[a-z-]+\\.bar\\.com}`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "insensitive host simple", + hostExp: "HostRegexp(`foo.bar.com`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": 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.hostExp, "v2", 0, handler) + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.urls { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.urls, results) + }) + } +} + +// This test is a copy from the v2 branch mux_test.go file. +func Test_addRoute(t *testing.T) { + testCases := []struct { + desc string + rule string + headers map[string]string + remoteAddr string + expected map[string]int + expectedError bool + }{ + { + desc: "no tree", + expectedError: true, + }, + { + desc: "Rule with no matcher", + rule: "rulewithnotmatcher", + expectedError: true, + }, + { + desc: "Host empty", + rule: "Host(``)", + 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: "Host IPv4", + rule: "Host(`127.0.0.1`)", + expected: map[string]int{ + "http://127.0.0.1/foo": http.StatusOK, + }, + }, + { + desc: "Host IPv6", + rule: "Host(`10::10`)", + expected: map[string]int{ + "http://10::10/foo": http.StatusOK, + }, + }, + { + desc: "Non-ASCII Host", + rule: "Host(`locàlhost`)", + expectedError: true, + }, + { + desc: "Non-ASCII HostRegexp", + rule: "HostRegexp(`locàlhost`)", + expectedError: true, + }, + { + desc: "HostHeader equivalent to Host", + rule: "HostHeader(`localhost`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + "http://bar/foo": http.StatusNotFound, + }, + }, + { + desc: "Host with trailing period in rule", + rule: "Host(`localhost.`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host with trailing period in domain", + rule: "Host(`localhost`)", + expected: map[string]int{ + "http://localhost./foo": http.StatusOK, + }, + }, + { + desc: "Host with trailing period in domain and rule", + rule: "Host(`localhost.`)", + expected: map[string]int{ + "http://localhost./foo": http.StatusOK, + }, + }, + { + desc: "wrong Host", + rule: "Host(`nope`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix", + rule: "Host(`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix wrong PathPrefix", + rule: "Host(`localhost`) && PathPrefix(`/bar`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix wrong Host", + rule: "Host(`nope`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix Host OR, first host", + rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix Host OR, second host", + rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://nope/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix Host OR, first host and wrong PathPrefix", + rule: "Host(`nope,localhost`) && PathPrefix(`/bar`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "HostRegexp with capturing group", + rule: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)", + expected: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "HostRegexp with non capturing group", + rule: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)", + expected: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "Methods with GET", + rule: "Method(`GET`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Methods with GET and POST", + rule: "Method(`GET`,`POST`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Methods with POST", + rule: "Method(`POST`)", + expected: map[string]int{ + // On v2 this test expect a http.StatusMethodNotAllowed status code. + // This was due to a custom behavior of mux https://github.com/containous/mux/blob/b2dd784e613f218225150a5e8b5742c5733bc1b6/mux.go#L130-L132. + // Unfortunately, this behavior cannot be ported easily due to the matcher func signature. + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + 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", + rule: `Host("tchouk") || Host("pouet")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://pouet/a": http.StatusOK, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with host OR (host AND path)", + rule: `Host("tchouk") || (Host("pouet") && Path("/powpow"))`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with host OR host AND path", + rule: `Host("tchouk") || Host("pouet") && Path("/powpow")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with (host OR host) AND path", + rule: `(Host("tchouk") || Host("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 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)", + rule: `(Host("tchouk") && Path("/titi")) || ((Host("pouet")) && Path("/powpow"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule without quote", + rule: `Host(tchouk)`, + expectedError: true, + }, + { + desc: "Rule case UPPER", + rule: `(HOST("tchouk") && PATHPREFIX("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case lower", + rule: `(host("tchouk") && pathprefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case CamelCase", + rule: `(Host("tchouk") && PathPrefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case Title", + rule: `(Host("tchouk") && Pathprefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule Path with error", + rule: `Path("titi")`, + expectedError: true, + }, + { + desc: "Rule PathPrefix with error", + rule: `PathPrefix("titi")`, + expectedError: true, + }, + { + desc: "Rule HostRegexp with error", + rule: `HostRegexp("{test")`, + expectedError: true, + }, + { + desc: "Rule Headers with error", + rule: `Headers("titi")`, + expectedError: true, + }, + { + desc: "Rule HeadersRegexp with error", + rule: `HeadersRegexp("titi")`, + expectedError: true, + }, + { + desc: "Rule Query", + rule: `Query("titi")`, + expectedError: true, + }, + { + desc: "Rule Query with bad syntax", + rule: `Query("titi={test")`, + expectedError: true, + }, + { + desc: "Rule with Path without args", + rule: `Host("tchouk") && Path()`, + expectedError: true, + }, + { + desc: "Rule with an empty path", + rule: `Host("tchouk") && Path("")`, + expectedError: true, + }, + { + desc: "Rule with an empty path", + rule: `Host("tchouk") && Path("", "/titi")`, + expectedError: true, + }, + { + desc: "Rule with not", + rule: `!Host("tchouk")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://test/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on Path", + rule: `!Path("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with or", + rule: `!(Host("tchouk") || Host("toto"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://toto/powpow": http.StatusNotFound, + "http://test/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with and", + rule: `!(Host("tchouk") && Path("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/toto": http.StatusOK, + "http://test/titi": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with and another not", + rule: `!(Host("tchouk") && !Path("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://toto/titi": http.StatusOK, + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Rule with not on two rule", + rule: `!Host("tchouk") || !Path("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/toto": http.StatusOK, + "http://test/titi": http.StatusOK, + }, + }, + { + desc: "Rule case with double not", + rule: `!(!(Host("tchouk") && Pathprefix("/titi")))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://test/titi": http.StatusNotFound, + }, + }, + { + desc: "Rule case with not domain", + rule: `!Host("tchouk") && Pathprefix("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/powpow": http.StatusNotFound, + "http://toto/powpow": http.StatusNotFound, + "http://toto/titi": http.StatusOK, + }, + }, + { + desc: "Rule with multiple host AND multiple path AND not", + rule: `!(Host("tchouk","pouet") && Path("/powpow", "/titi"))`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://pouet/powpow": http.StatusNotFound, + "http://tchouk/titi": http.StatusNotFound, + "http://pouet/titi": http.StatusNotFound, + "http://pouet/toto": http.StatusOK, + "http://plopi/a": http.StatusOK, + }, + }, + { + desc: "ClientIP empty", + rule: "ClientIP(``)", + expectedError: true, + }, + { + desc: "Invalid ClientIP", + rule: "ClientIP(`invalid`)", + expectedError: true, + }, + { + desc: "Non matching ClientIP", + rule: "ClientIP(`10.10.1.1`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "::1", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP", + rule: "ClientIP(`10.0.0.0`)", + remoteAddr: "10.0.0.0:8456", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several IP", + rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Non Matching IP with CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non Matching IPv6 with CIDR", + rule: "ClientIP(`11::/16`)", + remoteAddr: "10::", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP with CIDR", + rule: "ClientIP(`10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6 with CIDR", + rule: "ClientIP(`10::/16`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several CIDR", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among non matching CIDR and matching IP", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among matching CIDR and non matching IP", + rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + } + + 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, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // RequestDecorator is necessary for the hostV2 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) + } + }) + } +} diff --git a/pkg/muxer/http/mux.go b/pkg/muxer/http/mux.go index 979634aa1..33e700697 100644 --- a/pkg/muxer/http/mux.go +++ b/pkg/muxer/http/mux.go @@ -12,8 +12,9 @@ import ( // Muxer handles routing with rules. type Muxer struct { - routes routes - parser predicate.Parser + routes routes + parser predicate.Parser + parserV2 predicate.Parser } // NewMuxer returns a new muxer instance. @@ -28,8 +29,19 @@ func NewMuxer() (*Muxer, error) { return nil, fmt.Errorf("error while creating parser: %w", err) } + var matchersV2 []string + for matcher := range httpFuncsV2 { + matchersV2 = append(matchersV2, matcher) + } + + parserV2, err := rules.NewParser(matchersV2) + if err != nil { + return nil, fmt.Errorf("error while creating v2 parser: %w", err) + } + return &Muxer{ - parser: parser, + parser: parser, + parserV2: parserV2, }, nil } @@ -53,10 +65,26 @@ func GetRulePriority(rule string) int { } // AddRoute add a new route to the router. -func (m *Muxer) AddRoute(rule string, priority int, handler http.Handler) error { - parse, err := m.parser.Parse(rule) - if err != nil { - return fmt.Errorf("error while parsing rule %s: %w", rule, err) +func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler http.Handler) error { + var parse interface{} + var err error + var matcherFuncs map[string]func(*matchersTree, ...string) error + + switch syntax { + case "v2": + parse, err = m.parserV2.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = httpFuncsV2 + default: + parse, err = m.parser.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = httpFuncs } buildTree, ok := parse.(rules.TreeBuilder) @@ -65,7 +93,7 @@ func (m *Muxer) AddRoute(rule string, priority int, handler http.Handler) error } var matchers matchersTree - err = matchers.addRule(buildTree()) + err = matchers.addRule(buildTree(), matcherFuncs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule, err) } @@ -87,6 +115,9 @@ func ParseDomains(rule string) ([]string, error) { for matcher := range httpFuncs { matchers = append(matchers, matcher) } + for matcher := range httpFuncsV2 { + matchers = append(matchers, matcher) + } parser, err := rules.NewParser(matchers) if err != nil { @@ -166,25 +197,27 @@ func (m *matchersTree) match(req *http.Request) bool { } } -func (m *matchersTree) addRule(rule *rules.Tree) error { +type matcherFuncs map[string]func(*matchersTree, ...string) error + +func (m *matchersTree) addRule(rule *rules.Tree, funcs matcherFuncs) error { switch rule.Matcher { case "and", "or": m.operator = rule.Matcher m.left = &matchersTree{} - err := m.left.addRule(rule.RuleLeft) + err := m.left.addRule(rule.RuleLeft, funcs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) } m.right = &matchersTree{} - return m.right.addRule(rule.RuleRight) + return m.right.addRule(rule.RuleRight, funcs) default: err := rules.CheckRule(rule) if err != nil { return fmt.Errorf("error while checking rule %s: %w", rule.Matcher, err) } - err = httpFuncs[rule.Matcher](m, rule.Value...) + err = funcs[rule.Matcher](m, rule.Value...) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) } diff --git a/pkg/muxer/http/mux_test.go b/pkg/muxer/http/mux_test.go index a31a37881..efa8486a3 100644 --- a/pkg/muxer/http/mux_test.go +++ b/pkg/muxer/http/mux_test.go @@ -231,7 +231,7 @@ func TestMuxer(t *testing.T) { 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 { require.Error(t, err) return @@ -394,7 +394,7 @@ func Test_addRoutePriority(t *testing.T) { route.priority = GetRulePriority(route.rule) } - err := muxer.AddRoute(route.rule, route.priority, handler) + err := muxer.AddRoute(route.rule, "", route.priority, handler) require.NoError(t, err, route.rule) } @@ -519,7 +519,7 @@ func TestEmptyHost(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) require.NoError(t, err) // RequestDecorator is necessary for the host rule diff --git a/pkg/muxer/tcp/matcher_test.go b/pkg/muxer/tcp/matcher_test.go index 3ba833d49..531492e89 100644 --- a/pkg/muxer/tcp/matcher_test.go +++ b/pkg/muxer/tcp/matcher_test.go @@ -38,7 +38,7 @@ func Test_HostSNICatchAll(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) require.NoError(t, err) handler, catchAll := muxer.Match(ConnData{ @@ -144,7 +144,7 @@ func Test_HostSNI(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -227,7 +227,7 @@ func Test_HostSNIRegexp(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -299,7 +299,7 @@ func Test_ClientIP(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -363,7 +363,7 @@ func Test_ALPN(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return diff --git a/pkg/muxer/tcp/matcher_v2.go b/pkg/muxer/tcp/matcher_v2.go new file mode 100644 index 000000000..3d918b02e --- /dev/null +++ b/pkg/muxer/tcp/matcher_v2.go @@ -0,0 +1,240 @@ +package tcp + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/challenge/tlsalpn01" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/ip" +) + +var tcpFuncsV2 = map[string]func(*matchersTree, ...string) error{ + "ALPN": alpnV2, + "ClientIP": clientIPV2, + "HostSNI": hostSNIV2, + "HostSNIRegexp": hostSNIRegexpV2, +} + +func clientIPV2(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 +} + +// alpnV2 checks if any of the connection ALPN protocols matches one of the matcher protocols. +func alpnV2(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 +} + +// hostSNIV2 checks if the SNI Host of the connection match the matcher host. +func hostSNIV2(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 !hostOrIP.MatchString(host) { + return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname or IP", 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 +} + +// hostSNIRegexpV2 checks if the SNI Host of the connection matches the matcher host regexp. +func hostSNIRegexpV2(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 +} + +// 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 + 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) + } + + // Add the remaining. + raw := template[end:] + pattern.WriteString(regexp.QuoteMeta(raw)) + pattern.WriteByte('$') + + return pattern.String(), nil +} + +// varGroupName builds a capturing group name for the indexed variable. +// This function is a copy of varGroupName func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func varGroupName(idx int) string { + return "v" + strconv.Itoa(idx) +} + +// braceIndices returns the first level curly brace indices from a string. +// This function is a copy of braceIndices func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func braceIndices(s string) ([]int, error) { + var level, idx int + var idxs []int + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + if level++; level == 1 { + idx = i + } + case '}': + if level--; level == 0 { + idxs = append(idxs, idx, i+1) + } else if level < 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + } + } + if level != 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + return idxs, nil +} diff --git a/pkg/muxer/tcp/matcher_v2_test.go b/pkg/muxer/tcp/matcher_v2_test.go new file mode 100644 index 000000000..74ffcff80 --- /dev/null +++ b/pkg/muxer/tcp/matcher_v2_test.go @@ -0,0 +1,1008 @@ +package tcp + +import ( + "fmt" + "testing" + + "github.com/go-acme/lego/v4/challenge/tlsalpn01" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/tcp" +) + +// All the tests in the suite are a copy of tcp muxer tests on branch v2. +// Only the test for route priority has not been copied here, +// because the priority computation is no longer done when calling the muxer AddRoute method. +func Test_addTCPRouteV2(t *testing.T) { + testCases := []struct { + desc string + rule string + serverName string + remoteAddr string + protos []string + routeErr bool + matchErr bool + }{ + { + desc: "no tree", + routeErr: true, + }, + { + desc: "Rule with no matcher", + rule: "rulewithnotmatcher", + routeErr: true, + }, + { + desc: "Empty HostSNI rule", + rule: "HostSNI()", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Empty HostSNI rule", + rule: "HostSNI(``)", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Valid HostSNI rule matching", + rule: "HostSNI(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid negative HostSNI rule matching", + rule: "!HostSNI(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule matching with alternative case", + rule: "hostsni(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule matching with alternative case", + rule: "HOSTSNI(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule not matching", + rule: "HostSNI(`foobar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp()", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp(``)", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Valid HostSNIRegexp rule matching", + rule: "HostSNIRegexp(`{subdomain:[a-z]+}.foobar`)", + serverName: "sub.foobar", + }, + { + desc: "Valid negative HostSNIRegexp rule matching", + rule: "!HostSNIRegexp(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "hostsniregexp(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "HOSTSNIREGEXP(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule not matching", + rule: "HostSNIRegexp(`foobar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid negative HostSNI rule not matching", + rule: "!HostSNI(`bar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid HostSNIRegexp rule matching empty servername", + rule: "HostSNIRegexp(`{subdomain:[a-z]*}`)", + serverName: "", + }, + { + desc: "Valid HostSNIRegexp rule with one name", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto", + }, + { + desc: "Valid HostSNIRegexp rule with one name 2", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto.com", + matchErr: true, + }, + { + desc: "Empty ClientIP rule", + rule: "ClientIP()", + routeErr: true, + }, + { + desc: "Empty ClientIP rule", + rule: "ClientIP(``)", + routeErr: true, + }, + { + desc: "Invalid ClientIP", + rule: "ClientIP(`invalid`)", + routeErr: true, + }, + { + desc: "Invalid remoteAddr", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "not.an.IP:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative ClientIP rule matching", + rule: "!ClientIP(`20.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule matching with alternative case", + rule: "clientip(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule matching with alternative case", + rule: "CLIENTIP(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule not matching", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid negative ClientIP rule not matching", + rule: "!ClientIP(`10.0.0.2`)", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "[10::10]:80", + }, + { + desc: "Valid negative ClientIP rule matching IPv6", + rule: "!ClientIP(`10::10`)", + remoteAddr: "[::1]:80", + }, + { + desc: "Valid ClientIP rule not matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "[::1]:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching multiple IPs", + rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule matching CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "11.0.0.0:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "10.0.0.0:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching CIDR IPv6", + rule: "ClientIP(`11::/16`)", + remoteAddr: "[11::]:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR IPv6", + rule: "ClientIP(`11::/16`)", + remoteAddr: "[10::]:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching multiple CIDR", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR and matching IP", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule matching CIDR and not matching IP", + rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid HostSNI and ClientIP rule matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and ClientIP rule matching", + rule: "!HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and negative ClientIP rule matching", + rule: "HostSNI(`foobar`) && !ClientIP(`10.0.0.2`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!HostSNI(`bar`) && !ClientIP(`10.0.0.2`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI or negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) || ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP rule not matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP rule not matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid HostSNI or ClientIP rule not matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI x 3 OR rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI x 3 OR rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", + serverName: "baz", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP Combined rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid HostSNI and ClientIP Combined rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP Combined rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP Combined rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "baz", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule not matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "baz", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule not matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.3:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP more complex (but absurd) combined rule matching", + rule: "(HostSNI(`foobar`) || (HostSNI(`bar`) && !HostSNI(`foobar`))) && ((ClientIP(`10.0.0.1`) && !ClientIP(`10.0.0.2`)) || ClientIP(`10.0.0.2`)) ", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Invalid ALPN rule matching ACME-TLS/1", + rule: fmt.Sprintf("ALPN(`%s`)", tlsalpn01.ACMETLS1Protocol), + protos: []string{"foo"}, + routeErr: true, + }, + { + desc: "Valid ALPN rule matching single protocol", + rule: "ALPN(`foo`)", + protos: []string{"foo"}, + }, + { + desc: "Valid ALPN rule matching ACME-TLS/1 protocol", + rule: "ALPN(`foo`)", + protos: []string{tlsalpn01.ACMETLS1Protocol}, + matchErr: true, + }, + { + desc: "Valid ALPN rule not matching single protocol", + rule: "ALPN(`foo`)", + protos: []string{"bar"}, + matchErr: true, + }, + { + desc: "Valid alternative case ALPN rule matching single protocol without another being supported", + rule: "ALPN(`foo`) && !alpn(`h2`)", + protos: []string{"foo", "bar"}, + }, + { + desc: "Valid alternative case ALPN rule not matching single protocol because of another being supported", + rule: "ALPN(`foo`) && !alpn(`h2`)", + protos: []string{"foo", "h2", "bar"}, + matchErr: true, + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar"}, + serverName: "foo", + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule not matching by SNI", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar", "h2"}, + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule matching by ALPN", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar"}, + serverName: "bar", + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule not matching by protos", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"h2", "bar"}, + serverName: "bar", + matchErr: true, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + msg := "BYTES" + handler := tcp.HandlerFunc(func(conn tcp.WriteCloser) { + _, err := conn.Write([]byte(msg)) + require.NoError(t, err) + }) + + router, err := NewMuxer() + require.NoError(t, err) + + err = router.AddRoute(test.rule, "v2", 0, handler) + if test.routeErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + + addr := "0.0.0.0:0" + if test.remoteAddr != "" { + addr = test.remoteAddr + } + + conn := &fakeConn{ + call: map[string]int{}, + remoteAddr: fakeAddr{addr: addr}, + } + + connData, err := NewConnData(test.serverName, conn, test.protos) + require.NoError(t, err) + + matchingHandler, _ := router.Match(connData) + if test.matchErr { + require.Nil(t, matchingHandler) + return + } + + require.NotNil(t, matchingHandler) + + matchingHandler.ServeTCP(conn) + + n, ok := conn.call[msg] + assert.Equal(t, 1, n) + assert.True(t, ok) + }) + } +} + +func TestParseHostSNIV2(t *testing.T) { + testCases := []struct { + description string + expression string + domain []string + errorExpected bool + }{ + { + description: "Unknown rule", + expression: "Foobar(`foo.bar`,`test.bar`)", + errorExpected: true, + }, + { + description: "Many hostSNI rules", + expression: "HostSNI(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "Many hostSNI rules upper", + expression: "HOSTSNI(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "Many hostSNI rules lower", + expression: "hostsni(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "No hostSNI rule", + expression: "ClientIP(`10.1`)", + }, + { + description: "HostSNI rule and another rule", + expression: "HostSNI(`foo.bar`) && ClientIP(`10.1`)", + domain: []string{"foo.bar"}, + }, + { + description: "HostSNI rule to lower and another rule", + expression: "HostSNI(`Foo.Bar`) && ClientIP(`10.1`)", + domain: []string{"foo.bar"}, + }, + { + description: "HostSNI rule with no domain", + expression: "HostSNI() && ClientIP(`10.1`)", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.expression, func(t *testing.T) { + t.Parallel() + + domains, err := ParseHostSNI(test.expression) + + if test.errorExpected { + require.Errorf(t, err, "unable to parse correctly the domains in the HostSNI rule from %q", test.expression) + } else { + require.NoError(t, err, "%s: Error while parsing domain.", test.expression) + } + + assert.EqualValues(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression) + }) + } +} + +func Test_HostSNICatchAllV2(t *testing.T) { + testCases := []struct { + desc string + rule string + isCatchAll bool + }{ + { + desc: "HostSNI(`foobar`) is not catchAll", + rule: "HostSNI(`foobar`)", + }, + { + desc: "HostSNI(`*`) is catchAll", + rule: "HostSNI(`*`)", + isCatchAll: true, + }, + { + desc: "HOSTSNI(`*`) is catchAll", + rule: "HOSTSNI(`*`)", + isCatchAll: true, + }, + { + desc: `HostSNI("*") is catchAll`, + rule: `HostSNI("*")`, + isCatchAll: true, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + require.NoError(t, err) + + handler, catchAll := muxer.Match(ConnData{ + serverName: "foobar", + }) + require.NotNil(t, handler) + assert.Equal(t, test.isCatchAll, catchAll) + }) + } +} + +func Test_HostSNIV2(t *testing.T) { + testCases := []struct { + desc string + ruleHosts []string + serverName string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Non ASCII host", + ruleHosts: []string{"héhé"}, + buildErr: true, + }, + { + desc: "Not Matching hosts", + ruleHosts: []string{"foobar"}, + serverName: "bar", + matchErr: true, + }, + { + desc: "Matching globing host `*`", + ruleHosts: []string{"*"}, + serverName: "foobar", + }, + { + desc: "Matching globing host `*` and empty serverName", + ruleHosts: []string{"*"}, + serverName: "", + }, + { + desc: "Matching globing host `*` and another non matching host", + ruleHosts: []string{"foo", "*"}, + serverName: "bar", + }, + { + desc: "Matching globing host `*` and another non matching host, and empty servername", + ruleHosts: []string{"foo", "*"}, + serverName: "", + matchErr: true, + }, + { + desc: "Not Matching globing host with subdomain", + ruleHosts: []string{"*.bar"}, + buildErr: true, + }, + { + desc: "Not Matching host with trailing dot with ", + ruleHosts: []string{"foobar."}, + serverName: "foobar.", + }, + { + desc: "Matching host with trailing dot", + ruleHosts: []string{"foobar."}, + serverName: "foobar", + }, + { + desc: "Matching hosts", + ruleHosts: []string{"foobar", "foo-bar.baz"}, + serverName: "foobar", + }, + { + desc: "Matching hosts with subdomains", + ruleHosts: []string{"foo.bar"}, + serverName: "foo.bar", + }, + { + desc: "Matching IPv4", + ruleHosts: []string{"127.0.0.1"}, + serverName: "127.0.0.1", + }, + { + desc: "Matching IPv6", + ruleHosts: []string{"10::10"}, + serverName: "10::10", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matcherTree := &matchersTree{} + err := hostSNIV2(matcherTree, test.ruleHosts...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + serverName: test.serverName, + } + + assert.Equal(t, test.matchErr, !matcherTree.match(meta)) + }) + } +} + +func Test_HostSNIRegexpV2(t *testing.T) { + testCases := []struct { + desc string + pattern string + serverNames map[string]bool + buildErr bool + }{ + { + desc: "unbalanced braces", + pattern: "subdomain:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty group name", + pattern: "{:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty capturing group", + pattern: "{subdomain:}", + buildErr: true, + }, + { + desc: "malformed capturing group", + pattern: "{subdomain:(foo\\.?bar\\.com}", + buildErr: true, + }, + { + desc: "not interpreted as a regexp", + pattern: "bar.com", + serverNames: map[string]bool{ + "bar.com": true, + "barucom": false, + }, + }, + { + desc: "capturing group", + pattern: "{subdomain:(foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "non capturing group", + pattern: "{subdomain:(?:foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "regex insensitive", + pattern: "{dummy:[A-Za-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host", + pattern: "{dummy:[a-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host simple", + pattern: "foo.bar.com", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := hostSNIRegexpV2(matchersTree, test.pattern) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for serverName, match := range test.serverNames { + meta := ConnData{ + serverName: serverName, + } + + assert.Equal(t, match, matchersTree.match(meta)) + } + }) + } +} + +func Test_ClientIPV2(t *testing.T) { + testCases := []struct { + desc string + ruleCIDRs []string + remoteIP string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Malformed CIDR", + ruleCIDRs: []string{"héhé"}, + buildErr: true, + }, + { + desc: "Not matching empty remote IP", + ruleCIDRs: []string{"20.20.20.20"}, + matchErr: true, + }, + { + desc: "Not matching IP", + ruleCIDRs: []string{"20.20.20.20"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching IP", + ruleCIDRs: []string{"10.10.10.10"}, + remoteIP: "10.10.10.10", + }, + { + desc: "Not matching multiple IPs", + ruleCIDRs: []string{"20.20.20.20", "30.30.30.30"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching multiple IPs", + ruleCIDRs: []string{"10.10.10.10", "20.20.20.20", "30.30.30.30"}, + remoteIP: "20.20.20.20", + }, + { + desc: "Not matching CIDR", + ruleCIDRs: []string{"20.0.0.0/24"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching CIDR", + ruleCIDRs: []string{"20.0.0.0/8"}, + remoteIP: "20.10.10.10", + }, + { + desc: "Not matching multiple CIDRs", + ruleCIDRs: []string{"10.0.0.0/24", "20.0.0.0/24"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching multiple CIDRs", + ruleCIDRs: []string{"10.0.0.0/8", "20.0.0.0/8"}, + remoteIP: "20.10.10.10", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := clientIPV2(matchersTree, test.ruleCIDRs...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + remoteIP: test.remoteIP, + } + + assert.Equal(t, test.matchErr, !matchersTree.match(meta)) + }) + } +} + +func Test_ALPNV2(t *testing.T) { + testCases := []struct { + desc string + ruleALPNProtos []string + connProto string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "ACME TLS proto", + ruleALPNProtos: []string{tlsalpn01.ACMETLS1Protocol}, + buildErr: true, + }, + { + desc: "Not matching empty proto", + ruleALPNProtos: []string{"h2"}, + matchErr: true, + }, + { + desc: "Not matching ALPN", + ruleALPNProtos: []string{"h2"}, + connProto: "mqtt", + matchErr: true, + }, + { + desc: "Matching ALPN", + ruleALPNProtos: []string{"h2"}, + connProto: "h2", + }, + { + desc: "Not matching multiple ALPNs", + ruleALPNProtos: []string{"h2", "mqtt"}, + connProto: "h2c", + matchErr: true, + }, + { + desc: "Matching multiple ALPNs", + ruleALPNProtos: []string{"h2", "h2c", "mqtt"}, + connProto: "h2c", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := alpnV2(matchersTree, test.ruleALPNProtos...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + alpnProtos: []string{test.connProto}, + } + + assert.Equal(t, test.matchErr, !matchersTree.match(meta)) + }) + } +} diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index f23ce629d..35c4be8a6 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -41,8 +41,9 @@ func NewConnData(serverName string, conn tcp.WriteCloser, alpnProtos []string) ( // Muxer defines a muxer that handles TCP routing with rules. type Muxer struct { - routes routes - parser predicate.Parser + routes routes + parser predicate.Parser + parserV2 predicate.Parser } // NewMuxer returns a TCP muxer. @@ -57,7 +58,20 @@ func NewMuxer() (*Muxer, error) { return nil, fmt.Errorf("error while creating rules parser: %w", err) } - return &Muxer{parser: parser}, nil + var matchersV2 []string + for matcher := range tcpFuncsV2 { + matchersV2 = append(matchersV2, matcher) + } + + parserV2, err := rules.NewParser(matchersV2) + if err != nil { + return nil, fmt.Errorf("error while creating v2 rules parser: %w", err) + } + + return &Muxer{ + parser: parser, + parserV2: parserV2, + }, nil } // Match returns the handler of the first route matching the connection metadata, @@ -106,10 +120,26 @@ func GetRulePriority(rule string) int { // AddRoute adds a new route, associated to the given handler, at the given // priority, to the muxer. -func (m *Muxer) AddRoute(rule string, priority int, handler tcp.Handler) error { - parse, err := m.parser.Parse(rule) - if err != nil { - return fmt.Errorf("error while parsing rule %s: %w", rule, err) +func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler tcp.Handler) error { + var parse interface{} + var err error + var matcherFuncs map[string]func(*matchersTree, ...string) error + + switch syntax { + case "v2": + parse, err = m.parserV2.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = tcpFuncsV2 + default: + parse, err = m.parser.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = tcpFuncs } buildTree, ok := parse.(rules.TreeBuilder) @@ -120,7 +150,7 @@ func (m *Muxer) AddRoute(rule string, priority int, handler tcp.Handler) error { ruleTree := buildTree() var matchers matchersTree - err = matchers.addRule(ruleTree) + err = matchers.addRule(ruleTree, matcherFuncs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule, err) } @@ -155,6 +185,9 @@ func ParseHostSNI(rule string) ([]string, error) { for matcher := range tcpFuncs { matchers = append(matchers, matcher) } + for matcher := range tcpFuncsV2 { + matchers = append(matchers, matcher) + } parser, err := rules.NewParser(matchers) if err != nil { @@ -237,25 +270,27 @@ func (m *matchersTree) match(meta ConnData) bool { } } -func (m *matchersTree) addRule(rule *rules.Tree) error { +type matcherFuncs map[string]func(*matchersTree, ...string) error + +func (m *matchersTree) addRule(rule *rules.Tree, funcs matcherFuncs) error { switch rule.Matcher { case "and", "or": m.operator = rule.Matcher m.left = &matchersTree{} - err := m.left.addRule(rule.RuleLeft) + err := m.left.addRule(rule.RuleLeft, funcs) if err != nil { return err } m.right = &matchersTree{} - return m.right.addRule(rule.RuleRight) + return m.right.addRule(rule.RuleRight, funcs) default: err := rules.CheckRule(rule) if err != nil { return err } - err = tcpFuncs[rule.Matcher](m, rule.Value...) + err = funcs[rule.Matcher](m, rule.Value...) if err != nil { return err } diff --git a/pkg/muxer/tcp/mux_test.go b/pkg/muxer/tcp/mux_test.go index 5c52089b4..3b108b7ea 100644 --- a/pkg/muxer/tcp/mux_test.go +++ b/pkg/muxer/tcp/mux_test.go @@ -277,7 +277,7 @@ func Test_addTCPRoute(t *testing.T) { router, err := NewMuxer() require.NoError(t, err) - err = router.AddRoute(test.rule, 0, handler) + err = router.AddRoute(test.rule, "", 0, handler) if test.routeErr { require.Error(t, err) return @@ -447,7 +447,7 @@ func Test_Priority(t *testing.T) { matchedRule := "" for rule, priority := range test.rules { rule := rule - err := muxer.AddRoute(rule, priority, tcp.HandlerFunc(func(conn tcp.WriteCloser) { + err := muxer.AddRoute(rule, "", priority, tcp.HandlerFunc(func(conn tcp.WriteCloser) { matchedRule = rule })) require.NoError(t, err) diff --git a/pkg/provider/kubernetes/crd/kubernetes_http.go b/pkg/provider/kubernetes/crd/kubernetes_http.go index ca4f78219..6e3b1f2a5 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_http.go +++ b/pkg/provider/kubernetes/crd/kubernetes_http.go @@ -112,6 +112,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli r := &dynamic.Router{ Middlewares: mds, Priority: route.Priority, + RuleSyntax: route.Syntax, EntryPoints: ingressRoute.Spec.EntryPoints, Rule: route.Match, Service: serviceName, diff --git a/pkg/provider/kubernetes/crd/kubernetes_tcp.go b/pkg/provider/kubernetes/crd/kubernetes_tcp.go index b0759786f..d5fb434a3 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_tcp.go +++ b/pkg/provider/kubernetes/crd/kubernetes_tcp.go @@ -102,6 +102,7 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client Middlewares: mds, Rule: route.Match, Priority: route.Priority, + RuleSyntax: route.Syntax, Service: serviceName, } diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go index 6471381ae..475d160d4 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go @@ -33,6 +33,9 @@ type Route struct { // Priority defines the router's priority. // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#priority Priority int `json:"priority,omitempty"` + // Syntax defines the router's rule syntax. + // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax + Syntax string `json:"syntax,omitempty"` // Services defines the list of Service. // It can contain any combination of TraefikService and/or reference to a Kubernetes Service. Services []Service `json:"services,omitempty"` diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go index 5669e8f4f..5cfd69d4a 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go @@ -29,6 +29,9 @@ type RouteTCP struct { // Priority defines the router's priority. // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#priority_1 Priority int `json:"priority,omitempty"` + // Syntax defines the router's rule syntax. + // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1 + Syntax string `json:"syntax,omitempty"` // Services defines the list of TCP services. Services []ServiceTCP `json:"services,omitempty"` // Middlewares defines the list of references to MiddlewareTCP resources. diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index e9ed7e6a7..c632cde73 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -770,6 +770,7 @@ func (p *Provider) gatewayHTTPRouteToHTTPConf(ctx context.Context, ep string, li router := dynamic.Router{ Rule: rule, + RuleSyntax: "v3", EntryPoints: []string{ep}, } @@ -908,6 +909,7 @@ func gatewayTCPRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Li router := dynamic.TCPRouter{ Rule: "HostSNI(`*`)", EntryPoints: []string{ep}, + RuleSyntax: "v3", } if listener.Protocol == gatev1.TLSProtocolType && listener.TLS != nil { @@ -1072,6 +1074,7 @@ func gatewayTLSRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Li router := dynamic.TCPRouter{ Rule: rule, + RuleSyntax: "v3", EntryPoints: []string{ep}, TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, @@ -1395,7 +1398,7 @@ func extractHeaderRules(headers []gatev1.HTTPHeaderMatch) ([]string, error) { switch *header.Type { case gatev1.HeaderMatchExact: - headerRules = append(headerRules, fmt.Sprintf("Headers(`%s`,`%s`)", header.Name, header.Value)) + headerRules = append(headerRules, fmt.Sprintf("Header(`%s`,`%s`)", header.Name, header.Value)) default: return nil, fmt.Errorf("unsupported header match type %s", *header.Type) } diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 7c747a270..df3b95a0b 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -550,6 +550,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -609,6 +610,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "api@internal", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -641,6 +643,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -704,6 +707,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -773,6 +777,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-66e726cd8903b49727ae-wrr", Rule: "(Host(`foo.com`) || Host(`bar.com`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -832,6 +837,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.com$`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -891,6 +897,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.foo\\.com$`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -949,11 +956,13 @@ func TestLoadHTTPRoutes(t *testing.T) { "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", }, "default-http-app-1-my-gateway-web-d737b4933fa88e68ab8a": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bir`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-d737b4933fa88e68ab8a-wrr", }, }, @@ -1039,6 +1048,7 @@ func TestLoadHTTPRoutes(t *testing.T) { "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", }, }, @@ -1124,11 +1134,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-http-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-https-websecure-1c0cf64bde37d9d0df06": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-https-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -1213,11 +1225,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -1293,20 +1307,22 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-330d644a7f2079e8f454": { + "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-330d644a7f2079e8f454-wrr", - Rule: "Host(`foo.com`) && PathPrefix(`/bar`) && Headers(`my-header`,`foo`) && Headers(`my-header2`,`bar`)", + Service: "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37-wrr", + Rule: "Host(`foo.com`) && PathPrefix(`/bar`) && Header(`my-header`,`foo`) && Header(`my-header2`,`bar`)", + RuleSyntax: "v3", }, - "default-http-app-1-my-gateway-web-fe80e69a38713941ea22": { + "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-fe80e69a38713941ea22-wrr", - Rule: "Host(`foo.com`) && Path(`/bar`) && Headers(`my-header`,`bar`)", + Service: "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276-wrr", + Rule: "Host(`foo.com`) && Path(`/bar`) && Header(`my-header`,`bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-330d644a7f2079e8f454-wrr": { + "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -1316,7 +1332,7 @@ func TestLoadHTTPRoutes(t *testing.T) { }, }, }, - "default-http-app-1-my-gateway-web-fe80e69a38713941ea22-wrr": { + "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -1371,6 +1387,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-efde1997778109a1f6eb-wrr", Rule: "Host(`foo.com`) && Path(`/foo`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1430,11 +1447,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-efde1997778109a1f6eb-wrr", Rule: "Host(`foo.com`) && Path(`/foo`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597": { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597-wrr", Rule: "Host(`bar.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1520,6 +1539,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597-wrr", Rule: "Host(`bar.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1579,6 +1599,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", Rule: "Host(`example.org`) && PathPrefix(`/`)", + RuleSyntax: "v3", Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, }, }, @@ -1647,6 +1668,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", Rule: "Host(`example.org`) && PathPrefix(`/`)", + RuleSyntax: "v3", Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, }, }, @@ -1912,6 +1934,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -1969,11 +1992,13 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tcp-app-1-my-tcp-gateway-tcp-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-2-my-tcp-gateway-tcp-2-e3b0c44298fc1c149afb": { EntryPoints: []string{"tcp-2"}, Service: "default-tcp-app-2-my-tcp-gateway-tcp-2-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2053,6 +2078,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tcp-app-my-tcp-gateway-tcp-1-e3b0c44298fc1c149afb-wrr", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2144,6 +2170,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2203,6 +2230,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -2266,6 +2294,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2321,11 +2350,13 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb": { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2403,6 +2434,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2696,6 +2728,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tls-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -2761,6 +2794,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tls-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2819,6 +2853,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tls-app-1-my-tls-gateway-tcp-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2878,12 +2913,14 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-tls-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-1-my-tls-gateway-tcp-673acf455cb2dab0b43a": { EntryPoints: []string{"tcp"}, Service: "default-tls-app-1-my-tls-gateway-tcp-673acf455cb2dab0b43a-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2973,6 +3010,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -3042,6 +3080,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3100,6 +3139,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3158,6 +3198,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3216,6 +3257,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-d5342d75658583f03593-wrr-0", Rule: "HostSNI(`foo.example.com`) || HostSNI(`bar.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3274,6 +3316,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-default-my-gateway-tls-06ae57dcf13ab4c60ee5-wrr-0", Rule: "HostSNI(`foo.default`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3332,6 +3375,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-default-my-gateway-tls-06ae57dcf13ab4c60ee5-wrr-0", Rule: "HostSNI(`foo.default`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3340,6 +3384,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "bar-tls-app-bar-my-gateway-tls-2279fe75c5156dc5eb26-wrr-0", Rule: "HostSNI(`foo.bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3420,6 +3465,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "bar-tls-app-bar-my-gateway-tls-2279fe75c5156dc5eb26-wrr-0", Rule: "HostSNI(`foo.bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3478,6 +3524,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tls-app-my-gateway-tcp-1-673acf455cb2dab0b43a-wrr", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3702,17 +3749,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-1-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-1-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-1-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-1-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3771,11 +3821,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -3881,17 +3933,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3950,11 +4005,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4032,17 +4089,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -4051,11 +4111,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -4144,22 +4206,26 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd": { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4273,17 +4339,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "bar-tls-app-bar-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "bar-tls-app-bar-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -4342,11 +4411,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4423,11 +4494,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls"}, Service: "default-tcp-app-default-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -4474,11 +4547,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4807,7 +4882,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Path(`/foo/`) || Headers(`my-header`,`foo`)", + expectedRule: "Path(`/foo/`) || Header(`my-header`,`foo`)", }, { desc: "Path && Header rules", @@ -4828,7 +4903,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Path(`/foo/`) && Headers(`my-header`,`foo`)", + expectedRule: "Path(`/foo/`) && Header(`my-header`,`foo`)", }, { desc: "Host && Path && Header rules", @@ -4850,7 +4925,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Headers(`my-header`,`foo`)", + expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Header(`my-header`,`foo`)", }, { desc: "Host && (Path || Header) rules", @@ -4874,7 +4949,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || Headers(`my-header`,`foo`))", + expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || Header(`my-header`,`foo`))", }, } diff --git a/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json b/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json index 992a447d1..1dec58871 100644 --- a/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json +++ b/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json @@ -22,6 +22,11 @@ "priority": 2147483645 } }, + "services": { + "api": {}, + "dashboard": {}, + "noop": {} + }, "middlewares": { "dashboard_redirect": { "redirectRegex": { @@ -38,11 +43,6 @@ ] } } - }, - "services": { - "api": {}, - "dashboard": {}, - "noop": {} } }, "tcp": {}, diff --git a/pkg/provider/traefik/fixtures/full_configuration.json b/pkg/provider/traefik/fixtures/full_configuration.json index f09614e2e..6e9f2d4b3 100644 --- a/pkg/provider/traefik/fixtures/full_configuration.json +++ b/pkg/provider/traefik/fixtures/full_configuration.json @@ -54,6 +54,14 @@ "priority": 2147483647 } }, + "services": { + "api": {}, + "dashboard": {}, + "noop": {}, + "ping": {}, + "prometheus": {}, + "rest": {} + }, "middlewares": { "dashboard_redirect": { "redirectRegex": { @@ -70,14 +78,6 @@ ] } } - }, - "services": { - "api": {}, - "dashboard": {}, - "noop": {}, - "ping": {}, - "prometheus": {}, - "rest": {} } }, "tcp": {}, diff --git a/pkg/provider/traefik/fixtures/redirection.json b/pkg/provider/traefik/fixtures/redirection.json index 2b3b271fa..73ae77db3 100644 --- a/pkg/provider/traefik/fixtures/redirection.json +++ b/pkg/provider/traefik/fixtures/redirection.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-websecure": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/fixtures/redirection_port.json b/pkg/provider/traefik/fixtures/redirection_port.json index ead9bc0b1..a9e75438a 100644 --- a/pkg/provider/traefik/fixtures/redirection_port.json +++ b/pkg/provider/traefik/fixtures/redirection_port.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-443": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/fixtures/redirection_with_protocol.json b/pkg/provider/traefik/fixtures/redirection_with_protocol.json index 2b3b271fa..73ae77db3 100644 --- a/pkg/provider/traefik/fixtures/redirection_with_protocol.json +++ b/pkg/provider/traefik/fixtures/redirection_with_protocol.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-websecure": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/internal.go b/pkg/provider/traefik/internal.go index 71a6c321a..9a4105623 100644 --- a/pkg/provider/traefik/internal.go +++ b/pkg/provider/traefik/internal.go @@ -65,6 +65,7 @@ func (i *Provider) createConfiguration(ctx context.Context) *dynamic.Configurati TCP: &dynamic.TCPConfiguration{ Routers: make(map[string]*dynamic.TCPRouter), Services: make(map[string]*dynamic.TCPService), + Models: make(map[string]*dynamic.TCPModel), ServersTransports: make(map[string]*dynamic.TCPServersTransport), }, TLS: &dynamic.TLSConfiguration{ @@ -191,8 +192,13 @@ func (i *Provider) getEntryPointPort(name string, def *static.Redirections) (str } func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { + defaultRuleSyntax := "" + if i.staticCfg.Core != nil && i.staticCfg.Core.DefaultRuleSyntax != "" { + defaultRuleSyntax = i.staticCfg.Core.DefaultRuleSyntax + } + for name, ep := range i.staticCfg.EntryPoints { - if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil { + if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil && defaultRuleSyntax == "" { continue } @@ -208,7 +214,19 @@ func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { } } + m.DefaultRuleSyntax = defaultRuleSyntax + cfg.HTTP.Models[name] = m + + if cfg.TCP == nil { + continue + } + + mTCP := &dynamic.TCPModel{ + DefaultRuleSyntax: defaultRuleSyntax, + } + + cfg.TCP.Models[name] = mTCP } } diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go index bd4fda0f5..c6a88e590 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -24,6 +24,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint Routers: make(map[string]*dynamic.TCPRouter), Services: make(map[string]*dynamic.TCPService), Middlewares: make(map[string]*dynamic.TCPMiddleware), + Models: make(map[string]*dynamic.TCPModel), ServersTransports: make(map[string]*dynamic.TCPServersTransport), }, UDP: &dynamic.UDPConfiguration{ @@ -152,6 +153,13 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration { for name, rt := range cfg.HTTP.Routers { router := rt.DeepCopy() + if !router.DefaultRule && router.RuleSyntax == "" { + for _, model := range cfg.HTTP.Models { + router.RuleSyntax = model.DefaultRuleSyntax + break + } + } + eps := router.EntryPoints router.EntryPoints = nil @@ -183,6 +191,25 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration { cfg.HTTP.Routers = rts + if cfg.TCP == nil || len(cfg.TCP.Models) == 0 { + return cfg + } + + tcpRouters := make(map[string]*dynamic.TCPRouter) + + for _, rt := range cfg.TCP.Routers { + router := rt.DeepCopy() + + if router.RuleSyntax == "" { + for _, model := range cfg.TCP.Models { + router.RuleSyntax = model.DefaultRuleSyntax + break + } + } + } + + cfg.TCP.Routers = tcpRouters + return cfg } diff --git a/pkg/server/aggregator_test.go b/pkg/server/aggregator_test.go index ea1fb639f..1ef3bd8ac 100644 --- a/pkg/server/aggregator_test.go +++ b/pkg/server/aggregator_test.go @@ -473,6 +473,7 @@ func Test_mergeConfiguration_defaultTCPEntryPoint(t *testing.T) { Services: map[string]*dynamic.TCPService{ "service-1@provider-1": {}, }, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: make(map[string]*dynamic.TCPServersTransport), } diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go index 36125db64..a0fed4cae 100644 --- a/pkg/server/configurationwatcher_test.go +++ b/pkg/server/configurationwatcher_test.go @@ -92,6 +92,7 @@ func TestNewConfigurationWatcher(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, TLS: &dynamic.TLSConfiguration{ @@ -231,6 +232,7 @@ func TestIgnoreTransientConfiguration(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -400,6 +402,7 @@ func TestListenProvidersDoesNotSkipFlappingConfiguration(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -490,6 +493,7 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -625,6 +629,7 @@ func TestListenProvidersIgnoreIntermediateConfigs(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -693,6 +698,7 @@ func TestListenProvidersPublishesConfigForEachProvider(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, TLS: &dynamic.TLSConfiguration{ diff --git a/pkg/server/router/router.go b/pkg/server/router/router.go index 4b383e076..bfa99cfb0 100644 --- a/pkg/server/router/router.go +++ b/pkg/server/router/router.go @@ -131,7 +131,7 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string continue } - if err = muxer.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err = muxer.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue diff --git a/pkg/server/router/tcp/manager.go b/pkg/server/router/tcp/manager.go index df24ed704..cd10d5ccf 100644 --- a/pkg/server/router/tcp/manager.go +++ b/pkg/server/router/tcp/manager.go @@ -311,7 +311,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim if routerConfig.TLS == nil { logger.Debug().Msgf("Adding route for %q", routerConfig.Rule) - if err := router.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCP.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -321,7 +321,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim if routerConfig.TLS.Passthrough { logger.Debug().Msgf("Adding Passthrough route for %q", routerConfig.Rule) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -355,7 +355,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim logger.Debug().Msgf("Adding special TLS closing route for %q because broken TLS options %s", routerConfig.Rule, tlsOptionsName) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, &brokenTLSRouter{}); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, &brokenTLSRouter{}); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -389,7 +389,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim logger.Debug().Msgf("Adding TLS route for %q", routerConfig.Rule) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue diff --git a/pkg/server/router/tcp/router.go b/pkg/server/router/tcp/router.go index 711a60313..74f563486 100644 --- a/pkg/server/router/tcp/router.go +++ b/pkg/server/router/tcp/router.go @@ -201,9 +201,9 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) { conn.Close() } -// AddRoute defines a handler for the given rule. -func (r *Router) AddRoute(rule string, priority int, target tcp.Handler) error { - return r.muxerTCP.AddRoute(rule, priority, target) +// AddTCPRoute defines a handler for the given rule. +func (r *Router) AddTCPRoute(rule string, priority int, target tcp.Handler) error { + return r.muxerTCP.AddRoute(rule, "", priority, target) } // AddHTTPTLSConfig defines a handler for a given sniHost and sets the matching tlsConfig. @@ -267,7 +267,7 @@ func (r *Router) SetHTTPSForwarder(handler tcp.Handler) { } rule := "HostSNI(`" + sniHost + "`)" - if err := r.muxerHTTPS.AddRoute(rule, tcpmuxer.GetRulePriority(rule), tcpHandler); err != nil { + if err := r.muxerHTTPS.AddRoute(rule, "", tcpmuxer.GetRulePriority(rule), tcpHandler); err != nil { log.Error().Err(err).Msg("Error while adding route for host") } } diff --git a/pkg/server/router/tcp/router_test.go b/pkg/server/router/tcp/router_test.go index 29bb2c418..1bcd987b1 100644 --- a/pkg/server/router/tcp/router_test.go +++ b/pkg/server/router/tcp/router_test.go @@ -947,10 +947,10 @@ func TestPostgres(t *testing.T) { // This test requires to have a TLS route, but does not actually check the // content of the handler. It would require to code a TLS handshake to // check the SNI and content of the handlerFunc. - err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", 0, nil) + err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, nil) require.NoError(t, err) - err = router.AddRoute("HostSNI(`*`)", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { + err = router.muxerTCP.AddRoute("HostSNI(`*`)", "", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { _, _ = conn.Write([]byte("OK")) _ = conn.Close() })) diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index d050803f3..d53f160f8 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -47,7 +47,7 @@ func TestShutdownTCP(t *testing.T) { router, err := tcprouter.NewRouter() require.NoError(t, err) - err = router.AddRoute("HostSNI(`*`)", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) { + err = router.AddTCPRoute("HostSNI(`*`)", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) { _, err := http.ReadRequest(bufio.NewReader(conn)) if err != nil { return