diff --git a/.github/workflows/template-webui.yaml b/.github/workflows/template-webui.yaml index e6b2039c04..3e7c3a757a 100644 --- a/.github/workflows/template-webui.yaml +++ b/.github/workflows/template-webui.yaml @@ -2,7 +2,7 @@ name: Build Web UI on: workflow_call: {} env: - SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS: 360 # 15 days + SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS: 72 # 3 days jobs: build-webui: diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ef27a69b..234e8b7039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +## [v3.7.0-rc.1](https://github.com/traefik/traefik/tree/v3.7.0-rc.1) (2026-04-07) +[All Commits](https://github.com/traefik/traefik/compare/v3.7.0-ea.3...v3.7.0-rc.1) + +**Bug fixes:** +- **[k8s/ingress-nginx]** Fix rewrite-target annotation handling with empty path and non-regex path ([#12905](https://github.com/traefik/traefik/pull/12905) @LBF38) +- **[middleware]** Bump github.com/klauspost/compress v1.18.4 ([#12937](https://github.com/traefik/traefik/pull/12937) @thaJeztah) + +**Enhancement:** +- **[webui]** Display server weight in service detail view ([#12325](https://github.com/traefik/traefik/pull/12325) @murataslan1) +- **[webui, tls]** Add certificates menu and overview ([#12628](https://github.com/traefik/traefik/pull/12628) @holomekc) +- **[provider]** Add providers routing precedence configuration ([#12895](https://github.com/traefik/traefik/pull/12895) @juliens) +- **[k8s/ingress-nginx]** Support NGINX global auth annotation ([#12893](https://github.com/traefik/traefik/pull/12893) @foxcool) +- **[k8s/ingress-nginx]** Add limit-burst-multiplier annotation support ([#12899](https://github.com/traefik/traefik/pull/12899) @amazon7737) +- **[k8s/ingress-nginx, k8s/ingress, rules]** Add wildcard host in Host and HostSNI matchers ([#12884](https://github.com/traefik/traefik/pull/12884) @juliens) +- **[k8s/gatewayapi]** Support multiple certificateRefs on gateway listeners ([#12590](https://github.com/traefik/traefik/pull/12590) @mortennordbye) +- **[k8s/gatewayapi]** Add secret support for BackendTLSPolicy caCertificateRefs ([#12927](https://github.com/traefik/traefik/pull/12927) @kevinpollet) +- **[accesslogs, k8s/ingress-nginx]** Support nginx.ingress.kubernetes.io/enable-access-log annotation ([#12908](https://github.com/traefik/traefik/pull/12908) @ris-tlp) +- **[accesslogs, k8s/ingress-nginx, k8s/ingress]** Add Kubernetes Ingress logs fields ([#12913](https://github.com/traefik/traefik/pull/12913) @rtribotte) + +**Documentation:** +- **[docker]** Fix docker-compose.yaml location in Docker setup page ([#12860](https://github.com/traefik/traefik/pull/12860) @ScottA38) +- **[docker, consul, ecs, k8s]** Fix documentation on how to restrict the scope of service discovery ([#12645](https://github.com/traefik/traefik/pull/12645) @mloiseleur) +- **[k8s/gatewayapi]** Update gateway-api link in getting-started to v1.5.1 ([#12930](https://github.com/traefik/traefik/pull/12930) @isayme) +- **[k8s/ingress-nginx]** Add OVHcloud (OpenStack Octavia) to Cloud-Specific IP Management ([#12759](https://github.com/traefik/traefik/pull/12759) @antonin-a) +- **[k8s/ingress-nginx]** Clarify IngressClass selection logic ([#12926](https://github.com/traefik/traefik/pull/12926) @kevinpollet) +- Add redirects for deleted pages ([#12889](https://github.com/traefik/traefik/pull/12889) @sheddy-traefik) +- Fix default value of http.sanitizePath ([#12904](https://github.com/traefik/traefik/pull/12904) @iTob191) + +## [v3.6.13](https://github.com/traefik/traefik/tree/v3.6.13) (2026-04-07) +[All Commits](https://github.com/traefik/traefik/compare/v3.6.12...v3.6.13) + +**Bug fixes:** +- **[middleware]** Bump github.com/klauspost/compress v1.18.4 and fix TestNegotiation ([#12937](https://github.com/traefik/traefik/pull/12937) @thaJeztah) + +**Documentation:** +- **[docker]** Fix docker-compose.yaml location in Docker setup page ([#12860](https://github.com/traefik/traefik/pull/12860) @ScottA38) +- **[docker, consul, ecs, k8s]** Fix documentation on how to restrict the scope of service discovery ([#12645](https://github.com/traefik/traefik/pull/12645) @mloiseleur) +- **[k8s/ingress-nginx]** Add OVHcloud (OpenStack Octavia) to Cloud-Specific IP Management ([#12759](https://github.com/traefik/traefik/pull/12759) @antonin-a) +- **[k8s/ingress-nginx]** Clarify IngressClass selection logic ([#12926](https://github.com/traefik/traefik/pull/12926) @kevinpollet) +- Add missing redirects for Getting started ([#12886](https://github.com/traefik/traefik/pull/12886) @nmengin) +- Add redirects for deleted pages ([#12889](https://github.com/traefik/traefik/pull/12889) @sheddy-traefik) +- Fix default value of http.sanitizePath ([#12904](https://github.com/traefik/traefik/pull/12904) @iTob191) + ## [v3.7.0-ea.3](https://github.com/traefik/traefik/tree/v3.7.0-ea.3) (2026-03-26) [All Commits](https://github.com/traefik/traefik/compare/v3.7.0-ea.2...v3.7.0-ea.3) diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index d30ae6ae3b..cc04a38f34 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -303,7 +303,7 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err dialerManager := tcp.NewDialerManager(spiffeX509Source) acmeHTTPHandler := getHTTPChallengeHandler(acmeProviders, httpChallengeProvider) - managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler) + managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler, tlsManager) // Router factory diff --git a/docs/content/getting-started/kubernetes.md b/docs/content/getting-started/kubernetes.md index 882360547d..2e22a02a28 100644 --- a/docs/content/getting-started/kubernetes.md +++ b/docs/content/getting-started/kubernetes.md @@ -250,7 +250,7 @@ To use the Gateway API: Install the Gateway API CRDs in your cluster: ```bash -kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml ``` Create an HTTPRoute. This configuration: diff --git a/docs/content/migrate/nginx-to-traefik.md b/docs/content/migrate/nginx-to-traefik.md index a91c862c88..5755ffdbae 100644 --- a/docs/content/migrate/nginx-to-traefik.md +++ b/docs/content/migrate/nginx-to-traefik.md @@ -443,6 +443,42 @@ kubectl get svc -n ingress-nginx ingress-nginx-controller -o go-template='{{ $in For more details, see the [GKE LoadBalancer Service parameters documentation](https://cloud.google.com/kubernetes-engine/docs/concepts/service-load-balancer-parameters). +??? note "OVHcloud" + + OVHcloud supports static IP on OVHcloud Public Load Balancer, it is based on Openstack Octavia which allocates floating IPs to LoadBalancer services. This requires the [Openstack Cloud Controller Manager](https://github.com/kubernetes/cloud-provider-openstack/blob/master/docs/openstack-cloud-controller-manager/using-openstack-cloud-controller-manager.md) to be installed in your cluster. If you are using OVHcloud Managed Kubernetes Service (MKS), the Openstack Cloud Controller Manager is already installed and managed for you. + + To retain your existing floating IP when migrating from NGINX to Traefik: + + **Identify the existing public IP:** + + ```bash + NGINX_IP=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \ + -o go-template='{{ $ing := index .status.loadBalancer.ingress 0 }}{{ if $ing.ip }}{{ $ing.ip }}{{ else }}{{ $ing.hostname }}{{ end }}') + + echo "NGINX IP: $NGINX_IP" + ``` + + **Edit your existing NGINX LoadBalancer service to ensure that the floating IP is not released when the loadbalancer service is deleted:** + + + kubectl annotate svc my-lb-svc loadbalancer.openstack.org/keep-floatingip=true + ``` + + The `keep-floatingip` annotation prevents the floating IP from being released when the service is deleted or modified. + + **Delete the NGINX LoadBalancer service to release the floating IP** + + **Update `traefik-values.yaml`:** + + ```yaml + service: + type: LoadBalancer + spec: + loadBalancerIP: "" + ``` + + To learn more, see the [OVHcloud MKS Public Load Balancer annotations documentation](https://help.ovhcloud.com/csm/en-public-cloud-kubernetes-expose-applications-using-load-balancer?id=kb_article_view&sysparm_article=KB0062878#supported-annotations-features). + ??? note "Other Cloud Providers" - **DigitalOcean:** Supports `loadBalancerIP` with floating IPs diff --git a/docs/content/migrate/v3.md b/docs/content/migrate/v3.md index 7d6a917bf6..266200802c 100644 --- a/docs/content/migrate/v3.md +++ b/docs/content/migrate/v3.md @@ -682,3 +682,20 @@ To use the new options of the `retry` middleware with the Kubernetes CRD provide ```shell kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.7/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml ``` + +### Wildcard Host and HostSNI + +Since `v3.7.0`, the `Host` and `HostSNI` matchers support wildcard subdomain matching (e.g., `*.example.com`). +This allows matching any direct subdomain of a domain with a single-level wildcard prefix. +For example, `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + +This feature is only available with the v3 rule syntax (the default). + +#### TLSOptions with Wildcard Domains + +Since `v3.7.0`, TLSOptions can now be associated with routers using wildcard `Host` and `HostSNI` matchers (e.g., `Host(`*.example.com`)`). +This enables configuring different TLS options for wildcard domains. + +Previously, TLSOptions selection was limited to exact `Host` matches, and using `HostRegexp` or wildcards would fall back to the default TLS options with a warning message like: `No domain found in rule HostRegexp(...) the TLS option foo cannot be applied`. + +Note: TLSOptions for `HostRegexp` matchers remains unsupported. Use wildcard `Host` matchers as an alternative. diff --git a/docs/content/reference/install-configuration/configuration-options.md b/docs/content/reference/install-configuration/configuration-options.md index 47a57bef4b..fb3911979b 100644 --- a/docs/content/reference/install-configuration/configuration-options.md +++ b/docs/content/reference/install-configuration/configuration-options.md @@ -403,6 +403,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | providers.kubernetesingressnginx.disablesvcexternalname | Disable support for Services of type ExternalName. | false | | providers.kubernetesingressnginx.endpoint | Kubernetes server endpoint (required for external cluster client). | | | providers.kubernetesingressnginx.globalallowedresponseheaders | List of allowed response headers inside the custom headers annotations. | | +| providers.kubernetesingressnginx.globalauthurl | URL to the service that provides authentication for all the locations. Per ingress auth-url annotation has precedence over this option. | | | providers.kubernetesingressnginx.httpentrypoint | Defines the EntryPoint to use for HTTP requests. | | | providers.kubernetesingressnginx.httpsentrypoint | Defines the EntryPoint to use for HTTPS requests. | | | providers.kubernetesingressnginx.ingressclass | Name of the ingress class this controller satisfies. | nginx | @@ -447,6 +448,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | providers.nomad.throttleduration | Watch throttle duration. | 0 | | providers.nomad.watch | Watch Nomad Service events. | false | | providers.plugin._name_ | Plugins configuration. | | +| providers.precedence | Defines the routing precedence between providers. | kubernetesgateway, kubernetescrd, kubernetes, kubernetesingressnginx, swarm, docker, file, redis, knative, consul, consulcatalog, nomad, etcd, ecs, http, zookeeper, rest | | providers.providersthrottleduration | Backends throttle duration: minimum duration between 2 events from providers before applying a new configuration. It avoids unnecessary reloads if multiples events are sent in a short amount of time. | 2 | | providers.redis | Enables Redis provider. | false | | providers.redis.db | Database to be selected after connecting to the server. | 0 | diff --git a/docs/content/reference/install-configuration/entrypoints.md b/docs/content/reference/install-configuration/entrypoints.md index 39d496164e..e310ffa108 100644 --- a/docs/content/reference/install-configuration/entrypoints.md +++ b/docs/content/reference/install-configuration/entrypoints.md @@ -105,7 +105,7 @@ additionalArguments: | `http.encodedCharacters.`
`allowEncodedQuestionMark`
| Defines whether requests with encoded question mark characters in the path are allowed. | true | No | | `http.encodedCharacters.`
`allowEncodedHash`
| Defines whether requests with encoded hash characters in the path are allowed. | true | No | | `http.encodeQuerySemicolons` | Enable query semicolons encoding.
Use this option to avoid non-encoded semicolons to be interpreted as query parameter separators by Traefik.
When using this option, the non-encoded semicolons characters in query will be transmitted encoded to the backend.
More information [here](#encodequerysemicolons). | false | No | -| `http.sanitizePath` | Defines whether to enable the request path sanitization.
More information [here](#sanitizepath). | false | No | +| `http.sanitizePath` | Defines whether to enable the request path sanitization.
More information [here](#sanitizepath). | true | No | | `http.maxHeaderBytes` | Set the maximum size of request headers in bytes. | 1048576 | No | | `http.middlewares` | Set the list of middlewares that are prepended by default to the list of middlewares of each router associated to the named entry point.
More information [here](#httpmiddlewares). | - | No | | `http.tls` | Enable TLS on every router attached to the `entryPoint`.
If no certificate are set, a default self-signed certificate is generated by Traefik.
We recommend to not use self signed certificates in production. | - | No | diff --git a/docs/content/reference/install-configuration/observability/logs-and-accesslogs.md b/docs/content/reference/install-configuration/observability/logs-and-accesslogs.md index ea92f0d34f..fe5a26f329 100644 --- a/docs/content/reference/install-configuration/observability/logs-and-accesslogs.md +++ b/docs/content/reference/install-configuration/observability/logs-and-accesslogs.md @@ -384,6 +384,10 @@ Below the fields displayed with the generic CLF format: | `TLSVersion` | The TLS version used by the connection (e.g. `1.2`) (if connection is TLS). | | `TLSCipher` | The TLS cipher used by the connection (e.g. `TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA`) (if connection is TLS). | | `TLSClientSubject` | The string representation of the TLS client certificate's Subject (e.g. `CN=username,O=organization`). | +| `KubernetesIngressNamespace` | The namespace of the Kubernetes Ingress resource the router handles. Only available with the Kubernetes Ingress and Kubernetes Ingress Nginx providers. | +| `KubernetesIngressName` | The name of the Kubernetes Ingress resource the router handles. Only available with the Kubernetes Ingress and Kubernetes Ingress Nginx providers. | +| `KubernetesServiceName` | The name of the Kubernetes Service associated with the Ingress the router handles. Only available with the Kubernetes Ingress and Kubernetes Ingress Nginx providers. | +| `KubernetesServicePort` | The port of the Kubernetes Service associated with the Ingress the router handles. Only available with the Kubernetes Ingress and Kubernetes Ingress Nginx providers. | ### Log Rotation diff --git a/docs/content/reference/install-configuration/providers/docker.md b/docs/content/reference/install-configuration/providers/docker.md index 4f4e8c9de8..38f2788c47 100644 --- a/docs/content/reference/install-configuration/providers/docker.md +++ b/docs/content/reference/install-configuration/providers/docker.md @@ -45,7 +45,7 @@ services: | `providers.docker.username` | Defines the username for Basic HTTP authentication. This should be used when the Docker daemon socket is exposed through an HTTP proxy that requires Basic HTTP authentication.| "" | No | | `providers.docker.password` | Defines the password for Basic HTTP authentication. This should be used when the Docker daemon socket is exposed through an HTTP proxy that requires Basic HTTP authentication.| "" | No | | `providers.docker.useBindPortIP` | Instructs Traefik to use the IP/Port attached to the container's binding instead of its inner network IP/Port. See [here](#usebindportip) for more information | false | No | -| `providers.docker.exposedByDefault` | Expose containers by default through Traefik. See [here](./overview.md#exposedbydefault-and-traefikenable) for additional information | true | No | +| `providers.docker.exposedByDefault` | Expose containers by default through Traefik. If set to _false_, containers that do not have a `traefik.enable=true` label are ignored from the resulting routing configuration.
See [here](./overview.md#restrict-the-scope-of-service-discovery) for additional information | true | No | | `providers.docker.network` | Defines a default docker network to use for connections to all containers. This option can be overridden on a per-container basis with the `traefik.docker.network` label.| "" | No | | `providers.docker.defaultRule` | Defines what routing rule to apply to a container if no rule is defined by a label. See [here](#defaultrule) for more information. | ```"Host(`{{ normalize .Name }}`)"``` | No | | `providers.docker.httpClientTimeout` | Defines the client timeout (in seconds) for HTTP connections. If its value is 0, no timeout is set. | 0 | No | diff --git a/docs/content/reference/install-configuration/providers/hashicorp/consul-catalog.md b/docs/content/reference/install-configuration/providers/hashicorp/consul-catalog.md index 5104731ca7..c5eb260b51 100644 --- a/docs/content/reference/install-configuration/providers/hashicorp/consul-catalog.md +++ b/docs/content/reference/install-configuration/providers/hashicorp/consul-catalog.md @@ -36,7 +36,7 @@ Attaching tags to services: | `providers.consulCatalog.refreshInterval` | Defines the polling interval.| 15s | No | | `providers.consulCatalog.prefix` | Defines the prefix for Consul Catalog tags defining Traefik labels.| traefik | yes | | `providers.consulCatalog.requireConsistent` | Forces the read to be fully consistent. See [here](#requireconsistent) for more information.| false | yes | -| `providers.consulCatalog.exposedByDefault` | Expose Consul Catalog services by default in Traefik. If set to `false`, services that do not have a `traefik.enable=true` tag will be ignored from the resulting routing configuration. See [here](../overview.md#exposedbydefault-and-traefikenable). | true | no | +| `providers.consulCatalog.exposedByDefault` | Expose Consul Catalog services by default through Traefik. If set to _false_, services that do not have a `traefik.enable=true` tag are ignored from the resulting routing configuration.
See [here](../overview.md#restrict-the-scope-of-service-discovery) for additional information. | true | no | | `providers.consulCatalog.defaultRule` | The Default Host rule for all services. See [here](#defaultrule) for more information. | ```"Host(`{{ normalize .Name }}`)"``` | No | | `providers.consulCatalog.connectAware` | Enable Consul Connect support. If set to `true`, Traefik will be enabled to communicate with Connect services. | false | No | | `providers.consulCatalog.connectByDefault` | Consider every service as Connect capable by default. If set to true, Traefik will consider every Consul Catalog service to be Connect capable by default. The option can be overridden on an instance basis with the traefik.consulcatalog.connect tag. | false | No | diff --git a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-gateway.md b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-gateway.md index a79e2d6fda..334e928516 100644 --- a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-gateway.md +++ b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-gateway.md @@ -75,7 +75,7 @@ providers: | `providers.kubernetesGateway.token` | Bearer token used for the Kubernetes client configuration. | "" | No | | `providers.kubernetesGateway.certAuthFilePath` | Path to the certificate authority file.
Used for the Kubernetes client configuration. | "" | No | | `providers.kubernetesGateway.namespaces` | Array of namespaces to watch.
If left empty, watch all namespaces. | [] | No | -| `providers.kubernetesGateway.labelSelector` | Allow filtering on specific resource objects only using label selectors.
Only to Traefik [Custom Resources](./kubernetes-crd.md#routing-configuration) (they all must match the filter).
No effect on Kubernetes `Secrets`, `EndpointSlices` and `Services`.
See [label-selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) for details. | "" | No | +| `providers.kubernetesGateway.labelselector` | Allow filtering on `GatewayClass` only. If left empty, Traefik processes all GatewayClass objects in the configured namespaces.
See [label-selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) for details. | "" | No | | `providers.kubernetesGateway.throttleDuration` | Minimum amount of time to wait between two Kubernetes events before producing a new configuration.
This prevents a Kubernetes cluster that updates many times per second from continuously changing your Traefik configuration.
If empty, every event is caught. | 0s | No | | `providers.kubernetesGateway.nativeLBByDefault` | Defines whether to use Native Kubernetes load-balancing mode by default. For more information, please check out the `traefik.io/service.nativelb` service annotation documentation. | false | No | | `providers.kubernetesGateway.`
`statusAddress.hostname`
| Hostname copied to the Gateway `status.addresses`. | "" | No | diff --git a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx.md b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx.md index 6c4d796f4a..0bf573c69f 100644 --- a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx.md +++ b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx.md @@ -42,6 +42,12 @@ This provider discovers all Ingresses in the cluster by default, which may lead - Configure `watchNamespace` to limit discovery to specific namespaces - Use `watchNamespaceSelector` to target Ingresses based on namespace labels +### IngressClass Selection Logic + +By default, the provider selects all IngressClasses whose `spec.controller` matches `controllerClass` (default: `k8s.io/ingress-nginx`) and picks up every Ingress referencing any of them. +Setting `ingressClassByName: true` adds a second inclusion path: IngressClasses whose name matches `ingressClass` are also picked up, even if their `spec.controller` does not match `controllerClass`. +It does not narrow down the controller-based selection — both paths apply independently. + ## Configuration Example You can enable the Kubernetes Ingress NGINX provider as detailed below: @@ -160,10 +166,10 @@ This provider watches for incoming Ingress events and automatically translates N | `providers.`
`kubernetesIngressNGINX.`
`throttleDuration`
| Minimum amount of time to wait between two Kubernetes events before producing a new configuration.
This prevents a Kubernetes cluster that updates many times per second from continuously changing your Traefik configuration.
If empty, every event is caught. | 0s | No | | `providers.`
`kubernetesIngressNGINX.`
`watchNamespace`
| Namespace the controller watches for updates to Kubernetes objects. All namespaces are watched if this parameter is left empty. | "" | No | | `providers.`
`kubernetesIngressNGINX.`
`watchNamespaceSelector`
| Selector selects namespaces the controller watches for updates to Kubernetes objects. | "" | No | -| `providers.`
`kubernetesIngressNGINX.`
`ingressClass`
| Name of the ingress class this controller satisfies. | "nginx" | No | +| `providers.`
`kubernetesIngressNGINX.`
`ingressClass`
| Name of the IngressClass this controller handles. When `ingressClassByName` is `true`, IngressClasses with this name are included in discovery regardless of their `spec.controller` value. | "nginx" | No | | `providers.`
`kubernetesIngressNGINX.`
`controllerClass`
| Ingress Class Controller value this controller satisfies. | "" | No | | `providers.`
`kubernetesIngressNGINX.`
`watchIngressWithoutClass`
| Define if Ingress Controller should also watch for Ingresses without an IngressClass or the annotation specified. | false | No | -| `providers.`
`kubernetesIngressNGINX.`
`ingressClassByName`
| Define if Ingress Controller should watch for Ingress Class by Name together with Controller Class. | false | No | +| `providers.`
`kubernetesIngressNGINX.`
`ingressClassByName`
| When `true`, any IngressClass whose **name** matches `ingressClass` is include in discovery, even if its `spec.controller` does not match `controllerClass`. This is evaluated alongside the controller-based selection, not instead of it. | false | No | | `providers.`
`kubernetesIngressNGINX.`
`publishService`
| Service fronting the Ingress controller. Takes the form `namespace/name`. | "" | No | | `providers.`
`kubernetesIngressNGINX.`
`publishStatusAddress`
| Customized address (or addresses, separated by comma) to set as the load-balancer status of Ingress objects this controller satisfies. | "" | No | | `providers.`
`kubernetesIngressNGINX.`
`defaultBackendService`
| Service used to serve HTTP requests not matching any known server name (catch-all). Takes the form 'namespace/name'. | "" | No | diff --git a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md index db5522e7cf..ecc3e4ec29 100644 --- a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md +++ b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md @@ -52,7 +52,7 @@ which in turn creates the resulting routers, services, handlers, etc. | `providers.kubernetesIngress.token` | Bearer token used for the Kubernetes client configuration. | "" | No | | `providers.kubernetesIngress.certAuthFilePath` | Path to the certificate authority file.
Used for the Kubernetes client configuration. | "" | No | | `providers.kubernetesIngress.namespaces` | Array of namespaces to watch.
If left empty, watch all namespaces. | | No | -| `providers.kubernetesIngress.labelSelector` | Allow filtering on Ingress objects using label selectors.
No effect on Kubernetes `Secrets`, `EndpointSlices` and `Services`.
See [label-selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) for details. | "" | No | +| `providers.kubernetesIngress.labelselector` | Allow filtering on `Ingress` objects using label selectors.
No effect on Kubernetes `Secrets`, `EndpointSlices` and `Services`.
See [label-selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) for details. | "" | No | | `providers.kubernetesIngress.ingressClass` | The `IngressClass` resource name or the `kubernetes.io/ingress.class` annotation value that identifies resource objects to be processed.
If empty, resources missing the annotation, having an empty value, or the value `traefik` are processed. | "" | No | | `providers.kubernetesIngress.disableIngressClassLookup` | Prevent to discover IngressClasses in the cluster.
It alleviates the requirement of giving Traefik the rights to look IngressClasses up.
Ignore Ingresses with IngressClass.
Annotations are not affected by this option. | false | No | | `providers.kubernetesIngress.`
`ingressEndpoint.hostname`
| Hostname used for Kubernetes Ingress endpoints. | "" | No | diff --git a/docs/content/reference/install-configuration/providers/others/ecs.md b/docs/content/reference/install-configuration/providers/others/ecs.md index 2cca65977d..1e132c1bde 100644 --- a/docs/content/reference/install-configuration/providers/others/ecs.md +++ b/docs/content/reference/install-configuration/providers/others/ecs.md @@ -30,7 +30,7 @@ providers: | `providers.ecs.autoDiscoverClusters` | Search for services in cluster list. If set to `true` service discovery is enabled for all clusters. | false | No | | `providers.ecs.ecsAnywhere` | Enable ECS Anywhere support. | false | No | | `providers.ecs.clusters` | Search for services in cluster list. This option is ignored if `autoDiscoverClusters` is set to `true`. | `["default"]` | No | -| `providers.ecs.exposedByDefault` | Expose ECS services by default in Traefik. | true | No | +| `providers.ecs.exposedByDefault` | Expose ECS services by default through Traefik. If set to _false_, containers that do not have a `traefik.enable=true` label are ignored from the resulting routing configuration.
See [here](../overview.md#restrict-the-scope-of-service-discovery) for additional information. | true | No | | `providers.ecs.constraints` | Defines an expression that Traefik matches against the container labels to determine whether to create any route for that container. See [here](#constraints) for more information. | true | No | | `providers.ecs.healthyTasksOnly` | Defines whether Traefik discovers only healthy tasks (`HEALTHY` healthStatus). | false | No | | `providers.ecs.defaultRule` | The Default Host rule for all services. See [here](#defaultrule) for more information. | ```"Host(`{{ normalize .Name }}`)"``` | No | diff --git a/docs/content/reference/install-configuration/providers/overview.md b/docs/content/reference/install-configuration/providers/overview.md index cbdbffb4d6..fa69fbe474 100644 --- a/docs/content/reference/install-configuration/providers/overview.md +++ b/docs/content/reference/install-configuration/providers/overview.md @@ -133,31 +133,94 @@ metadata: spec: ``` +## Restrict the Scope of Service Discovery + By default, Traefik creates routes for all detected containers. If you want to limit the scope of the Traefik service discovery, i.e. disallow route creation for some containers, you can do so in two different ways: -- the generic configuration option `exposedByDefault`, -- a finer granularity mechanism based on constraints. +1. With [Consul Catalog](./hashicorp/consul-catalog.md#opt-providers-consulCatalog-exposedByDefault), + [Docker](./docker.md#opt-providers-docker-exposedByDefault), + [ECS](./others/ecs.md#opt-providers-ecs-exposedByDefault), + [Nomad](./hashicorp/nomad.md#opt-providers-nomad-exposedByDefault) and + [Swarm](./swarm.md#opt-providers-swarm-exposedByDefault) + providers, you can set `exposedByDefault` to `false` and add a label `traefik.enable=true` + on containers you want to expose -### `exposedByDefault` and `traefik.enable` +2. Use a finer-grained mechanism based on label selector or constraints. -List of providers that support these features: +!!! info "The following providers support constraints" -- [Docker](./docker.md#configuration-options) -- [ECS](./others/ecs.md#configuration-options) -- [Consul Catalog](./hashicorp/consul-catalog.md#configuration-options) -- [Nomad](./hashicorp/nomad.md#configuration-options) + - [Consul Catalog](./hashicorp/consul-catalog.md#constraints) + - [Docker](./docker.md#constraints) + - [ECS](./others/ecs.md#constraints) + - [Nomad](./hashicorp/nomad.md#constraints) + - [Swarm](./swarm.md#constraints) -### Constraints +!!! info "The following providers support label selectors" -List of providers that support constraints: + - [Kubernetes CRD](./kubernetes/kubernetes-crd.md#opt-providers-kubernetesCRD-labelselector) + - [Kubernetes Gateway API](./kubernetes/kubernetes-gateway.md#opt-providers-kubernetesGateway-labelselector) + - [Kubernetes Ingress](./kubernetes/kubernetes-ingress.md#opt-providers-kubernetesIngress-labelselector) -- [Docker](./docker.md#constraints) -- [ECS](./others/ecs.md#constraints) -- [Consul Catalog](./hashicorp/consul-catalog.md#constraints) -- [Nomad](./hashicorp/nomad.md#constraints) +## Providers Precedence + +### `providers.precedence` + +_Optional_ + +When two routers from **different providers** define the same rule with equal numeric [priority](../../routing-configuration/http/routing/rules-and-priority.md#priority-calculation), +the `precedence` option determines which provider's route takes precedence. + +The list is ordered from highest to lowest precedence: a provider listed first wins over providers listed later. + +```yaml tab="File (YAML)" +providers: + precedence: + - kubernetescrd + - kubernetes + - file +``` + +```toml tab="File (TOML)" +[providers] + precedence = ["kubernetescrd", "kubernetes", "file"] +``` + +```bash tab="CLI" +--providers.precedence=kubernetescrd,kubernetes,file +``` + +#### Default precedence + +When `precedence` is not set, Traefik uses the following default order (highest precedence first): + +| Position | Provider name | +|----------|--------------------------| +| 1 | `kubernetesgateway` | +| 2 | `kubernetescrd` | +| 3 | `kubernetes` | +| 4 | `kubernetesingressnginx` | +| 5 | `swarm` | +| 6 | `docker` | +| 7 | `file` | +| 8 | `redis` | +| 9 | `knative` | +| 10 | `consul` | +| 11 | `consulcatalog` | +| 12 | `nomad` | +| 13 | `etcd` | +| 14 | `ecs` | +| 15 | `http` | +| 16 | `zookeeper` | +| 17 | `rest` | + +!!! note + + - `precedence` only acts as a **tiebreaker**: it is applied only when two routes from different providers share the same numeric `priority` value. An explicit router priority always takes precedence. + - A provider absent from `precedence` loses to any listed provider. + - Provider names are case-insensitive. {% include-markdown "includes/traefik-for-business-applications.md" %} diff --git a/docs/content/reference/install-configuration/providers/swarm.md b/docs/content/reference/install-configuration/providers/swarm.md index 66d404a13e..68ce3c2eb2 100644 --- a/docs/content/reference/install-configuration/providers/swarm.md +++ b/docs/content/reference/install-configuration/providers/swarm.md @@ -50,7 +50,7 @@ services: | `providers.swarm.username` | Defines the username for Basic HTTP authentication. This should be used when the Docker daemon socket is exposed through an HTTP proxy that requires Basic HTTP authentication. | "" | No | | `providers.swarm.password` | Defines the password for Basic HTTP authentication. This should be used when the Docker daemon socket is exposed through an HTTP proxy that requires Basic HTTP authentication. | "" | No | | `providers.swarm.useBindPortIP` | Instructs Traefik to use the IP/Port attached to the container's binding instead of its inner network IP/Port. See [here](#usebindportip) for more information | false | No | -| `providers.swarm.exposedByDefault` | Expose containers by default through Traefik. See [here](./overview.md#exposedbydefault-and-traefikenable) for additional information | true | No | +| `providers.swarm.exposedByDefault` | Expose containers by default through Traefik. If set to _false_, containers that do not have a `traefik.enable=true` label are excluded from the resulting routing configuration.
See [here](./overview.md#restrict-the-scope-of-service-discovery) for additional information | true | No | | `providers.swarm.network` | Defines a default docker network to use for connections to all containers. This option can be overridden on a per-container basis with the `traefik.swarm.network` label. | "" | No | | `providers.swarm.defaultRule` | Defines what routing rule to apply to a container if no rule is defined by a label. See [here](#defaultrule) for more information | ```"Host(`{{ normalize .Name }}`)"``` | No | | `providers.swarm.refreshSeconds` | Defines the polling interval for Swarm Mode. | "15s" | No | diff --git a/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md b/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md index 69712a373b..4576d07693 100644 --- a/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md +++ b/docs/content/reference/routing-configuration/http/routing/rules-and-priority.md @@ -24,7 +24,7 @@ The table below lists all the available matchers: |-----------------------------------------------------------------|:-------------------------------------------------------------------------------| | [```Header(`key`, `value`)```](#header-and-headerregexp) | Matches requests containing a header named `key` set to `value`. | | [```HeaderRegexp(`key`, `regexp`)```](#header-and-headerregexp) | Matches requests containing a header named `key` matching `regexp`. | -| [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. | +| [```Host(`domain`)```](#host-and-hostregexp) | Matches requests host set to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`). | | [```HostRegexp(`regexp`)```](#host-and-hostregexp) | Matches requests host matching `regexp`. | | [```Method(`method`)```](#method) | Matches requests method set to `method`. | | [```Path(`path`)```](#path-pathprefix-and-pathregexp) | Matches requests path set to `path`. | @@ -54,6 +54,15 @@ If no `Host` is set in the request URL (for example, it's an IP address), these These matchers will match the request's host in lowercase. +!!! info "Wildcard subdomain matching" + + The `Host` matcher supports a single-level wildcard prefix (`*.example.com`) to match any direct subdomain of `example.com`. + It should be preferred over the `HostRegexp` matcher as it allows attaching a TLS option and is more efficient. + + A wildcard matches exactly one subdomain label: `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + + This is only available with the **v3 rule syntax** (the default). + | Behavior | Rule | |-----------------------------------------------------------------|:------------------------------------------------------------------------| | Match requests with `Host` set to `example.com`. | ```Host(`example.com`)``` | @@ -232,6 +241,12 @@ Traefik reserves a range of priorities for its internal routers, the maximum use - `(MaxInt32 - 1000)` = `2147482647` for 32-bit platforms, - `(MaxInt64 - 1000)` = `9223372036854774807` for 64-bit platforms. +!!! info "Providers Precedence" + + When two routes from **different providers** share the same numeric priority, + Traefik uses the [`providers.precedence`](../../../install-configuration/providers/overview.md#providers-precedence) install configuration option to determine which route takes precedence. + The provider listed first in `precedence` wins the tie. + ### Example ```yaml tab="Structured (YAML)" diff --git a/docs/content/reference/routing-configuration/kubernetes/gateway-api.md b/docs/content/reference/routing-configuration/kubernetes/gateway-api.md index 31fdc5dd57..784a834247 100644 --- a/docs/content/reference/routing-configuration/kubernetes/gateway-api.md +++ b/docs/content/reference/routing-configuration/kubernetes/gateway-api.md @@ -110,6 +110,36 @@ data: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRUUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1Nzd2dna25BZ0VBQW9JQ0FRQ1pNNm1WNUJkV2V0QzYKbXZ6dGFQaGxTY2dJY253emdzbDhBcXp3RDJNOVJWVkZwRUxBbTh2OTNlRWtMZEY2ZnNkY0FhUXQxWlFDSFdYbwovZkxwUTVVa1B4dXY2TVArdjRtSjB2OGRLRlo3UjcwaTVud1lCMkVlVkw2RUNZaWlxNmZ6VEtsa3F6U0QvNW93CnpTdy96amtHVGEwSXcvdkg5YXNINzRIajNXdEE3RythenZjaVlhQTZxK1dWYlZxNlBIanF6em9obEFuMkh1anoKNmhEamFpZXN2TGx3QS9CL3JoRHNBS2graTByOEFKUTFqM0JiTGJuYkJyWmZqYnBJUjNhYVh5amkwK3RQWENnVQo5M0JlOXZtS2U2U2NHUjcvNk9ubWJrU3NMY2RXaFpVNzcrL2c4WllsZGhwS01nczc4ekJYUlh4bHlzSThHRUtqClNYbnd5OVpmTU9kSTRKQU1jbU9JTmlZQWNtWm5JTUJIa0xacFl3aUl0eFdseXVJcWxoZFpkSHNrTFdNSjBSTHUKMUZoUzJwRVdDTmUzN1QwYjlobTRYNDlydUFiekl1M01xSmczcXFIdGRMNGRkZ1JZRHNjMXd0cDJrR2dBZGxDaQpySHJReitZeDBCTU9WbGRHM2htUlFIeWRxMkhyWW0xeEFDNWpMZ3FvaVZhY09wd0xKY21PcGsrZWVNQkNZNVo0CnpDWDdYV3l3YVZjbTJzdmhpTzE4QmRSK2g5aFliMkRNZDFCendDbE95endQcUlvQy9uNGRURG96Ry9GT3NySzgKLzRGeHc2NkNWZjNxODMwTVB3UndsQ0sxQ0I3RjNQK3lKWkpPelRuK05QZ2dGQW9NaGZUYXBQWTFhUGlWajBzVApvb0IwWjhjRVdUZE5ycUFObVBrOWgzaEIycG44SndJREFRQUJBb0lDQUVCa2dKRXA3ODAvamVBQktQSTR2cjhFCkJmblc5UEZKdFpwVUhaQkJSM3NIVzFJTU9xcHVVWTJBNXhLbjEzWmZOemdxMEhFYlpqeUZVc0pkaXU0VW8razYKUlU3b3pRaVVSU0VTK0h1dTZycWlhcEx5d1pIditCZ2hrbm80NzU4Lyt6VytNU3pJOFNmU0ZXTVJ1ZG1QdWxRMQo3ZGJUV1U2d3FaU0tUTlFUeXZMYzdnUHBuZUpybWtkTzNRNnppZ0RoVGdtVDFHRXNzZ3NxN3NzbXhMWnhkZithCnkyNlRtVkJ4UDFlUzV6OVpHTWxYRFBSK044RjdOTFVrMng3S21WT3NCZVBZdjN5bmlpNHZGQUhNQndWRFZadXAKWUlUajRpMjZIaVhtanlLM2t5T0F2anNWSElRMXh1QTBCZFROdC84WXRtYllJL005QitydVg0UDJiRFNUMktRKwo4TlN2Uk9wbVppcnBHZkY3bExMSGpJUjlTMFhCWDd6VDRoWnBRWnpqK3NEWnhDM2Y3TGIwRFlKYkp0TmlDYTQxCmNpTjhNUlNldzNneHZ0RVk0RzdnN3hjbkJNdjdNT3RwQTE3d2gvMHdLd0h0amYzSWh2TmIzdkZwT0k5d1FqSzYKSlRQMng4bENJV0tyalpObVN0UksreHJTN3hTOUZVdnBhSVlyclRLQkZWSmcyMURCYWI1L3hqRlBlQWxXejczSwpvVkhsa0hLdXNMSjZLczZzcXo0ZG9mbzg3dkFsUFJzTXRkZ1ZnZFIzNXhLTGtEWXNIbGxML3Z5dE9oSkNieXB5CkJqQm1TR0RMdzBDdWplaHVtU2czYjdSUGVTNk5rbHNqUEIrMVpzRjhpVGdCcjMyM1hvTmNha2dhWWVYQlg4NFAKaE1WZHUxWk1rbXJZMWhXTzEydnhBb0lCQVFEWU5Vb2xCMkhsdWlDcVFqdmU4UFNhV0YxRmhwNUlOMGJIZEppeApIdkhqMkplVHJ6V1pUZFlIdFNJR2RzalhTOTBESXo2bXJhMW9YZXFRYlgrODVlOUFQQkZnRTJmNU5uTzBCSVVJCk5hMXRiVGpIOUhjRGRzQmJKUkZwYnk1ODZUb3lhdFY3bS9zcjVpQUZlZFMyenFOTm1XUmZOdllZS2xselZoSEUKdUd4ZjZxMHJTWktVQUhja2s1bU5Yais3WFhZaTgyemErVEE3ZjBDVm5OamR3OXFpd3B2aTJKTFB2SnA0bWt6KwpyMEN1RW9yV2NhMUdTL2hTVWdXemw3NzhQdlRpZFI2RW4zMDB2ZlIyTE84aG1xRjhVL3Bpb2UrTDVjSllRNnNKCk1YMngrYThzWFFpZ3dwdG02aUNxQ2FuS3ExN3NUZS9RTmQ1czdwb3ZOaHVKOHd3dEFvSUJBUUMxWmM5ZktPUFgKVzZSN1VoaHRRcmhIc3htQnRIQ1BuNWRLbjN5MElNNWRBaEFSdFZDV1U0M0hOTHpCNW1LbjU2dnZrSVNFaXdBbAoxTGhuY2I3YXQ2cHpTSlZtMm5oTDhjeUZrdGQrNzVyL1FHNFlpNnZQbHNBODV5ZXZpZDhZcWswaHdaZXExY05xCmxETUN3NWsrV0drckM2VW5jZXNIc2FWbDJTUGdZV1c1L3I1NnVxUnBsaVFka1EyZmlEYWRyblVueU8ycHg3bFMKVG1HemZaNmtzTWh2MlZFR1NPRm05aUo0dWlPb0xyZVhoU1RQRmxTdjdZUTZSWWtSaGgwT0tqdXM5bXZacEIxWApjcytYN0UyVTNlM0RZelpCR0NFdmxxaFNWTFRScjdIN21pMWxUMEozR0RzbDdiUk9xOE50WVdQa3hhSlNCUnQ5Ck9TcTlkTm9CcGRvakFvSUJBQ2lQdnN3NW1WVW0yUS80QXhGdE5RWnJ3M3ZTcUlrMXpaS0h2a21rVzQ3NlNGMk4KaGttdmY1TE1tWWlLNmx6eHY1SGlIOVBYUzJ3RUNvaHo4bjMyeVM3TTFobW5LbDlucHNkRC9jMHZmTXpGcTl4ZgpjYUIxdTlxZGxxbW9FUm1nQzZuL3Z2TkVyUmRzUWQrbEhwSDVMRXZYbGl3Q3ZLS0Y5MmdhNHBSOFlPQ1J2MUVhCnFXUVl2a0ZmYTNSSkZUM0taK3BncnJCYUJZRnoreUxXWFIwbHJEUFN2TG9QRldQaHB6MHUvWGplV2cwT0wzdlIKc2NjNVkybldOM21jNDFpaFd3SE5KUitPYUVmbnh5QVFpQUJPNlRMUThtMWtvZk1sOUpMb2h3TGZoUXhKb21KNQpSYkFiTWxwWlhDMXFTSzliL1IvcDh5NmxuSWZsTDRuaDVjSzRsVFVDZ2dFQUpSSHVSQU1tTksrTXVJcjVaUEs2Cm1DUjR0UEg4QXMzWmJDMlZuWFlLMWlVQ3hhdXBFVjkzM05yaExEcjV0Rmg2NFpWR0Q1UWNicDYvSkp5eEpSOWQKblB1YlZJNlhBT1lrSnJQd2lBZE5SSmFWS1R6NTJvMXpNYjhIZEM4WHdZR2tDNTcxY0xzSW1YSTV6bm5NaWxvaworK0FBVzBSRGhLb0FKQVV3K0x6T3ZpamFJbGljR3R2TSs2SFdCK0VkVURJRHpTS1p0eFdTd01nMTNTbHh6elExCmNlNFdTZE9CQkxxT0p0L2JRNVp3ZkcyQUxUWGlEcVhhWE5JekJickRtMDUwTFkrYVVMcmlLQ25WVkxXODBReGQKZDQyQjIrR2pmb2NxVk5Ec3R1RlIzUm9QNXVGQXN2Zm50b09TVW5WMWxaZk9nMFVFUEFEQk1tRUpZL2hLU1FYcwp3d0tDQVFBNWQya2hFQ1c1V3QrMzRYWnl1b3NFTjZ4UDExbC96VDRBZjhGSWtQLzlkb0JXRnBhc28zbG1NcXZHCmhPeFErbnZBSjFhNzhZRjA4N3p1UC9DZkQ0UElOUTV4YzZHMDNQdG5JOVNVT0dpMDB4Zlg5MU5NMHBHYWJqb0QKZ0RqVzJxSkJDaVB5N0RIR1RlZkU5eUNUbkhrY1NBbWllVGc3aGFyeEZPOUREZTJKbzhKQXV2SHI1aGVxazVIcgpLYlgzTy9vNUMwcWVnYW1rWVRLcHZzV2VFdXhkY2l5LzFQd3NnV3BuV1JPWllQNENrSkJweEx1bDNVamVSY3dkCnRhcjBJYU52WlV2NFd4U0JZdWVHMDFyYUd2SDZtTTcyTEExR3MrMytwTnZwUVo3bGo2S09tcFlhQUlhemVxY2MKTjJjT2R5U1RqZmQ5OFlNVFAxbmIyK3N1Yy91VAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg== ``` +### Multiple TLS Certificates + +Traefik supports multiple secret `certificateRefs` per Gateway listener. +If one of the certificates is invalid or cannot be loaded, the listener will be considered invalid and will not be able to serve traffic until the issue is resolved. + +For example, the following `Gateway` listener references two different certificates: + +```yaml +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: multi-cert-gateway + namespace: default +spec: + gatewayClassName: traefik + listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: + - name: example-com-tls + - name: example-org-tls + allowedRoutes: + namespaces: + from: Same +``` + ## Exposing a Route Once a `Gateway` is deployed (see [Deploying a Gateway](#deploying-a-gateway)) `HTTPRoute`, `TCPRoute`, diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 94ea3a2fff..c90882a645 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -384,6 +384,7 @@ The following annotations are organized by category for easier navigation. | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------------------------------------------| | `nginx.ingress.kubernetes.io/limit-rps` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | | `nginx.ingress.kubernetes.io/limit-rpm` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | +| `nginx.ingress.kubernetes.io/limit-burst-multiplier` | Default to a multiplier of 5 if the configured value is less than 1. Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | ### Buffering @@ -397,6 +398,12 @@ The following annotations are organized by category for easier navigation. | `nginx.ingress.kubernetes.io/proxy-buffers-number` | With Traefik, `proxy-buffer-numbers` is actually used to compute the size of a single buffer (size * number). | | `nginx.ingress.kubernetes.io/proxy-max-temp-file-size` | | +### Observability + +| Annotation | Limitations / Notes | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `nginx.ingress.kubernetes.io/enable-access-log` | Access logs must first be enabled in the [install configuration](../../../install-configuration/observability/logs-and-accesslogs/#access-logs) (globally or per entrypoint) for this annotation to take effect. When access logs are enabled, this annotation allows opting out specific Ingresses by setting it to `"false"`. Conversely, when access logs are disabled on an entrypoint, setting this annotation to `"true"` allows opting in specific Ingresses. | + ### Timeout | Annotation | Limitations / Notes | @@ -456,7 +463,6 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o | `nginx.ingress.kubernetes.io/limit-rate-after` | | | `nginx.ingress.kubernetes.io/limit-rate` | | | `nginx.ingress.kubernetes.io/limit-whitelist` | | -| `nginx.ingress.kubernetes.io/limit-burst-multiplier` | | | `nginx.ingress.kubernetes.io/limit-connections` | | | `nginx.ingress.kubernetes.io/global-rate-limit` | | | `nginx.ingress.kubernetes.io/global-rate-limit-window` | | @@ -477,7 +483,6 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o | `nginx.ingress.kubernetes.io/ssl-ciphers` | | | `nginx.ingress.kubernetes.io/ssl-prefer-server-ciphers` | | | `nginx.ingress.kubernetes.io/connection-proxy-header` | | -| `nginx.ingress.kubernetes.io/enable-access-log` | | | `nginx.ingress.kubernetes.io/enable-opentracing` | | | `nginx.ingress.kubernetes.io/opentracing-trust-incoming-span` | | | `nginx.ingress.kubernetes.io/enable-opentelemetry` | | diff --git a/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md b/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md index 2e6f7b9661..59e6f79da6 100644 --- a/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md +++ b/docs/content/reference/routing-configuration/tcp/routing/rules-and-priority.md @@ -18,7 +18,7 @@ The table below lists all the available matchers: | Rule | Description | |-------------------------------------------------------------|:-------------------------------------------------------------------------------------------------| -| [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`.
More information [here](#hostsni-and-hostsniregexp). | +| [```HostSNI(`domain`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication is equal to `domain`. Supports wildcard subdomain matching (e.g. `*.example.com`).
More information [here](#hostsni-and-hostsniregexp). | | [```HostSNIRegexp(`regexp`)```](#hostsni-and-hostsniregexp) | Checks if the connection's Server Name Indication matches `regexp`.
Use a [Go](https://golang.org/pkg/regexp/) flavored syntax.
More information [here](#hostsni-and-hostsniregexp). | | [```ClientIP(`ip`)```](#clientip) | Checks if the connection's client IP correspond to `ip`. It accepts IPv4, IPv6 and CIDR formats.
More information [here](#clientip). | | [```ALPN(`protocol`)```](#alpn) | Checks if the connection's ALPN protocol equals `protocol`.
More information [here](#alpn). | @@ -59,6 +59,15 @@ These matchers do not support non-ASCII characters, use punycode encoded values when one wants a non-TLS router that matches all (non-TLS) requests, one should use the specific ```HostSNI(`*`)``` syntax. +!!! info "Wildcard subdomain matching" + + The `HostSNI` matcher supports a single-level wildcard prefix (`*.example.com`) to match any direct subdomain of `example.com`. + It should be preferred over the `HostSNIRegexp` matcher as it allows attaching a TLS option and is more efficient. + + A wildcard matches exactly one subdomain label: `*.example.com` matches `foo.example.com` but not `foo.bar.example.com` or `example.com` itself. + + This is only available with the **v3 rule syntax** (the default). + #### Examples Match all connections: @@ -77,7 +86,13 @@ Match TCP connections sent to `example.com`: HostSNI(`example.com`) ``` -Match TCP connections opened on any subdomain of `example.com`: +Match TCP connections opened on any direct subdomain of `example.com` (e.g. `foo.example.com`): + +```yaml +HostSNI(`*.example.com`) +``` + +Match TCP connections opened on any subdomain of `example.com` (including nested subdomains), using a regular expression: ```yaml HostSNIRegexp(`^.+\.example\.com$`) @@ -201,3 +216,9 @@ Traefik reserves a range of priorities for its internal routers, the maximum use - `(MaxInt32 - 1000)` for 32-bit platforms, - `(MaxInt64 - 1000)` for 64-bit platforms. + +!!! info "Providers Precedence" + + When two routes from **different providers** share the same numeric priority, + Traefik uses the [`providers.precedence`](../../../install-configuration/providers/overview.md#providers-precedence) install configuration option to determine which route takes precedence. + The provider listed first in `precedence` wins the tie. diff --git a/docs/content/setup/docker.md b/docs/content/setup/docker.md index 40789d099a..c4a0e37a01 100644 --- a/docs/content/setup/docker.md +++ b/docs/content/setup/docker.md @@ -59,7 +59,7 @@ tls: keyFile: /certs/local.key ``` -In the same folder as the `dynamic/tls.yaml` file, create a `docker-compose.yaml` file and include the following: +In your project root folder (the parent folder to the `dynamic/tls.yaml` file), create a `docker-compose.yaml` file and include the following: ```yaml services: diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c45c110e0b..48b06ff700 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -110,6 +110,7 @@ plugins: 'https/acme.md': 'reference/install-configuration/tls/certificate-resolvers/acme.md' 'https/tailscale.md': 'reference/install-configuration/tls/certificate-resolvers/tailscale.md' 'https/spiffe.md': 'reference/install-configuration/tls/spiffe.md' + 'https/ocsp.md': 'reference/install-configuration/tls/ocsp.md' # Middlewares 'middlewares/overview.md': 'reference/routing-configuration/http/middlewares/overview.md' # HTTP @@ -168,6 +169,11 @@ plugins: "reference/dynamic-configuration/nomad.md": 'reference/routing-configuration/other-providers/nomad.md' 'reference/dynamic-configuration/ecs.md': 'reference/routing-configuration/other-providers/ecs.md' 'reference/dynamic-configuration/kv.md': 'reference/routing-configuration/other-providers/kv.md' + 'reference/dynamic-configuration/kv-ref.md': 'reference/routing-configuration/other-providers/kv.md' + 'reference/install-configuration/cli-options-list.md': 'reference/install-configuration/configuration-options.md' + 'reference/install-configuration/observability/healthcheck/cli.md': 'reference/install-configuration/observability/healthcheck.md' + 'reference/install-configuration/observability/healthcheck/ping.md': 'reference/install-configuration/observability/healthcheck.md' + 'reference/install-configuration/observability/options-list.md': 'reference/install-configuration/configuration-options.md' ## Plugins 'plugins/index.md': "extend/extend-traefik.md" ## Migration diff --git a/go.mod b/go.mod index e561a721bf..9a0b72f75c 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/huandu/xstrings v1.5.0 github.com/influxdata/influxdb-client-go/v2 v2.7.0 github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab // No tag on the repo. - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.5 github.com/kvtools/consul v1.0.2 github.com/kvtools/etcdv3 v1.0.3 github.com/kvtools/redis v1.2.0 diff --git a/go.sum b/go.sum index d8683d4204..49d8710304 100644 --- a/go.sum +++ b/go.sum @@ -782,8 +782,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= diff --git a/integration/fixtures/https/https_wildcard_host.toml b/integration/fixtures/https/https_wildcard_host.toml new file mode 100644 index 0000000000..a39891406c --- /dev/null +++ b/integration/fixtures/https/https_wildcard_host.toml @@ -0,0 +1,36 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.websecure] + address = ":4443" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + # Wildcard router: routes any *.snitest.com subdomain to service1. + [http.routers.wildcard] + service = "service1" + rule = "Host(`*.snitest.com`)" + [http.routers.wildcard.tls] + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "http://127.0.0.1:9040" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" diff --git a/integration/fixtures/https/https_wildcard_tls_options.toml b/integration/fixtures/https/https_wildcard_tls_options.toml new file mode 100644 index 0000000000..eea893174d --- /dev/null +++ b/integration/fixtures/https/https_wildcard_tls_options.toml @@ -0,0 +1,64 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.websecure] + address = ":4443" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + # Wildcard router covering all *.snitest.com subdomains with TLS option "foo" (minTLS12). + [http.routers.wildcard] + service = "service1" + rule = "Host(`*.snitest.com`)" + [http.routers.wildcard.tls] + options = "foo" + + # foo.snitest.com uses TLS option "bar" (minTLS13) + [http.routers.bar] + service = "service1" + rule = "Host(`foo.snitest.com`)" + [http.routers.bar.tls] + options = "bar" + +# minTLS11 +[http.routers.other] + service = "service1" + rule = "Host(`other.snitest.com`)" + [http.routers.other.tls] + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "{{ .BackendURL }}" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" + +[tls.options] + [tls.options.foo] + minVersion = "VersionTLS12" + maxVersion = "VersionTLS12" + + + [tls.options.bar] + minVersion = "VersionTLS13" + maxVersion = "VersionTLS13" + + [tls.options.default] + minVersion = "VersionTLS11" + maxVersion = "VersionTLS11" diff --git a/integration/fixtures/providers-precedence.toml b/integration/fixtures/providers-precedence.toml new file mode 100644 index 0000000000..4565714994 --- /dev/null +++ b/integration/fixtures/providers-precedence.toml @@ -0,0 +1,48 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[api] + insecure = true + +[providers] + precedence = {{ .Precedence }} + + [providers.file] + filename = "{{ .SelfFilename }}" + + [providers.docker] + endpoint = "{{ .DockerHost }}" + exposedByDefault = false + +## dynamic configuration ## + +[http.routers] + [http.routers.file-router] + rule = "PathPrefix(`/http`)" + service = "file-service" + entryPoints = ["web"] + +[http.services] + [http.services.file-service.loadBalancer] + [[http.services.file-service.loadBalancer.servers]] + url = "http://{{ .FileBackendAddress }}" + +[tcp.routers] + [tcp.routers.file-router] + rule = "HostSNI(`*`)" + service = "file-service" + entryPoints = ["web"] + +[tcp.services] + [tcp.services.file-service.loadBalancer] + [[tcp.services.file-service.loadBalancer.servers]] + address = "{{ .FileBackendAddress }}" \ No newline at end of file diff --git a/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml b/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml new file mode 100644 index 0000000000..11c0cfca03 --- /dev/null +++ b/integration/fixtures/tcp/wildcard-hostsni-tls-options.toml @@ -0,0 +1,65 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.tcp] + address = ":8093" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[tcp] + [tcp.routers] + # Wildcard router covering *.snitest.com with TLS option "foo" (minTLS12). + [tcp.routers.wildcard] + rule = "HostSNI(`*.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.wildcard.tls] + options = "foo" + + # Override: bar.snitest.com uses TLS option "bar" (minTLS13), stricter than the wildcard. + [tcp.routers.bar] + rule = "HostSNI(`bar.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.bar.tls] + options = "bar" + + [tcp.routers.default] + rule = "HostSNI(`other.snitest.com`)" + service = "backend" + entryPoints = ["tcp"] + [tcp.routers.default.tls] + + [tcp.services] + [tcp.services.backend.loadBalancer] + [[tcp.services.backend.loadBalancer.servers]] + address = "{{ .Backend }}" + +[[tls.certificates]] + certFile = "fixtures/https/wildcard.snitest.com.cert" + keyFile = "fixtures/https/wildcard.snitest.com.key" + +[tls.options] + [tls.options.default] + minVersion = "VersionTLS11" + maxVersion = "VersionTLS11" + + [tls.options.foo] + minVersion = "VersionTLS12" + maxVersion = "VersionTLS12" + + [tls.options.bar] + minVersion = "VersionTLS13" + maxVersion = "VersionTLS13" diff --git a/integration/healthcheck_test.go b/integration/healthcheck_test.go index 08f009de2a..e9f0049e3d 100644 --- a/integration/healthcheck_test.go +++ b/integration/healthcheck_test.go @@ -577,7 +577,7 @@ func (s *HealthCheckSuite) TestPropagateNoHealthCheck() { s.traefikCmd(withConfigFile(file)) // wait for traefik - err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 60*time.Second, try.BodyContains("Host(`noop.localhost`)"), try.BodyNotContains("Host(`root.localhost`)")) + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 60*time.Second, try.BodyContains("Host(`noop.localhost`)"), try.BodyContains("cannot register wsp1 as updater for wsp-service1@file")) require.NoError(s.T(), err) rootReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000", nil) diff --git a/integration/https_test.go b/integration/https_test.go index 68319fea1e..b9b64e8d67 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -30,6 +30,41 @@ func TestHTTPSSuite(t *testing.T) { suite.Run(t, &HTTPSSuite{}) } +// TestWithWildcardHost verifies that a wildcard Host rule Host(`*.snitest.com`) +// routes HTTPS requests for any matching subdomain to the configured service. +func (s *SimpleSuite) TestWithWildcardHost() { + backend := startTestServer("9040", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/https/https_wildcard_host.toml", struct{}{}) + s.traefikCmd(withConfigFile(file)) + + // Wait for Traefik to load the wildcard router. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("Host(`*.snitest.com`)")) + require.NoError(s.T(), err) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + // foo.snitest.com matches the wildcard and must be routed to the backend. + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = "foo.snitest.com" + err = try.RequestWithTransport(req, 5*time.Second, tr, try.StatusCodeIs(http.StatusOK)) + require.NoError(s.T(), err) + + // bar.snitest.com also matches the wildcard and must be routed to the backend. + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = "bar.snitest.com" + err = try.RequestWithTransport(req, 3*time.Second, tr, try.StatusCodeIs(http.StatusOK)) + require.NoError(s.T(), err) +} + // TestWithSNIConfigHandshake involves a client sending a SNI hostname of // "snitest.com", which happens to match the CN of 'snitest.com.crt'. The test // verifies that traefik presents the correct certificate. @@ -115,7 +150,6 @@ func (s *HTTPSSuite) TestWithSNIConfigRoute() { } // TestWithTLSOptions verifies that traefik routes the requests with the associated tls options. - func (s *HTTPSSuite) TestWithTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -196,8 +230,71 @@ func (s *HTTPSSuite) TestWithTLSOptions() { require.NoError(s.T(), err) } -// TestWithConflictingTLSOptions checks that routers with same SNI but different TLS options get fallbacked to the default TLS options. +func (s *HTTPSSuite) TestWithTLSOptionsAndWildcard() { + backend := startTestServer("0", http.StatusNoContent, "") + defer backend.Close() + err := try.GetRequest(backend.URL, 1*time.Second, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + file := s.adaptFile("fixtures/https/https_wildcard_tls_options.toml", struct{ BackendURL string }{backend.URL}) + s.traefikCmd(withConfigFile(file)) + + // wait for Traefik + err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`*.snitest.com`)")) + require.NoError(s.T(), err) + + tr1 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS12, + MinVersion: tls.VersionTLS12, + ServerName: "bar.snitest.com", + }, + } + + tr2 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS13, + MinVersion: tls.VersionTLS13, + ServerName: "foo.snitest.com", + }, + } + + tr3 := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS11, + MinVersion: tls.VersionTLS11, + ServerName: "other.snitest.com", + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr1.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 30*time.Second, tr1, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr2.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 3*time.Second, tr2, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) + + req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil) + require.NoError(s.T(), err) + req.Host = tr3.TLSClientConfig.ServerName + + err = try.RequestWithTransport(req, 3*time.Second, tr3, try.StatusCodeIs(http.StatusNoContent)) + require.NoError(s.T(), err) +} + +// TestWithConflictingTLSOptions checks that routers with same SNI but different TLS options get fallbacked to the +// default TLS options. func (s *HTTPSSuite) TestWithConflictingTLSOptions() { file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -262,7 +359,6 @@ func (s *HTTPSSuite) TestWithConflictingTLSOptions() { // TestWithSNIStrictNotMatchedRequest involves a client sending a SNI hostname of // "snitest.org", which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik closes the connection. - func (s *HTTPSSuite) TestWithSNIStrictNotMatchedRequest() { file := s.adaptFile("fixtures/https/https_sni_strict.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -284,7 +380,6 @@ func (s *HTTPSSuite) TestWithSNIStrictNotMatchedRequest() { // TestWithDefaultCertificate involves a client sending a SNI hostname of // "snitest.org", which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik returns the default certificate. - func (s *HTTPSSuite) TestWithDefaultCertificate() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -316,7 +411,6 @@ func (s *HTTPSSuite) TestWithDefaultCertificate() { // TestWithDefaultCertificateNoSNI involves a client sending a request with no ServerName // which does not match the CN of 'snitest.com.crt'. The test // verifies that traefik returns the default certificate. - func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -348,7 +442,6 @@ func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI() { // "www.snitest.com", which matches the CN of two static certificates: // 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test // verifies that traefik returns the non-wildcard certificate. - func (s *HTTPSSuite) TestWithOverlappingStaticCertificate() { file := s.adaptFile("fixtures/https/https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -381,7 +474,6 @@ func (s *HTTPSSuite) TestWithOverlappingStaticCertificate() { // "www.snitest.com", which matches the CN of two dynamic certificates: // 'wildcard.snitest.com.crt', and `www.snitest.com.crt`. The test // verifies that traefik returns the non-wildcard certificate. - func (s *HTTPSSuite) TestWithOverlappingDynamicCertificate() { file := s.adaptFile("fixtures/https/dynamic_https_sni_default_cert.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -410,9 +502,8 @@ func (s *HTTPSSuite) TestWithOverlappingDynamicCertificate() { assert.Equal(s.T(), "h2", proto) } -// TestWithClientCertificateAuthentication -// The client can send a certificate signed by a CA trusted by the server but it's optional. - +// TestWithClientCertificateAuthentication tests that a client can send a certificate signed by a CA trusted by the server +// but it's optional. func (s *HTTPSSuite) TestWithClientCertificateAuthentication() { file := s.adaptFile("fixtures/https/clientca/https_1ca1config.toml", struct{}{}) s.traefikCmd(withConfigFile(file)) @@ -464,9 +555,8 @@ func (s *HTTPSSuite) TestWithClientCertificateAuthentication() { assert.NoError(s.T(), err, "should be allowed to connect to server") } -// TestWithClientCertificateAuthentication -// Use two CA:s and test that clients with client signed by either of them can connect. - +// TestWithClientCertificateAuthenticationMultipleCAs uses two CA:s +// and test that clients with client signed by either of them can connect. func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAs() { server1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server1")) })) server2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server2")) })) @@ -557,9 +647,8 @@ func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAs() { assert.Error(s.T(), err) } -// TestWithClientCertificateAuthentication -// Use two CA:s in two different files and test that clients with client signed by either of them can connect. - +// TestWithClientCertificateAuthenticationMultipleCAsMultipleFiles uses two CA:s in two different files +// and test that clients with client signed by either of them can connect. func (s *HTTPSSuite) TestWithClientCertificateAuthenticationMultipleCAsMultipleFiles() { server1 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server1")) })) server2 := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { _, _ = rw.Write([]byte("server2")) })) @@ -768,7 +857,6 @@ func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithNoChange() { // that traefik updates its configuration when the HTTPS configuration is modified and // it routes the requests to the expected backends thanks to given certificate if possible // otherwise thanks to the default one. - func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithChange() { dynamicConfFileName := s.adaptFile("fixtures/https/dynamic_https.toml", struct{}{}) confFileName := s.adaptFile("fixtures/https/dynamic_https_sni.toml", struct { @@ -833,7 +921,6 @@ func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithChange() { // that traefik updates its configuration when the HTTPS configuration is modified, even if it totally deleted, and // it routes the requests to the expected backends thanks to given certificate if possible // otherwise thanks to the default one. - func (s *HTTPSSuite) TestWithSNIDynamicConfigRouteWithTlsConfigurationDeletion() { dynamicConfFileName := s.adaptFile("fixtures/https/dynamic_https.toml", struct{}{}) confFileName := s.adaptFile("fixtures/https/dynamic_https_sni.toml", struct { @@ -1143,7 +1230,7 @@ func (s *HTTPSSuite) TestWithInvalidTLSOption() { } } -// modifyCertificateConfFileContent replaces the content of a HTTPS configuration file. +// modifyCertificateConfFileContent replaces the content of an HTTPS configuration file. func (s *HTTPSSuite) modifyCertificateConfFileContent(certFileName, confFileName string) { file, err := os.OpenFile("./"+confFileName, os.O_WRONLY, os.ModeExclusive) require.NoError(s.T(), err) diff --git a/integration/k8s_test.go b/integration/k8s_test.go index 2c70d6adbb..df2c28724a 100644 --- a/integration/k8s_test.go +++ b/integration/k8s_test.go @@ -111,13 +111,13 @@ func (s *K8sSuite) TestGatewayConfiguration() { s.testConfiguration("testdata/rawdata-gateway.json", "8080") } -func (s *K8sSuite) TestIngressclass() { +func (s *K8sSuite) TestIngressClass() { s.traefikCmd(withConfigFile("fixtures/k8s_ingressclass.toml")) s.testConfiguration("testdata/rawdata-ingressclass.json", "8080") } -func (s *K8sSuite) TestDisableIngressclassLookup() { +func (s *K8sSuite) TestDisableIngressClassLookup() { s.traefikCmd(withConfigFile("fixtures/k8s_ingressclass_disabled.toml")) s.testConfiguration("testdata/rawdata-ingressclass-disabled.json", "8080") diff --git a/integration/resources/compose/providers-precedence.yml b/integration/resources/compose/providers-precedence.yml new file mode 100644 index 0000000000..1c0913c969 --- /dev/null +++ b/integration/resources/compose/providers-precedence.yml @@ -0,0 +1,9 @@ +services: + whoami: + image: traefik/whoami + labels: + traefik.enable: "true" + traefik.http.routers.docker-router.rule: "PathPrefix(`/http`)" + traefik.http.routers.docker-router.entryPoints: web + traefik.tcp.routers.docker-router.rule: "HostSNI(`*`)" + traefik.tcp.routers.docker-router.entryPoints: web diff --git a/integration/simple_test.go b/integration/simple_test.go index fadeb19101..159e08175b 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -2656,3 +2656,88 @@ func (s *SimpleSuite) TestServiceMiddleware() { // The whoami service should have received the X-Custom-Header that was added by the service middleware assert.Contains(s.T(), string(body), "X-Custom-Header: service-middleware-test") } + +// TestProviderPrecedenceFileWins verifies that, when two providers define +// routes with the same rule and auto-computed priority, the provider listed +// first in providers.precedence takes precedence (lower index = higher +// provider priority). +// +// Setup: +// - providers.file → file-router → fileBackend (body: "from-file") +// - providers.docker → docker-router → whoami container +// - precedence = ["file", "docker"] → file is index 0 → wins +func (s *SimpleSuite) TestProviderPrecedenceFileWins() { + s.createComposeProject("providers-precedence") + s.composeUp("whoami") + defer s.composeDown() + + fileBackend := startTestServer("9042", http.StatusOK, "from-file") + defer fileBackend.Close() + + file := s.adaptFile("fixtures/providers-precedence.toml", struct { + Precedence string + FileBackendAddress string + DockerHost string + }{ + Precedence: `["file", "docker"]`, + FileBackendAddress: "127.0.0.1:9042", + DockerHost: s.getDockerHost(), + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for both providers to have loaded their routers. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, + try.StatusCodeIs(http.StatusOK), + try.BodyContains("file-router@file"), + try.BodyContains("docker-router@docker")) + require.NoError(s.T(), err) + + // The file provider has higher priority → requests must reach the file backend. + err = try.GetRequest("http://127.0.0.1:8000/http", 5*time.Second, try.BodyContains("from-file")) + require.NoError(s.T(), err) + + // This request should be handled by the TCP route. + err = try.GetRequest("http://127.0.0.1:8000/tcp", 5*time.Second, try.BodyContains("from-file")) + require.NoError(s.T(), err) +} + +// TestProviderPrecedenceDockerWins mirrors TestProviderPrecedenceFileWins +// but reverses the precedence so that the Docker provider wins instead. +// +// Setup: +// - providers.file → file-router → fileBackend (body: "from-file") +// - providers.docker → docker-router → whoami container +// - precedence = ["docker", "file"] → docker is index 0 → wins +func (s *SimpleSuite) TestProviderPrecedenceDockerWins() { + s.createComposeProject("providers-precedence") + s.composeUp("whoami") + defer s.composeDown() + + fileBackend := startTestServer("9042", http.StatusOK, "from-file") + defer fileBackend.Close() + + file := s.adaptFile("fixtures/providers-precedence.toml", struct { + Precedence string + FileBackendAddress string + DockerHost string + }{ + Precedence: `["docker", "file"]`, + FileBackendAddress: "127.0.0.1:9042", + DockerHost: s.getDockerHost(), + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for both providers to have loaded their routers. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 10*time.Second, + try.BodyContains("file-router@file"), + try.BodyContains("docker-router@docker")) + require.NoError(s.T(), err) + + // The Docker provider has higher priority → requests must reach the whoami container. + err = try.GetRequest("http://127.0.0.1:8000/http", 5*time.Second, try.BodyContains("Hostname:")) + require.NoError(s.T(), err) + + // This request should be handled by the TCP route. + err = try.GetRequest("http://127.0.0.1:8000/tcp", 5*time.Second, try.BodyContains("Hostname:")) + require.NoError(s.T(), err) +} diff --git a/integration/tcp_test.go b/integration/tcp_test.go index 0b4b36e57a..f66c6b9385 100644 --- a/integration/tcp_test.go +++ b/integration/tcp_test.go @@ -438,6 +438,95 @@ func (s *TCPSuite) TestPostgresSTARTTLSPassthrough() { assert.Equal(s.T(), byte('R'), header[0]) } +// TestTCPWildcardHostSNI verifies that a wildcard HostSNI rule HostSNI(`*.snitest.com`) +// routes TLS connections for any matching subdomain to the configured backend. +func (s *SimpleSuite) TestTCPWildcardHostSNI() { + backend := startTestServer("9041", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/tcp/wildcard-hostsni-tls-options.toml", struct { + Backend string + }{ + Backend: "127.0.0.1:9041", + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for the wildcard TCP router to be loaded. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("HostSNI(`*.snitest.com`)")) + require.NoError(s.T(), err) + + // foo.snitest.com matches the wildcard: TLS connection must succeed. + conn, err := tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "foo.snitest.com", + InsecureSkipVerify: true, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com also matches the wildcard: TLS connection must succeed. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "bar.snitest.com", + InsecureSkipVerify: true, + }) + require.NoError(s.T(), err) + conn.Close() +} + +// TestTCPWildcardHostSNITLSOptions verifies that: +// - a wildcard HostSNI rule HostSNI(`*.snitest.com`) with TLS option "foo" (minTLS12) +// routes and accepts TLS 1.2 connections for any matching subdomain; +// - a more specific rule HostSNI(`bar.snitest.com`) with TLS option "bar" (minTLS13) +// takes priority for that subdomain and rejects TLS 1.2-only connections. +func (s *SimpleSuite) TestTCPWildcardHostSNITLSOptions() { + backend := startTestServer("9041", http.StatusOK, "") + defer backend.Close() + + file := s.adaptFile("fixtures/tcp/wildcard-hostsni-tls-options.toml", struct { + Backend string + }{ + Backend: "127.0.0.1:9041", + }) + s.traefikCmd(withConfigFile(file)) + + // Wait for both TCP routers to be loaded. + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 5*time.Second, + try.BodyContains("HostSNI(`*.snitest.com`)")) + require.NoError(s.T(), err) + + // foo.snitest.com matches the wildcard (TLS option "foo", minTLS12). + // A TLS 1.2 connection must succeed. + conn, err := tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "foo.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com has a specific rule with TLS option "bar" (minTLS13). + // A TLS 1.2-only connection must be rejected. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "bar.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }) + require.NoError(s.T(), err) + conn.Close() + + // bar.snitest.com without a version cap: connection must succeed. + conn, err = tls.Dial("tcp", "127.0.0.1:8093", &tls.Config{ + ServerName: "other.snitest.com", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS11, + MaxVersion: tls.VersionTLS11, + }) + require.NoError(s.T(), err) + conn.Close() +} + func welcome(addr string) (string, error) { tcpAddr, err := net.ResolveTCPAddr("tcp", addr) if err != nil { diff --git a/integration/testdata/rawdata-ingress-label-selector.json b/integration/testdata/rawdata-ingress-label-selector.json index a4081171c1..c4203aa7e2 100644 --- a/integration/testdata/rawdata-ingress-label-selector.json +++ b/integration/testdata/rawdata-ingress-label-selector.json @@ -47,13 +47,21 @@ "web" ], "service": "default-whoami-http", - "rule": "Host(`whoami.test`) \u0026\u0026 PathPrefix(`/whoami`)", + "rule": "Host(\"whoami.test\") \u0026\u0026 PathPrefix(\"/whoami\")", "priority": 44, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "test.ingress", + "serviceName": "whoami", + "servicePort": "http" + } + } }, "status": "enabled", "using": [ diff --git a/integration/testdata/rawdata-ingress.json b/integration/testdata/rawdata-ingress.json index a317099fb8..b4498b43fe 100644 --- a/integration/testdata/rawdata-ingress.json +++ b/integration/testdata/rawdata-ingress.json @@ -47,13 +47,21 @@ "web" ], "service": "default-whoami-http", - "rule": "Host(`whoami.test.https`) \u0026\u0026 PathPrefix(`/whoami`)", + "rule": "Host(\"whoami.test.https\") \u0026\u0026 PathPrefix(\"/whoami\")", "priority": 50, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "test.ingress.https", + "serviceName": "whoami", + "servicePort": "http" + } + } }, "status": "enabled", "using": [ @@ -65,13 +73,21 @@ "web" ], "service": "default-whoami-http", - "rule": "Host(`whoami.test`) \u0026\u0026 PathPrefix(`/whoami`)", + "rule": "Host(\"whoami.test\") \u0026\u0026 PathPrefix(\"/whoami\")", "priority": 44, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "test.ingress", + "serviceName": "whoami", + "servicePort": "http" + } + } }, "status": "enabled", "using": [ @@ -83,13 +99,21 @@ "web" ], "service": "default-whoami-80", - "rule": "Host(`whoami.test.drop`) \u0026\u0026 PathPrefix(`/drop`)", + "rule": "Host(\"whoami.test.drop\") \u0026\u0026 PathPrefix(\"/drop\")", "priority": 47, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "whoami-drop-route", + "serviceName": "whoami", + "servicePort": "80" + } + } }, "status": "enabled", "using": [ @@ -101,13 +125,21 @@ "web" ], "service": "default-whoami-80", - "rule": "Host(`whoami.test.keep`) \u0026\u0026 PathPrefix(`/keep`)", + "rule": "Host(\"whoami.test.keep\") \u0026\u0026 PathPrefix(\"/keep\")", "priority": 47, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "whoami-keep-route", + "serviceName": "whoami", + "servicePort": "80" + } + } }, "status": "enabled", "using": [ diff --git a/integration/testdata/rawdata-ingressclass.json b/integration/testdata/rawdata-ingressclass.json index c860c8862d..2874aafcdb 100644 --- a/integration/testdata/rawdata-ingressclass.json +++ b/integration/testdata/rawdata-ingressclass.json @@ -47,13 +47,21 @@ "web" ], "service": "default-whoami-80", - "rule": "Host(`whoami.test.keep`) \u0026\u0026 PathPrefix(`/keep`)", + "rule": "Host(\"whoami.test.keep\") \u0026\u0026 PathPrefix(\"/keep\")", "priority": 47, "observability": { "accessLogs": true, "metrics": true, "tracing": true, - "traceVerbosity": "minimal" + "traceVerbosity": "minimal", + "metadata": { + "ingress": { + "namespace": "default", + "ingressName": "whoami-keep-route", + "serviceName": "whoami", + "servicePort": "80" + } + } }, "status": "enabled", "using": [ diff --git a/integration/try/condition.go b/integration/try/condition.go index 66c70a899d..6034b2a22f 100644 --- a/integration/try/condition.go +++ b/integration/try/condition.go @@ -1,6 +1,7 @@ package try import ( + "bytes" "context" "errors" "fmt" @@ -25,6 +26,8 @@ func BodyContains(values ...string) ResponseCondition { return fmt.Errorf("failed to read response body: %w", err) } + res.Body = io.NopCloser(bytes.NewBuffer(body)) + for _, value := range values { if !strings.Contains(string(body), value) { return fmt.Errorf("could not find '%s' in body '%s'", value, string(body)) @@ -43,6 +46,8 @@ func BodyNotContains(values ...string) ResponseCondition { return fmt.Errorf("failed to read response body: %w", err) } + res.Body = io.NopCloser(bytes.NewBuffer(body)) + for _, value := range values { if strings.Contains(string(body), value) { return fmt.Errorf("find '%s' in body '%s'", value, string(body)) @@ -61,6 +66,8 @@ func BodyContainsOr(values ...string) ResponseCondition { return fmt.Errorf("failed to read response body: %w", err) } + res.Body = io.NopCloser(bytes.NewBuffer(body)) + for _, value := range values { if strings.Contains(string(body), value) { return nil @@ -79,6 +86,8 @@ func HasBody() ResponseCondition { return fmt.Errorf("failed to read response body: %w", err) } + res.Body = io.NopCloser(bytes.NewBuffer(body)) + if len(body) == 0 { return errors.New("response doesn't have body content") } diff --git a/pkg/api/certificate.go b/pkg/api/certificate.go new file mode 100644 index 0000000000..32bf9aae1c --- /dev/null +++ b/pkg/api/certificate.go @@ -0,0 +1,172 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "sort" + "time" +) + +const ( + certStatusEnabled = "enabled" + certStatusWarning = "warning" + certStatusExpired = "expired" +) + +// certificateRepresentation represents a certificate in the API. +type certificateRepresentation struct { + Name string `json:"name"` // SHA-256 fingerprint of the DER-encoded certificate. + SANs []string `json:"sans"` + NotAfter time.Time `json:"notAfter"` + NotBefore time.Time `json:"notBefore"` + SerialNumber string `json:"serialNumber"` + CommonName string `json:"commonName"` + IssuerOrg string `json:"issuerOrg,omitempty"` + IssuerCN string `json:"issuerCN,omitempty"` + IssuerCountry string `json:"issuerCountry,omitempty"` + Organization string `json:"organization,omitempty"` + Country string `json:"country,omitempty"` + Version string `json:"version"` + KeyType string `json:"keyType"` + KeySize int `json:"keySize,omitempty"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + CertFingerprint string `json:"certFingerprint"` + PublicKeyFingerprint string `json:"publicKeyFingerprint"` + Status string `json:"status"` +} + +// Interface methods for sort.go compatibility. +func (c certificateRepresentation) name() string { + return c.CommonName +} + +func (c certificateRepresentation) status() string { + return c.Status +} + +func (c certificateRepresentation) issuer() string { + if c.IssuerOrg != "" { + return c.IssuerOrg + } + return c.IssuerCN +} + +func (c certificateRepresentation) validUntil() time.Time { + return c.NotAfter +} + +// buildCertificateRepresentation builds a certificateRepresentation from an x509 certificate. +func buildCertificateRepresentation(cert *x509.Certificate) certificateRepresentation { + keyType, keySize := extractKeyInfo(cert) + certFingerprint, pubKeyFingerprint := extractFingerprints(cert) + issuerOrg, issuerCN, issuerCountry := extractIssuerInfo(cert) + organization, country := extractSubjectInfo(cert) + + return certificateRepresentation{ + Name: certFingerprint, + SANs: extractSANs(cert), + NotAfter: cert.NotAfter, + NotBefore: cert.NotBefore, + SerialNumber: cert.SerialNumber.String(), + CommonName: cert.Subject.CommonName, + IssuerOrg: issuerOrg, + IssuerCN: issuerCN, + IssuerCountry: issuerCountry, + Organization: organization, + Country: country, + Version: formatVersion(cert.Version), + KeyType: keyType, + KeySize: keySize, + SignatureAlgorithm: cert.SignatureAlgorithm.String(), + CertFingerprint: certFingerprint, + PublicKeyFingerprint: pubKeyFingerprint, + Status: getCertificateStatus(cert.NotAfter), + } +} + +// extractSANs extracts Subject Alternative Names from a certificate. +func extractSANs(cert *x509.Certificate) []string { + sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)) + sans = append(sans, cert.DNSNames...) + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + sort.Strings(sans) + return sans +} + +// extractKeyInfo determines the key type and size from a certificate. +func extractKeyInfo(cert *x509.Certificate) (keyType string, keySize int) { + keyType = "Unknown" + keySize = 0 + + switch pubKey := cert.PublicKey.(type) { + case *rsa.PublicKey: + keyType = "RSA" + keySize = pubKey.N.BitLen() + case *ecdsa.PublicKey: + keyType = "ECDSA" + keySize = pubKey.Curve.Params().BitSize + } + + return keyType, keySize +} + +// extractFingerprints calculates SHA-256 fingerprints for certificate and public key. +func extractFingerprints(cert *x509.Certificate) (certFingerprint, pubKeyFingerprint string) { + certHash := sha256.Sum256(cert.Raw) + certFingerprint = hex.EncodeToString(certHash[:]) + + pubKeyBytes, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err == nil { + pubKeyHash := sha256.Sum256(pubKeyBytes) + pubKeyFingerprint = hex.EncodeToString(pubKeyHash[:]) + } + + return certFingerprint, pubKeyFingerprint +} + +// extractIssuerInfo extracts issuer information from a certificate. +func extractIssuerInfo(cert *x509.Certificate) (org, cn, country string) { + if len(cert.Issuer.Organization) > 0 { + org = cert.Issuer.Organization[0] + } + cn = cert.Issuer.CommonName + if len(cert.Issuer.Country) > 0 { + country = cert.Issuer.Country[0] + } + return org, cn, country +} + +// extractSubjectInfo extracts subject information from a certificate. +func extractSubjectInfo(cert *x509.Certificate) (organization, country string) { + if len(cert.Subject.Organization) > 0 { + organization = cert.Subject.Organization[0] + } + if len(cert.Subject.Country) > 0 { + country = cert.Subject.Country[0] + } + return organization, country +} + +// formatVersion formats the X.509 version for display. +func formatVersion(version int) string { + return fmt.Sprintf("v%d", version) +} + +// getCertificateStatus returns the status of a certificate based on its expiry. +func getCertificateStatus(notAfter time.Time) string { + remaining := time.Until(notAfter) + if remaining < 0 { + return certStatusExpired + } + // Show warning for certificates with validity less than 30 days left. + if remaining < 30*24*time.Hour { + return certStatusWarning + } + return certStatusEnabled +} diff --git a/pkg/api/handler.go b/pkg/api/handler.go index 2cd9e22709..49424d9be0 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -11,6 +11,7 @@ import ( "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/static" + "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/version" ) @@ -58,12 +59,14 @@ type Handler struct { // runtimeConfiguration is the data set used to create all the data representations exposed by the API. runtimeConfiguration *runtime.Configuration + + tlsManager *tls.Manager } // NewBuilder returns a http.Handler builder based on runtime.Configuration. -func NewBuilder(staticConfig static.Configuration) func(*runtime.Configuration) http.Handler { +func NewBuilder(staticConfig static.Configuration, tlsManager *tls.Manager) func(*runtime.Configuration) http.Handler { return func(configuration *runtime.Configuration) http.Handler { - return New(staticConfig, configuration).createRouter() + return New(staticConfig, configuration).WithTLSManager(tlsManager).createRouter() } } @@ -81,8 +84,14 @@ func New(staticConfig static.Configuration, runtimeConfig *runtime.Configuration } } +// WithTLSManager sets the TLS manager on the handler, enabling the certificate API endpoints. +func (h *Handler) WithTLSManager(tlsManager *tls.Manager) *Handler { + h.tlsManager = tlsManager + return h +} + // createRouter creates API routes and router. -func (h Handler) createRouter() *mux.Router { +func (h *Handler) createRouter() *mux.Router { router := mux.NewRouter().UseEncodedPath() apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().UseEncodedPath() @@ -120,12 +129,15 @@ func (h Handler) createRouter() *mux.Router { apiRouter.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices) apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService) + apiRouter.Methods(http.MethodGet).Path("/api/certificates").HandlerFunc(h.getCertificates) + apiRouter.Methods(http.MethodGet).Path("/api/certificates/{certificateID}").HandlerFunc(h.getCertificate) + version.Handler{}.Append(apiRouter) return router } -func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) { siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services)) for k, v := range h.runtimeConfiguration.Services { siRepr[k] = &serviceInfoRepresentation{ diff --git a/pkg/api/handler_certificate.go b/pkg/api/handler_certificate.go new file mode 100644 index 0000000000..1595578570 --- /dev/null +++ b/pkg/api/handler_certificate.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" +) + +func keepCertificate(cert certificateRepresentation, criterion *searchCriterion) bool { + if criterion == nil { + return true + } + + // Combine all searchable fields + searchFields := make([]string, 0, 3+len(cert.SANs)) + searchFields = append(searchFields, cert.CommonName, cert.IssuerOrg, cert.IssuerCN) + searchFields = append(searchFields, cert.SANs...) + + return criterion.withStatus(cert.Status) && + criterion.searchIn(searchFields...) +} + +func (h *Handler) getCertificates(rw http.ResponseWriter, request *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + allCerts := h.extractCertificates() + + query := request.URL.Query() + criterion := newSearchCriterion(query) + + results := make([]certificateRepresentation, 0, len(allCerts)) + for _, cert := range allCerts { + if keepCertificate(cert, criterion) { + results = append(results, cert) + } + } + + sortCertificates(query, results) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + writeError(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.Error().Err(err).Msg("Unable to encode certificates") + writeError(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) getCertificate(rw http.ResponseWriter, request *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + certID := mux.Vars(request)["certificateID"] + + if h.tlsManager == nil { + writeError(rw, fmt.Sprintf("certificate not found: %s", certID), http.StatusNotFound) + return + } + + certs := h.tlsManager.GetServerCertificates() + x509Cert, ok := certs[certID] + + if !ok { + writeError(rw, fmt.Sprintf("certificate not found: %s", certID), http.StatusNotFound) + return + } + + cert := buildCertificateRepresentation(x509Cert) + + if err := json.NewEncoder(rw).Encode(cert); err != nil { + log.Error().Err(err).Str("id", certID).Msg("Unable to encode certificate") + writeError(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) extractCertificates() []certificateRepresentation { + if h.tlsManager == nil { + return []certificateRepresentation{} + } + + x509Certs := h.tlsManager.GetServerCertificates() + result := make([]certificateRepresentation, 0, len(x509Certs)) + + for _, cert := range x509Certs { + rep := buildCertificateRepresentation(cert) + result = append(result, rep) + } + + return result +} diff --git a/pkg/api/handler_certificate_test.go b/pkg/api/handler_certificate_test.go new file mode 100644 index 0000000000..2ba1d9aabc --- /dev/null +++ b/pkg/api/handler_certificate_test.go @@ -0,0 +1,603 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/config/static" + tlspkg "github.com/traefik/traefik/v3/pkg/tls" + "github.com/traefik/traefik/v3/pkg/types" +) + +// generateTestCertificate creates a test certificate with the given parameters. +// The certificate will be valid from notBefore to notAfter. +func generateTestCertificate(commonName string, sans []string, notBefore, notAfter time.Time) (types.FileOrContent, types.FileOrContent, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + CommonName: commonName, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + // Add SANs, distinguishing IP addresses from DNS names. + for _, san := range sans { + if ip := net.ParseIP(san); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, san) + } + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return types.FileOrContent(certPEM), types.FileOrContent(keyPEM), nil +} + +func TestHandler_Certificates(t *testing.T) { + type expected struct { + statusCode int + validateResponse func(t *testing.T, body []byte) + } + + type certSetup struct { + loadCerts bool + loadMultipleCerts bool + } + + // Generate test certificates dynamically with valid expiration dates + now := time.Now() + + // Certificate valid for 50+ years (status: "enabled") + localhostCert, localhostKey, err := generateTestCertificate( + "", + []string{"127.0.0.1", "::1", "example.com"}, + now.Add(-24*time.Hour), + now.Add(50*365*24*time.Hour), + ) + require.NoError(t, err) + + // Certificate with warning status (expires in 15 days) + warnCert, warnKey, err := generateTestCertificate( + "warning.com", + []string{"warning.com", "www.warning.com"}, + now.Add(-24*time.Hour), + now.Add(15*24*time.Hour), + ) + require.NoError(t, err) + + // Certificate with expired status (already expired) + expiredCert, expiredKey, err := generateTestCertificate( + "expired.com", + []string{"expired.com"}, + now.Add(-365*24*time.Hour), + now.Add(-24*time.Hour), + ) + require.NoError(t, err) + + // Certificate for search testing (different common name / SANs) + acmeCert, acmeKey, err := generateTestCertificate( + "acme.example.org", + []string{"acme.example.org", "api.acme.example.org"}, + now.Add(-24*time.Hour), + now.Add(50*365*24*time.Hour), + ) + require.NoError(t, err) + + // Compute fingerprint from the generated localhost cert PEM + block, _ := pem.Decode([]byte(localhostCert)) + parsed, _ := x509.ParseCertificate(block.Bytes) + hash := sha256.Sum256(parsed.Raw) + localhostFingerprint := hex.EncodeToString(hash[:]) + + testCases := []struct { + desc string + path string + setup certSetup + expected expected + }{ + { + desc: "all certificates, but no certificates loaded", + path: "/api/certificates", + setup: certSetup{loadCerts: false}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "all certificates, with one certificate loaded", + path: "/api/certificates", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + + cert := certs[0] + assert.Regexp(t, `^[0-9a-f]{64}$`, cert["name"]) + assert.Equal(t, "Acme Co", cert["issuerOrg"]) + assert.Equal(t, "enabled", cert["status"]) + assert.ElementsMatch(t, []any{"127.0.0.1", "::1", "example.com"}, cert["sans"]) + }, + }, + }, + { + desc: "certificates filtered by search text - example", + path: "/api/certificates?search=example", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + sans := certs[0]["sans"].([]any) + assert.Contains(t, sans, "example.com") + }, + }, + }, + { + desc: "certificates filtered by search text - no match", + path: "/api/certificates?search=nonexistent", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "certificates sorted by status", + path: "/api/certificates?sortBy=status", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "enabled", certs[0]["status"]) + }, + }, + }, + { + desc: "certificates sorted by validUntil descending", + path: "/api/certificates?sortBy=validUntil&direction=desc", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + }, + }, + }, + { + desc: "certificates filtered by status - enabled", + path: "/api/certificates?status=enabled", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "enabled", certs[0]["status"]) + }, + }, + }, + { + desc: "certificates filtered by status - expired", + path: "/api/certificates?status=expired", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "one certificate by fingerprint", + path: "/api/certificates/" + localhostFingerprint, + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var cert map[string]any + require.NoError(t, json.Unmarshal(body, &cert)) + assert.Regexp(t, `^[0-9a-f]{64}$`, cert["name"]) + assert.Equal(t, "enabled", cert["status"]) + assert.ElementsMatch(t, []any{"127.0.0.1", "::1", "example.com"}, cert["sans"]) + }, + }, + }, + { + desc: "certificate does not exist", + path: "/api/certificates/non-existent-certificate", + setup: certSetup{loadCerts: false}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "multiple certificates with different statuses", + path: "/api/certificates", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify all statuses are present + statuses := make(map[string]int) + for _, cert := range certs { + status := cert["status"].(string) + statuses[status]++ + } + assert.Equal(t, 2, statuses["enabled"]) + assert.Equal(t, 1, statuses["warning"]) + assert.Equal(t, 1, statuses["expired"]) + }, + }, + }, + { + desc: "certificates sorted by name ascending", + path: "/api/certificates?sortBy=name&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify names are in ascending order + prevName := "" + for _, cert := range certs { + commonName := cert["commonName"].(string) + if prevName != "" { + assert.LessOrEqual(t, prevName, commonName) + } + prevName = commonName + } + }, + }, + }, + { + desc: "certificates sorted by name descending", + path: "/api/certificates?sortBy=name&direction=desc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify names are in descending order + prevName := "zzzzzzz" + for _, cert := range certs { + commonName := cert["commonName"].(string) + assert.GreaterOrEqual(t, prevName, commonName) + prevName = commonName + } + }, + }, + }, + { + desc: "certificates sorted by status ascending", + path: "/api/certificates?sortBy=status&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify statuses are in ascending order (enabled < expired < warning) + prevStatus := "" + for _, cert := range certs { + status := cert["status"].(string) + if prevStatus != "" { + assert.LessOrEqual(t, prevStatus, status) + } + prevStatus = status + } + }, + }, + }, + { + desc: "certificates sorted by issuer", + path: "/api/certificates?sortBy=issuer", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // All certificates have same issuer "Acme Co" + for _, cert := range certs { + assert.Equal(t, "Acme Co", cert["issuerOrg"]) + } + }, + }, + }, + { + desc: "certificates sorted by validUntil ascending", + path: "/api/certificates?sortBy=validUntil&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify notAfter dates are in ascending order + var prevTime time.Time + for _, cert := range certs { + notAfter := cert["notAfter"].(string) + certTime, err := time.Parse(time.RFC3339, notAfter) + require.NoError(t, err) + if !prevTime.IsZero() { + assert.False(t, certTime.Before(prevTime)) + } + prevTime = certTime + } + }, + }, + }, + { + desc: "certificates filtered by status - warning", + path: "/api/certificates?status=warning", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "warning", certs[0]["status"]) + assert.Contains(t, certs[0]["sans"], "warning.com") + }, + }, + }, + { + desc: "certificates filtered by status - expired", + path: "/api/certificates?status=expired", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "expired", certs[0]["status"]) + assert.Contains(t, certs[0]["sans"], "expired.com") + }, + }, + }, + { + desc: "certificates filtered by search - commonName", + path: "/api/certificates?search=acme.example.org", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "acme.example.org", certs[0]["commonName"]) + }, + }, + }, + { + desc: "certificates filtered by search - issuerOrg", + path: "/api/certificates?search=Acme", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + // All certificates have "Acme Co" as issuer + require.Len(t, certs, 4) + }, + }, + }, + { + desc: "certificates with comprehensive field validation", + path: "/api/certificates", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Check the certificate with commonName set (warning.com) + var certWithCN map[string]any + for _, c := range certs { + if c["commonName"] == "warning.com" { + certWithCN = c + break + } + } + require.NotNil(t, certWithCN, "Should find certificate with commonName") + + // Validate all expected fields are present + assert.NotEmpty(t, certWithCN["name"]) + assert.NotEmpty(t, certWithCN["sans"]) + assert.NotEmpty(t, certWithCN["notAfter"]) + assert.NotEmpty(t, certWithCN["notBefore"]) + assert.NotEmpty(t, certWithCN["serialNumber"]) + assert.Equal(t, "warning.com", certWithCN["commonName"]) + assert.NotEmpty(t, certWithCN["issuerOrg"]) + assert.NotEmpty(t, certWithCN["version"]) + assert.Equal(t, "RSA", certWithCN["keyType"]) + assert.InDelta(t, float64(2048), certWithCN["keySize"], 0) + assert.NotEmpty(t, certWithCN["signatureAlgorithm"]) + assert.NotEmpty(t, certWithCN["certFingerprint"]) + assert.NotEmpty(t, certWithCN["publicKeyFingerprint"]) + assert.Equal(t, "warning", certWithCN["status"]) + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + tlsManager := tlspkg.NewManager(nil) + + if test.setup.loadCerts { + dynamicConfigs := []*tlspkg.CertAndStores{{ + Certificate: tlspkg.Certificate{ + CertFile: localhostCert, + KeyFile: localhostKey, + }, + }} + + tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) + } + + if test.setup.loadMultipleCerts { + dynamicConfigs := []*tlspkg.CertAndStores{ + { + Certificate: tlspkg.Certificate{ + CertFile: localhostCert, + KeyFile: localhostKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: warnCert, + KeyFile: warnKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: expiredCert, + KeyFile: expiredKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: acmeCert, + KeyFile: acmeKey, + }, + }, + } + + tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) + } + + handler := New(static.Configuration{API: &static.API{}, Global: &static.Global{}}, nil).WithTLSManager(tlsManager) + server := httptest.NewServer(handler.createRouter()) + + resp, err := http.DefaultClient.Get(server.URL + test.path) + require.NoError(t, err) + + require.Equal(t, test.expected.statusCode, resp.StatusCode) + + contents, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + err = resp.Body.Close() + require.NoError(t, err) + + // Only validate content type and body for success responses + if resp.StatusCode == http.StatusOK { + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + if test.expected.validateResponse != nil { + test.expected.validateResponse(t, contents) + } + } + }) + } +} diff --git a/pkg/api/handler_entrypoint.go b/pkg/api/handler_entrypoint.go index 2595d9faa9..ced9a254eb 100644 --- a/pkg/api/handler_entrypoint.go +++ b/pkg/api/handler_entrypoint.go @@ -19,7 +19,7 @@ type entryPointRepresentation struct { Name string `json:"name,omitempty"` } -func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { results := make([]entryPointRepresentation, 0, len(h.staticConfig.EntryPoints)) for name, ep := range h.staticConfig.EntryPoints { @@ -50,7 +50,7 @@ func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) { scapedEntryPointID := mux.Vars(request)["entryPointID"] entryPointID, err := url.PathUnescape(scapedEntryPointID) diff --git a/pkg/api/handler_http.go b/pkg/api/handler_http.go index 9cc71fd0ef..4ec58003e3 100644 --- a/pkg/api/handler_http.go +++ b/pkg/api/handler_http.go @@ -71,7 +71,7 @@ func newMiddlewareRepresentation(name string, mi *runtime.MiddlewareInfo) middle } } -func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRouters(rw http.ResponseWriter, request *http.Request) { results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers)) query := request.URL.Query() @@ -102,7 +102,7 @@ func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -128,7 +128,7 @@ func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getServices(rw http.ResponseWriter, request *http.Request) { results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services)) query := request.URL.Query() @@ -159,7 +159,7 @@ func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) @@ -185,7 +185,7 @@ func (h Handler) getService(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares)) query := request.URL.Query() @@ -216,7 +216,7 @@ func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) { scapedMiddlewareID := mux.Vars(request)["middlewareID"] middlewareID, err := url.PathUnescape(scapedMiddlewareID) diff --git a/pkg/api/handler_overview.go b/pkg/api/handler_overview.go index 9279370a65..d9d55281cf 100644 --- a/pkg/api/handler_overview.go +++ b/pkg/api/handler_overview.go @@ -8,6 +8,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/static" + "github.com/traefik/traefik/v3/pkg/tls" ) type schemeOverview struct { @@ -30,14 +31,15 @@ type features struct { } type overview struct { - HTTP schemeOverview `json:"http"` - TCP schemeOverview `json:"tcp"` - UDP schemeOverview `json:"udp"` - Features features `json:"features,omitempty"` - Providers []string `json:"providers,omitempty"` + HTTP schemeOverview `json:"http"` + TCP schemeOverview `json:"tcp"` + UDP schemeOverview `json:"udp"` + Certificates *section `json:"certificates,omitempty"` + Features features `json:"features,omitempty"` + Providers []string `json:"providers,omitempty"` } -func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getOverview(rw http.ResponseWriter, request *http.Request) { result := overview{ HTTP: schemeOverview{ Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers), @@ -53,8 +55,9 @@ func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) { Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters), Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices), }, - Features: getFeatures(h.staticConfig), - Providers: getProviders(h.staticConfig), + Certificates: getCertificatesSection(h.tlsManager), + Features: getFeatures(h.staticConfig), + Providers: getProviders(h.staticConfig), } rw.Header().Set("Content-Type", "application/json") @@ -285,3 +288,29 @@ func getTracing(conf static.Configuration) string { return "" } + +func getCertificatesSection(tlsManager *tls.Manager) *section { + if tlsManager == nil { + return nil + } + + x509Certs := tlsManager.GetServerCertificates() + var countWarnings int + var countErrors int + + for _, cert := range x509Certs { + status := getCertificateStatus(cert.NotAfter) + switch status { + case certStatusExpired: + countErrors++ + case certStatusWarning: + countWarnings++ + } + } + + return §ion{ + Total: len(x509Certs), + Warnings: countWarnings, + Errors: countErrors, + } +} diff --git a/pkg/api/handler_overview_test.go b/pkg/api/handler_overview_test.go index 8dabef1b1f..d294b7ace5 100644 --- a/pkg/api/handler_overview_test.go +++ b/pkg/api/handler_overview_test.go @@ -232,6 +232,7 @@ func TestHandler_Overview(t *testing.T) { Global: &static.Global{}, API: &static.API{}, Providers: &static.Providers{ + Precedence: []string{"foo"}, Docker: &docker.Provider{}, Swarm: &docker.SwarmProvider{}, File: &file.Provider{}, diff --git a/pkg/api/handler_support_dump.go b/pkg/api/handler_support_dump.go index 08f0e0e0a5..82b5d7e201 100644 --- a/pkg/api/handler_support_dump.go +++ b/pkg/api/handler_support_dump.go @@ -13,7 +13,7 @@ import ( "github.com/traefik/traefik/v3/pkg/version" ) -func (h Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) { +func (h *Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) { logger := log.Ctx(req.Context()) staticConfig, err := redactor.Anonymize(h.staticConfig) diff --git a/pkg/api/handler_tcp.go b/pkg/api/handler_tcp.go index 39656c0289..7b2b180468 100644 --- a/pkg/api/handler_tcp.go +++ b/pkg/api/handler_tcp.go @@ -66,7 +66,7 @@ func newTCPMiddlewareRepresentation(name string, mi *runtime.TCPMiddlewareInfo) } } -func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters)) query := request.URL.Query() @@ -97,7 +97,7 @@ func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -123,7 +123,7 @@ func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices)) query := request.URL.Query() @@ -154,7 +154,7 @@ func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) @@ -180,7 +180,7 @@ func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) { results := make([]tcpMiddlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares)) query := request.URL.Query() @@ -211,7 +211,7 @@ func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request } } -func (h Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) { scapedMiddlewareID := mux.Vars(request)["middlewareID"] middlewareID, err := url.PathUnescape(scapedMiddlewareID) diff --git a/pkg/api/handler_udp.go b/pkg/api/handler_udp.go index db7185abdb..cede6c16f3 100644 --- a/pkg/api/handler_udp.go +++ b/pkg/api/handler_udp.go @@ -45,7 +45,7 @@ func newUDPServiceRepresentation(name string, si *runtime.UDPServiceInfo) udpSer } } -func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { results := make([]udpRouterRepresentation, 0, len(h.runtimeConfiguration.UDPRouters)) query := request.URL.Query() @@ -76,7 +76,7 @@ func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -102,7 +102,7 @@ func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { results := make([]udpServiceRepresentation, 0, len(h.runtimeConfiguration.UDPServices)) query := request.URL.Query() @@ -133,7 +133,7 @@ func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) diff --git a/pkg/api/sort.go b/pkg/api/sort.go index cf70679cd7..c6135e6196 100644 --- a/pkg/api/sort.go +++ b/pkg/api/sort.go @@ -4,6 +4,7 @@ import ( "cmp" "net/url" "sort" + "time" ) const ( @@ -14,6 +15,9 @@ const ( const ( ascendantSorting = "asc" descendantSorting = "desc" + + sortFieldName = "name" + sortFieldStatus = "status" ) type orderedWithName interface { @@ -31,6 +35,14 @@ type orderedRouter interface { entryPointsCount() int } +type orderedCertificate interface { + orderedWithName + + status() string + issuer() string + validUntil() time.Time +} + func sortRouters[T orderedRouter](values url.Values, routers []T) { sortBy := values.Get(sortByParam) @@ -40,7 +52,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, routers) case "provider": @@ -49,7 +61,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) { case "priority": sortByFunc(direction, routers, func(i int) int { return routers[i].priority() }) - case "status": + case sortFieldStatus: sortByFunc(direction, routers, func(i int) string { return routers[i].status() }) case "rule": @@ -170,7 +182,7 @@ func sortServices[T orderedService](values url.Values, services []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, services) case "type": @@ -182,7 +194,7 @@ func sortServices[T orderedService](values url.Values, services []T) { case "provider": sortByFunc(direction, services, func(i int) string { return services[i].provider() }) - case "status": + case sortFieldStatus: sortByFunc(direction, services, func(i int) string { return services[i].status() }) default: @@ -291,7 +303,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, middlewares) case "type": @@ -300,7 +312,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) { case "provider": sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].provider() }) - case "status": + case sortFieldStatus: sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].status() }) default: @@ -340,6 +352,56 @@ func (m tcpMiddlewareRepresentation) status() string { return m.Status } +func sortCertificates[T orderedCertificate](values url.Values, certificates []T) { + sortBy := values.Get(sortByParam) + + direction := values.Get(directionParam) + if direction == "" { + direction = ascendantSorting + } + + switch sortBy { + case sortFieldName, "cn": + sortByName(direction, certificates) + + case sortFieldStatus: + sortByFunc(direction, certificates, func(i int) string { return certificates[i].status() }) + + case "issuer": + sortByFunc(direction, certificates, func(i int) string { return certificates[i].issuer() }) + + case "validUntil": + sortByTime(direction, certificates, func(i int) time.Time { return certificates[i].validUntil() }) + + default: + sortByName(direction, certificates) + } +} + +func sortByTime[T orderedWithName](direction string, results []T, fn func(int) time.Time) { + // Ascending + if direction == ascendantSorting { + sort.Slice(results, func(i, j int) bool { + ti, tj := fn(i), fn(j) + if ti.Equal(tj) { + return results[i].name() < results[j].name() + } + return ti.Before(tj) + }) + + return + } + + // Descending + sort.Slice(results, func(i, j int) bool { + ti, tj := fn(i), fn(j) + if ti.Equal(tj) { + return results[i].name() > results[j].name() + } + return ti.After(tj) + }) +} + func sortByName[T orderedWithName](direction string, results []T) { // Ascending if direction == ascendantSorting { diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index 1a8aeb31aa..cf716d5948 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -168,6 +168,10 @@ type RouterObservabilityConfig struct { // +kubebuilder:validation:Enum=minimal;detailed // +kubebuilder:default=minimal TraceVerbosity otypes.TracingVerbosity `json:"traceVerbosity,omitempty" toml:"traceVerbosity,omitempty" yaml:"traceVerbosity,omitempty" export:"true"` + + // Metadata holds the metadata for this router. + // Metadata cannot be user-defined for now. + Metadata *ObservabilityMetadata `json:"metadata,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-"` } // SetDefaults Default values for a RouterObservabilityConfig. @@ -177,6 +181,23 @@ func (r *RouterObservabilityConfig) SetDefaults() { // +k8s:deepcopy-gen=true +// ObservabilityMetadata holds the observability metadata configuration. +type ObservabilityMetadata struct { + Ingress *KubernetesIngressMetadata `json:"ingress,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-"` +} + +// +k8s:deepcopy-gen=true + +// KubernetesIngressMetadata holds the Kubernetes Ingress metadata. +type KubernetesIngressMetadata struct { + Namespace string `json:"namespace,omitempty"` + IngressName string `json:"ingressName,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + ServicePort string `json:"servicePort,omitempty"` +} + +// +k8s:deepcopy-gen=true + // Mirroring holds the Mirroring configuration. type Mirroring struct { Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 5eb1d5488b..b470070274 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -957,6 +957,22 @@ func (in *InFlightReq) DeepCopy() *InFlightReq { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesIngressMetadata) DeepCopyInto(out *KubernetesIngressMetadata) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesIngressMetadata. +func (in *KubernetesIngressMetadata) DeepCopy() *KubernetesIngressMetadata { + if in == nil { + return nil + } + out := new(KubernetesIngressMetadata) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Message) DeepCopyInto(out *Message) { *out = *in @@ -1245,6 +1261,27 @@ func (in *Model) DeepCopy() *Model { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservabilityMetadata) DeepCopyInto(out *ObservabilityMetadata) { + *out = *in + if in.Ingress != nil { + in, out := &in.Ingress, &out.Ingress + *out = new(KubernetesIngressMetadata) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservabilityMetadata. +func (in *ObservabilityMetadata) DeepCopy() *ObservabilityMetadata { + if in == nil { + return nil + } + out := new(ObservabilityMetadata) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PassTLSClientCert) DeepCopyInto(out *PassTLSClientCert) { *out = *in @@ -1614,6 +1651,11 @@ func (in *RouterObservabilityConfig) DeepCopyInto(out *RouterObservabilityConfig *out = new(bool) **out = **in } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(ObservabilityMetadata) + (*in).DeepCopyInto(*out) + } return } diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index e3cb00a079..aa1241d3bf 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "regexp" + "slices" "strings" "time" @@ -57,6 +58,27 @@ const ( DefaultUDPTimeout = 3 * time.Second ) +// providerNames is the ordered list of the Traefik provider names. +var providerNames = []string{ + gateway.ProviderName, + crd.ProviderName, + ingress.ProviderName, + ingressnginx.ProviderName, + docker.SwarmName, + docker.DockerName, + file.ProviderName, + redis.ProviderName, + knative.ProviderName, + consul.ProviderName, + consulcatalog.ProviderName, + nomad.ProviderName, + etcd.ProviderName, + ecs.ProviderName, + http.ProviderName, + zk.ProviderName, + rest.ProviderName, +} + // Allowed characters in URL following RFC 3986 (https://www.rfc-editor.org/rfc/rfc3986#section-2) var validBasePath = regexp.MustCompile(`^/[a-zA-Z0-9/_.:~-]*$`) @@ -238,6 +260,7 @@ func (t *Tracing) SetDefaults() { // Providers contains providers configuration. type Providers struct { ProvidersThrottleDuration ptypes.Duration `description:"Backends throttle duration: minimum duration between 2 events from providers before applying a new configuration. It avoids unnecessary reloads if multiples events are sent in a short amount of time." json:"providersThrottleDuration,omitempty" toml:"providersThrottleDuration,omitempty" yaml:"providersThrottleDuration,omitempty" export:"true"` + Precedence []string `description:"Defines the routing precedence between providers." json:"precedence,omitempty" toml:"precedence,omitempty" yaml:"precedence,omitempty" export:"true"` Docker *docker.Provider `description:"Enables Docker provider." json:"docker,omitempty" toml:"docker,omitempty" yaml:"docker,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` Swarm *docker.SwarmProvider `description:"Enables Docker Swarm provider." json:"swarm,omitempty" toml:"swarm,omitempty" yaml:"swarm,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` @@ -260,6 +283,11 @@ type Providers struct { Plugin map[string]PluginConf `description:"Plugins configuration." json:"plugin,omitempty" toml:"plugin,omitempty" yaml:"plugin,omitempty"` } +// SetDefaults sets the default values. +func (p *Providers) SetDefaults() { + p.Precedence = providerNames +} + // SetEffectiveConfiguration adds missing configuration parameters derived from existing ones. // It also takes care of maintaining backwards compatibility. func (c *Configuration) SetEffectiveConfiguration() { @@ -290,6 +318,10 @@ func (c *Configuration) SetEffectiveConfiguration() { c.Tracing.ResourceAttributes = c.Tracing.GlobalAttributes } + for i, providerName := range c.Providers.Precedence { + c.Providers.Precedence[i] = strings.ToLower(providerName) + } + if c.Providers.Docker != nil { if c.Providers.Docker.HTTPClientTimeout < 0 { c.Providers.Docker.HTTPClientTimeout = 0 @@ -426,6 +458,14 @@ func (c *Configuration) ValidateConfiguration() error { } } + if c.Providers != nil { + for _, providerName := range c.Providers.Precedence { + if !slices.Contains(providerNames, providerName) { + return fmt.Errorf("provider %q is not a valid provider name", providerName) + } + } + } + if c.Providers != nil && c.Providers.KubernetesIngressNGINX != nil { if c.Providers.KubernetesIngressNGINX.WatchNamespace != "" && c.Providers.KubernetesIngressNGINX.WatchNamespaceSelector != "" { return errors.New("watchNamespace and watchNamespaceSelector options are mutually exclusive") diff --git a/pkg/config/static/static_config_test.go b/pkg/config/static/static_config_test.go index 5289e46792..33609bb4d7 100644 --- a/pkg/config/static/static_config_test.go +++ b/pkg/config/static/static_config_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/provider/acme" ) @@ -50,7 +51,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { { desc: "empty", conf: &Configuration{ - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, }, expected: &Configuration{ EntryPoints: EntryPoints{"http": &EntryPoint{ @@ -83,13 +84,13 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { Timeout: 3000000000, }, }}, - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, }, }, { desc: "ACME simple", conf: &Configuration{ - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -131,7 +132,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { Timeout: 3000000000, }, }}, - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -147,7 +148,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { { desc: "ACME deprecation DelayBeforeCheck", conf: &Configuration{ - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -190,7 +191,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { Timeout: 3000000000, }, }}, - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -210,7 +211,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { { desc: "ACME deprecation DisablePropagationCheck", conf: &Configuration{ - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -253,7 +254,7 @@ func TestConfiguration_SetEffectiveConfiguration(t *testing.T) { Timeout: 3000000000, }, }}, - Providers: &Providers{}, + Providers: &Providers{Precedence: providerNames}, CertificatesResolvers: map[string]CertificateResolver{ "foo": { ACME: &acme.Configuration{ @@ -378,3 +379,55 @@ func TestValidateConfiguration_BasePath(t *testing.T) { }) } } + +func TestProvidersPrecedence(t *testing.T) { + testCases := []struct { + desc string + cfg *Configuration + expectedError bool + expected []string + }{ + { + desc: "No precedence", + cfg: &Configuration{ + Providers: &Providers{ + Precedence: providerNames, + }, + }, + expected: providerNames, + }, + { + desc: "Precedence with non existing provider", + cfg: &Configuration{ + Providers: &Providers{ + Precedence: []string{"unknown"}, + }, + }, + expectedError: true, + }, + { + desc: "Precedence with upper case provider", + cfg: &Configuration{ + Providers: &Providers{ + Precedence: []string{"DOCKER"}, + }, + }, + expected: []string{"docker"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + test.cfg.SetEffectiveConfiguration() + err := test.cfg.ValidateConfiguration() + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, test.cfg.Providers.Precedence) + } + }) + } +} diff --git a/pkg/middlewares/accesslog/logdata.go b/pkg/middlewares/accesslog/logdata.go index c9010fdd61..4d7aee2d3e 100644 --- a/pkg/middlewares/accesslog/logdata.go +++ b/pkg/middlewares/accesslog/logdata.go @@ -87,6 +87,17 @@ const ( OTelTraceID = "trace_id" // OTelSpanID is the OTel-conformant log attribute for the span identifier. OTelSpanID = "span_id" + + // Kubernetes Ingress fields. + + // KubernetesIngressNamespace is the namespace of the Kubernetes Ingress resource the router handles. + KubernetesIngressNamespace = "KubernetesIngressNamespace" + // KubernetesIngressName is the name of the Kubernetes Ingress resource the router handles. + KubernetesIngressName = "KubernetesIngressName" + // KubernetesServiceName is the name of the Kubernetes service associated with Ingress the router handles. + KubernetesServiceName = "KubernetesServiceName" + // KubernetesServicePort is the port of the Kubernetes service associated with Ingress the router handles. + KubernetesServicePort = "KubernetesServicePort" ) // These are written out in the default case when no config is provided to specify keys of interest. @@ -94,11 +105,21 @@ var defaultCoreKeys = [...]string{ StartUTC, Duration, RouterName, + ServiceAddr, ServiceName, ServiceURL, + ClientAddr, ClientHost, ClientPort, ClientUsername, + GzipRatio, + StartLocal, + Overhead, + RetryAttempts, + TLSVersion, + TLSCipher, + TLSClientSubject, + RequestAddr, RequestHost, RequestPort, RequestMethod, @@ -121,18 +142,6 @@ func init() { for _, k := range defaultCoreKeys { allCoreKeys[k] = struct{}{} } - allCoreKeys[ServiceAddr] = struct{}{} - allCoreKeys[ClientAddr] = struct{}{} - allCoreKeys[RequestAddr] = struct{}{} - allCoreKeys[GzipRatio] = struct{}{} - allCoreKeys[StartLocal] = struct{}{} - allCoreKeys[Overhead] = struct{}{} - allCoreKeys[RetryAttempts] = struct{}{} - allCoreKeys[TLSVersion] = struct{}{} - allCoreKeys[TLSCipher] = struct{}{} - allCoreKeys[TLSClientSubject] = struct{}{} - allCoreKeys[OTelTraceID] = struct{}{} - allCoreKeys[OTelSpanID] = struct{}{} } // CoreLogData holds the fields computed from the request/response. diff --git a/pkg/middlewares/accesslog/logger.go b/pkg/middlewares/accesslog/logger.go index f0c8300910..c5eca1228c 100644 --- a/pkg/middlewares/accesslog/logger.go +++ b/pkg/middlewares/accesslog/logger.go @@ -204,6 +204,15 @@ func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request, next http }, } + if metadata := observability.GetObservabilityMetadata(req.Context()); metadata != nil { + if metadata.Ingress != nil { + logDataTable.Core[KubernetesIngressNamespace] = metadata.Ingress.Namespace + logDataTable.Core[KubernetesIngressName] = metadata.Ingress.IngressName + logDataTable.Core[KubernetesServiceName] = metadata.Ingress.ServiceName + logDataTable.Core[KubernetesServicePort] = metadata.Ingress.ServicePort + } + } + if span := trace.SpanFromContext(req.Context()); span != nil { spanContext := span.SpanContext() if spanContext.HasTraceID() && spanContext.HasSpanID() { diff --git a/pkg/middlewares/accesslog/logger_test.go b/pkg/middlewares/accesslog/logger_test.go index 557f1ef0c4..8bd07c0626 100644 --- a/pkg/middlewares/accesslog/logger_test.go +++ b/pkg/middlewares/accesslog/logger_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ptypes "github.com/traefik/paerser/types" + "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/middlewares/capture" "github.com/traefik/traefik/v3/pkg/middlewares/observability" otypes "github.com/traefik/traefik/v3/pkg/observability/types" @@ -452,7 +453,7 @@ func TestLoggerHeaderFields(t *testing.T) { func TestCommonLogger(t *testing.T) { logFilePath := filepath.Join(t.TempDir(), logFileNameSuffix) config := &otypes.AccessLog{FilePath: logFilePath, Format: CommonFormat} - doLogging(t, config, false) + doLogging(t, config, false, false) logData, err := os.ReadFile(logFilePath) require.NoError(t, err) @@ -464,7 +465,7 @@ func TestCommonLogger(t *testing.T) { func TestCommonLoggerWithBufferingSize(t *testing.T) { logFilePath := filepath.Join(t.TempDir(), logFileNameSuffix) config := &otypes.AccessLog{FilePath: logFilePath, Format: CommonFormat, BufferingSize: 1024} - doLogging(t, config, false) + doLogging(t, config, false, false) // wait a bit for the buffer to be written in the file. time.Sleep(50 * time.Millisecond) @@ -479,7 +480,7 @@ func TestCommonLoggerWithBufferingSize(t *testing.T) { func TestLoggerGenericCLF(t *testing.T) { logFilePath := filepath.Join(t.TempDir(), logFileNameSuffix) config := &otypes.AccessLog{FilePath: logFilePath, Format: GenericCLFFormat} - doLogging(t, config, false) + doLogging(t, config, false, false) logData, err := os.ReadFile(logFilePath) require.NoError(t, err) @@ -491,7 +492,7 @@ func TestLoggerGenericCLF(t *testing.T) { func TestLoggerGenericCLFWithBufferingSize(t *testing.T) { logFilePath := filepath.Join(t.TempDir(), logFileNameSuffix) config := &otypes.AccessLog{FilePath: logFilePath, Format: GenericCLFFormat, BufferingSize: 1024} - doLogging(t, config, false) + doLogging(t, config, false, false) // wait a bit for the buffer to be written in the file. time.Sleep(50 * time.Millisecond) @@ -541,6 +542,7 @@ func TestLoggerJSON(t *testing.T) { config *otypes.AccessLog tls bool tracing bool + metadata bool expected map[string]func(t *testing.T, value any) }{ { @@ -626,6 +628,50 @@ func TestLoggerJSON(t *testing.T) { OTelSpanID: assertString("0100000000000000"), }, }, + { + desc: "default config with metadata", + config: &otypes.AccessLog{ + FilePath: "", + Format: JSONFormat, + }, + metadata: true, + expected: map[string]func(t *testing.T, value any){ + RequestContentSize: assertFloat64(0), + RequestHost: assertString(testHostname), + RequestAddr: assertString(testHostname), + RequestMethod: assertString(testMethod), + RequestPath: assertString(testPath), + RequestProtocol: assertString(testProto), + RequestScheme: assertString(testScheme), + RequestPort: assertString("-"), + DownstreamStatus: assertFloat64(float64(testStatus)), + DownstreamContentSize: assertFloat64(float64(len(testContent))), + OriginContentSize: assertFloat64(float64(len(testContent))), + OriginStatus: assertFloat64(float64(testStatus)), + RequestRefererHeader: assertString(testReferer), + RequestUserAgentHeader: assertString(testUserAgent), + RouterName: assertString(testRouterName), + ServiceURL: assertString(testServiceName), + ClientUsername: assertString(testUsername), + ClientHost: assertString(testHostname), + ClientPort: assertString(strconv.Itoa(testPort)), + ClientAddr: assertString(fmt.Sprintf("%s:%d", testHostname, testPort)), + "level": assertString("info"), + "msg": assertString(""), + "downstream_Content-Type": assertString("text/plain; charset=utf-8"), + RequestCount: assertFloat64NotZero(), + Duration: assertFloat64NotZero(), + Overhead: assertFloat64NotZero(), + RetryAttempts: assertFloat64(float64(testRetryAttempts)), + "time": assertNotEmpty(), + "StartLocal": assertNotEmpty(), + "StartUTC": assertNotEmpty(), + KubernetesIngressNamespace: assertString("test-namespace"), + KubernetesIngressName: assertString("test-ingress"), + KubernetesServiceName: assertString("test-service"), + KubernetesServicePort: assertString("test-port"), + }, + }, { desc: "default config, with TLS request", config: &otypes.AccessLog{ @@ -788,9 +834,9 @@ func TestLoggerJSON(t *testing.T) { test.config.FilePath = logFilePath if test.tls { - doLoggingTLS(t, test.config, test.tracing) + doLoggingTLS(t, test.config, test.tracing, test.metadata) } else { - doLogging(t, test.config, test.tracing) + doLogging(t, test.config, test.tracing, test.metadata) } logData, err := os.ReadFile(logFilePath) @@ -1062,7 +1108,7 @@ func TestNewLogHandlerOutputStdout(t *testing.T) { file, restoreStdout := captureStdout(t) defer restoreStdout() - doLogging(t, test.config, false) + doLogging(t, test.config, false, false) written, err := os.ReadFile(file.Name()) require.NoError(t, err, "unable to read captured stdout from file") @@ -1150,7 +1196,7 @@ func captureStdout(t *testing.T) (out *os.File, restoreStdout func()) { return file, restoreStdout } -func doLoggingTLSOpt(t *testing.T, config *otypes.AccessLog, enableTLS, tracing bool) { +func doLoggingTLSOpt(t *testing.T, config *otypes.AccessLog, enableTLS, tracing, metadata bool) { t.Helper() logger, err := NewHandler(t.Context(), config) require.NoError(t, err) @@ -1199,9 +1245,22 @@ func doLoggingTLSOpt(t *testing.T, config *otypes.AccessLog, enableTLS, tracing // Injection of the observability variables in the request context. chain = chain.Append(func(next http.Handler) (http.Handler, error) { - return observability.WithObservabilityHandler(next, observability.Observability{ + obs := observability.Observability{ AccessLogsEnabled: true, - }), nil + } + + if metadata { + obs.Metadata = &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "test-namespace", + IngressName: "test-ingress", + ServiceName: "test-service", + ServicePort: "test-port", + }, + } + } + + return observability.WithObservabilityHandler(next, obs), nil }) chain = chain.Append(logger.AliceConstructor()) @@ -1211,16 +1270,16 @@ func doLoggingTLSOpt(t *testing.T, config *otypes.AccessLog, enableTLS, tracing handler.ServeHTTP(httptest.NewRecorder(), req) } -func doLoggingTLS(t *testing.T, config *otypes.AccessLog, tracing bool) { +func doLoggingTLS(t *testing.T, config *otypes.AccessLog, tracing, metadata bool) { t.Helper() - doLoggingTLSOpt(t, config, true, tracing) + doLoggingTLSOpt(t, config, true, tracing, metadata) } -func doLogging(t *testing.T, config *otypes.AccessLog, tracing bool) { +func doLogging(t *testing.T, config *otypes.AccessLog, tracing, metadata bool) { t.Helper() - doLoggingTLSOpt(t, config, false, tracing) + doLoggingTLSOpt(t, config, false, tracing, metadata) } func logWriterTestHandlerFunc(rw http.ResponseWriter, r *http.Request) { diff --git a/pkg/middlewares/compress/compress_test.go b/pkg/middlewares/compress/compress_test.go index 9754de238a..a2b77869d8 100644 --- a/pkg/middlewares/compress/compress_test.go +++ b/pkg/middlewares/compress/compress_test.go @@ -93,9 +93,14 @@ func TestNegotiation(t *testing.T) { expEncoding: gzipName, }, { - desc: "multi accept header list, prefer gzip", + // github.com/klauspost/compress v1.18.4 and up use zstd + // as preferred compression if all accept headers have + // an equal "q" (implicit "q=1.0"). + // + // see https://github.com/klauspost/compress/pull/1121 + desc: "multi accept header list, prefer best", acceptEncHeader: "gzip, br, zstd", - expEncoding: gzipName, + expEncoding: zstdName, }, } diff --git a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go index 9855a9470c..8e8c4944b8 100644 --- a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go +++ b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go @@ -19,6 +19,8 @@ const ( xForwardedPrefixHeader = "X-Forwarded-Prefix" ) +var replacementRegex = regexp.MustCompile(`\$[0-9]+`) + // RewriteTarget is a middleware used to replace the path of a URL request. type rewriteTarget struct { next http.Handler @@ -72,7 +74,7 @@ func (rt *rewriteTarget) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } newTarget = rt.regexp.ReplaceAllString(currentPath, rt.replacement) } else { - newTarget = rt.replacement + newTarget = replacementRegex.ReplaceAllString(rt.replacement, "") } // If the replacement resolves to an absolute URL, issue a 302 redirect. diff --git a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go index acd52be0af..eec5f38f26 100644 --- a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go +++ b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go @@ -161,6 +161,16 @@ func TestRewriteTarget(t *testing.T) { expectedStatusCode: http.StatusFound, expectedRedirectURL: "https://bar.example.org/foo", }, + { + desc: "regex with full URL replacement - multiple paths, no regex", + path: "/foo/a/b/c", + config: dynamic.RewriteTarget{ + Regex: "", + Replacement: "https://bar.example.org/$1", + }, + expectedStatusCode: http.StatusFound, + expectedRedirectURL: "https://bar.example.org/", + }, } for _, test := range testCases { diff --git a/pkg/middlewares/observability/observability.go b/pkg/middlewares/observability/observability.go index a02ca292dc..b9f63e94f7 100644 --- a/pkg/middlewares/observability/observability.go +++ b/pkg/middlewares/observability/observability.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/traefik/traefik/v3/pkg/config/dynamic" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) @@ -19,6 +20,7 @@ type Observability struct { SemConvMetricsEnabled bool TracingEnabled bool DetailedTracingEnabled bool + Metadata *dynamic.ObservabilityMetadata } // WithObservabilityHandler sets the observability state in the context for the next handler. @@ -64,6 +66,15 @@ func DetailedTracingEnabled(ctx context.Context) bool { return ok && obs.DetailedTracingEnabled } +// GetObservabilityMetadata returns the observability metadata. +func GetObservabilityMetadata(ctx context.Context) *dynamic.ObservabilityMetadata { + obs, ok := ctx.Value(observabilityKey).(Observability) + if ok { + return obs.Metadata + } + return nil +} + // SetStatusErrorf flags the span as in error and log an event. func SetStatusErrorf(ctx context.Context, format string, args ...any) { if span := trace.SpanFromContext(ctx); span != nil { diff --git a/pkg/middlewares/requestdecorator/request_decorator.go b/pkg/middlewares/requestdecorator/request_decorator.go index e2a82395ca..b7ef6d065c 100644 --- a/pkg/middlewares/requestdecorator/request_decorator.go +++ b/pkg/middlewares/requestdecorator/request_decorator.go @@ -64,8 +64,8 @@ func parseHost(addr string) string { return host } -// GetCanonizedHost retrieves the canonized host from the given context (previously stored in the request context by the middleware). -func GetCanonizedHost(ctx context.Context) string { +// GetCanonicalHost retrieves the canonical host from the given context (previously stored in the request context by the middleware). +func GetCanonicalHost(ctx context.Context) string { if val, ok := ctx.Value(canonicalKey).(string); ok { return val } diff --git a/pkg/middlewares/requestdecorator/request_decorator_test.go b/pkg/middlewares/requestdecorator/request_decorator_test.go index c959808269..687b3a8fba 100644 --- a/pkg/middlewares/requestdecorator/request_decorator_test.go +++ b/pkg/middlewares/requestdecorator/request_decorator_test.go @@ -42,7 +42,7 @@ func TestRequestHost(t *testing.T) { t.Parallel() next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - host := GetCanonizedHost(r.Context()) + host := GetCanonicalHost(r.Context()) assert.Equal(t, test.expected, host) }) diff --git a/pkg/middlewares/snicheck/snicheck.go b/pkg/middlewares/snicheck/snicheck.go index 89f817fcbc..de474997f5 100644 --- a/pkg/middlewares/snicheck/snicheck.go +++ b/pkg/middlewares/snicheck/snicheck.go @@ -55,7 +55,7 @@ func getHost(req *http.Request) string { return h } - h = requestdecorator.GetCanonizedHost(req.Context()) + h = requestdecorator.GetCanonicalHost(req.Context()) if h != "" { return h } diff --git a/pkg/muxer/http/matcher.go b/pkg/muxer/http/matcher.go index d952a6bc26..506cc2c1d6 100644 --- a/pkg/muxer/http/matcher.go +++ b/pkg/muxer/http/matcher.go @@ -6,11 +6,11 @@ import ( "regexp" "slices" "strings" - "unicode" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/muxer" ) var httpFuncs = matcherBuilderFuncs{ @@ -69,33 +69,33 @@ func method(tree *matchersTree, methods ...string) error { } func host(tree *matchersTree, hosts ...string) error { - host := hosts[0] + hostExpr := hosts[0] - if !IsASCII(host) { - return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", host) + if !muxer.IsASCII(hostExpr) { + return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", hostExpr) } - host = strings.ToLower(host) + hostExpr = strings.ToLower(hostExpr) tree.matcher = func(req *http.Request) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) + reqHost := requestdecorator.GetCanonicalHost(req.Context()) if len(reqHost) == 0 { return false } - if reqHost == host { + if muxer.DomainMatchHostExpression(reqHost, hostExpr) { return true } flatH := requestdecorator.GetCNAMEFlatten(req.Context()) if len(flatH) > 0 { - return strings.EqualFold(flatH, host) + return muxer.DomainMatchHostExpression(flatH, hostExpr) } // Check for match on trailing period on host - if last := len(host) - 1; last >= 0 && host[last] == '.' { - h := host[:last] - if reqHost == h { + if last := len(hostExpr) - 1; last >= 0 && hostExpr[last] == '.' { + h := hostExpr[:last] + if muxer.DomainMatchHostExpression(reqHost, h) { return true } } @@ -103,7 +103,7 @@ func host(tree *matchersTree, hosts ...string) error { // Check for match on trailing period on request if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { h := reqHost[:last] - if h == host { + if muxer.DomainMatchHostExpression(h, hostExpr) { return true } } @@ -117,7 +117,7 @@ func host(tree *matchersTree, hosts ...string) error { func hostRegexp(tree *matchersTree, hosts ...string) error { host := hosts[0] - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) } @@ -127,7 +127,7 @@ func hostRegexp(tree *matchersTree, hosts ...string) error { } tree.matcher = func(req *http.Request) bool { - return re.MatchString(requestdecorator.GetCanonizedHost(req.Context())) || + return re.MatchString(requestdecorator.GetCanonicalHost(req.Context())) || re.MatchString(requestdecorator.GetCNAMEFlatten(req.Context())) } @@ -252,14 +252,3 @@ func queryRegexp(tree *matchersTree, queries ...string) error { return nil } - -// IsASCII checks if the given string contains only ASCII characters. -func IsASCII(s string) bool { - for i := range len(s) { - if s[i] > unicode.MaxASCII { - return false - } - } - - return true -} diff --git a/pkg/muxer/http/matcher_test.go b/pkg/muxer/http/matcher_test.go index 35550ecaf9..a852766160 100644 --- a/pkg/muxer/http/matcher_test.go +++ b/pkg/muxer/http/matcher_test.go @@ -72,9 +72,9 @@ func TestClientIPMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -147,9 +147,9 @@ func TestMethodMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -256,6 +256,16 @@ func TestHostMatcher(t *testing.T) { "https://🦭.com": http.StatusNotFound, }, }, + { + desc: "wildcard matcher", + rule: "Host(`*.example.com`)", + expected: map[string]int{ + "https://test.example.com": http.StatusOK, + "https://other.example.com": http.StatusOK, + "https://example.com": http.StatusNotFound, + "https://test.otherexample.com": http.StatusNotFound, + }, + }, } for _, test := range testCases { @@ -266,9 +276,9 @@ func TestHostMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -367,9 +377,9 @@ func TestHostRegexpMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -442,9 +452,9 @@ func TestPathMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -536,9 +546,9 @@ func TestPathRegexpMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -609,9 +619,9 @@ func TestPathPrefixMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -697,9 +707,9 @@ func TestHeaderMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -806,9 +816,9 @@ func TestHeaderRegexpMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -896,9 +906,9 @@ func TestQueryMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "", 0, handler) + err = muxer.AddRoute(test.rule, "", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -1011,9 +1021,9 @@ func TestQueryRegexpMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - 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 index 36f426f48d..e0583c26c3 100644 --- a/pkg/muxer/http/matcher_v2.go +++ b/pkg/muxer/http/matcher_v2.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/muxer" ) var httpFuncsV2 = matcherBuilderFuncs{ @@ -78,7 +79,7 @@ func pathPrefixV2(tree *matchersTree, paths ...string) error { func hostV2(tree *matchersTree, hosts ...string) error { for i, host := range hosts { - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for \"Host\" matcher, non-ASCII characters are not allowed", host) } @@ -86,7 +87,7 @@ func hostV2(tree *matchersTree, hosts ...string) error { } tree.matcher = func(req *http.Request) bool { - reqHost := requestdecorator.GetCanonizedHost(req.Context()) + reqHost := requestdecorator.GetCanonicalHost(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) { @@ -206,7 +207,7 @@ func hostRegexpV2(tree *matchersTree, hosts ...string) error { router := mux.NewRouter() for _, host := range hosts { - if !IsASCII(host) { + if !muxer.IsASCII(host) { return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) } diff --git a/pkg/muxer/http/matcher_v2_test.go b/pkg/muxer/http/matcher_v2_test.go index d05e57e75b..76449ece5c 100644 --- a/pkg/muxer/http/matcher_v2_test.go +++ b/pkg/muxer/http/matcher_v2_test.go @@ -76,9 +76,9 @@ func TestClientIPV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -154,9 +154,9 @@ func TestMethodV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -280,9 +280,9 @@ func TestHostV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -384,9 +384,9 @@ func TestHostRegexpV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -480,9 +480,9 @@ func TestPathV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -574,9 +574,9 @@ func TestPathPrefixV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -662,9 +662,9 @@ func TestHeadersMatcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -771,9 +771,9 @@ func TestHeaderRegexpV2Matcher(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) return @@ -865,9 +865,9 @@ func TestHostRegexp(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.hostExp, "v2", 0, handler) + err = muxer.AddRoute(test.hostExp, "v2", 0, "", handler) require.NoError(t, err) results := make(map[string]int) @@ -1534,9 +1534,9 @@ func Test_addRoute(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - err = muxer.AddRoute(test.rule, "v2", 0, handler) + err = muxer.AddRoute(test.rule, "v2", 0, "", handler) if test.expectedError { require.Error(t, err) } else { diff --git a/pkg/muxer/http/mux.go b/pkg/muxer/http/mux.go index 99c34a49dd..b948c9fe11 100644 --- a/pkg/muxer/http/mux.go +++ b/pkg/muxer/http/mux.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "sort" "strings" @@ -24,15 +25,20 @@ type MatcherFunc func(*http.Request) bool type Muxer struct { routes routes - parser SyntaxParser - defaultHandler http.Handler + parser SyntaxParser + defaultHandler http.Handler + providersPrecedence []string } // NewMuxer returns a new muxer instance. -func NewMuxer(parser SyntaxParser) *Muxer { +func NewMuxer(parser SyntaxParser, providersPrecedence []string) *Muxer { + providersPrecedence = slices.Clone(providersPrecedence) + slices.Reverse(providersPrecedence) + return &Muxer{ - parser: parser, - defaultHandler: http.NotFoundHandler(), + parser: parser, + defaultHandler: http.NotFoundHandler(), + providersPrecedence: providersPrecedence, } } @@ -71,16 +77,17 @@ func GetRulePriority(rule string) int { } // AddRoute add a new route to the router. -func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler http.Handler) error { +func (m *Muxer) AddRoute(rule string, syntax string, priority int, providerName string, handler http.Handler) error { matchers, err := m.parser.parse(syntax, rule) if err != nil { return fmt.Errorf("error while parsing rule %s: %w", rule, err) } m.routes = append(m.routes, &route{ - handler: handler, - matchers: matchers, - priority: priority, + handler: handler, + matchers: matchers, + priority: priority, + providerPriority: slices.Index(m.providersPrecedence, providerName), }) sort.Sort(m.routes) @@ -206,7 +213,10 @@ func (r routes) Len() int { return len(r) } func (r routes) Swap(i, j int) { r[i], r[j] = r[j], r[i] } // Less implements sort.Interface. -func (r routes) Less(i, j int) bool { return r[i].priority > r[j].priority } +func (r routes) Less(i, j int) bool { + return r[i].priority > r[j].priority || + (r[i].priority == r[j].priority && r[i].providerPriority > r[j].providerPriority) +} // route holds the matchers to match HTTP route, // and the handler that will serve the request. @@ -218,6 +228,8 @@ type route struct { // priority is used to disambiguate between two (or more) rules that would all match for a given request. // Computed from the matching rule length, if not user-set. priority int + // providerPriority is used to disambiguate between two (or more) rules that would all match for a given request and have the same priority. + providerPriority int } // matchersTree represents the matchers tree structure. diff --git a/pkg/muxer/http/mux_test.go b/pkg/muxer/http/mux_test.go index 36cc992218..eea2cc017a 100644 --- a/pkg/muxer/http/mux_test.go +++ b/pkg/muxer/http/mux_test.go @@ -228,10 +228,10 @@ func TestMuxer(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) 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 @@ -264,9 +264,10 @@ func TestMuxer(t *testing.T) { func Test_addRoutePriority(t *testing.T) { type Case struct { - xFrom string - rule string - priority int + xFrom string + rule string + priority int + providerName string } testCases := []struct { @@ -375,6 +376,120 @@ func Test_addRoutePriority(t *testing.T) { }, expected: "header3", }, + { + desc: "Same priority and rule, kubernetescrd wins over kubernetes", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetes", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetescrd", + }, + }, + expected: "header2", + }, + { + desc: "Same priority and rule, kubernetesgateway wins over kubernetescrd", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetescrd", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetesgateway", + }, + }, + expected: "header2", + }, + { + desc: "Same priority and rule, kubernetesgateway wins over kubernetes", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetesgateway", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetes", + }, + }, + expected: "header1", + }, + { + desc: "Same priority and rule, kubernetescrd wins over kubernetesingressnginx", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetesingressnginx", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetescrd", + }, + }, + expected: "header2", + }, + { + desc: "Same priority and rule, known provider wins over unknown provider", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "unknownprovider", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetes", + }, + }, + expected: "header2", + }, + { + desc: "Higher numeric priority wins regardless of provider", + path: "/my", + cases: []Case{ + { + xFrom: "header1", + rule: "PathPrefix(`/my`)", + priority: 20, + providerName: "kubernetesingressnginx", + }, + { + xFrom: "header2", + rule: "PathPrefix(`/my`)", + priority: 10, + providerName: "kubernetesgateway", + }, + }, + expected: "header1", + }, } for _, test := range testCases { @@ -383,7 +498,7 @@ func Test_addRoutePriority(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, []string{"kubernetesgateway", "kubernetescrd", "kubernetes", "kubernetesingressnginx"}) for _, route := range test.cases { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -394,7 +509,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, route.providerName, handler) require.NoError(t, err, route.rule) } @@ -517,9 +632,9 @@ func TestEmptyHost(t *testing.T) { parser, err := NewSyntaxParser() require.NoError(t, err) - muxer := NewMuxer(parser) + muxer := NewMuxer(parser, nil) - 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/muxer.go b/pkg/muxer/muxer.go new file mode 100644 index 0000000000..d7fc0ecf4b --- /dev/null +++ b/pkg/muxer/muxer.go @@ -0,0 +1,31 @@ +package muxer + +import ( + "strings" + "unicode" +) + +// IsASCII checks if the given string contains only ASCII characters. +func IsASCII(s string) bool { + for i := range len(s) { + if s[i] > unicode.MaxASCII { + return false + } + } + + return true +} + +// DomainMatchHostExpression returns true if the domain matches the host expression. +// The host expression can be a wildcard, in which case it will match any subdomain of the domain. +// For example, if the domain is "example.com" and the host expression is "*.example.com", this function will return true. +// If the host expression is "example.com", this function will also return true. +func DomainMatchHostExpression(domain string, hostExpr string) bool { + if strings.HasPrefix(hostExpr, "*") { + labels := strings.Split(domain, ".") + labels[0] = "*" + return strings.EqualFold(hostExpr, strings.Join(labels, ".")) + } + + return strings.EqualFold(domain, hostExpr) +} diff --git a/pkg/muxer/tcp/matcher.go b/pkg/muxer/tcp/matcher.go index aadefa8c10..b29caeb8b2 100644 --- a/pkg/muxer/tcp/matcher.go +++ b/pkg/muxer/tcp/matcher.go @@ -5,11 +5,11 @@ import ( "regexp" "slices" "strings" - "unicode" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/ip" + "github.com/traefik/traefik/v3/pkg/muxer" ) var tcpFuncs = map[string]func(*matchersTree, ...string) error{ @@ -62,36 +62,31 @@ func clientIP(tree *matchersTree, clientIP ...string) error { return nil } -var hostOrIP = regexp.MustCompile(`^[[:word:]\.\-\:]+$`) +var hostOrIP = regexp.MustCompile(`^(\*\.)?[[:word:]\.\-\:]+$`) // hostSNI checks if the SNI Host of the connection match the matcher host. func hostSNI(tree *matchersTree, hosts ...string) error { - host := hosts[0] + hostExpr := hosts[0] - if host == "*" { + if hostExpr == "*" { // Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP, // it allows matching with an empty serverName. tree.matcher = func(meta ConnData) bool { return true } return nil } - if !hostOrIP.MatchString(host) { - return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", host) + if !hostOrIP.MatchString(hostExpr) { + return fmt.Errorf("invalid value for HostSNI matcher, %q is not a valid hostname", hostExpr) } + hostExpr = strings.TrimSuffix(hostExpr, ".") + tree.matcher = func(meta ConnData) bool { if meta.serverName == "" { return false } - if host == meta.serverName { - return true - } - - // trim trailing period in case of FQDN - host = strings.TrimSuffix(host, ".") - - return host == meta.serverName + return muxer.DomainMatchHostExpression(meta.serverName, hostExpr) } return nil @@ -101,7 +96,7 @@ func hostSNI(tree *matchersTree, hosts ...string) error { func hostSNIRegexp(tree *matchersTree, templates ...string) error { template := templates[0] - if !isASCII(template) { + if !muxer.IsASCII(template) { return fmt.Errorf("invalid value for HostSNIRegexp matcher, %q is not a valid hostname", template) } @@ -116,14 +111,3 @@ func hostSNIRegexp(tree *matchersTree, templates ...string) error { return nil } - -// isASCII checks if the given string contains only ASCII characters. -func isASCII(s string) bool { - for i := range len(s) { - if s[i] > unicode.MaxASCII { - return false - } - } - - return true -} diff --git a/pkg/muxer/tcp/matcher_test.go b/pkg/muxer/tcp/matcher_test.go index e5e265fe7b..5ba7d8a1e7 100644 --- a/pkg/muxer/tcp/matcher_test.go +++ b/pkg/muxer/tcp/matcher_test.go @@ -34,10 +34,10 @@ func Test_HostSNICatchAll(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) 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{ @@ -71,11 +71,6 @@ func Test_HostSNI(t *testing.T) { rule: "HostSNI(`example.com`, `example.org`)", buildErr: true, }, - { - desc: "Invalid HostSNI matcher (globing sub domain)", - rule: "HostSNI(`*.com`)", - buildErr: true, - }, { desc: "Invalid HostSNI matcher (non ASCII host)", rule: "HostSNI(`🦭.com`)", @@ -115,12 +110,6 @@ func Test_HostSNI(t *testing.T) { serverName: "", match: true, }, - { - desc: "Matching host with trailing dot", - rule: "HostSNI(`example.com.`)", - serverName: "example.com.", - match: true, - }, { desc: "Matching host with trailing dot but not in server name", rule: "HostSNI(`example.com.`)", @@ -139,16 +128,28 @@ func Test_HostSNI(t *testing.T) { serverName: "foo_bar.example.com", match: true, }, + { + desc: "Matching hosts with subdomains with wildcard", + rule: "HostSNI(`*.example.com`)", + serverName: "foo.example.com", + match: true, + }, + { + desc: "Matching hosts with subdomains with wildcard", + rule: "HostSNI(`*.*.example.com`)", + serverName: "toto.foo.example.com", + buildErr: true, + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) 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,10 +228,10 @@ func Test_HostSNIRegexp(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) 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 @@ -298,10 +299,10 @@ func Test_ClientIP(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) 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 @@ -361,10 +362,10 @@ func Test_ALPN(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) 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 index 8071599940..d5fff39a9e 100644 --- a/pkg/muxer/tcp/matcher_v2.go +++ b/pkg/muxer/tcp/matcher_v2.go @@ -21,6 +21,8 @@ var tcpFuncsV2 = map[string]func(*matchersTree, ...string) error{ "HostSNIRegexp": hostSNIRegexpV2, } +var hostOrIPv2 = regexp.MustCompile(`^[[:word:]\.\-\:]+$`) + func clientIPV2(tree *matchersTree, clientIPs ...string) error { checker, err := ip.NewChecker(clientIPs) if err != nil { @@ -80,7 +82,7 @@ func hostSNIV2(tree *matchersTree, hosts ...string) error { continue } - if !hostOrIP.MatchString(host) { + if !hostOrIPv2.MatchString(host) { return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname or IP", host) } diff --git a/pkg/muxer/tcp/matcher_v2_test.go b/pkg/muxer/tcp/matcher_v2_test.go index b871a7eb4f..9f28139a84 100644 --- a/pkg/muxer/tcp/matcher_v2_test.go +++ b/pkg/muxer/tcp/matcher_v2_test.go @@ -470,10 +470,10 @@ func Test_addTCPRouteV2(t *testing.T) { require.NoError(t, err) }) - router, err := NewMuxer() + router, err := NewMuxer(nil) require.NoError(t, err) - err = router.AddRoute(test.rule, "v2", 0, handler) + err = router.AddRoute(test.rule, "v2", 0, "", handler) if test.routeErr { require.Error(t, err) return @@ -606,10 +606,10 @@ func Test_HostSNICatchAllV2(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer(nil) require.NoError(t, err) - err = muxer.AddRoute(test.rule, "v2", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "v2", 0, "", tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) require.NoError(t, err) handler, catchAll := muxer.Match(ConnData{ diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index db80d82892..083c63a8cb 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -3,6 +3,7 @@ package tcp import ( "fmt" "net" + "slices" "sort" "strings" @@ -27,12 +28,10 @@ func NewConnData(serverName string, conn tcp.WriteCloser, alpnProtos []string) ( return ConnData{}, fmt.Errorf("error while parsing remote address %q: %w", conn.RemoteAddr().String(), err) } - // as per https://datatracker.ietf.org/doc/html/rfc6066: - // > The hostname is represented as a byte string using ASCII encoding without a trailing dot. - // so there is no need to trim a potential trailing dot - serverName = types.CanonicalDomain(serverName) - return ConnData{ + // As per https://datatracker.ietf.org/doc/html/rfc6066: + // > The hostname is represented as a byte string using ASCII encoding without a trailing dot. + // so there is no need to trim a potential trailing dot serverName: types.CanonicalDomain(serverName), remoteIP: remoteIP, alpnProtos: alpnProtos, @@ -41,13 +40,15 @@ 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 - parserV2 predicate.Parser + routes routes + + parser predicate.Parser + parserV2 predicate.Parser + providersPrecedence []string } // NewMuxer returns a TCP muxer. -func NewMuxer() (*Muxer, error) { +func NewMuxer(providersPrecedence []string) (*Muxer, error) { var matcherNames []string for matcherName := range tcpFuncs { matcherNames = append(matcherNames, matcherName) @@ -68,9 +69,13 @@ func NewMuxer() (*Muxer, error) { return nil, fmt.Errorf("error while creating v2 rules parser: %w", err) } + providersPrecedence = slices.Clone(providersPrecedence) + slices.Reverse(providersPrecedence) + return &Muxer{ - parser: parser, - parserV2: parserV2, + parser: parser, + parserV2: parserV2, + providersPrecedence: providersPrecedence, }, nil } @@ -120,7 +125,7 @@ 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, syntax string, priority int, handler tcp.Handler) error { +func (m *Muxer) AddRoute(rule string, syntax string, priority int, providerName string, handler tcp.Handler) error { var parse any var err error var matcherFuncs map[string]func(*matchersTree, ...string) error @@ -161,10 +166,11 @@ func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler tcp.H } newRoute := &route{ - handler: handler, - matchers: matchers, - catchAll: catchAll, - priority: priority, + handler: handler, + matchers: matchers, + catchAll: catchAll, + priority: priority, + providerPriority: slices.Index(m.providersPrecedence, providerName), } m.routes = append(m.routes, newRoute) @@ -217,7 +223,10 @@ func (r routes) Len() int { return len(r) } func (r routes) Swap(i, j int) { r[i], r[j] = r[j], r[i] } // Less implements sort.Interface. -func (r routes) Less(i, j int) bool { return r[i].priority > r[j].priority } +func (r routes) Less(i, j int) bool { + return r[i].priority > r[j].priority || + (r[i].priority == r[j].priority && r[i].providerPriority > r[j].providerPriority) +} // route holds the matchers to match TCP route, // and the handler that will serve the connection. @@ -232,6 +241,8 @@ type route struct { // all match for a given request. // Computed from the matching rule length, if not user-set. priority int + // providerPriority is used to disambiguate between two (or more) rules that would all match for a given request and have the same priority. + providerPriority int } // matchersTree represents the matchers tree structure. diff --git a/pkg/muxer/tcp/mux_test.go b/pkg/muxer/tcp/mux_test.go index 81f15e2dec..708ffb7f32 100644 --- a/pkg/muxer/tcp/mux_test.go +++ b/pkg/muxer/tcp/mux_test.go @@ -272,10 +272,10 @@ func Test_addTCPRoute(t *testing.T) { require.NoError(t, err) }) - router, err := NewMuxer() + router, err := NewMuxer(nil) 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 @@ -388,47 +388,66 @@ func TestParseHostSNI(t *testing.T) { } func Test_Priority(t *testing.T) { + type rule struct { + rule string + priority int + provider string + } + testCases := []struct { - desc string - rules map[string]int - serverName string - expectedRule string + desc string + rules []rule + serverName string + expectedRule string + expectedProvider string }{ { - desc: "One matching rule, calculated priority", - rules: map[string]int{ - "HostSNI(`example.com`)": 0, - "HostSNI(`example.org`)": 0, + desc: "One matching rule, same priority", + rules: []rule{ + {rule: "HostSNI(`example.com`)"}, + {rule: "HostSNI(`example.org`)"}, }, - expectedRule: "HostSNI(`example.com`)", serverName: "example.com", + expectedRule: "HostSNI(`example.com`)", }, { desc: "One matching rule, custom priority", - rules: map[string]int{ - "HostSNI(`example.org`)": 0, - "HostSNI(`example.com`)": 10000, + rules: []rule{ + {rule: "HostSNI(`example.com`)", priority: 10000}, + {rule: "HostSNI(`example.org`)"}, }, - expectedRule: "HostSNI(`example.org`)", serverName: "example.org", + expectedRule: "HostSNI(`example.org`)", }, { - desc: "Two matching rules, calculated priority", - rules: map[string]int{ - "HostSNI(`example.org`)": 0, - "HostSNI(`example.com`)": 0, + desc: "Same rule and priority, kubernetescrd wins over kubernetes", + rules: []rule{ + {rule: "HostSNI(`example.org`)", provider: "kubernetescrd"}, + {rule: "HostSNI(`example.org`)", provider: "kubernetes"}, }, - expectedRule: "HostSNI(`example.org`)", - serverName: "example.org", + serverName: "example.org", + expectedRule: "HostSNI(`example.org`)", + expectedProvider: "kubernetescrd", }, { - desc: "Two matching rules, custom priority", - rules: map[string]int{ - "HostSNI(`example.com`)": 10000, - "HostSNI(`example.org`)": 0, + desc: "Same rule and priority, known provider wins over unknown provider", + rules: []rule{ + {rule: "HostSNI(`example.org`)", provider: "kubernetescrd"}, + {rule: "HostSNI(`example.org`)", provider: "foo"}, }, - expectedRule: "HostSNI(`example.com`)", - serverName: "example.com", + serverName: "example.org", + expectedRule: "HostSNI(`example.org`)", + expectedProvider: "kubernetescrd", + }, + { + desc: "Same rule, higher numeric priority wins regardless of provider", + rules: []rule{ + {rule: "HostSNI(`example.org`)", priority: 10, provider: "kubernetescrd"}, + {rule: "HostSNI(`example.org`)", priority: 20, provider: "kubernetes"}, + }, + serverName: "example.org", + expectedRule: "HostSNI(`example.org`)", + expectedProvider: "kubernetes", }, } @@ -436,13 +455,17 @@ func Test_Priority(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - muxer, err := NewMuxer() + muxer, err := NewMuxer([]string{"kubernetescrd", "kubernetes"}) require.NoError(t, err) - matchedRule := "" - for rule, priority := range test.rules { - err := muxer.AddRoute(rule, "", priority, tcp.HandlerFunc(func(conn tcp.WriteCloser) { - matchedRule = rule + var ( + matchedRule string + matchedProvider string + ) + for _, r := range test.rules { + err := muxer.AddRoute(r.rule, "", r.priority, r.provider, tcp.HandlerFunc(func(conn tcp.WriteCloser) { + matchedRule = r.rule + matchedProvider = r.provider })) require.NoError(t, err) } @@ -454,6 +477,7 @@ func Test_Priority(t *testing.T) { handler.ServeTCP(nil) assert.Equal(t, test.expectedRule, matchedRule) + assert.Equal(t, test.expectedProvider, matchedProvider) }) } } diff --git a/pkg/provider/consulcatalog/consul_catalog.go b/pkg/provider/consulcatalog/consul_catalog.go index 0519db3953..a678f07109 100644 --- a/pkg/provider/consulcatalog/consul_catalog.go +++ b/pkg/provider/consulcatalog/consul_catalog.go @@ -27,8 +27,8 @@ import ( // defaultTemplateRule is the default template for the default rule. const defaultTemplateRule = "Host(`{{ normalize .Name }}`)" -// providerName is the Consul Catalog provider name. -const providerName = "consulcatalog" +// ProviderName is the Consul Catalog provider name. +const ProviderName = "consulcatalog" var _ provider.Provider = (*Provider)(nil) @@ -58,7 +58,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { if len(p.Namespaces) == 0 { return []*Provider{{ Configuration: p.Configuration, - name: providerName, + name: ProviderName, }} } @@ -66,7 +66,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { for _, namespace := range p.Namespaces { providers = append(providers, &Provider{ Configuration: p.Configuration, - name: providerName + "-" + namespace, + name: ProviderName + "-" + namespace, namespace: namespace, }) } @@ -145,7 +145,7 @@ func (p *Provider) Init() error { // In case they didn't initialize Provider with BuildProviders. if p.name == "" { - p.name = providerName + p.name = ProviderName } return nil diff --git a/pkg/provider/docker/pdocker.go b/pkg/provider/docker/pdocker.go index 937f6cdea0..3a3c232849 100644 --- a/pkg/provider/docker/pdocker.go +++ b/pkg/provider/docker/pdocker.go @@ -21,7 +21,8 @@ import ( "github.com/traefik/traefik/v3/pkg/safe" ) -const dockerName = "docker" +// DockerName is the docker provider name. +const DockerName = "docker" var _ provider.Provider = (*Provider)(nil) @@ -53,7 +54,7 @@ func (p *Provider) Init() error { // Provide allows the docker provider to provide configurations to traefik using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { pool.GoCtx(func(routineCtx context.Context) { - logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, dockerName).Logger() + logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, DockerName).Logger() ctxLog := logger.WithContext(routineCtx) operation := func() error { @@ -61,7 +62,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. ctx, cancel := context.WithCancel(ctxLog) defer cancel() - ctx = log.Ctx(ctx).With().Str(logs.ProviderName, dockerName).Logger().WithContext(ctx) + ctx = log.Ctx(ctx).With().Str(logs.ProviderName, DockerName).Logger().WithContext(ctx) dockerClient, err := p.createClient(ctxLog) if err != nil { @@ -88,7 +89,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. configuration := builder.build(ctxLog, dockerDataList) configurationChan <- dynamic.Message{ - ProviderName: dockerName, + ProviderName: DockerName, Configuration: configuration, } @@ -111,7 +112,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. configuration := builder.build(ctx, containers) if configuration != nil { message := dynamic.Message{ - ProviderName: dockerName, + ProviderName: DockerName, Configuration: configuration, } select { diff --git a/pkg/provider/docker/pswarm.go b/pkg/provider/docker/pswarm.go index d16df07f86..8dcc0306d1 100644 --- a/pkg/provider/docker/pswarm.go +++ b/pkg/provider/docker/pswarm.go @@ -22,7 +22,8 @@ import ( "github.com/traefik/traefik/v3/pkg/safe" ) -const swarmName = "swarm" +// SwarmName is the swarm provider name. +const SwarmName = "swarm" var _ provider.Provider = (*SwarmProvider)(nil) @@ -57,7 +58,7 @@ func (p *SwarmProvider) Init() error { // Provide allows the docker provider to provide configurations to traefik using the given configuration channel. func (p *SwarmProvider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { pool.GoCtx(func(routineCtx context.Context) { - logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, swarmName).Logger() + logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, SwarmName).Logger() ctxLog := logger.WithContext(routineCtx) operation := func() error { @@ -65,7 +66,7 @@ func (p *SwarmProvider) Provide(configurationChan chan<- dynamic.Message, pool * ctx, cancel := context.WithCancel(ctxLog) defer cancel() - ctx = log.Ctx(ctx).With().Str(logs.ProviderName, swarmName).Logger().WithContext(ctx) + ctx = log.Ctx(ctx).With().Str(logs.ProviderName, SwarmName).Logger().WithContext(ctx) dockerClient, err := p.createClient(ctx) if err != nil { @@ -92,7 +93,7 @@ func (p *SwarmProvider) Provide(configurationChan chan<- dynamic.Message, pool * configuration := builder.build(ctxLog, dockerDataList) configurationChan <- dynamic.Message{ - ProviderName: swarmName, + ProviderName: SwarmName, Configuration: configuration, } if p.Watch { @@ -102,7 +103,7 @@ func (p *SwarmProvider) Provide(configurationChan chan<- dynamic.Message, pool * ticker := time.NewTicker(time.Duration(p.RefreshSeconds)) pool.GoCtx(func(ctx context.Context) { - logger := log.Ctx(ctx).With().Str(logs.ProviderName, swarmName).Logger() + logger := log.Ctx(ctx).With().Str(logs.ProviderName, SwarmName).Logger() ctx = logger.WithContext(ctx) defer close(errChan) @@ -119,7 +120,7 @@ func (p *SwarmProvider) Provide(configurationChan chan<- dynamic.Message, pool * configuration := builder.build(ctx, services) if configuration != nil { configurationChan <- dynamic.Message{ - ProviderName: swarmName, + ProviderName: SwarmName, Configuration: configuration, } } diff --git a/pkg/provider/ecs/ecs.go b/pkg/provider/ecs/ecs.go index c8186fe7d7..9cb6af78ff 100644 --- a/pkg/provider/ecs/ecs.go +++ b/pkg/provider/ecs/ecs.go @@ -29,6 +29,9 @@ import ( "github.com/traefik/traefik/v3/pkg/safe" ) +// ProviderName is the ECS provider name. +const ProviderName = "ecs" + // Provider holds configurations of the provider. type Provider struct { Constraints string `description:"Constraints is an expression that Traefik matches against the container's labels to determine whether to create any route for that container." json:"constraints,omitempty" toml:"constraints,omitempty" yaml:"constraints,omitempty" export:"true"` @@ -107,7 +110,7 @@ func (p *Provider) Init() error { // Provide configuration to traefik from ECS. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { pool.GoCtx(func(routineCtx context.Context) { - logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, "ecs").Logger() + logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(routineCtx) operation := func() error { diff --git a/pkg/provider/file/file.go b/pkg/provider/file/file.go index a93d4b5513..96c2942884 100644 --- a/pkg/provider/file/file.go +++ b/pkg/provider/file/file.go @@ -26,7 +26,8 @@ import ( "github.com/traefik/traefik/v3/pkg/types" ) -const providerName = "file" +// ProviderName is the file provider name. +const ProviderName = "file" var _ provider.Provider = (*Provider)(nil) @@ -52,7 +53,7 @@ func (p *Provider) Init() error { // Provide allows the file provider to provide configurations to traefik // using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() if p.Watch { var watchItems []string @@ -174,7 +175,7 @@ func (p *Provider) addWatcher(pool *safe.Pool, items []string, configurationChan // Process events pool.GoCtx(func(ctx context.Context) { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() defer watcher.Close() for { select { @@ -218,7 +219,7 @@ func (p *Provider) applyConfiguration(configurationChan chan<- dynamic.Message) // buildConfiguration loads configuration either from file or a directory // specified by 'Filename'/'Directory' and returns a 'Configuration' object. func (p *Provider) buildConfiguration() (*dynamic.Configuration, error) { - ctx := log.With().Str(logs.ProviderName, providerName).Logger().WithContext(context.Background()) + ctx := log.With().Str(logs.ProviderName, ProviderName).Logger().WithContext(context.Background()) if len(p.Directory) > 0 { configurations, err := p.collectFileConfigs(ctx, p.Directory, "") diff --git a/pkg/provider/http/http.go b/pkg/provider/http/http.go index 757bd3a3d9..b78cf5e533 100644 --- a/pkg/provider/http/http.go +++ b/pkg/provider/http/http.go @@ -25,6 +25,9 @@ import ( var _ provider.Provider = (*Provider)(nil) +// ProviderName is the http provider name. +const ProviderName = "http" + const defaultMaxResponseBodySize = -1 // Provider is a provider.Provider implementation that queries an HTTP(s) endpoint for a configuration. @@ -78,7 +81,7 @@ func (p *Provider) Init() error { // Provide allows the provider to provide configurations to traefik using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { pool.GoCtx(func(routineCtx context.Context) { - logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, "http").Logger() + logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(routineCtx) operation := func() error { diff --git a/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/route.go b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/route.go index bdb04f4d3e..59adbc6ca2 100644 --- a/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/route.go +++ b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/route.go @@ -26,10 +26,6 @@ THE SOFTWARE. package v1alpha1 -import ( - dynamic "github.com/traefik/traefik/v3/pkg/config/dynamic" -) - // RouteApplyConfiguration represents a declarative configuration of the Route type for use // with apply. // @@ -58,7 +54,7 @@ type RouteApplyConfiguration struct { Middlewares []MiddlewareRefApplyConfiguration `json:"middlewares,omitempty"` // Observability defines the observability configuration for a router. // More info: https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/routing/observability/ - Observability *dynamic.RouterObservabilityConfig `json:"observability,omitempty"` + Observability *RouterObservabilityConfigApplyConfiguration `json:"observability,omitempty"` } // RouteApplyConfiguration constructs a declarative configuration of the Route type for use with @@ -128,7 +124,7 @@ func (b *RouteApplyConfiguration) WithMiddlewares(values ...*MiddlewareRefApplyC // WithObservability sets the Observability field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Observability field is set to the value of the last call. -func (b *RouteApplyConfiguration) WithObservability(value dynamic.RouterObservabilityConfig) *RouteApplyConfiguration { - b.Observability = &value +func (b *RouteApplyConfiguration) WithObservability(value *RouterObservabilityConfigApplyConfiguration) *RouteApplyConfiguration { + b.Observability = value return b } diff --git a/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/routerobservabilityconfig.go b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/routerobservabilityconfig.go new file mode 100644 index 0000000000..59520c082d --- /dev/null +++ b/pkg/provider/kubernetes/crd/generated/applyconfiguration/traefikio/v1alpha1/routerobservabilityconfig.go @@ -0,0 +1,85 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016-2020 Containous SAS; 2020-2026 Traefik Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + types "github.com/traefik/traefik/v3/pkg/observability/types" +) + +// RouterObservabilityConfigApplyConfiguration represents a declarative configuration of the RouterObservabilityConfig type for use +// with apply. +// +// RouterObservabilityConfig holds the observability configuration for a router. +// More info: https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/routing/observability/ +type RouterObservabilityConfigApplyConfiguration struct { + // AccessLogs enables access logs for this router. + AccessLogs *bool `json:"accessLogs,omitempty"` + // Metrics enables metrics for this router. + Metrics *bool `json:"metrics,omitempty"` + // Tracing enables tracing for this router. + Tracing *bool `json:"tracing,omitempty"` + // TraceVerbosity defines the verbosity level of the tracing for this router. + TraceVerbosity *types.TracingVerbosity `json:"traceVerbosity,omitempty"` +} + +// RouterObservabilityConfigApplyConfiguration constructs a declarative configuration of the RouterObservabilityConfig type for use with +// apply. +func RouterObservabilityConfig() *RouterObservabilityConfigApplyConfiguration { + return &RouterObservabilityConfigApplyConfiguration{} +} + +// WithAccessLogs sets the AccessLogs field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AccessLogs field is set to the value of the last call. +func (b *RouterObservabilityConfigApplyConfiguration) WithAccessLogs(value bool) *RouterObservabilityConfigApplyConfiguration { + b.AccessLogs = &value + return b +} + +// WithMetrics sets the Metrics field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Metrics field is set to the value of the last call. +func (b *RouterObservabilityConfigApplyConfiguration) WithMetrics(value bool) *RouterObservabilityConfigApplyConfiguration { + b.Metrics = &value + return b +} + +// WithTracing sets the Tracing field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Tracing field is set to the value of the last call. +func (b *RouterObservabilityConfigApplyConfiguration) WithTracing(value bool) *RouterObservabilityConfigApplyConfiguration { + b.Tracing = &value + return b +} + +// WithTraceVerbosity sets the TraceVerbosity field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the TraceVerbosity field is set to the value of the last call. +func (b *RouterObservabilityConfigApplyConfiguration) WithTraceVerbosity(value types.TracingVerbosity) *RouterObservabilityConfigApplyConfiguration { + b.TraceVerbosity = &value + return b +} diff --git a/pkg/provider/kubernetes/crd/generated/applyconfiguration/utils.go b/pkg/provider/kubernetes/crd/generated/applyconfiguration/utils.go index 082e745a88..de36fd858d 100644 --- a/pkg/provider/kubernetes/crd/generated/applyconfiguration/utils.go +++ b/pkg/provider/kubernetes/crd/generated/applyconfiguration/utils.go @@ -118,6 +118,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &traefikiov1alpha1.RootCAApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Route"): return &traefikiov1alpha1.RouteApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("RouterObservabilityConfig"): + return &traefikiov1alpha1.RouterObservabilityConfigApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RouteTCP"): return &traefikiov1alpha1.RouteTCPApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RouteUDP"): diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index fcf636420d..8c35cabec3 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -44,7 +44,8 @@ const ( ) const ( - providerName = "kubernetescrd" + // ProviderName is the Kubernetes CRD provider name. + ProviderName = "kubernetescrd" providerNamespaceSeparator = "@" ) @@ -76,7 +77,7 @@ func (p *Provider) Init() error { // Provide allows the k8s provider to provide configurations to traefik // using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(context.Background()) k8sClient, err := p.newK8sClient(ctxLog) @@ -131,7 +132,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. default: p.lastConfiguration.Set(confHash) configurationChan <- dynamic.Message{ - ProviderName: providerName, + ProviderName: ProviderName, Configuration: conf, } } @@ -166,7 +167,7 @@ func (p *Provider) FillExtensionBuilderRegistry(registry gateway.ExtensionBuilde return "", nil, fmt.Errorf("namespace %q is not allowed", namespace) } - return makeID(namespace, name) + providerNamespaceSeparator + providerName, nil, nil + return makeID(namespace, name) + providerNamespaceSeparator + ProviderName, nil, nil }) registry.RegisterBackendFuncs(traefikv1alpha1.GroupName, "TraefikService", func(name, namespace string) (string, *dynamic.Service, error) { @@ -174,7 +175,7 @@ func (p *Provider) FillExtensionBuilderRegistry(registry gateway.ExtensionBuilde return "", nil, fmt.Errorf("namespace %q is not allowed", namespace) } - return makeID(namespace, name) + providerNamespaceSeparator + providerName, nil, nil + return makeID(namespace, name) + providerNamespaceSeparator + ProviderName, nil, nil }) } diff --git a/pkg/provider/kubernetes/crd/kubernetes_http.go b/pkg/provider/kubernetes/crd/kubernetes_http.go index 7e0e089b81..054592e1cc 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_http.go +++ b/pkg/provider/kubernetes/crd/kubernetes_http.go @@ -122,14 +122,22 @@ 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, - Observability: route.Observability, - ParentRefs: parentRouterNames, + Middlewares: mds, + Priority: route.Priority, + RuleSyntax: route.Syntax, + EntryPoints: ingressRoute.Spec.EntryPoints, + Rule: route.Match, + Service: serviceName, + ParentRefs: parentRouterNames, + } + + if route.Observability != nil { + r.Observability = &dynamic.RouterObservabilityConfig{ + AccessLogs: route.Observability.AccessLogs, + Metrics: route.Observability.Metrics, + Tracing: route.Observability.Tracing, + TraceVerbosity: route.Observability.TraceVerbosity, + } } if ingressRoute.Spec.TLS != nil { @@ -178,7 +186,7 @@ func makeMiddlewareKeys(ctx context.Context, namespace string, middlewares []tra for _, mi := range middlewares { name := mi.Name - if !allowCrossNamespace && strings.HasSuffix(mi.Name, providerNamespaceSeparator+providerName) { + if !allowCrossNamespace && strings.HasSuffix(mi.Name, providerNamespaceSeparator+ProviderName) { // Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd), // if the provider namespace kubernetescrd is used, // we don't allow this format to avoid cross namespace references. @@ -521,7 +529,7 @@ func (c configBuilder) makeServersTransportKey(parentNamespace string, serversTr return "", nil } - if !c.allowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+providerName) { + if !c.allowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+ProviderName) { // Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd), // if the provider namespace kubernetescrd is used, // we don't allow this format to avoid cross namespace references. @@ -543,7 +551,7 @@ func (c configBuilder) loadServers(parentNamespace string, svc traefikv1alpha1.L } // If the service uses explicitly the provider suffix - sanitizedName := strings.TrimSuffix(svc.Name, providerNamespaceSeparator+providerName) + sanitizedName := strings.TrimSuffix(svc.Name, providerNamespaceSeparator+ProviderName) service, exists, err := c.client.GetService(namespace, sanitizedName) if err != nil { return nil, err @@ -799,7 +807,7 @@ func fullServiceName(ctx context.Context, namespace string, service traefikv1alp } name, pName := splitSvcNameProvider(service.Name) - if pName == providerName { + if pName == ProviderName { return provider.Normalize(fmt.Sprintf("%s-%s", namespace, name)) } diff --git a/pkg/provider/kubernetes/crd/kubernetes_tcp.go b/pkg/provider/kubernetes/crd/kubernetes_tcp.go index 77a363606c..d8ac3fa39a 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_tcp.go +++ b/pkg/provider/kubernetes/crd/kubernetes_tcp.go @@ -339,7 +339,7 @@ func (p *Provider) makeTCPServersTransportKey(parentNamespace string, serversTra return "", nil } - if !p.AllowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+providerName) { + if !p.AllowCrossNamespace && strings.HasSuffix(serversTransportName, providerNamespaceSeparator+ProviderName) { // Since we are not able to know if another namespace is in the name (namespace-name@kubernetescrd), // if the provider namespace kubernetescrd is used, // we don't allow this format to avoid cross namespace references. diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go index 52733b871d..3c4d13ddb3 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go @@ -2,6 +2,7 @@ package v1alpha1 import ( "github.com/traefik/traefik/v3/pkg/config/dynamic" + otypes "github.com/traefik/traefik/v3/pkg/observability/types" "github.com/traefik/traefik/v3/pkg/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -54,7 +55,22 @@ type Route struct { Middlewares []MiddlewareRef `json:"middlewares,omitempty"` // Observability defines the observability configuration for a router. // More info: https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/routing/observability/ - Observability *dynamic.RouterObservabilityConfig `json:"observability,omitempty"` + Observability *RouterObservabilityConfig `json:"observability,omitempty"` +} + +// RouterObservabilityConfig holds the observability configuration for a router. +// More info: https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/routing/observability/ +type RouterObservabilityConfig struct { + // AccessLogs enables access logs for this router. + AccessLogs *bool `json:"accessLogs,omitempty" toml:"accessLogs,omitempty" yaml:"accessLogs,omitempty" export:"true"` + // Metrics enables metrics for this router. + Metrics *bool `json:"metrics,omitempty" toml:"metrics,omitempty" yaml:"metrics,omitempty" export:"true"` + // Tracing enables tracing for this router. + Tracing *bool `json:"tracing,omitempty" toml:"tracing,omitempty" yaml:"tracing,omitempty" export:"true"` + // TraceVerbosity defines the verbosity level of the tracing for this router. + // +kubebuilder:validation:Enum=minimal;detailed + // +kubebuilder:default=minimal + TraceVerbosity otypes.TracingVerbosity `json:"traceVerbosity,omitempty" toml:"traceVerbosity,omitempty" yaml:"traceVerbosity,omitempty" export:"true"` } // TLS holds the TLS configuration. diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go index feb3dedfab..10098f252d 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go @@ -1382,7 +1382,7 @@ func (in *Route) DeepCopyInto(out *Route) { } if in.Observability != nil { in, out := &in.Observability, &out.Observability - *out = new(dynamic.RouterObservabilityConfig) + *out = new(RouterObservabilityConfig) (*in).DeepCopyInto(*out) } return @@ -1449,6 +1449,37 @@ func (in *RouteUDP) DeepCopy() *RouteUDP { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouterObservabilityConfig) DeepCopyInto(out *RouterObservabilityConfig) { + *out = *in + if in.AccessLogs != nil { + in, out := &in.AccessLogs, &out.AccessLogs + *out = new(bool) + **out = **in + } + if in.Metrics != nil { + in, out := &in.Metrics, &out.Metrics + *out = new(bool) + **out = **in + } + if in.Tracing != nil { + in, out := &in.Tracing, &out.Tracing + *out = new(bool) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouterObservabilityConfig. +func (in *RouterObservabilityConfig) DeepCopy() *RouterObservabilityConfig { + if in == nil { + return nil + } + out := new(RouterObservabilityConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServerHealthCheck) DeepCopyInto(out *ServerHealthCheck) { *out = *in diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_backend_tls_policy.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_backend_tls_policy.yml index f183c92d8c..167d1d29a4 100644 --- a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_backend_tls_policy.yml +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_backend_tls_policy.yml @@ -70,6 +70,12 @@ spec: - group: core kind: ConfigMap name: ca-file-2 + - group: "" + kind: Secret + name: ca-file + - group: core + kind: Secret + name: ca-file-2 --- apiVersion: v1 @@ -88,3 +94,21 @@ metadata: namespace: default data: ca.crt: "CA2" + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ca-file + namespace: default +data: + ca.crt: Q0ExLXNlY3JldA== + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ca-file-2 + namespace: default +data: + ca.crt: Q0EyLXNlY3JldA== diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs.yml new file mode 100644 index 0000000000..4766f554bd --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs.yml @@ -0,0 +1,78 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: supersecret + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJxRENDQVU2Z0F3SUJBZ0lVWU9zcjBRZ0hPQnE0a1lSQ0w1K1REZFZ0NmJRd0NnWUlLb1pJemowRUF3SXcKRmpFVU1CSUdBMVVFQXd3TFpYaGhiWEJzWlM1amIyMHdIaGNOTWpVeE1ERXdNRGN4TnpNd1doY05NelV4TURBNApNRGN4TnpNd1dqQVdNUlF3RWdZRFZRUUREQXRsZUdGdGNHeGxMbU52YlRCWk1CTUdCeXFHU000OUFnRUdDQ3FHClNNNDlBd0VIQTBJQUJET3JpdzNaUTd3SWhXcmJQUzZKRlFUM2JUb05DRjAwdlNWNWZhYjZUYlh5TDh0bHNHcmUKVFJJRjJFd2dzdGVNT2t4R0tLU2xEdnVhRHdxOHAvcVYrMHVqZWpCNE1CMEdBMVVkRGdRV0JCUk1Fa3VleFhRaApVdERnUmcxS0J2NzJDRHErRXpBZkJnTlZIU01FR0RBV2dCUk1Fa3VleFhRaFV0RGdSZzFLQnY3MkNEcStFekFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUNVR0ExVWRFUVFlTUJ5Q0MyVjRZVzF3YkdVdVkyOXRnZzBxTG1WNFlXMXcKYkdVdVkyOXRNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUURzODdWazBzd0E2SGdPSmpST3llMW14RDgzcWNHeQpwZUZnb3hWOTNEeStjd0lnVjBNTUVKSmJWc1R5WkszRVErK1hjNXJFTDc4bnJKK1lJRVYrckNVV2o1VT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ253Z0w1RFk0VUIxNHNNNmYKRGlrUWR0cWgyUVcxQXJmRjRmYzFVRnppZmRHaFJBTkNBQVF6cTRzTjJVTzhDSVZxMnowdWlSVUU5MjA2RFFoZApOTDBsZVgybStrMjE4aS9MWmJCcTNrMFNCZGhNSUxMWGpEcE1SaWlrcFE3N21nOEt2S2Y2bGZ0TAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t + +--- +apiVersion: v1 +kind: Secret +metadata: + name: supersecret2 + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJxRENDQVU2Z0F3SUJBZ0lVWU9zcjBRZ0hPQnE0a1lSQ0w1K1REZFZ0NmJRd0NnWUlLb1pJemowRUF3SXcKRmpFVU1CSUdBMVVFQXd3TFpYaGhiWEJzWlM1amIyMHdIaGNOTWpVeE1ERXdNRGN4TnpNd1doY05NelV4TURBNApNRGN4TnpNd1dqQVdNUlF3RWdZRFZRUUREQXRsZUdGdGNHeGxMbU52YlRCWk1CTUdCeXFHU000OUFnRUdDQ3FHClNNNDlBd0VIQTBJQUJET3JpdzNaUTd3SWhXcmJQUzZKRlFUM2JUb05DRjAwdlNWNWZhYjZUYlh5TDh0bHNHcmUKVFJJRjJFd2dzdGVNT2t4R0tLU2xEdnVhRHdxOHAvcVYrMHVqZWpCNE1CMEdBMVVkRGdRV0JCUk1Fa3VleFhRaApVdERnUmcxS0J2NzJDRHErRXpBZkJnTlZIU01FR0RBV2dCUk1Fa3VleFhRaFV0RGdSZzFLQnY3MkNEcStFekFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUNVR0ExVWRFUVFlTUJ5Q0MyVjRZVzF3YkdVdVkyOXRnZzBxTG1WNFlXMXcKYkdVdVkyOXRNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUURzODdWazBzd0E2SGdPSmpST3llMW14RDgzcWNHeQpwZUZnb3hWOTNEeStjd0lnVjBNTUVKSmJWc1R5WkszRVErK1hjNXJFTDc4bnJKK1lJRVYrckNVV2o1VT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ253Z0w1RFk0VUIxNHNNNmYKRGlrUWR0cWgyUVcxQXJmRjRmYzFVRnppZmRHaFJBTkNBQVF6cTRzTjJVTzhDSVZxMnowdWlSVUU5MjA2RFFoZApOTDBsZVgybStrMjE4aS9MWmJCcTNrMFNCZGhNSUxMWGpEcE1SaWlrcFE3N21nOEt2S2Y2bGZ0TAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t + +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + certificateRefs: + - kind: Secret + name: supersecret + group: "" + - kind: Secret + name: supersecret2 + group: "" + allowedRoutes: + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "foo.com" + rules: + - matches: + - path: + type: Exact + value: /bar + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs_one_missing.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs_one_missing.yml new file mode 100644 index 0000000000..7fa32e0c60 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/with_multiple_certificaterefs_one_missing.yml @@ -0,0 +1,67 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: supersecret + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJxRENDQVU2Z0F3SUJBZ0lVWU9zcjBRZ0hPQnE0a1lSQ0w1K1REZFZ0NmJRd0NnWUlLb1pJemowRUF3SXcKRmpFVU1CSUdBMVVFQXd3TFpYaGhiWEJzWlM1amIyMHdIaGNOTWpVeE1ERXdNRGN4TnpNd1doY05NelV4TURBNApNRGN4TnpNd1dqQVdNUlF3RWdZRFZRUUREQXRsZUdGdGNHeGxMbU52YlRCWk1CTUdCeXFHU000OUFnRUdDQ3FHClNNNDlBd0VIQTBJQUJET3JpdzNaUTd3SWhXcmJQUzZKRlFUM2JUb05DRjAwdlNWNWZhYjZUYlh5TDh0bHNHcmUKVFJJRjJFd2dzdGVNT2t4R0tLU2xEdnVhRHdxOHAvcVYrMHVqZWpCNE1CMEdBMVVkRGdRV0JCUk1Fa3VleFhRaApVdERnUmcxS0J2NzJDRHErRXpBZkJnTlZIU01FR0RBV2dCUk1Fa3VleFhRaFV0RGdSZzFLQnY3MkNEcStFekFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUNVR0ExVWRFUVFlTUJ5Q0MyVjRZVzF3YkdVdVkyOXRnZzBxTG1WNFlXMXcKYkdVdVkyOXRNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUURzODdWazBzd0E2SGdPSmpST3llMW14RDgzcWNHeQpwZUZnb3hWOTNEeStjd0lnVjBNTUVKSmJWc1R5WkszRVErK1hjNXJFTDc4bnJKK1lJRVYrckNVV2o1VT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ253Z0w1RFk0VUIxNHNNNmYKRGlrUWR0cWgyUVcxQXJmRjRmYzFVRnppZmRHaFJBTkNBQVF6cTRzTjJVTzhDSVZxMnowdWlSVUU5MjA2RFFoZApOTDBsZVgybStrMjE4aS9MWmJCcTNrMFNCZGhNSUxMWGpEcE1SaWlrcFE3N21nOEt2S2Y2bGZ0TAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t + +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + certificateRefs: + - kind: Secret + name: supersecret + group: "" + - kind: Secret + name: missing-secret + group: "" + allowedRoutes: + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "foo.com" + rules: + - matches: + - path: + type: Exact + value: /bar + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go index 9fb65dfc74..00f58facf5 100644 --- a/pkg/provider/kubernetes/gateway/httproute.go +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -585,38 +585,55 @@ func (p *Provider) loadServersTransport(namespace string, policy *gatev1.Backend } for _, caCertRef := range policy.Spec.Validation.CACertificateRefs { - if (caCertRef.Group != "" && caCertRef.Group != groupCore) || caCertRef.Kind != "ConfigMap" { + if (caCertRef.Group != "" && caCertRef.Group != groupCore) || (caCertRef.Kind != "ConfigMap" && caCertRef.Kind != "Secret") { return nil, metav1.Condition{ Type: string(gatev1.BackendTLSPolicyConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: policy.Generation, LastTransitionTime: metav1.Now(), Reason: string(gatev1.BackendTLSPolicyReasonInvalidKind), - Message: "Only ConfigMaps are supported", + Message: "Only ConfigMaps and Secrets are supported", } } - configMap, err := p.client.GetConfigMap(namespace, string(caCertRef.Name)) - if err != nil { + var caCRT string + switch caCertRef.Kind { + case "ConfigMap": + configmap, err := p.client.GetConfigMap(namespace, string(caCertRef.Name)) + if err != nil { + return nil, metav1.Condition{ + Type: string(gatev1.BackendTLSPolicyConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: policy.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.BackendTLSPolicyReasonInvalidCACertificateRef), + Message: fmt.Sprintf("getting configmap %s/%s: %s", namespace, string(caCertRef.Name), err), + } + } + caCRT = configmap.Data["ca.crt"] + case "Secret": + secret, err := p.client.GetSecret(namespace, string(caCertRef.Name)) + if err != nil { + return nil, metav1.Condition{ + Type: string(gatev1.BackendTLSPolicyConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: policy.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.BackendTLSPolicyReasonInvalidCACertificateRef), + Message: fmt.Sprintf("getting secret %s/%s: %s", namespace, string(caCertRef.Name), err), + } + } + caCRT = string(secret.Data["ca.crt"]) + } + + if caCRT == "" { return nil, metav1.Condition{ Type: string(gatev1.BackendTLSPolicyConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: policy.Generation, LastTransitionTime: metav1.Now(), Reason: string(gatev1.BackendTLSPolicyReasonInvalidCACertificateRef), - Message: fmt.Sprintf("getting configmap %s/%s: %s", namespace, string(caCertRef.Name), err), - } - } - - caCRT, ok := configMap.Data["ca.crt"] - if !ok || caCRT == "" { - return nil, metav1.Condition{ - Type: string(gatev1.BackendTLSPolicyConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: policy.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.BackendTLSPolicyReasonInvalidCACertificateRef), - Message: fmt.Sprintf("configmap %s/%s does not have a ca.crt", namespace, string(caCertRef.Name)), + Message: fmt.Sprintf("%s %s/%s does not have a ca.crt", caCertRef.Kind, namespace, string(caCertRef.Name)), } } diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 8b7ea1e68e..7910dc4534 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -34,7 +34,8 @@ import ( ) const ( - providerName = "kubernetesgateway" + // ProviderName is the Kubernetes Gateway API provider name. + ProviderName = "kubernetesgateway" controllerName = "traefik.io/gateway-controller" @@ -166,7 +167,7 @@ func (p *Provider) SetRouterTransform(routerTransform k8s.RouterTransform) { // Init the provider. func (p *Provider) Init() error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() var err error p.client, err = p.newK8sClient(logger.WithContext(context.Background())) @@ -179,7 +180,7 @@ func (p *Provider) Init() error { // Provide allows the k8s provider to provide configurations to traefik using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(context.Background()) pool.GoCtx(func(ctxPool context.Context) { @@ -221,7 +222,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. default: p.lastConfiguration.Set(confHash) configurationChan <- dynamic.Message{ - ProviderName: providerName, + ProviderName: ProviderName, Configuration: conf, } } @@ -427,7 +428,7 @@ func (p *Provider) loadConfigurationFromGateways(ctx context.Context) *dynamic.C } func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gateway, conf *dynamic.Configuration) []gatewayListener { - tlsConfigs := make(map[string]*tls.CertAndStores) + tlsCerts := make(map[string]*tls.CertAndStores) allocatedListeners := make(map[string]struct{}) gatewayListeners := make([]gatewayListener, len(gateway.Spec.Listeners)) @@ -528,30 +529,22 @@ func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gat // TLS if listener.Protocol == gatev1.HTTPSProtocolType || listener.Protocol == gatev1.TLSProtocolType { - if listener.TLS == nil || (len(listener.TLS.CertificateRefs) == 0 && listener.TLS.Mode != nil && *listener.TLS.Mode != gatev1.TLSModePassthrough) { - // update "Detached" status with "UnsupportedProtocol" reason + if listener.TLS == nil { gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, LastTransitionTime: metav1.Now(), Reason: "InvalidTLSConfiguration", // TODO check the spec if a proper reason is introduced at some point - Message: fmt.Sprintf("No TLS configuration for Gateway Listener %s:%d and protocol %q", - listener.Name, listener.Port, listener.Protocol), + Message: fmt.Sprintf("No TLS configuration for Gateway Listener %s:%d and protocol %q", listener.Name, listener.Port, listener.Protocol), }) - continue } - var tlsModeType gatev1.TLSModeType - if listener.TLS.Mode != nil { - tlsModeType = *listener.TLS.Mode - } - - isTLSPassthrough := tlsModeType == gatev1.TLSModePassthrough + tlsMode := ptr.Deref(listener.TLS.Mode, gatev1.TLSModeTerminate) + isTLSPassthrough := tlsMode == gatev1.TLSModePassthrough if isTLSPassthrough && len(listener.TLS.CertificateRefs) > 0 { - // https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.GatewayTLSConfig log.Ctx(ctx).Warn().Msg("In case of Passthrough TLS mode, no TLS settings take effect as the TLS session from the client is NOT terminated at the Gateway") } @@ -559,7 +552,7 @@ func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gat // Protocol TLS -> Passthrough -> TLSRoute // Protocol TLS -> Terminate -> TLSRoute // Protocol HTTPS -> Terminate -> HTTPRoute - if listener.Protocol == gatev1.HTTPSProtocolType && isTLSPassthrough { + if isTLSPassthrough && listener.Protocol == gatev1.HTTPSProtocolType { gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionFalse, @@ -568,13 +561,11 @@ func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gat Reason: string(gatev1.ListenerReasonUnsupportedProtocol), Message: "HTTPS protocol is not supported with TLS mode Passthrough", }) - continue } if !isTLSPassthrough { if len(listener.TLS.CertificateRefs) == 0 { - // update "ResolvedRefs" status true with "InvalidCertificateRef" reason gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, @@ -583,74 +574,73 @@ func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gat Reason: string(gatev1.ListenerReasonInvalidCertificateRef), Message: "One TLS CertificateRef is required in Terminate mode", }) - continue } - // TODO Should we support multiple certificates? - certificateRef := listener.TLS.CertificateRefs[0] + var errCertConditions []metav1.Condition + listenerTLSCerts := make(map[string]*tls.CertAndStores) + for _, certificateRef := range listener.TLS.CertificateRefs { + if certificateRef.Kind == nil || *certificateRef.Kind != "Secret" || certificateRef.Group == nil || (*certificateRef.Group != "" && *certificateRef.Group != groupCore) { + errCertConditions = append(errCertConditions, metav1.Condition{ + Type: string(gatev1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: gateway.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.ListenerReasonInvalidCertificateRef), + Message: fmt.Sprintf("Unsupported TLS CertificateRef group/kind: %s/%s", groupToString(certificateRef.Group), kindToString(certificateRef.Kind)), + }) + continue + } - if certificateRef.Kind == nil || *certificateRef.Kind != "Secret" || - certificateRef.Group == nil || (*certificateRef.Group != "" && *certificateRef.Group != groupCore) { - // update "ResolvedRefs" status true with "InvalidCertificateRef" reason - gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonInvalidCertificateRef), - Message: fmt.Sprintf("Unsupported TLS CertificateRef group/kind: %s/%s", groupToString(certificateRef.Group), kindToString(certificateRef.Kind)), - }) + certificateNamespace := string(ptr.Deref(certificateRef.Namespace, gatev1.Namespace(gateway.Namespace))) + if err := p.isReferenceGranted(kindGateway, gateway.Namespace, groupCore, "Secret", string(certificateRef.Name), certificateNamespace); err != nil { + errCertConditions = append(errCertConditions, metav1.Condition{ + Type: string(gatev1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: gateway.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.ListenerReasonRefNotPermitted), + Message: fmt.Sprintf("Cannot reference CertificateRef %s/%s: %s", certificateNamespace, certificateRef.Name, err), + }) + continue + } - continue - } - - certificateNamespace := gateway.Namespace - if certificateRef.Namespace != nil && string(*certificateRef.Namespace) != gateway.Namespace { - certificateNamespace = string(*certificateRef.Namespace) - } - - if err := p.isReferenceGranted(kindGateway, gateway.Namespace, groupCore, "Secret", string(certificateRef.Name), certificateNamespace); err != nil { - gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonRefNotPermitted), - Message: fmt.Sprintf("Cannot load CertificateRef %s/%s: %s", certificateNamespace, certificateRef.Name, err), - }) - - continue - } - - configKey := certificateNamespace + "/" + string(certificateRef.Name) - if _, tlsExists := tlsConfigs[configKey]; !tlsExists { - tlsConf, err := p.getTLS(certificateRef.Name, certificateNamespace) - if err != nil { - // update "ResolvedRefs" status false with "InvalidCertificateRef" reason - // update "Programmed" status false with "Invalid" reason - gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, - metav1.Condition{ + configKey := certificateNamespace + "/" + string(certificateRef.Name) + if _, tlsExists := listenerTLSCerts[configKey]; !tlsExists { + tlsCert, err := p.getTLSCert(certificateRef.Name, certificateNamespace) + if err != nil { + errCertConditions = append(errCertConditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, LastTransitionTime: metav1.Now(), Reason: string(gatev1.ListenerReasonInvalidCertificateRef), - Message: fmt.Sprintf("Error while retrieving certificate: %v", err), - }, - metav1.Condition{ - Type: string(gatev1.ListenerConditionProgrammed), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonInvalid), - Message: fmt.Sprintf("Error while retrieving certificate: %v", err), - }, - ) - - continue + Message: fmt.Sprintf("Cannot load CertificateRef %s/%s: %s", certificateNamespace, certificateRef.Name, err), + }) + continue + } + listenerTLSCerts[configKey] = tlsCert + } + } + + if len(errCertConditions) > 0 { + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, errCertConditions...) + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ + Type: string(gatev1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: gateway.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.ListenerReasonInvalid), + Message: "Invalid CertificateRefs", + }) + continue + } + + // Only copy if the certificate TLS config is not already known. + for key, listenerTLSCert := range listenerTLSCerts { + if _, ok := tlsCerts[key]; !ok { + tlsCerts[key] = listenerTLSCert } - tlsConfigs[configKey] = tlsConf } } } @@ -658,8 +648,8 @@ func (p *Provider) loadGatewayListeners(ctx context.Context, gateway *gatev1.Gat gatewayListeners[i].Attached = true } - if len(tlsConfigs) > 0 { - conf.TLS.Certificates = append(conf.TLS.Certificates, getTLSConfig(tlsConfigs)...) + if len(tlsCerts) > 0 { + conf.TLS.Certificates = append(conf.TLS.Certificates, getTLSConfig(tlsCerts)...) } return gatewayListeners @@ -830,7 +820,7 @@ func (p *Provider) isReferenceGranted(fromKind, fromNamespace, toGroup, toKind, return nil } -func (p *Provider) getTLS(secretName gatev1.ObjectName, namespace string) (*tls.CertAndStores, error) { +func (p *Provider) getTLSCert(secretName gatev1.ObjectName, namespace string) (*tls.CertAndStores, error) { secret, err := p.client.GetSecret(namespace, string(secretName)) if err != nil { return nil, fmt.Errorf("getting secret: %w", err) diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index ccb2382650..c89660142f 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -782,6 +782,110 @@ func TestLoadHTTPRoutes(t *testing.T) { }, }, }, + { + desc: "HTTPRoute with multiple TLS certificateRefs", + paths: []string{"services.yml", "httproute/with_multiple_certificaterefs.yml"}, + entryPoints: map[string]Entrypoint{"websecure": { + Address: ":443", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "httproute-default-http-app-1-gw-default-my-gateway-ep-websecure-0-af329269dd38031b03e3": { + EntryPoints: []string{"websecure"}, + Service: "httproute-default-http-app-1-gw-default-my-gateway-ep-websecure-0-af329269dd38031b03e3-wrr", + Rule: "Host(\"foo.com\") && Path(\"/bar\")", + Priority: 100008, + RuleSyntax: "default", + TLS: &dynamic.RouterTLSConfig{}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "httproute-default-http-app-1-gw-default-my-gateway-ep-websecure-0-af329269dd38031b03e3-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-http-80", + Weight: ptr.To(1), + }, + }, + }, + }, + "default-whoami-http-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: dynamic.BalancerStrategyWRR, + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{ + CertFile: types.FileOrContent(listenerCert), + KeyFile: types.FileOrContent(listenerKey), + }, + }, + { + Certificate: tls.Certificate{ + CertFile: types.FileOrContent(listenerCert), + KeyFile: types.FileOrContent(listenerKey), + }, + }, + }, + }, + }, + }, + { + desc: "HTTPRoute with multiple certificateRefs, one missing", + paths: []string{"services.yml", "httproute/with_multiple_certificaterefs_one_missing.yml"}, + entryPoints: map[string]Entrypoint{"websecure": { + Address: ":443", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{}, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{}, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Simple HTTPRoute, with multiple hosts", paths: []string{"services.yml", "httproute/with_multiple_host.yml"}, @@ -2356,6 +2460,8 @@ func TestLoadHTTPRoutes(t *testing.T) { RootCAs: []types.FileOrContent{ "CA1", "CA2", + "CA1-secret", + "CA2-secret", }, }, }, diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index b957aec314..0bfde0087e 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -21,6 +21,7 @@ type IngressConfig struct { AuthResponseHeaders *string `annotation:"nginx.ingress.kubernetes.io/auth-response-headers"` AuthSnippet *string `annotation:"nginx.ingress.kubernetes.io/auth-snippet"` AuthMethod *string `annotation:"nginx.ingress.kubernetes.io/auth-method"` + EnableGlobalAuth *bool `annotation:"nginx.ingress.kubernetes.io/enable-global-auth"` AuthTLSSecret *string `annotation:"nginx.ingress.kubernetes.io/auth-tls-secret"` AuthTLSVerifyClient *string `annotation:"nginx.ingress.kubernetes.io/auth-tls-verify-client"` @@ -80,11 +81,14 @@ type IngressConfig struct { CORSAllowOrigin *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-origin"` CORSMaxAge *int `annotation:"nginx.ingress.kubernetes.io/cors-max-age"` + EnableAccessLog *bool `annotation:"nginx.ingress.kubernetes.io/enable-access-log"` + WhitelistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/whitelist-source-range"` AllowlistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/allowlist-source-range"` - LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"` - LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"` + LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"` + LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"` + LimitBurstMultiplier *int `annotation:"nginx.ingress.kubernetes.io/limit-burst-multiplier"` CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"` diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-access-log.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-access-log.yml new file mode 100644 index 0000000000..8d94424356 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-access-log.yml @@ -0,0 +1,66 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-access-log-enabled + namespace: default + annotations: + nginx.ingress.kubernetes.io/enable-access-log: "true" + +spec: + ingressClassName: nginx + rules: + - host: accesslog-enabled.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-access-log-disabled + namespace: default + annotations: + nginx.ingress.kubernetes.io/enable-access-log: "false" + +spec: + ingressClassName: nginx + rules: + - host: accesslog-disabled.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-access-log-default + namespace: default + +spec: + ingressClassName: nginx + rules: + - host: accesslog-default.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-global-auth-disabled.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-global-auth-disabled.yml new file mode 100644 index 0000000000..85726e7dc7 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-global-auth-disabled.yml @@ -0,0 +1,22 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-global-auth-disabled + namespace: default + annotations: + nginx.ingress.kubernetes.io/enable-global-auth: "false" + +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-burst-multiplier.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-burst-multiplier.yml new file mode 100644 index 0000000000..724f3fcd39 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-burst-multiplier.yml @@ -0,0 +1,43 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-limit-burst-multiplier + namespace: default + annotations: + nginx.ingress.kubernetes.io/limit-rps: "10" + nginx.ingress.kubernetes.io/limit-burst-multiplier: "10" +spec: + ingressClassName: nginx + rules: + - host: whoami-burst.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-limit-burst-multiplier-zero + namespace: default + annotations: + nginx.ingress.kubernetes.io/limit-rps: "10" + nginx.ingress.kubernetes.io/limit-burst-multiplier: "0" +spec: + ingressClassName: nginx + rules: + - host: whoami-burst-zero.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml new file mode 100644 index 0000000000..1e9b4578c3 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host-tls.yml @@ -0,0 +1,23 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-wildcard-host-tls + namespace: default +spec: + ingressClassName: nginx + rules: + - host: "*.localhost" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 + tls: + - hosts: + - "*.localhost" + secretName: whoami-tls diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml new file mode 100644 index 0000000000..7aa7e5d1b1 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-wildcard-host.yml @@ -0,0 +1,19 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-wildcard-host + namespace: default +spec: + ingressClassName: nginx + rules: + - host: "*.localhost" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-without-auth.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-without-auth.yml new file mode 100644 index 0000000000..26176e8097 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-without-auth.yml @@ -0,0 +1,20 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-without-auth + namespace: default + +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index dc3f0ad270..d9326df61a 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -32,7 +32,8 @@ import ( ) const ( - providerName = "kubernetesingressnginx" + // ProviderName is the Kubernetes Ingress NGINX provider name. + ProviderName = "kubernetesingressnginx" // NGINX default values. annotationIngressClass = "kubernetes.io/ingress.class" @@ -176,6 +177,7 @@ type Provider struct { Token types.FileOrContent `description:"Kubernetes bearer token (not needed for in-cluster client). It accepts either a token value or a file path to the token." json:"token,omitempty" toml:"token,omitempty" yaml:"token,omitempty" loggable:"false"` CertAuthFilePath string `description:"Kubernetes certificate authority file path (not needed for in-cluster client)." json:"certAuthFilePath,omitempty" toml:"certAuthFilePath,omitempty" yaml:"certAuthFilePath,omitempty"` ThrottleDuration ptypes.Duration `description:"Ingress refresh throttle duration." json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"` + GlobalAuthURL string `description:"URL to the service that provides authentication for all the locations. Per ingress auth-url annotation has precedence over this option." json:"globalAuthURL,omitempty" toml:"globalAuthURL,omitempty" yaml:"globalAuthURL,omitempty" export:"true"` WatchNamespace string `description:"Namespace the controller watches for updates to Kubernetes objects. All namespaces are watched if this parameter is left empty." json:"watchNamespace,omitempty" toml:"watchNamespace,omitempty" yaml:"watchNamespace,omitempty" export:"true"` WatchNamespaceSelector string `description:"Selector selects namespaces the controller watches for updates to Kubernetes objects." json:"watchNamespaceSelector,omitempty" toml:"watchNamespaceSelector,omitempty" yaml:"watchNamespaceSelector,omitempty" export:"true"` @@ -278,7 +280,7 @@ func (p *Provider) Init() error { // Provide allows the k8s provider to provide configurations to traefik using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(context.Background()) pool.GoCtx(func(ctxPool context.Context) { @@ -322,7 +324,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. default: p.lastConfiguration.Set(confHash) configurationChan <- dynamic.Message{ - ProviderName: providerName, + ProviderName: ProviderName, Configuration: conf, } } @@ -415,24 +417,36 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration return conf } + obs := &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + // No ingress and no service port with the global default backend. + Namespace: p.defaultBackendServiceNamespace, + ServiceName: p.defaultBackendServiceName, + }, + }, + } + // Add the default backend service router to the configuration. conf.HTTP.Routers[defaultBackendName] = &dynamic.Router{ EntryPoints: p.NonTLSEntryPoints, Rule: `PathPrefix("/")`, // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. - RuleSyntax: "default", - Priority: math.MinInt32, - Service: defaultBackendName, + RuleSyntax: "default", + Priority: math.MinInt32, + Service: defaultBackendName, + Observability: obs, } conf.HTTP.Routers[defaultBackendTLSName] = &dynamic.Router{ EntryPoints: p.TLSEntryPoints, Rule: `PathPrefix("/")`, // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. - RuleSyntax: "default", - Priority: math.MinInt32, - Service: defaultBackendName, - TLS: &dynamic.RouterTLSConfig{}, + RuleSyntax: "default", + Priority: math.MinInt32, + Service: defaultBackendName, + TLS: &dynamic.RouterTLSConfig{}, + Observability: obs, } conf.HTTP.Services[defaultBackendName] = svc @@ -584,9 +598,10 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } var defaultBackendService *dynamic.Service + var defaultBackendObs *dynamic.RouterObservabilityConfig if ingress.Spec.DefaultBackend != nil && ingress.Spec.DefaultBackend.Service != nil { var err error - defaultBackendService, err = p.buildService(ingress.Namespace, *ingress.Spec.DefaultBackend, namedServersTransport, ingress.IngressConfig) + defaultBackendService, err = p.buildService(ingress.Namespace, *ingress.Spec.DefaultBackend, &namedServersTransport, ingress.IngressConfig) if err != nil { logger.Error(). Str("serviceName", ingress.Spec.DefaultBackend.Service.Name). @@ -594,6 +609,17 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration Err(err). Msg("Cannot create default backend service") } + + defaultBackendObs = &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: ingress.Namespace, + IngressName: ingress.Name, + ServiceName: ingress.Spec.DefaultBackend.Service.Name, + ServicePort: portString(ingress.Spec.DefaultBackend.Service.Port), + }, + }, + } } if defaultBackendService != nil && len(ingress.Spec.Rules) == 0 { @@ -601,9 +627,10 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration EntryPoints: p.NonTLSEntryPoints, Rule: `PathPrefix("/")`, // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. - RuleSyntax: "default", - Priority: math.MinInt32, - Service: defaultBackendName, + RuleSyntax: "default", + Priority: math.MinInt32, + Service: defaultBackendName, + Observability: defaultBackendObs, } if err := p.applyMiddlewares(ingress, defaultBackendName, "", "", ingress.Spec.DefaultBackend, hosts, rt, conf, ""); err != nil { @@ -622,6 +649,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration TLS: &dynamic.RouterTLSConfig{ Options: clientAuthTLSOptionName, }, + Observability: defaultBackendObs, } if err := p.applyMiddlewares(ingress, defaultBackendTLSName, "", "", ingress.Spec.DefaultBackend, hosts, rtTLS, conf, ""); err != nil { @@ -630,10 +658,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration conf.HTTP.Routers[defaultBackendTLSName] = rtTLS - if namedServersTransport != nil && defaultBackendService.LoadBalancer != nil { - defaultBackendService.LoadBalancer.ServersTransport = namedServersTransport.Name - conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport - } + conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport conf.HTTP.Services[defaultBackendName] = defaultBackendService } @@ -691,10 +716,11 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rt := &dynamic.Router{ EntryPoints: p.NonTLSEntryPoints, - Rule: buildHostRule(rule.Host), + Rule: fmt.Sprintf("Host(%q)", rule.Host), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. - RuleSyntax: "default", - Service: key, + RuleSyntax: "default", + Service: key, + Observability: defaultBackendObs, } if err := p.applyMiddlewares(ingress, key, "", "", ingress.Spec.DefaultBackend, hosts, rt, conf, serverSnippets[rule.Host]); err != nil { @@ -705,13 +731,14 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration rtTLS := &dynamic.Router{ EntryPoints: p.TLSEntryPoints, - Rule: buildHostRule(rule.Host), + Rule: fmt.Sprintf("Host(%q)", rule.Host), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. RuleSyntax: "default", Service: key, TLS: &dynamic.RouterTLSConfig{ Options: clientAuthTLSOptionName, }, + Observability: defaultBackendObs, } if err := p.applyMiddlewares(ingress, key+"-tls", "", "", ingress.Spec.DefaultBackend, hosts, rtTLS, conf, serverSnippets[rule.Host]); err != nil { @@ -720,11 +747,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration conf.HTTP.Routers[key+"-tls"] = rtTLS - if namedServersTransport != nil && defaultBackendService.LoadBalancer != nil { - defaultBackendService.LoadBalancer.ServersTransport = namedServersTransport.Name - conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport - } - + conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport conf.HTTP.Services[key] = defaultBackendService } @@ -744,9 +767,20 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration continue } + pathObs := &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: ingress.Namespace, + IngressName: ingress.Name, + ServiceName: pa.Backend.Service.Name, + ServicePort: portString(pa.Backend.Service.Port), + }, + }, + } + // TODO: if no service, do not add middlewares and 503. serviceName := provider.Normalize(ingress.Namespace + "-" + ingress.Name + "-" + pa.Backend.Service.Name + "-" + portString(pa.Backend.Service.Port)) - service, err := p.buildService(ingress.Namespace, pa.Backend, namedServersTransport, ingress.IngressConfig) + service, err := p.buildService(ingress.Namespace, pa.Backend, &namedServersTransport, ingress.IngressConfig) if err != nil { logger.Error(). Str("serviceName", pa.Backend.Service.Name). @@ -767,7 +801,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration canaryBackend, hasCanaryBackend := canaryBackends[canaryBackendKey(ingress.Namespace, *pa.Backend.Service)] if hasCanaryBackend { canaryServiceName = serviceName + "-canary" - canaryService, err = p.buildService(ingress.Namespace, *canaryBackend.IngressBackend, namedServersTransport, ingress.IngressConfig) + canaryService, err = p.buildService(ingress.Namespace, *canaryBackend.IngressBackend, &namedServersTransport, ingress.IngressConfig) if err != nil { logger.Error(). Str("serviceName", canaryBackend.IngressBackend.Service.Name). @@ -793,8 +827,9 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration EntryPoints: p.NonTLSEntryPoints, Rule: buildRule(ctxIngress, rule.Host, pa, ingress.IngressConfig, hosts, hostsWithUseRegex), // "default" stands for the default rule syntax in Traefik v3, i.e. the v3 syntax. - RuleSyntax: "default", - Service: serviceName, + RuleSyntax: "default", + Service: serviceName, + Observability: pathObs, } routerKey := provider.Normalize(fmt.Sprintf("%s-%s-rule-%d-path-%d", ingress.Namespace, ingress.Name, ri, pi)) @@ -809,6 +844,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration TLS: &dynamic.RouterTLSConfig{ Options: clientAuthTLSOptionName, }, + Observability: pathObs, } routerKeyTLS := routerKey + "-tls" @@ -836,11 +872,11 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration if hasCanaryBackend && canaryBackend.RequiresCanaryRouter() { canaryRouterKey := routerKey + "-canary" canaryRouter := &dynamic.Router{ - EntryPoints: rt.EntryPoints, - Rule: canaryBackend.AppendCanaryRule(rt.Rule), - RuleSyntax: rt.RuleSyntax, - Service: canaryServiceName, - TLS: rt.TLS, + EntryPoints: rt.EntryPoints, + Rule: canaryBackend.AppendCanaryRule(rt.Rule), + RuleSyntax: rt.RuleSyntax, + Service: canaryServiceName, + Observability: pathObs, } conf.HTTP.Routers[canaryRouterKey] = canaryRouter @@ -851,11 +887,12 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration // default TLS router canaryRouterKeyTLS := canaryRouterKey + "-tls" canaryRouterTLS := &dynamic.Router{ - EntryPoints: rtTLS.EntryPoints, - Rule: canaryBackend.AppendCanaryRule(rtTLS.Rule), - RuleSyntax: rtTLS.RuleSyntax, - Service: canaryServiceName, - TLS: rtTLS.TLS, + EntryPoints: rtTLS.EntryPoints, + Rule: canaryBackend.AppendCanaryRule(rtTLS.Rule), + RuleSyntax: rtTLS.RuleSyntax, + Service: canaryServiceName, + TLS: rtTLS.TLS, + Observability: pathObs, } conf.HTTP.Routers[canaryRouterKeyTLS] = canaryRouterTLS @@ -867,11 +904,11 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration if hasCanaryBackend && canaryBackend.RequiresNonCanaryRouter() { nonCanaryRouterKey := routerKey + "-non-canary" nonCanaryRouter := &dynamic.Router{ - EntryPoints: rt.EntryPoints, - Rule: canaryBackend.AppendNonCanaryRule(rt.Rule), - RuleSyntax: rt.RuleSyntax, - Service: serviceName, - TLS: rt.TLS, + EntryPoints: rt.EntryPoints, + Rule: canaryBackend.AppendNonCanaryRule(rt.Rule), + RuleSyntax: rt.RuleSyntax, + Service: serviceName, + Observability: pathObs, } conf.HTTP.Routers[nonCanaryRouterKey] = nonCanaryRouter @@ -882,11 +919,12 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration // default TLS router nonCanaryRouterKeyTLS := nonCanaryRouterKey + "-tls" nonCanaryRouterTLS := &dynamic.Router{ - EntryPoints: rtTLS.EntryPoints, - Rule: canaryBackend.AppendNonCanaryRule(rtTLS.Rule), - RuleSyntax: rtTLS.RuleSyntax, - Service: serviceName, - TLS: rtTLS.TLS, + EntryPoints: rtTLS.EntryPoints, + Rule: canaryBackend.AppendNonCanaryRule(rtTLS.Rule), + RuleSyntax: rtTLS.RuleSyntax, + Service: serviceName, + TLS: rtTLS.TLS, + Observability: pathObs, } conf.HTTP.Routers[nonCanaryRouterKeyTLS] = nonCanaryRouterTLS @@ -895,9 +933,7 @@ func (p *Provider) loadConfiguration(ctx context.Context) *dynamic.Configuration } } - if namedServersTransport != nil { - conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport - } + conf.HTTP.ServersTransports[namedServersTransport.Name] = namedServersTransport.ServersTransport } } } @@ -943,11 +979,11 @@ func (p *Provider) isIngressValid(ingress ingress) error { return nil } -func (p *Provider) buildServersTransport(ctx context.Context, namespace, name string, cfg IngressConfig) (*namedServersTransport, error) { +func (p *Provider) buildServersTransport(ctx context.Context, namespace, name string, cfg IngressConfig) (namedServersTransport, error) { proxyConnectTimeout := ptr.Deref(cfg.ProxyConnectTimeout, p.ProxyConnectTimeout) proxyReadTimeout := ptr.Deref(cfg.ProxyReadTimeout, p.ProxyReadTimeout) proxySendTimeout := ptr.Deref(cfg.ProxySendTimeout, p.ProxySendTimeout) - nst := &namedServersTransport{ + nst := namedServersTransport{ Name: provider.Normalize(namespace + "-" + name), ServersTransport: &dynamic.ServersTransport{ ForwardingTimeouts: &dynamic.ForwardingTimeouts{ @@ -980,17 +1016,17 @@ func (p *Provider) buildServersTransport(ctx context.Context, namespace, name st if sslSecret := ptr.Deref(cfg.ProxySSLSecret, ""); sslSecret != "" { parts := strings.Split(sslSecret, "/") if len(parts) != 2 { - return nil, fmt.Errorf("malformed proxy SSL secret: %s, expected namespace/name", sslSecret) + return namedServersTransport{}, fmt.Errorf("malformed proxy SSL secret: %s, expected namespace/name", sslSecret) } secretNamespace, secretName := parts[0], parts[1] if !p.AllowCrossNamespaceResources && secretNamespace != namespace { - return nil, fmt.Errorf("cross-namespace proxy ssl secret is not allowed: secret %s/%s is not from ingress namespace %q", secretName, secretNamespace, namespace) + return namedServersTransport{}, fmt.Errorf("cross-namespace proxy ssl secret is not allowed: secret %s/%s is not from ingress namespace %q", secretName, secretNamespace, namespace) } blocks, err := p.certificateBlocks(secretNamespace, secretName) if err != nil { - return nil, fmt.Errorf("getting certificate blocks: %w", err) + return namedServersTransport{}, fmt.Errorf("getting certificate blocks: %w", err) } if blocks.CA != nil { @@ -1291,11 +1327,13 @@ func (p *Provider) applyMiddlewares(ingress ingress, routerKey, rulePath, ruleHo return nil } + applyAccessLogConfiguration(ingress.IngressConfig, rt) + if err := p.applyCustomHTTPErrors(ingress.Namespace, ingress.Name, routerKey, backend, ingress.IngressConfig, rt, conf); err != nil { return fmt.Errorf("applying custom HTTP errors: %w", err) } applyAppRootConfiguration(routerKey, ingress.IngressConfig, rt, conf) - applyFromToWwwRedirect(hosts, ruleHost, routerKey, ingress.IngressConfig, rt, conf) + applyFromToWwwRedirect(hosts, ruleHost, routerKey, ingress, backend, rt, conf) applyRedirect(routerKey, ingress.IngressConfig, rt, conf) if err := p.applyBasicAuthConfiguration(ingress.Namespace, routerKey, ingress.IngressConfig, rt, conf); err != nil { @@ -1350,7 +1388,13 @@ func (p *Provider) applyMiddlewares(ingress ingress, routerKey, rulePath, ruleHo func (p *Provider) applySnippetsAndAuth(routerName, serverSnippet string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { configurationSnippet := ptr.Deref(ingressConfig.ConfigurationSnippet, "") + + // Use per-ingress auth-url if set; fall back to global auth URL if enabled (default true). authURL := ptr.Deref(ingressConfig.AuthURL, "") + if authURL == "" && p.GlobalAuthURL != "" && ptr.Deref(ingressConfig.EnableGlobalAuth, true) { + authURL = p.GlobalAuthURL + } + if serverSnippet == "" && configurationSnippet == "" && authURL == "" { return } @@ -1447,6 +1491,16 @@ func (p *Provider) applyCustomHTTPErrors(namespace, ingressName, routerName stri return nil } +func getLimitBurstMultiplier(config IngressConfig) int64 { + multiplier := ptr.Deref(config.LimitBurstMultiplier, defaultLimitBurstMultiplier) + + if multiplier < 1 { + multiplier = defaultLimitBurstMultiplier + } + + return int64(multiplier) +} + func applyLimitRPMConfiguration(routerName string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { limitRPM := ptr.Deref(ingressConfig.LimitRPM, 0) if limitRPM <= 0 { @@ -1458,7 +1512,7 @@ func applyLimitRPMConfiguration(routerName string, ingressConfig IngressConfig, RateLimit: &dynamic.RateLimit{ Average: int64(limitRPM), Period: ptypes.Duration(time.Minute), - Burst: int64(limitRPM) * defaultLimitBurstMultiplier, + Burst: int64(limitRPM) * getLimitBurstMultiplier(ingressConfig), }, } @@ -1476,7 +1530,7 @@ func applyLimitRPSConfiguration(routerName string, ingressConfig IngressConfig, RateLimit: &dynamic.RateLimit{ Average: int64(limitRPS), Period: ptypes.Duration(time.Second), - Burst: int64(limitRPS) * defaultLimitBurstMultiplier, + Burst: int64(limitRPS) * getLimitBurstMultiplier(ingressConfig), }, } @@ -1586,11 +1640,15 @@ func applyRewriteTargetConfiguration(rulePath, routerName string, ingressConfig } rewriteTargetMiddlewareName := routerName + "-rewrite-target" + regex := rulePath + if regex != "" { + // Location modifier regex on ingress-nginx is case-insensitive. + regex = "(?i)" + regex + } // The usage of rewrite-target annotation implies the usage of regex. rewriteTarget := &dynamic.RewriteTarget{ - // Location modifier regex on ingress-nginx is case-insensitive. - Regex: "(?i)" + rulePath, + Regex: regex, Replacement: rewrite, } @@ -1625,8 +1683,8 @@ func applyAppRootConfiguration(routerName string, ingressConfig IngressConfig, r rt.Middlewares = append(rt.Middlewares, appRootMiddlewareName) } -func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { - if ingressConfig.FromToWwwRedirect == nil || !*ingressConfig.FromToWwwRedirect { +func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, ingress ingress, backend *netv1.IngressBackend, rt *dynamic.Router, conf *dynamic.Configuration) { + if ingress.IngressConfig.FromToWwwRedirect == nil || !*ingress.IngressConfig.FromToWwwRedirect { return } @@ -1656,6 +1714,16 @@ func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, }, } + ingressMetadata := &dynamic.KubernetesIngressMetadata{ + Namespace: ingress.Namespace, + IngressName: ingress.Name, + } + + if backend != nil && backend.Service != nil { + ingressMetadata.ServiceName = backend.Service.Name + ingressMetadata.ServicePort = portString(backend.Service.Port) + } + wwwRedirectRouter := &dynamic.Router{ EntryPoints: rt.EntryPoints, Rule: newRule, @@ -1665,6 +1733,11 @@ func applyFromToWwwRedirect(hosts map[string]bool, ruleHost, routerName string, Middlewares: []string{fromToWwwRedirectMiddlewareName}, Service: rt.Service, TLS: rt.TLS, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: ingressMetadata, + }, + }, } conf.HTTP.Routers[routerName+"-from-to-www-redirect"] = wwwRedirectRouter } @@ -1840,6 +1913,18 @@ func applyAllowedSourceRangeConfiguration(routerName string, ingressConfig Ingre rt.Middlewares = append(rt.Middlewares, allowedSourceRangeMiddlewareName) } +func applyAccessLogConfiguration(ingressConfig IngressConfig, rt *dynamic.Router) { + if ingressConfig.EnableAccessLog == nil { + return + } + + if rt.Observability == nil { + rt.Observability = &dynamic.RouterObservabilityConfig{} + } + + rt.Observability.AccessLogs = ptr.To(*ingressConfig.EnableAccessLog) +} + func (p *Provider) applyBufferingConfiguration(routerName string, ingressConfig IngressConfig, rt *dynamic.Router, conf *dynamic.Configuration) error { disableRequestBuffering := !p.ProxyRequestBuffering if ingressConfig.ProxyRequestBuffering != nil { @@ -2149,7 +2234,7 @@ func buildRule(ctx context.Context, host string, pa netv1.HTTPIngressPath, confi var hostRules []string for _, h := range hosts { - hostRules = append(hostRules, buildHostRule(h)) + hostRules = append(hostRules, fmt.Sprintf("Host(%q)", h)) } if len(hostRules) > 1 { @@ -2182,15 +2267,6 @@ func buildRule(ctx context.Context, host string, pa netv1.HTTPIngressPath, confi return strings.Join(rules, " && ") } -func buildHostRule(host string) string { - if strings.HasPrefix(host, "*.") { - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - return fmt.Sprintf("HostRegexp(%q)", fmt.Sprintf("^%s$", host)) - } - - return fmt.Sprintf("Host(%q)", host) -} - // buildPrefixRule is a helper function to build a path prefix rule that matches path prefix split by `/`. // For example, the paths `/abc`, `/abc/`, and `/abc/def` would all match the prefix `/abc`, // but the path `/abcd` would not. See TestStrictPrefixMatchingRule() for more examples. diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index d39a98744c..8b8d91e1b7 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -31,6 +31,7 @@ func TestLoadIngresses(t *testing.T) { allowCrossNamespaceResources bool globalAllowedResponseHeaders []string allowSnippetAnnotations bool + globalAuthURL string strictValidatePathType *bool paths []string expected *dynamic.Configuration @@ -77,6 +78,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -84,6 +95,16 @@ func TestLoadIngresses(t *testing.T) { Middlewares: []string{"default-ingress-with-cross-namespace-headers-rule-0-path-0-custom-headers", "default-ingress-with-cross-namespace-headers-rule-0-path-0-retry"}, RuleSyntax: "default", Service: "default-ingress-with-cross-namespace-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-custom-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -91,7 +112,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-tls-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -99,7 +130,17 @@ func TestLoadIngresses(t *testing.T) { Middlewares: []string{"default-ingress-with-cross-namespace-headers-rule-0-path-0-tls-custom-headers", "default-ingress-with-cross-namespace-headers-rule-0-path-0-tls-retry"}, RuleSyntax: "default", Service: "default-ingress-with-cross-namespace-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -225,6 +266,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -232,6 +283,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-cross-namespace-headers-rule-0-path-0-custom-headers", "default-ingress-with-cross-namespace-headers-rule-0-path-0-retry"}, Service: "default-ingress-with-cross-namespace-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-custom-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -239,7 +300,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-tls-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -247,7 +318,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-cross-namespace-headers-rule-0-path-0-tls-custom-headers", "default-ingress-with-cross-namespace-headers-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-cross-namespace-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -374,12 +455,32 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0": { EntryPoints: []string{"http"}, Rule: `Host("cross-namespace.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-cross-namespace-headers-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-custom-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -387,14 +488,34 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-custom-headers-rule-0-path-0-tls-custom-headers", "default-ingress-with-custom-headers-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-custom-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-cross-namespace-headers-rule-0-path-0-tls": { EntryPoints: []string{"https"}, Rule: `Host("cross-namespace.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-cross-namespace-headers-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cross-namespace-headers", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -501,6 +622,16 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.RouterTLSConfig{}, Middlewares: []string{"default-ingress-with-no-annotation-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-no-annotation-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-no-annotation", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-no-annotation-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -508,6 +639,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-no-annotation-rule-0-path-0-retry"}, Service: "default-ingress-with-no-annotation-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-no-annotation", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -585,6 +726,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-basicauth-rule-0-path-0-basic-auth", "default-ingress-with-basicauth-rule-0-path-0-retry"}, Service: "default-ingress-with-basicauth-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-basicauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-basicauth-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -592,7 +743,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-basicauth-rule-0-path-0-tls-basic-auth", "default-ingress-with-basicauth-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-basicauth-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-basicauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -677,6 +838,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-forwardauth-rule-0-path-0-snippet", "default-ingress-with-forwardauth-rule-0-path-0-retry"}, Service: "default-ingress-with-forwardauth-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-forwardauth-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -684,7 +855,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-forwardauth-rule-0-path-0-tls-snippet", "default-ingress-with-forwardauth-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-forwardauth-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -796,6 +977,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-forwardauth-snippet-rule-0-path-0-snippet", "default-ingress-with-forwardauth-snippet-rule-0-path-0-retry"}, Service: "default-ingress-with-forwardauth-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-forwardauth-snippet-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -803,7 +994,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-forwardauth-snippet-rule-0-path-0-tls-snippet", "default-ingress-with-forwardauth-snippet-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-forwardauth-snippet-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -874,6 +1075,395 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Global Auth applied when no local auth-url", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-without-auth.yml", + }, + globalAuthURL: "http://auth.example.com/verify", + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-without-auth-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-without-auth-rule-0-path-0-snippet", "default-ingress-without-auth-rule-0-path-0-retry"}, + Service: "default-ingress-without-auth-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-auth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-without-auth-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-without-auth-rule-0-path-0-tls-snippet", "default-ingress-without-auth-rule-0-path-0-tls-retry"}, + Service: "default-ingress-without-auth-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-auth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-without-auth-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + Auth: &dynamic.Auth{ + Address: "http://auth.example.com/verify", + }, + }, + }, + "default-ingress-without-auth-rule-0-path-0-tls-snippet": { + Snippet: &dynamic.Snippet{ + Auth: &dynamic.Auth{ + Address: "http://auth.example.com/verify", + }, + }, + }, + "default-ingress-without-auth-rule-0-path-0-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + "default-ingress-without-auth-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-without-auth-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-without-auth", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-without-auth": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Global Auth disabled by annotation", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-global-auth-disabled.yml", + }, + globalAuthURL: "http://auth.example.com/verify", + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-global-auth-disabled-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-global-auth-disabled-rule-0-path-0-retry"}, + Service: "default-ingress-with-global-auth-disabled-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-global-auth-disabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-global-auth-disabled-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-global-auth-disabled-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-global-auth-disabled-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-global-auth-disabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-global-auth-disabled-rule-0-path-0-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + "default-ingress-with-global-auth-disabled-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-global-auth-disabled-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-global-auth-disabled", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-global-auth-disabled": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Local auth-url takes precedence over global auth", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-forwardauth.yml", + }, + globalAuthURL: "http://global-auth.example.com/verify", + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-forwardauth-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && Path("/forwardauth")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-forwardauth-rule-0-path-0-snippet", "default-ingress-with-forwardauth-rule-0-path-0-retry"}, + Service: "default-ingress-with-forwardauth-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-forwardauth-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && Path("/forwardauth")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-forwardauth-rule-0-path-0-tls-snippet", "default-ingress-with-forwardauth-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-forwardauth-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-forwardauth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-forwardauth-rule-0-path-0-snippet": { + Snippet: &dynamic.Snippet{ + Auth: &dynamic.Auth{ + Address: "http://whoami.default.svc/", + Method: http.MethodGet, + AuthResponseHeaders: []string{"X-Foo", "X-Bar"}, + AuthSigninURL: "https://auth.example.com/oauth2/start?rd=foo", + }, + }, + }, + "default-ingress-with-forwardauth-rule-0-path-0-tls-snippet": { + Snippet: &dynamic.Snippet{ + Auth: &dynamic.Auth{ + Address: "http://whoami.default.svc/", + Method: http.MethodGet, + AuthResponseHeaders: []string{"X-Foo", "X-Bar"}, + AuthSigninURL: "https://auth.example.com/oauth2/start?rd=foo", + }, + }, + }, + "default-ingress-with-forwardauth-rule-0-path-0-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + "default-ingress-with-forwardauth-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-forwardauth-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-forwardauth", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-forwardauth": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "No global auth when GlobalAuthURL not configured", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-without-auth.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-without-auth-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-without-auth-rule-0-path-0-retry"}, + Service: "default-ingress-without-auth-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-auth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-without-auth-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-without-auth-rule-0-path-0-tls-retry"}, + Service: "default-ingress-without-auth-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-auth", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-without-auth-rule-0-path-0-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + "default-ingress-without-auth-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{Attempts: 3}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-without-auth-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-without-auth", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-without-auth": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "SSL Redirect", paths: []string{ @@ -896,6 +1486,16 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.RouterTLSConfig{}, Middlewares: []string{"default-ingress-with-ssl-redirect-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-ssl-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-ssl-redirect-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -903,6 +1503,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-ssl-redirect-rule-0-path-0-retry"}, Service: "default-ingress-with-ssl-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-without-ssl-redirect-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -910,6 +1520,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-without-ssl-redirect-rule-0-path-0-retry"}, Service: "default-ingress-without-ssl-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-without-ssl-redirect-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -918,6 +1538,16 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.RouterTLSConfig{}, Middlewares: []string{"default-ingress-without-ssl-redirect-rule-0-path-0-tls-retry"}, Service: "default-ingress-without-ssl-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-without-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-force-ssl-redirect-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -925,6 +1555,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-force-ssl-redirect-rule-0-path-0-retry"}, Service: "default-ingress-with-force-ssl-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-force-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-force-ssl-redirect-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -932,7 +1572,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-force-ssl-redirect-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-force-ssl-redirect-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-force-ssl-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1128,6 +1778,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-sticky-rule-0-path-0-retry"}, Service: "default-ingress-with-sticky-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-sticky", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-sticky-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1135,7 +1795,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-sticky-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-sticky-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-sticky", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1217,6 +1887,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-ssl-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-ssl-whoami-tls-443", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-ssl", + ServiceName: "whoami-tls", + ServicePort: "443", + }, + }, + }, }, "default-ingress-with-proxy-ssl-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1224,7 +1904,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-ssl-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-ssl-whoami-tls-443", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-ssl", + ServiceName: "whoami-tls", + ServicePort: "443", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1296,6 +1986,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-cors-rule-0-path-0-cors", "default-ingress-with-cors-rule-0-path-0-retry"}, Service: "default-ingress-with-cors-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-cors-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1303,7 +2003,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-cors-rule-0-path-0-tls-cors", "default-ingress-with-cors-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-cors-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-cors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1392,6 +2102,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-service-upstream-rule-0-path-0-retry"}, Service: "default-ingress-with-service-upstream-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-service-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-service-upstream-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1399,7 +2119,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-service-upstream-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-service-upstream-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-service-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1465,6 +2195,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-upstream-vhost-rule-0-path-0-vhost", "default-ingress-with-upstream-vhost-rule-0-path-0-retry"}, Service: "default-ingress-with-upstream-vhost-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-upstream-vhost", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-upstream-vhost-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1472,7 +2212,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-upstream-vhost-rule-0-path-0-tls-vhost", "default-ingress-with-upstream-vhost-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-upstream-vhost-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-upstream-vhost", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1551,6 +2301,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0-retry"}, Service: "default-ingress-with-x-forwarded-prefix-no-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-no-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1558,7 +2318,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-x-forwarded-prefix-no-rewrite-target-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-no-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1627,6 +2397,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-rule-0-path-0-retry"}, Service: "default-ingress-with-x-forwarded-prefix-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -1634,6 +2414,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-retry"}, Service: "default-ingress-with-x-forwarded-prefix-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -1641,6 +2431,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-retry"}, Service: "default-ingress-with-x-forwarded-prefix-three-groups-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-three-groups", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-x-forwarded-prefix-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1648,7 +2448,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-rule-0-path-0-tls-rewrite-target", "default-ingress-with-x-forwarded-prefix-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-x-forwarded-prefix-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1656,7 +2466,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-tls-rewrite-target", "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-x-forwarded-prefix-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1664,7 +2484,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-tls-rewrite-target", "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-x-forwarded-prefix-three-groups-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-x-forwarded-prefix-three-groups", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1847,6 +2677,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-with-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1854,7 +2694,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -1923,6 +2773,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-a-with-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-a-with-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-b-without-use-regex-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -1930,6 +2790,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-b-without-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-b-without-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-a-with-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1937,7 +2807,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-a-with-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-a-with-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-b-without-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -1945,7 +2825,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-b-without-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-b-without-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -2050,6 +2940,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-a-with-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-a-with-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-b-without-use-regex-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -2057,6 +2957,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-b-without-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-b-without-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-a-with-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -2064,7 +2974,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-a-with-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-a-with-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-b-without-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -2072,7 +2992,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-b-without-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-b-without-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -2176,6 +3106,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-rule-0-path-0-rewrite-target", "default-ingress-with-rewrite-target-rule-0-path-0-retry"}, }, "default-ingress-with-rewrite-target-rule-0-path-0-tls": { @@ -2183,6 +3123,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-rule-0-path-0-tls-rewrite-target", "default-ingress-with-rewrite-target-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2191,6 +3141,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target-no-regex.localhost") && (Path("/something") || PathPrefix("/something/"))`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-no-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry"}, }, "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-tls": { @@ -2198,6 +3158,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target-no-regex.localhost") && (Path("/something") || PathPrefix("/something/"))`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-no-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-no-regex-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2315,6 +3285,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target-no-regex.localhost") && Path("/original")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-no-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-rewrite-target", "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry", @@ -2325,6 +3305,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target-no-regex.localhost") && Path("/original")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-no-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-no-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-tls-rewrite-target", "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-tls-retry", @@ -2409,6 +3399,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("shared.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-a-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-a-with-rewrite-target-rule-0-path-0-rewrite-target", "default-ingress-a-with-rewrite-target-rule-0-path-0-retry"}, }, "default-ingress-b-without-rewrite-target-rule-0-path-0": { @@ -2416,6 +3416,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("shared.localhost") && PathRegexp("(?i)^/static")`, RuleSyntax: "default", Service: "default-ingress-b-without-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-b-without-rewrite-target-rule-0-path-0-retry"}, }, "default-ingress-a-with-rewrite-target-rule-0-path-0-tls": { @@ -2423,6 +3433,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("shared.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-a-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-a-with-rewrite-target-rule-0-path-0-tls-rewrite-target", "default-ingress-a-with-rewrite-target-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2431,6 +3451,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("shared.localhost") && PathRegexp("(?i)^/static")`, RuleSyntax: "default", Service: "default-ingress-b-without-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-b-without-rewrite-target-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2532,6 +3562,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-a-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-a-with-rewrite-target-rule-0-path-0-rewrite-target", "default-ingress-a-with-rewrite-target-rule-0-path-0-retry"}, }, "default-ingress-b-without-rewrite-target-rule-0-path-0": { @@ -2539,6 +3579,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("no-rewrite.localhost") && (Path("/static") || PathPrefix("/static/"))`, RuleSyntax: "default", Service: "default-ingress-b-without-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-b-without-rewrite-target-rule-0-path-0-retry"}, }, "default-ingress-a-with-rewrite-target-rule-0-path-0-tls": { @@ -2546,6 +3596,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-a-with-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-a-with-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-a-with-rewrite-target-rule-0-path-0-tls-rewrite-target", "default-ingress-a-with-rewrite-target-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2554,6 +3614,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("no-rewrite.localhost") && (Path("/static") || PathPrefix("/static/"))`, RuleSyntax: "default", Service: "default-ingress-b-without-rewrite-target-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-b-without-rewrite-target", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-b-without-rewrite-target-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2655,6 +3725,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-use-regex-false-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-use-regex-false", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-use-regex-false-rule-0-path-0-rewrite-target", "default-ingress-with-rewrite-target-use-regex-false-rule-0-path-0-retry"}, }, "default-ingress-with-rewrite-target-use-regex-false-rule-0-path-0-tls": { @@ -2662,6 +3742,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("rewrite-target.localhost") && PathRegexp("(?i)^/something(/|$)(.*)")`, RuleSyntax: "default", Service: "default-ingress-with-rewrite-target-use-regex-false-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-rewrite-target-use-regex-false", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-rewrite-target-use-regex-false-rule-0-path-0-tls-rewrite-target", "default-ingress-with-rewrite-target-use-regex-false-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2735,6 +3825,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("app-root.localhost") && (Path("/bar") || PathPrefix("/bar/"))`, RuleSyntax: "default", Service: "default-ingress-with-app-root-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-app-root", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-app-root-rule-0-path-0-app-root", "default-ingress-with-app-root-rule-0-path-0-retry"}, }, "default-ingress-with-app-root-rule-0-path-0-tls": { @@ -2742,6 +3842,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("app-root.localhost") && (Path("/bar") || PathPrefix("/bar/"))`, RuleSyntax: "default", Service: "default-ingress-with-app-root-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-app-root", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-app-root-rule-0-path-0-tls-app-root", "default-ingress-with-app-root-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -2824,6 +3934,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-app-root-rule-0-path-0-retry"}, Service: "default-ingress-with-app-root-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-app-root", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-app-root-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -2831,7 +3951,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-app-root-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-app-root-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-app-root", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -2900,12 +4030,32 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-retry"}, Service: "default-ingress-with-www-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-www-host-rule-0-path-0-from-to-www-redirect": { EntryPoints: []string{"http"}, Rule: `Host("host.localhost")`, RuleSyntax: "default", Service: "default-ingress-with-www-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-from-to-www-redirect"}, }, "default-ingress-with-www-host-rule-0-path-0-tls": { @@ -2914,13 +4064,33 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-www-host-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-www-host-rule-0-path-0-tls-from-to-www-redirect": { EntryPoints: []string{"https"}, Rule: `Host("host.localhost")`, RuleSyntax: "default", Service: "default-ingress-with-www-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-tls-from-to-www-redirect"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -3005,12 +4175,32 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-host-rule-0-path-0-retry"}, Service: "default-ingress-with-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-host-rule-0-path-0-from-to-www-redirect": { EntryPoints: []string{"http"}, Rule: `Host("www.host.localhost")`, RuleSyntax: "default", Service: "default-ingress-with-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-host-rule-0-path-0-from-to-www-redirect"}, }, "default-ingress-with-host-rule-0-path-0-tls": { @@ -3019,13 +4209,33 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-host-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-host-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-host-rule-0-path-0-tls-from-to-www-redirect": { EntryPoints: []string{"https"}, Rule: `Host("www.host.localhost")`, RuleSyntax: "default", Service: "default-ingress-with-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-host-rule-0-path-0-tls-from-to-www-redirect"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -3110,6 +4320,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-host-rule-0-path-0-retry"}, Service: "default-ingress-with-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-www-host-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -3117,6 +4337,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-retry"}, Service: "default-ingress-with-www-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-host-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3124,7 +4354,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-host-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-host-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-www-host-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3132,7 +4372,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-www-host-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-www-host-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-www-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3237,6 +4487,14 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Priority: math.MinInt32, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + ServiceName: "whoami", + }, + }, + }, }, "default-backend-tls": { EntryPoints: []string{"https"}, @@ -3245,6 +4503,14 @@ func TestLoadIngresses(t *testing.T) { Priority: math.MinInt32, TLS: &dynamic.RouterTLSConfig{}, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + ServiceName: "whoami", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -3292,6 +4558,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-single-ip-rule-0-path-0-allowed-source-range", "default-ingress-with-whitelist-single-ip-rule-0-path-0-retry"}, Service: "default-ingress-with-whitelist-single-ip-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-single-ip", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-whitelist-single-ip-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3299,7 +4575,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-single-ip-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-whitelist-single-ip-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-whitelist-single-ip-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-single-ip", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3378,6 +4664,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-single-cidr-rule-0-path-0-allowed-source-range", "default-ingress-with-whitelist-single-cidr-rule-0-path-0-retry"}, Service: "default-ingress-with-whitelist-single-cidr-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-single-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-whitelist-single-cidr-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3385,7 +4681,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-single-cidr-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-whitelist-single-cidr-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-whitelist-single-cidr-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-single-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3464,6 +4770,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-multiple-ip-and-cidr-rule-0-path-0-allowed-source-range", "default-ingress-with-whitelist-multiple-ip-and-cidr-rule-0-path-0-retry"}, Service: "default-ingress-with-whitelist-multiple-ip-and-cidr-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-multiple-ip-and-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-whitelist-multiple-ip-and-cidr-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3471,7 +4787,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-multiple-ip-and-cidr-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-whitelist-multiple-ip-and-cidr-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-whitelist-multiple-ip-and-cidr-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-multiple-ip-and-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3550,6 +4876,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-empty-rule-0-path-0-retry"}, Service: "default-ingress-with-whitelist-empty-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-empty", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-whitelist-empty-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3557,7 +4893,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-whitelist-empty-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-whitelist-empty-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-whitelist-empty", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3626,6 +4972,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-empty-rule-0-path-0-retry"}, Service: "default-ingress-with-allowlist-empty-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-empty", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-allowlist-empty-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3633,7 +4989,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-empty-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-allowlist-empty-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-empty", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3702,6 +5068,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-single-ip-rule-0-path-0-allowed-source-range", "default-ingress-with-allowlist-single-ip-rule-0-path-0-retry"}, Service: "default-ingress-with-allowlist-single-ip-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-single-ip", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-allowlist-single-ip-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3709,7 +5085,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-single-ip-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-allowlist-single-ip-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-allowlist-single-ip-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-single-ip", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3788,6 +5174,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-single-cidr-rule-0-path-0-allowed-source-range", "default-ingress-with-allowlist-single-cidr-rule-0-path-0-retry"}, Service: "default-ingress-with-allowlist-single-cidr-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-single-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-allowlist-single-cidr-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3795,7 +5191,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-single-cidr-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-allowlist-single-cidr-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-allowlist-single-cidr-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-single-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3874,6 +5280,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-multiple-ip-and-cidr-rule-0-path-0-allowed-source-range", "default-ingress-with-allowlist-multiple-ip-and-cidr-rule-0-path-0-retry"}, Service: "default-ingress-with-allowlist-multiple-ip-and-cidr-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-multiple-ip-and-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-allowlist-multiple-ip-and-cidr-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -3881,7 +5297,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-allowlist-multiple-ip-and-cidr-rule-0-path-0-tls-allowed-source-range", "default-ingress-with-allowlist-multiple-ip-and-cidr-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-allowlist-multiple-ip-and-cidr-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-allowlist-multiple-ip-and-cidr", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -3940,6 +5366,212 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Enable Access Log", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-access-log.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-access-log-enabled-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("accesslog-enabled.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-enabled-rule-0-path-0-retry"}, + Service: "default-ingress-with-access-log-enabled-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + AccessLogs: ptr.To(true), + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-enabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-access-log-enabled-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("accesslog-enabled.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-enabled-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-access-log-enabled-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + AccessLogs: ptr.To(true), + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-enabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-access-log-disabled-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("accesslog-disabled.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-disabled-rule-0-path-0-retry"}, + Service: "default-ingress-with-access-log-disabled-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + AccessLogs: ptr.To(false), + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-disabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-access-log-disabled-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("accesslog-disabled.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-disabled-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-access-log-disabled-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + AccessLogs: ptr.To(false), + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-disabled", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-access-log-default-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("accesslog-default.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-default-rule-0-path-0-retry"}, + Service: "default-ingress-with-access-log-default-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-default", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-access-log-default-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("accesslog-default.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-access-log-default-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-access-log-default-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-access-log-default", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-access-log-enabled-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-access-log-enabled-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-access-log-disabled-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-access-log-disabled-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-access-log-default-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-access-log-default-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-access-log-enabled-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-access-log-enabled", + }, + }, + "default-ingress-with-access-log-disabled-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-access-log-disabled", + }, + }, + "default-ingress-with-access-log-default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-access-log-default", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-access-log-enabled": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-ingress-with-access-log-disabled": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-ingress-with-access-log-default": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Permanent Redirect", paths: []string{ @@ -3959,6 +5591,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-permanent-redirect-rule-0-path-0-tls": { @@ -3966,6 +5608,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4049,6 +5701,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-permanent-redirect-rule-0-path-0-tls": { @@ -4056,6 +5718,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4139,6 +5811,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-permanent-redirect-rule-0-path-0-tls": { @@ -4146,6 +5828,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("permanent-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-permanent-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-permanent-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-permanent-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-permanent-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4229,6 +5921,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-redirect-rule-0-path-0-redirect", "default-ingress-with-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-redirect-rule-0-path-0-tls": { @@ -4236,6 +5938,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4319,6 +6031,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-temporal-redirect-rule-0-path-0-tls": { @@ -4326,6 +6048,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4409,6 +6141,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-temporal-redirect-rule-0-path-0-tls": { @@ -4416,6 +6158,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4499,6 +6251,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-retry"}, }, "default-ingress-with-temporal-redirect-rule-0-path-0-tls": { @@ -4506,6 +6268,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("temporal-redirect.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-temporal-redirect-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-temporal-redirect", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-temporal-redirect-rule-0-path-0-tls-redirect", "default-ingress-with-temporal-redirect-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -4590,6 +6362,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-timeout-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -4597,7 +6379,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -4662,6 +6454,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-timeout-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -4669,7 +6471,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -4734,6 +6546,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-timeout-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -4741,7 +6563,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-timeout-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-timeout-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -4807,6 +6639,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-secret-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-auth-tls-secret-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-secret", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, TLS: &dynamic.RouterTLSConfig{ Options: "default-ingress-with-auth-tls-secret-default-ca-secret", }, @@ -4817,6 +6659,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-secret-rule-0-path-0-retry"}, Service: "default-ingress-with-auth-tls-secret-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-secret", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -4919,6 +6771,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-verify-client-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-auth-tls-verify-client-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-verify-client", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, TLS: &dynamic.RouterTLSConfig{ Options: "default-ingress-with-auth-tls-verify-client-default-ca-secret", }, @@ -4929,6 +6791,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-verify-client-rule-0-path-0-retry"}, Service: "default-ingress-with-auth-tls-verify-client-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-verify-client", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5029,6 +6901,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-and-default-backend-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors-and-default-backend", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-custom-http-errors", "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-retry"}, }, "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-tls": { @@ -5036,6 +6918,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-and-default-backend-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors-and-default-backend", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-tls-custom-http-errors", "default-ingress-with-custom-http-errors-and-default-backend-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -5160,6 +7052,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-custom-http-errors-rule-0-path-0-custom-http-errors", "default-ingress-with-custom-http-errors-rule-0-path-0-retry", @@ -5170,6 +7072,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-custom-http-errors-rule-0-path-0-tls-custom-http-errors", "default-ingress-with-custom-http-errors-rule-0-path-0-tls-retry", @@ -5182,6 +7094,14 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Priority: math.MinInt32, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + ServiceName: "whoami-b", + }, + }, + }, }, "default-backend-tls": { EntryPoints: []string{"https"}, @@ -5190,6 +7110,14 @@ func TestLoadIngresses(t *testing.T) { Priority: math.MinInt32, TLS: &dynamic.RouterTLSConfig{}, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + ServiceName: "whoami-b", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5298,6 +7226,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-custom-http-errors-rule-0-path-0-retry"}, }, "default-ingress-with-custom-http-errors-rule-0-path-0-tls": { @@ -5305,6 +7243,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-custom-http-errors-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-custom-http-errors", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-custom-http-errors-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -5370,6 +7318,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-default-backend-annotation-empty-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-default-backend-annotation", + ServiceName: "empty", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-default-backend-annotation-rule-0-path-0-retry"}, }, "default-ingress-with-default-backend-annotation-rule-0-path-0-tls": { @@ -5377,6 +7335,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-default-backend-annotation-empty-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-default-backend-annotation", + ServiceName: "empty", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-default-backend-annotation-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -5447,6 +7415,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-body-size-rule-0-path-0-buffering", "default-ingress-with-proxy-body-size-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-body-size-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-body-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-body-size-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5454,7 +7432,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-body-size-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-body-size-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-body-size-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-body-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5539,6 +7527,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-client-body-buffer-size-rule-0-path-0-buffering", "default-ingress-with-client-body-buffer-size-rule-0-path-0-retry"}, Service: "default-ingress-with-client-body-buffer-size-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-client-body-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-client-body-buffer-size-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5546,7 +7544,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-client-body-buffer-size-rule-0-path-0-tls-buffering", "default-ingress-with-client-body-buffer-size-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-client-body-buffer-size-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-client-body-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5631,6 +7639,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-body-size-and-client-body-buffer-size-rule-0-path-0-buffering", "default-ingress-with-proxy-body-size-and-client-body-buffer-size-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-body-size-and-client-body-buffer-size-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-body-size-and-client-body-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-body-size-and-client-body-buffer-size-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5638,7 +7656,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-body-size-and-client-body-buffer-size-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-body-size-and-client-body-buffer-size-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-body-size-and-client-body-buffer-size-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-body-size-and-client-body-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5723,6 +7751,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffer-size-rule-0-path-0-buffering", "default-ingress-with-proxy-buffer-size-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-buffer-size-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-buffer-size-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5730,7 +7768,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffer-size-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-buffer-size-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-buffer-size-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffer-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5817,6 +7865,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffers-number-rule-0-path-0-buffering", "default-ingress-with-proxy-buffers-number-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-buffers-number-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffers-number", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-buffers-number-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5824,7 +7882,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffers-number-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-buffers-number-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-buffers-number-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffers-number", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -5911,6 +7979,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffer-size-and-number-rule-0-path-0-buffering", "default-ingress-with-proxy-buffer-size-and-number-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-buffer-size-and-number-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffer-size-and-number", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-buffer-size-and-number-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -5918,7 +7996,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-buffer-size-and-number-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-buffer-size-and-number-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-buffer-size-and-number-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-buffer-size-and-number", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -6005,6 +8093,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-max-temp-file-size-rule-0-path-0-buffering", "default-ingress-with-proxy-max-temp-file-size-rule-0-path-0-retry"}, Service: "default-ingress-with-proxy-max-temp-file-size-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-max-temp-file-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-max-temp-file-size-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -6012,7 +8110,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-proxy-max-temp-file-size-rule-0-path-0-tls-buffering", "default-ingress-with-proxy-max-temp-file-size-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-proxy-max-temp-file-size-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-max-temp-file-size", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -6099,6 +8207,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-server-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-server-snippet-rule-0-path-0-snippet", "default-ingress-with-server-snippet-rule-0-path-0-retry", @@ -6109,6 +8227,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-server-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-server-snippet-rule-0-path-0-tls-snippet", "default-ingress-with-server-snippet-rule-0-path-0-tls-retry", @@ -6188,6 +8316,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-configuration-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-configuration-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-configuration-snippet-rule-0-path-0-snippet", "default-ingress-with-configuration-snippet-rule-0-path-0-retry", @@ -6198,6 +8336,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-configuration-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-configuration-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-configuration-snippet-rule-0-path-0-tls-snippet", "default-ingress-with-configuration-snippet-rule-0-path-0-tls-retry", @@ -6277,6 +8425,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-both-snippets-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-both-snippets", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-both-snippets-rule-0-path-0-snippet", "default-ingress-with-both-snippets-rule-0-path-0-retry", @@ -6287,6 +8445,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-both-snippets-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-both-snippets", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-both-snippets-rule-0-path-0-tls-snippet", "default-ingress-with-both-snippets-rule-0-path-0-tls-retry", @@ -6434,6 +8602,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-server-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-server-snippet-rule-0-path-0-snippet", "default-ingress-with-server-snippet-rule-0-path-0-retry"}, }, "default-ingress-with-server-snippet-rule-0-path-0-tls": { @@ -6441,6 +8619,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-server-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-server-snippet-rule-0-path-0-tls-snippet", "default-ingress-with-server-snippet-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -6517,6 +8705,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-configuration-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-configuration-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-configuration-snippet-rule-0-path-0-snippet", "default-ingress-with-configuration-snippet-rule-0-path-0-retry"}, }, "default-ingress-with-configuration-snippet-rule-0-path-0-tls": { @@ -6524,6 +8722,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-configuration-snippet-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-configuration-snippet", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-configuration-snippet-rule-0-path-0-tls-snippet", "default-ingress-with-configuration-snippet-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -6600,6 +8808,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-both-snippets-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-both-snippets", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-both-snippets-rule-0-path-0-snippet", "default-ingress-with-both-snippets-rule-0-path-0-retry"}, }, "default-ingress-with-both-snippets-rule-0-path-0-tls": { @@ -6607,6 +8825,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("snippet.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-both-snippets-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-both-snippets", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-both-snippets-rule-0-path-0-tls-snippet", "default-ingress-with-both-snippets-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -6686,6 +8914,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-pass-certificate-to-upstream-rule-0-path-0-tls-pass-certificate-to-upstream", "default-ingress-with-auth-tls-pass-certificate-to-upstream-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-auth-tls-pass-certificate-to-upstream-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-pass-certificate-to-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, TLS: &dynamic.RouterTLSConfig{ Options: "default-ingress-with-auth-tls-pass-certificate-to-upstream-default-ca-secret", }, @@ -6696,6 +8934,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-auth-tls-pass-certificate-to-upstream-rule-0-path-0-retry"}, Service: "default-ingress-with-auth-tls-pass-certificate-to-upstream-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-auth-tls-pass-certificate-to-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -6803,6 +9051,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-next-upstream-off-rule-0-path-0": { @@ -6810,12 +9068,32 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-off-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-off", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-proxy-next-upstream-rule-0-path-0-tls": { EntryPoints: []string{"https"}, Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -6824,7 +9102,17 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-off-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-off", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -6914,6 +9202,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-tries-unlimited-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-tries-unlimited", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-tries-unlimited-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-next-upstream-tries-rule-0-path-0": { @@ -6921,6 +9219,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-tries-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-tries", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-tries-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-next-upstream-tries-unlimited-rule-0-path-0-tls": { @@ -6928,6 +9236,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-tries-unlimited-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-tries-unlimited", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-tries-unlimited-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -6936,6 +9254,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-tries-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-tries", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-tries-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7033,6 +9361,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-timeout-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-timeout-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-next-upstream-timeout-rule-0-path-0-tls": { @@ -7040,6 +9378,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-next-upstream-timeout-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-next-upstream-timeout", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-next-upstream-timeout-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7108,6 +9456,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-server-alias-rule-0-path-0-retry"}, Service: "default-ingress-with-server-alias-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-alias", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-server-alias-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -7115,7 +9473,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-server-alias-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-server-alias-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-server-alias", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -7185,6 +9553,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-primary-ingress-rule-0-path-0-retry"}, Service: "default-primary-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "primary-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-alias-ingress-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -7192,6 +9570,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-alias-ingress-rule-0-path-0-retry"}, Service: "default-alias-ingress-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "alias-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-primary-ingress-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -7199,7 +9587,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-primary-ingress-rule-0-path-0-tls-retry"}, Service: "default-primary-ingress-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "primary-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-alias-ingress-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -7207,7 +9605,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-alias-ingress-rule-0-path-0-tls-retry"}, Service: "default-alias-ingress-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "alias-ingress", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -7303,6 +9711,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("proxy-http-version.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-http-version-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-http-version", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-http-version-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-http-version-rule-0-path-0-tls": { @@ -7310,6 +9728,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("proxy-http-version.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-http-version-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-http-version", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-http-version-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7376,6 +9804,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("proxy-http-version-unsupported.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-http-version-unsupported-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-http-version-unsupported", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-http-version-unsupported-rule-0-path-0-retry"}, }, "default-ingress-with-proxy-http-version-unsupported-rule-0-path-0-tls": { @@ -7383,6 +9821,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("proxy-http-version-unsupported.localhost") && Path("/")`, RuleSyntax: "default", Service: "default-ingress-with-proxy-http-version-unsupported-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-proxy-http-version-unsupported", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-proxy-http-version-unsupported-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7448,6 +9896,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-upstream-hash-by-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-upstream-hash-by", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-upstream-hash-by-rule-0-path-0-retry"}, }, "default-ingress-with-upstream-hash-by-rule-0-path-0-tls": { @@ -7455,6 +9913,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("whoami.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-upstream-hash-by-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-upstream-hash-by", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-upstream-hash-by-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7521,6 +9989,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-rule-0-path-0-retry"}, }, "default-ingress-with-canary-rule-0-path-0-tls": { @@ -7528,6 +10006,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7629,6 +10117,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-and-sticky-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-and-sticky", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-and-sticky-rule-0-path-0-retry"}, }, "default-ingress-with-canary-and-sticky-rule-0-path-0-tls": { @@ -7636,6 +10134,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-and-sticky-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-and-sticky", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-and-sticky-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7773,6 +10281,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-weight-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-weight-rule-0-path-0-retry"}, }, "default-ingress-with-canary-weight-rule-0-path-0-tls": { @@ -7780,6 +10298,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-weight-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-weight-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7881,6 +10409,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-header-rule-0-path-0-canary": { @@ -7888,6 +10426,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-by-header-rule-0-path-0-tls": { @@ -7895,6 +10443,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -7903,6 +10461,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-rule-0-path-0-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8014,6 +10582,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-value-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-value", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-value-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-header-value-rule-0-path-0-canary": { @@ -8021,6 +10599,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "bar"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-value-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-value", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-value-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-by-header-value-rule-0-path-0-tls": { @@ -8028,6 +10616,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-value-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-value", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-value-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8036,6 +10634,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "bar"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-value-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-value", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-value-rule-0-path-0-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8147,6 +10755,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-pattern-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-pattern", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-pattern-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-header-pattern-rule-0-path-0-canary": { @@ -8154,6 +10772,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (HeaderRegexp("Foo", "bar(.*)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-pattern-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-pattern", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-pattern-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-by-header-pattern-rule-0-path-0-tls": { @@ -8161,6 +10789,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-pattern-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-pattern", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-pattern-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8169,6 +10807,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (HeaderRegexp("Foo", "bar(.*)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-pattern-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-pattern", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-pattern-rule-0-path-0-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8280,6 +10928,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-misconfigured-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-misconfigured", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-misconfigured-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-header-misconfigured-rule-0-path-0-tls": { @@ -8287,6 +10945,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-misconfigured-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-misconfigured", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-misconfigured-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8388,6 +11056,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-cookie-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-cookie", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-cookie-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-cookie-rule-0-path-0-canary": { @@ -8395,6 +11073,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (HeaderRegexp("Cookie", "(^|;\\s*)foo=always(;|$)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-cookie-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-cookie", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-cookie-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-by-cookie-rule-0-path-0-tls": { @@ -8402,6 +11090,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-cookie-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-cookie", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-cookie-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8410,6 +11108,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (HeaderRegexp("Cookie", "(^|;\\s*)foo=always(;|$)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-cookie-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-cookie", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-cookie-rule-0-path-0-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8521,6 +11229,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-retry"}, }, "default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-canary": { @@ -8528,6 +11246,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always") || (HeaderRegexp("Cookie", "(^|;\\s*)foo=always(;|$)") && !Header("Foo", "never")))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-non-canary": { @@ -8535,6 +11263,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "never") || HeaderRegexp("Cookie", "(^|;\\s*)foo=never(;|$)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-non-canary-retry"}, }, "default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-tls": { @@ -8542,6 +11280,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8550,6 +11298,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always") || (HeaderRegexp("Cookie", "(^|;\\s*)foo=always(;|$)") && !Header("Foo", "never")))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8558,6 +11316,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "never") || HeaderRegexp("Cookie", "(^|;\\s*)foo=never(;|$)"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-by-header-and-cookie-and-weight-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-by-header-and-cookie-and-weight", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-by-header-and-cookie-and-weight-rule-0-path-0-non-canary-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8679,6 +11447,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-rule-0-path-0-app-root", "default-ingress-with-canary-middlewares-rule-0-path-0-retry", @@ -8689,6 +11467,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-rule-0-path-0-canary-app-root", "default-ingress-with-canary-middlewares-rule-0-path-0-canary-retry", @@ -8699,6 +11487,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-rule-0-path-0-tls-app-root", "default-ingress-with-canary-middlewares-rule-0-path-0-tls-retry", @@ -8710,6 +11508,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-rule-0-path-0-canary-tls-app-root", "default-ingress-with-canary-middlewares-rule-0-path-0-canary-tls-retry", @@ -8848,6 +11656,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-non-matching-canary-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-non-matching-canary", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-non-matching-canary-rule-0-path-0-retry"}, }, "default-ingress-with-non-matching-canary-rule-0-path-0-tls": { @@ -8855,6 +11673,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-non-matching-canary-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-non-matching-canary", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-non-matching-canary-rule-0-path-0-tls-retry"}, TLS: &dynamic.RouterTLSConfig{}, }, @@ -8925,6 +11753,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-and-tls-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares-and-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-app-root", "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-retry"}, }, "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-canary": { @@ -8932,6 +11770,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-and-tls-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares-and-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{"default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-canary-app-root", "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-canary-retry"}, }, "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-tls": { @@ -8939,6 +11787,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `Host("production.localhost") && PathPrefix("/")`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-and-tls-whoami-80-wrr", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares-and-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-tls-app-root", "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-tls-retry", @@ -8950,6 +11808,16 @@ func TestLoadIngresses(t *testing.T) { Rule: `(Host("production.localhost") && PathPrefix("/")) && (Header("Foo", "always"))`, RuleSyntax: "default", Service: "default-ingress-with-canary-middlewares-and-tls-whoami-80-canary", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-canary-middlewares-and-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, Middlewares: []string{ "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-canary-tls-app-root", "default-ingress-with-canary-middlewares-and-tls-rule-0-path-0-canary-tls-retry", @@ -9084,6 +11952,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rps-rule-0-path-0-limit-rps", "default-ingress-with-limit-rps-rule-0-path-0-retry"}, Service: "default-ingress-with-limit-rps-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rps", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-limit-rps-zero-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -9091,6 +11969,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rps-zero-rule-0-path-0-retry"}, Service: "default-ingress-with-limit-rps-zero-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rps-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-limit-rps-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -9098,7 +11986,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rps-rule-0-path-0-tls-limit-rps", "default-ingress-with-limit-rps-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-limit-rps-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rps", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-limit-rps-zero-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -9106,7 +12004,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rps-zero-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-limit-rps-zero-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rps-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -9217,6 +12125,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rpm-rule-0-path-0-limit-rpm", "default-ingress-with-limit-rpm-rule-0-path-0-retry"}, Service: "default-ingress-with-limit-rpm-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rpm", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-limit-rpm-zero-rule-0-path-0": { EntryPoints: []string{"http"}, @@ -9224,6 +12142,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rpm-zero-rule-0-path-0-retry"}, Service: "default-ingress-with-limit-rpm-zero-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rpm-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-limit-rpm-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -9231,7 +12159,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rpm-rule-0-path-0-tls-limit-rpm", "default-ingress-with-limit-rpm-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-limit-rpm-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rpm", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, "default-ingress-with-limit-rpm-zero-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -9239,7 +12177,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-limit-rpm-zero-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-limit-rpm-zero-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-rpm-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -9330,6 +12278,138 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Limit Burst Multiplier", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-limit-burst-multiplier.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{Routers: map[string]*dynamic.TCPRouter{}, Services: map[string]*dynamic.TCPService{}}, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-limit-burst-multiplier-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami-burst.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-burst-multiplier-rule-0-path-0-limit-rps", "default-ingress-with-limit-burst-multiplier-rule-0-path-0-retry"}, + Service: "default-ingress-with-limit-burst-multiplier-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-burst-multiplier", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-limit-burst-multiplier-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami-burst.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-burst-multiplier-rule-0-path-0-tls-limit-rps", "default-ingress-with-limit-burst-multiplier-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-limit-burst-multiplier-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-burst-multiplier", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami-burst-zero.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-limit-rps", "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-retry"}, + Service: "default-ingress-with-limit-burst-multiplier-zero-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-burst-multiplier-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami-burst-zero.localhost") && Path("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-tls-limit-rps", "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-limit-burst-multiplier-zero-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-burst-multiplier-zero", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-limit-burst-multiplier-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-burst-multiplier-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-burst-multiplier-rule-0-path-0-limit-rps": { + RateLimit: &dynamic.RateLimit{Average: 10, Burst: 100, Period: ptypes.Duration(time.Second)}, + }, + "default-ingress-with-limit-burst-multiplier-rule-0-path-0-tls-limit-rps": { + RateLimit: &dynamic.RateLimit{Average: 10, Burst: 100, Period: ptypes.Duration(time.Second)}, + }, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-limit-rps": { + RateLimit: &dynamic.RateLimit{Average: 10, Burst: 50, Period: ptypes.Duration(time.Second)}, + }, + "default-ingress-with-limit-burst-multiplier-zero-rule-0-path-0-tls-limit-rps": { + RateLimit: &dynamic.RateLimit{Average: 10, Burst: 50, Period: ptypes.Duration(time.Second)}, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-limit-burst-multiplier-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{{URL: "http://10.10.0.1:80"}, {URL: "http://10.10.0.2:80"}}, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{FlushInterval: dynamic.DefaultFlushInterval}, + ServersTransport: "default-ingress-with-limit-burst-multiplier", + }, + }, + "default-ingress-with-limit-burst-multiplier-zero-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{{URL: "http://10.10.0.1:80"}, {URL: "http://10.10.0.2:80"}}, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{FlushInterval: dynamic.DefaultFlushInterval}, + ServersTransport: "default-ingress-with-limit-burst-multiplier-zero", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-limit-burst-multiplier": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{DialTimeout: ptypes.Duration(60 * time.Second), ReadTimeout: ptypes.Duration(60 * time.Second), WriteTimeout: ptypes.Duration(60 * time.Second), IdleConnTimeout: ptypes.Duration(60 * time.Second)}, + }, + "default-ingress-with-limit-burst-multiplier-zero": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{DialTimeout: ptypes.Duration(60 * time.Second), ReadTimeout: ptypes.Duration(60 * time.Second), WriteTimeout: ptypes.Duration(60 * time.Second), IdleConnTimeout: ptypes.Duration(60 * time.Second)}, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Use Regex with Prefix pathType and StrictValidatePathType enabled", paths: []string{ @@ -9372,6 +12452,16 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-use-regex-rule-0-path-0-retry"}, Service: "default-ingress-with-use-regex-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, }, "default-ingress-with-use-regex-rule-0-path-0-tls": { EntryPoints: []string{"https"}, @@ -9379,7 +12469,17 @@ func TestLoadIngresses(t *testing.T) { RuleSyntax: "default", Middlewares: []string{"default-ingress-with-use-regex-rule-0-path-0-tls-retry"}, Service: "default-ingress-with-use-regex-whoami-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-use-regex", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Middlewares: map[string]*dynamic.Middleware{ @@ -9420,6 +12520,200 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Wildcard host", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-wildcard-host.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-wildcard-host-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-wildcard-host-rule-0-path-0-retry"}, + Service: "default-ingress-with-wildcard-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-wildcard-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-wildcard-host-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + TLS: &dynamic.RouterTLSConfig{}, + Middlewares: []string{"default-ingress-with-wildcard-host-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-wildcard-host-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-wildcard-host", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-wildcard-host-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-wildcard-host-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-wildcard-host-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-wildcard-host", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-wildcard-host": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Wildcard host with TLS", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "secrets.yml", + "ingresses/ingress-with-wildcard-host-tls.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-wildcard-host-tls-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-wildcard-host-tls-rule-0-path-0-retry"}, + Service: "default-ingress-with-wildcard-host-tls-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-wildcard-host-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-wildcard-host-tls-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("*.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + TLS: &dynamic.RouterTLSConfig{}, + Middlewares: []string{"default-ingress-with-wildcard-host-tls-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-wildcard-host-tls-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-wildcard-host-tls", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-wildcard-host-tls-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-wildcard-host-tls-rule-0-path-0-tls-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-wildcard-host-tls-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + {URL: "http://10.10.0.1:80"}, + {URL: "http://10.10.0.2:80"}, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ServersTransport: "default-ingress-with-wildcard-host-tls", + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-wildcard-host-tls": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{ + CertFile: "-----BEGIN CERTIFICATE-----", + KeyFile: "-----BEGIN CERTIFICATE-----", + }, + }, + }, + }, + }, + }, { desc: "Ingress with multiple paths and one invalid path with StrictValidatePathType drops the whole ingress", paths: []string{ @@ -9468,6 +12762,7 @@ func TestLoadIngresses(t *testing.T) { TLSEntryPoints: []string{"https"}, allowedHeaders: test.globalAllowedResponseHeaders, AllowCrossNamespaceResources: test.allowCrossNamespaceResources, + GlobalAuthURL: test.globalAuthURL, } p.SetDefaults() if test.strictValidatePathType != nil { diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index e0303aed23..7d670ce22f 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -8,7 +8,6 @@ import ( "math" "net" "os" - "regexp" "slices" "sort" "strconv" @@ -75,10 +74,13 @@ func (p *Provider) Init() error { return nil } +// ProviderName is the Kubernetes Ingress provider name. +const ProviderName = "kubernetes" + // Provide allows the k8s provider to provide configurations to traefik // using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, "kubernetes").Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(context.Background()) k8sClient, err := p.newK8sClient(ctxLog) @@ -131,7 +133,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. default: p.lastConfiguration.Set(confHash) configurationChan <- dynamic.Message{ - ProviderName: "kubernetes", + ProviderName: ProviderName, Configuration: conf, } } @@ -304,13 +306,29 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl RuleSyntax: "default", Priority: math.MinInt32, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: ingress.Namespace, + IngressName: ingress.Name, + ServiceName: ingress.Spec.DefaultBackend.Service.Name, + ServicePort: portString(ingress.Spec.DefaultBackend.Service.Port), + }, + }, + }, } if rtConfig != nil && rtConfig.Router != nil { rt.EntryPoints = rtConfig.Router.EntryPoints rt.Middlewares = rtConfig.Router.Middlewares rt.TLS = rtConfig.Router.TLS - rt.Observability = rtConfig.Router.Observability + + if rtConfig.Router.Observability != nil { + rt.Observability.AccessLogs = rtConfig.Router.Observability.AccessLogs + rt.Observability.Metrics = rtConfig.Router.Observability.Metrics + rt.Observability.Tracing = rtConfig.Router.Observability.Tracing + rt.Observability.TraceVerbosity = rtConfig.Router.Observability.TraceVerbosity + } } p.applyRouterTransform(ctxIngress, rt, ingress) @@ -356,16 +374,10 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl continue } - portString := pa.Backend.Service.Port.Name - - if len(pa.Backend.Service.Port.Name) == 0 { - portString = strconv.Itoa(int(pa.Backend.Service.Port.Number)) - } - - serviceName := provider.Normalize(ingress.Namespace + "-" + pa.Backend.Service.Name + "-" + portString) + serviceName := provider.Normalize(ingress.Namespace + "-" + pa.Backend.Service.Name + "-" + portString(pa.Backend.Service.Port)) conf.HTTP.Services[serviceName] = service - rt := p.loadRouter(rule, pa, rtConfig, serviceName) + rt := p.loadRouter(ingress, rule, pa, rtConfig, serviceName) p.applyRouterTransform(ctxIngress, rt, ingress) @@ -687,9 +699,19 @@ func (p *Provider) loadService(client Client, namespace string, backend netv1.In return svc, nil } -func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, rtConfig *RouterConfig, serviceName string) *dynamic.Router { +func (p *Provider) loadRouter(ingress *netv1.Ingress, rule netv1.IngressRule, pa netv1.HTTPIngressPath, rtConfig *RouterConfig, serviceName string) *dynamic.Router { rt := &dynamic.Router{ Service: serviceName, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: ingress.Namespace, + IngressName: ingress.Name, + ServiceName: pa.Backend.Service.Name, + ServicePort: portString(pa.Backend.Service.Port), + }, + }, + }, } if rtConfig != nil && rtConfig.Router != nil { @@ -698,7 +720,13 @@ func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, rt.EntryPoints = rtConfig.Router.EntryPoints rt.Middlewares = rtConfig.Router.Middlewares rt.TLS = rtConfig.Router.TLS - rt.Observability = rtConfig.Router.Observability + + if rtConfig.Router.Observability != nil { + rt.Observability.AccessLogs = rtConfig.Router.Observability.AccessLogs + rt.Observability.Metrics = rtConfig.Router.Observability.Metrics + rt.Observability.Tracing = rtConfig.Router.Observability.Tracing + rt.Observability.TraceVerbosity = rtConfig.Router.Observability.TraceVerbosity + } } var rules []string @@ -706,7 +734,7 @@ func (p *Provider) loadRouter(rule netv1.IngressRule, pa netv1.HTTPIngressPath, if rt.RuleSyntax == "v2" || (rt.RuleSyntax == "" && p.DefaultRuleSyntax == "v2") { rules = append(rules, buildHostRuleV2(rule.Host)) } else { - rules = append(rules, buildHostRule(rule.Host)) + rules = append(rules, fmt.Sprintf("Host(%q)", rule.Host)) } } @@ -737,15 +765,6 @@ func buildHostRuleV2(host string) string { return fmt.Sprintf("Host(%q)", host) } -func buildHostRule(host string) string { - if strings.HasPrefix(host, "*.") { - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - return fmt.Sprintf("HostRegexp(%q)", fmt.Sprintf("^%s$", host)) - } - - return fmt.Sprintf("Host(%q)", host) -} - func getCertificates(ctx context.Context, ingress *netv1.Ingress, k8sClient Client, tlsConfigs map[string]*tls.CertAndStores) error { for _, t := range ingress.Spec.TLS { if t.SecretName == "" { @@ -935,3 +954,10 @@ func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *s return eventsChanBuffered } + +func portString(port netv1.ServiceBackendPort) string { + if port.Name == "" { + return strconv.Itoa(int(port.Number)) + } + return port.Name +} diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index 924e4b4c62..7be35de784 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -73,6 +73,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -129,6 +138,13 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { Tracing: pointer(true), Metrics: pointer(true), TraceVerbosity: otypes.MinimalVerbosity, + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, }, }, }, @@ -172,10 +188,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-foo": { Rule: `PathPrefix("/foo")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -206,13 +240,31 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { HTTP: &dynamic.HTTPConfiguration{ Middlewares: map[string]*dynamic.Middleware{}, Routers: map[string]*dynamic.Router{ - "testing-bar-bar-97cb2ba265f7a5df4ab9": { - Rule: `HostRegexp("^[a-zA-Z0-9-]+\\.bar$") && PathPrefix("/bar")`, + "testing-bar-bar-41871576e140babe40bd": { + Rule: `Host("*.bar") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-bar-bar-605945111a3c9f84dc65": { Rule: `Host("bar") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -246,10 +298,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-foo-bar-930f0e8b221e60bc7ab7": { Rule: `PathPrefix("/foo/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-foo-bar-207cc2245cb31ba18e29": { Rule: `PathPrefix("/foo-bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -283,10 +353,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-foo": { Rule: `PathPrefix("/foo")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -320,6 +408,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -353,6 +450,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-example-com": { Rule: `Host("example.com")`, Service: "testing-example-com-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "example-com", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -383,10 +489,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-traefik-tchouk-foo": { Rule: `Host("traefik.tchouk") && PathPrefix("/foo")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -420,10 +544,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-traefik-courgette-carotte": { Rule: `Host("traefik.courgette") && PathPrefix("/carotte")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -457,10 +599,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-traefik-courgette-carotte": { Rule: `Host("traefik.courgette") && PathPrefix("/carotte")`, Service: "testing-service2-8082", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service2", + ServicePort: "8082", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -512,6 +672,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -591,6 +760,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { RuleSyntax: "default", Service: "default-backend", Priority: math.MinInt32, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -624,6 +802,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -657,6 +844,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -690,6 +886,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -723,10 +928,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, "testing-traefik-tchouk-foo": { Rule: `Host("traefik.tchouk") && PathPrefix("/foo")`, Service: "testing-service1-carotte", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "carotte", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -777,6 +1000,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -810,10 +1042,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, "toto-toto-traefik-tchouk-bar": { Rule: `Host("toto.traefik.tchouk") && PathPrefix("/bar")`, Service: "toto-service1-tchouk", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "toto", + ServiceName: "service1", + ServicePort: "tchouk", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -884,6 +1134,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-traefik-port-port": { Rule: `Host("traefik.port") && PathPrefix("/port")`, Service: "testing-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -914,7 +1173,16 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-example-com": { Rule: `Host("example.com")`, Service: "testing-example-com-80", - TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "example-com", + ServicePort: "80", + }, + }, + }, + TLS: &dynamic.RouterTLSConfig{}, }, }, Services: map[string]*dynamic.Service{ @@ -955,6 +1223,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-443", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "443", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -988,6 +1265,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-8443", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8443", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1022,6 +1308,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-8443", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8443", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1057,6 +1352,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { RuleSyntax: "default", Service: "default-backend", Priority: math.MinInt32, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1090,6 +1394,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1161,8 +1474,17 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { Middlewares: map[string]*dynamic.Middleware{}, Routers: map[string]*dynamic.Router{ "testing-foobar-com-bar": { - Rule: `HostRegexp("^[a-zA-Z0-9-]+\\.foobar\\.com$") && PathPrefix("/bar")`, + Rule: `Host("*.foobar.com") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1196,6 +1518,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-foobar-com-bar": { Rule: `HostRegexp("{subdomain:[a-zA-Z0-9-]+}.foobar.com") && PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1228,10 +1559,28 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-foo": { Rule: `PathPrefix("/foo")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1263,6 +1612,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-foo": { Rule: `PathPrefix("/foo")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1293,6 +1651,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1323,6 +1690,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `Path("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1353,6 +1729,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `Path("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1383,6 +1768,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `Path("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1413,6 +1807,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1446,6 +1849,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1479,6 +1891,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1509,6 +1930,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1565,6 +1995,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-foobar", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "foobar", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1607,6 +2046,16 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { RuleSyntax: "default", Priority: math.MinInt32, Service: "default-backend", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + IngressName: "defaultbackend", + ServiceName: "defaultservice", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1637,6 +2086,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1678,6 +2136,15 @@ func TestLoadConfigurationFromIngresses(t *testing.T) { "testing-bar": { Rule: `(Path("/bar") || PathPrefix("/bar/"))`, Service: "testing-service1-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "80", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1752,6 +2219,15 @@ func TestLoadConfigurationFromIngressesWithExternalNameServices(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1782,6 +2258,16 @@ func TestLoadConfigurationFromIngressesWithExternalNameServices(t *testing.T) { "testing-example-com-bar": { Rule: `PathPrefix("/bar")`, Service: "testing-service-bar-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + IngressName: "example.com", + ServiceName: "service-bar", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1813,6 +2299,16 @@ func TestLoadConfigurationFromIngressesWithExternalNameServices(t *testing.T) { "testing-example-com-foo": { Rule: `PathPrefix("/foo")`, Service: "testing-service-foo-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + IngressName: "example.com", + ServiceName: "service-foo", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1866,6 +2362,15 @@ func TestLoadConfigurationFromIngressesWithNativeLB(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -1916,6 +2421,15 @@ func TestLoadConfigurationFromIngressesWithNodePortLB(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -2154,6 +2668,15 @@ func TestLoadConfigurationFromIngressesWithNativeLBByDefault(t *testing.T) { "testing-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "testing-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "testing", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -2182,6 +2705,16 @@ func TestLoadConfigurationFromIngressesWithNativeLBByDefault(t *testing.T) { "default-global-native-lb-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "default-service1-8080", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "global-native-lb", + ServiceName: "service1", + ServicePort: "8080", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -2210,6 +2743,16 @@ func TestLoadConfigurationFromIngressesWithNativeLBByDefault(t *testing.T) { "default-global-native-lb-traefik-tchouk-bar": { Rule: `Host("traefik.tchouk") && PathPrefix("/bar")`, Service: "default-native-disabled-svc-web", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "global-native-lb", + ServiceName: "native-disabled-svc", + ServicePort: "web", + }, + }, + }, }, }, Services: map[string]*dynamic.Service{ @@ -2465,10 +3008,10 @@ func TestStrictPrefixMatchingRule(t *testing.T) { parser, err := traefikhttp.NewSyntaxParser() require.NoError(t, err) - muxer := traefikhttp.NewMuxer(parser) + muxer := traefikhttp.NewMuxer(parser, nil) rule := buildStrictPrefixMatchingRule(tt.path) - err = muxer.AddRoute(rule, "", 0, handler) + err = muxer.AddRoute(rule, "", 0, "", handler) require.NoError(t, err) w := httptest.NewRecorder() diff --git a/pkg/provider/kubernetes/knative/kubernetes.go b/pkg/provider/kubernetes/knative/kubernetes.go index 4ebad6d20b..15fae2fa13 100644 --- a/pkg/provider/kubernetes/knative/kubernetes.go +++ b/pkg/provider/kubernetes/knative/kubernetes.go @@ -32,7 +32,8 @@ import ( ) const ( - providerName = "knative" + // ProviderName is the Knative provider name. + ProviderName = "knative" traefikIngressClassName = "traefik.ingress.networking.knative.dev" ) @@ -61,7 +62,7 @@ type Provider struct { // Init the provider. func (p *Provider) Init() error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() // Initializes Kubernetes client. var err error @@ -75,7 +76,7 @@ func (p *Provider) Init() error { // Provide allows the knative provider to provide configurations to traefik using the given configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - logger := log.With().Str(logs.ProviderName, providerName).Logger() + logger := log.With().Str(logs.ProviderName, ProviderName).Logger() ctxLog := logger.WithContext(context.Background()) pool.GoCtx(func(ctxPool context.Context) { @@ -117,7 +118,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. default: p.lastConfiguration.Set(confHash) configurationChan <- dynamic.Message{ - ProviderName: providerName, + ProviderName: ProviderName, Configuration: conf, } } diff --git a/pkg/provider/kv/consul/consul.go b/pkg/provider/kv/consul/consul.go index 14d8e82b66..6657f60c32 100644 --- a/pkg/provider/kv/consul/consul.go +++ b/pkg/provider/kv/consul/consul.go @@ -12,8 +12,8 @@ import ( "github.com/traefik/traefik/v3/pkg/types" ) -// providerName is the Consul provider name. -const providerName = "consul" +// ProviderName is the Consul provider name. +const ProviderName = "consul" var _ provider.Provider = (*Provider)(nil) @@ -38,7 +38,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { if len(p.Namespaces) == 0 { return []*Provider{{ Provider: p.Provider, - name: providerName, + name: ProviderName, token: p.Token, tls: p.TLS, }} @@ -48,7 +48,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { for _, namespace := range p.Namespaces { providers = append(providers, &Provider{ Provider: p.Provider, - name: providerName + "-" + namespace, + name: ProviderName + "-" + namespace, namespace: namespace, token: p.Token, tls: p.TLS, @@ -78,7 +78,7 @@ func (p *Provider) Init() error { // In case they didn't initialize with BuildProviders. if p.name == "" { - p.name = providerName + p.name = ProviderName } config := &consul.Config{ diff --git a/pkg/provider/kv/etcd/etcd.go b/pkg/provider/kv/etcd/etcd.go index 9da0610761..783fd238ef 100644 --- a/pkg/provider/kv/etcd/etcd.go +++ b/pkg/provider/kv/etcd/etcd.go @@ -11,6 +11,9 @@ import ( "github.com/traefik/traefik/v3/pkg/types" ) +// ProviderName is the Etcd provider name. +const ProviderName = "etcd" + var _ provider.Provider = (*Provider)(nil) // Provider holds configurations of the provider. @@ -44,5 +47,5 @@ func (p *Provider) Init() error { } } - return p.Provider.Init(etcdv3.StoreName, "etcd", config) + return p.Provider.Init(etcdv3.StoreName, ProviderName, config) } diff --git a/pkg/provider/kv/redis/redis.go b/pkg/provider/kv/redis/redis.go index 930cf087de..65aae95c69 100644 --- a/pkg/provider/kv/redis/redis.go +++ b/pkg/provider/kv/redis/redis.go @@ -11,6 +11,9 @@ import ( "github.com/traefik/traefik/v3/pkg/types" ) +// ProviderName is the Redis provider name. +const ProviderName = "redis" + var _ provider.Provider = (*Provider)(nil) // Provider holds configurations of the provider. @@ -90,5 +93,5 @@ func (p *Provider) Init() error { } } - return p.Provider.Init(redis.StoreName, "redis", config) + return p.Provider.Init(redis.StoreName, ProviderName, config) } diff --git a/pkg/provider/kv/zk/zk.go b/pkg/provider/kv/zk/zk.go index 9d66cee34a..77ca8034a3 100644 --- a/pkg/provider/kv/zk/zk.go +++ b/pkg/provider/kv/zk/zk.go @@ -8,6 +8,9 @@ import ( "github.com/traefik/traefik/v3/pkg/provider/kv" ) +// ProviderName is the ZooKeeper provider name. +const ProviderName = "zookeeper" + var _ provider.Provider = (*Provider)(nil) // Provider holds configurations of the provider. @@ -32,5 +35,5 @@ func (p *Provider) Init() error { Password: p.Password, } - return p.Provider.Init(zookeeper.StoreName, "zookeeper", config) + return p.Provider.Init(zookeeper.StoreName, ProviderName, config) } diff --git a/pkg/provider/nomad/nomad.go b/pkg/provider/nomad/nomad.go index ec3b8e2d80..b7daf559fb 100644 --- a/pkg/provider/nomad/nomad.go +++ b/pkg/provider/nomad/nomad.go @@ -23,8 +23,8 @@ import ( ) const ( - // providerName is the name of this provider. - providerName = "nomad" + // ProviderName is the Nomad provider name. + ProviderName = "nomad" // defaultTemplateRule is the default template for the default rule. defaultTemplateRule = "Host(`{{ normalize .Name }}`)" @@ -68,7 +68,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { if len(p.Namespaces) == 0 { return []*Provider{{ Configuration: p.Configuration, - name: providerName, + name: ProviderName, }} } @@ -76,7 +76,7 @@ func (p *ProviderBuilder) BuildProviders() []*Provider { for _, namespace := range p.Namespaces { providers = append(providers, &Provider{ Configuration: p.Configuration, - name: providerName + "-" + namespace, + name: ProviderName + "-" + namespace, namespace: namespace, }) } @@ -169,7 +169,7 @@ func (p *Provider) Init() error { // In case they didn't initialize Provider with BuildProviders if p.name == "" { - p.name = providerName + p.name = ProviderName } return nil diff --git a/pkg/provider/rest/rest.go b/pkg/provider/rest/rest.go index e0a03bb4be..187b5ddc48 100644 --- a/pkg/provider/rest/rest.go +++ b/pkg/provider/rest/rest.go @@ -13,6 +13,9 @@ import ( "github.com/unrolled/render" ) +// ProviderName is the REST provider name. +const ProviderName = "rest" + var _ provider.Provider = (*Provider)(nil) // Provider is a provider.Provider implementation that provides a Rest API. @@ -40,7 +43,7 @@ func (p *Provider) CreateRouter() *mux.Router { func (p *Provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) - if vars["provider"] != "rest" { + if vars["provider"] != ProviderName { http.Error(rw, "Only 'rest' provider can be updated through the REST API", http.StatusBadRequest) return } @@ -53,7 +56,7 @@ func (p *Provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) { return } - p.configurationChan <- dynamic.Message{ProviderName: "rest", Configuration: configuration} + p.configurationChan <- dynamic.Message{ProviderName: ProviderName, Configuration: configuration} if err := templatesRenderer.JSON(rw, http.StatusOK, configuration); err != nil { log.Error().Err(err).Send() } diff --git a/pkg/provider/traefik/internal.go b/pkg/provider/traefik/internal.go index ddde13c3a1..45614f66e1 100644 --- a/pkg/provider/traefik/internal.go +++ b/pkg/provider/traefik/internal.go @@ -17,7 +17,12 @@ import ( "github.com/traefik/traefik/v3/pkg/tls" ) -const defaultInternalEntryPointName = "traefik" +const ( + // ProviderName is the internal Traefik provider name. + ProviderName = "internal" + + defaultInternalEntryPointName = "traefik" +) var _ provider.Provider = (*Provider)(nil) @@ -38,10 +43,10 @@ func (i *Provider) ThrottleDuration() time.Duration { // Provide allows the provider to provide configurations to traefik using the given configuration channel. func (i *Provider) Provide(configurationChan chan<- dynamic.Message, _ *safe.Pool) error { - ctx := log.With().Str(logs.ProviderName, "internal").Logger().WithContext(context.Background()) + ctx := log.With().Str(logs.ProviderName, ProviderName).Logger().WithContext(context.Background()) configurationChan <- dynamic.Message{ - ProviderName: "internal", + ProviderName: ProviderName, Configuration: i.createConfiguration(ctx), } diff --git a/pkg/server/middleware/observability.go b/pkg/server/middleware/observability.go index 0ffe34165a..41a0ffe677 100644 --- a/pkg/server/middleware/observability.go +++ b/pkg/server/middleware/observability.go @@ -132,6 +132,7 @@ func (o *ObservabilityMgr) observabilityContextHandler(next http.Handler, intern SemConvMetricsEnabled: o.shouldMeterSemConv(internal, config), TracingEnabled: o.shouldTrace(internal, config, otypes.MinimalVerbosity), DetailedTracingEnabled: o.shouldTrace(internal, config, otypes.DetailedVerbosity), + Metadata: config.Metadata, }) } diff --git a/pkg/server/router/router.go b/pkg/server/router/router.go index 594b77efe4..718568b278 100644 --- a/pkg/server/router/router.go +++ b/pkg/server/router/router.go @@ -39,13 +39,14 @@ type serviceManager interface { // Manager A route/router manager. type Manager struct { - routerHandlers map[string]http.Handler - serviceManager serviceManager - observabilityMgr *middleware.ObservabilityMgr - middlewaresBuilder middlewareChainBuilder - conf *runtime.Configuration - tlsManager *tls.Manager - parser httpmuxer.SyntaxParser + routerHandlers map[string]http.Handler + serviceManager serviceManager + observabilityMgr *middleware.ObservabilityMgr + middlewaresBuilder middlewareChainBuilder + conf *runtime.Configuration + tlsManager *tls.Manager + parser httpmuxer.SyntaxParser + providersPrecedence []string } // NewManager creates a new Manager. @@ -55,15 +56,17 @@ func NewManager(conf *runtime.Configuration, observabilityMgr *middleware.ObservabilityMgr, tlsManager *tls.Manager, parser httpmuxer.SyntaxParser, + providersPrecedence []string, ) *Manager { return &Manager{ - routerHandlers: make(map[string]http.Handler), - serviceManager: serviceManager, - observabilityMgr: observabilityMgr, - middlewaresBuilder: middlewaresBuilder, - conf: conf, - tlsManager: tlsManager, - parser: parser, + routerHandlers: make(map[string]http.Handler), + serviceManager: serviceManager, + observabilityMgr: observabilityMgr, + middlewaresBuilder: middlewaresBuilder, + conf: conf, + tlsManager: tlsManager, + parser: parser, + providersPrecedence: providersPrecedence, } } @@ -225,7 +228,7 @@ func (m *Manager) getHTTPRouters(ctx context.Context, entryPoints []string, tls } func (m *Manager) buildEntryPointHandler(ctx context.Context, entryPointName string, configs map[string]*runtime.RouterInfo, config dynamic.RouterObservabilityConfig) (http.Handler, error) { - muxer := httpmuxer.NewMuxer(m.parser) + muxer := httpmuxer.NewMuxer(m.parser, m.providersPrecedence) defaultHandler, err := m.observabilityMgr.BuildEPChain(ctx, entryPointName, false, config).Then(http.NotFoundHandler()) if err != nil { @@ -274,7 +277,7 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, entryPointName str continue } - if err = muxer.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { + if err = muxer.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, providerName(routerName), handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue @@ -456,7 +459,7 @@ func (m *Manager) handleCycle(victimRouter string, path []string) { // buildChildRoutersMuxer creates a muxer for child routers. func (m *Manager) buildChildRoutersMuxer(ctx context.Context, entryPointName string, childRefs []string) (http.Handler, error) { - childMuxer := httpmuxer.NewMuxer(m.parser) + childMuxer := httpmuxer.NewMuxer(m.parser, m.providersPrecedence) // Set a default handler for the child muxer (404 Not Found). childMuxer.SetDefaultHandler(http.NotFoundHandler()) @@ -490,7 +493,7 @@ func (m *Manager) buildChildRoutersMuxer(ctx context.Context, entryPointName str } // Add the child router to the muxer. - if err = childMuxer.AddRoute(childRouter.Rule, childRouter.RuleSyntax, childRouter.Priority, childHandler); err != nil { + if err = childMuxer.AddRoute(childRouter.Rule, childRouter.RuleSyntax, childRouter.Priority, providerName(childName), childHandler); err != nil { childRouter.AddError(err, true) logger.Error().Err(err).Send() continue @@ -506,3 +509,11 @@ func (m *Manager) buildChildRoutersMuxer(ctx context.Context, entryPointName str return childMuxer, nil } + +func providerName(routerName string) string { + parts := strings.Split(routerName, "@") + if len(parts) == 2 { + return parts[1] + } + return "" +} diff --git a/pkg/server/router/router_test.go b/pkg/server/router/router_test.go index 6b058c3ad3..9a97dee9d2 100644 --- a/pkg/server/router/router_test.go +++ b/pkg/server/router/router_test.go @@ -332,7 +332,7 @@ func TestRouterManager_Get(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, []string{}) handlers := routerManager.BuildHandlers(t.Context(), test.entryPoints, false) @@ -720,7 +720,7 @@ func TestRuntimeConfiguration(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, []string{}) _ = routerManager.BuildHandlers(t.Context(), entryPoints, false) _ = routerManager.BuildHandlers(t.Context(), entryPoints, true) @@ -801,7 +801,7 @@ func TestProviderOnMiddlewares(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, []string{}) _ = routerManager.BuildHandlers(t.Context(), entryPoints, false) @@ -856,7 +856,7 @@ func BenchmarkRouterServe(b *testing.B) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(b, err) - routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser) + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, []string{}) handlers := routerManager.BuildHandlers(b.Context(), entryPoints, false) @@ -905,6 +905,155 @@ func BenchmarkService(b *testing.B) { } } +func TestProvidersPrecedence(t *testing.T) { + // Each provider gets its own service with a fake URL whose host encodes the + // provider label. labellingProxyBuilder writes the host back as the X-From + // response header so the test can identify which backend was selected. + // + // Service names must be fully qualified ("svc@") because the + // router manager qualifies every unqualified name with the provider embedded + // in the router's own name ("router@"). + svcFor := func(provider string) *dynamic.Service { + return &dynamic.Service{ + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: dynamic.BalancerStrategyWRR, + Servers: []dynamic.Server{{URL: "http://" + provider}}, + }, + } + } + + testCases := []struct { + desc string + providersPrecedence []string + routersConfig map[string]*dynamic.Router + serviceConfig map[string]*dynamic.Service + expectedFrom string + }{ + { + desc: "kubernetescrd beats kubernetes when listed after it", + providersPrecedence: []string{"kubernetescrd", "kubernetes"}, + routersConfig: map[string]*dynamic.Router{ + // Service names are bare; the manager qualifies them with the + // provider extracted from the router key (@kubernetes / @kubernetescrd). + "router@kubernetes": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + "router@kubernetescrd": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "svc@kubernetes": svcFor("kubernetes"), + "svc@kubernetescrd": svcFor("kubernetescrd"), + }, + expectedFrom: "kubernetescrd", + }, + { + desc: "kubernetes beats kubernetescrd when listed after it", + providersPrecedence: []string{"kubernetes", "kubernetescrd"}, + routersConfig: map[string]*dynamic.Router{ + "router@kubernetes": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + "router@kubernetescrd": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "svc@kubernetes": svcFor("kubernetes"), + "svc@kubernetescrd": svcFor("kubernetescrd"), + }, + expectedFrom: "kubernetes", + }, + { + desc: "higher numeric priority wins regardless of providersPrecedence", + providersPrecedence: []string{"kubernetescrd", "kubernetes"}, + routersConfig: map[string]*dynamic.Router{ + "router@kubernetes": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Priority: 100, + Service: "svc", + }, + "router@kubernetescrd": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Priority: 10, + Service: "svc", + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "svc@kubernetes": svcFor("kubernetes"), + "svc@kubernetescrd": svcFor("kubernetescrd"), + }, + expectedFrom: "kubernetes", + }, + { + desc: "provider not in providersPrecedence loses to any listed provider", + providersPrecedence: []string{"kubernetes"}, + routersConfig: map[string]*dynamic.Router{ + "router@file": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + "router@kubernetes": { + EntryPoints: []string{"web"}, + Rule: "Host(`foo.bar`)", + Service: "svc", + }, + }, + serviceConfig: map[string]*dynamic.Service{ + "svc@file": svcFor("file"), + "svc@kubernetes": svcFor("kubernetes"), + }, + expectedFrom: "kubernetes", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rtConf := runtime.NewConfig(dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Services: test.serviceConfig, + Routers: test.routersConfig, + Middlewares: map[string]*dynamic.Middleware{}, + }, + }) + + transportManager := service.NewTransportManager(nil) + transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) + + serviceManager := service.NewManager(rtConf.Services, nil, nil, transportManager, labellingProxyBuilder{}) + middlewaresBuilder := middleware.NewBuilder(rtConf.Middlewares, serviceManager, nil) + tlsManager := traefiktls.NewManager(nil) + + parser, err := httpmuxer.NewSyntaxParser() + require.NoError(t, err) + + routerManager := NewManager(rtConf, serviceManager, middlewaresBuilder, nil, tlsManager, parser, test.providersPrecedence) + handlers := routerManager.BuildHandlers(t.Context(), []string{"web"}, false) + + w := httptest.NewRecorder() + req := testhelpers.MustNewRequest(http.MethodGet, "http://foo.bar/", nil) + requestdecorator.New(nil).ServeHTTP(w, req, handlers["web"].ServeHTTP) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, test.expectedFrom, w.Header().Get("X-From"), "wrong provider won the route") + }) + } +} + func TestManager_ComputeMultiLayerRouting(t *testing.T) { testCases := []struct { desc string @@ -1449,7 +1598,7 @@ func TestManager_buildChildRoutersMuxer(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, []string{}) // Compute multi-layer routing to populate ChildRefs manager.ParseRouterTree() @@ -1640,7 +1789,7 @@ func TestManager_buildHTTPHandler_WithChildRouters(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, []string{}) // Run ParseRouterTree to validate configuration and populate ChildRefs/errors manager.ParseRouterTree() @@ -1787,7 +1936,7 @@ func TestManager_BuildHandlers_WithChildRouters(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, []string{}) // Compute multi-layer routing to set up parent-child relationships manager.ParseRouterTree() @@ -1942,7 +2091,7 @@ func TestManager_BuildHandlers_Deny(t *testing.T) { parser, err := httpmuxer.NewSyntaxParser() require.NoError(t, err) - manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser) + manager := NewManager(conf, serviceManager, middlewareBuilder, nil, nil, parser, []string{}) // Compute multi-layer routing to set up parent-child relationships manager.ParseRouterTree() @@ -2014,3 +2163,17 @@ func (p proxyBuilderMock) Build(_ string, _ *url.URL, _, _ bool, _ time.Duration func (p proxyBuilderMock) Update(_ map[string]*dynamic.ServersTransport) { panic("implement me") } + +// labellingProxyBuilder builds a handler that writes the target URL host as +// the X-From response header, allowing tests to identify which backend was +// selected by the router. +type labellingProxyBuilder struct{} + +func (l labellingProxyBuilder) Build(_ string, target *url.URL, _, _ bool, _ time.Duration) (http.Handler, error) { + label := target.Host + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-From", label) + }), nil +} + +func (l labellingProxyBuilder) Update(_ map[string]*dynamic.ServersTransport) {} diff --git a/pkg/server/router/tcp/manager.go b/pkg/server/router/tcp/manager.go index c266e84d57..a580644b02 100644 --- a/pkg/server/router/tcp/manager.go +++ b/pkg/server/router/tcp/manager.go @@ -12,6 +12,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/middlewares/snicheck" + "github.com/traefik/traefik/v3/pkg/muxer" httpmuxer "github.com/traefik/traefik/v3/pkg/muxer/http" tcpmuxer "github.com/traefik/traefik/v3/pkg/muxer/tcp" "github.com/traefik/traefik/v3/pkg/observability/logs" @@ -29,12 +30,13 @@ type middlewareBuilder interface { // Manager is a route/router manager. type Manager struct { - serviceManager *tcpservice.Manager - middlewaresBuilder middlewareBuilder - httpHandlers map[string]http.Handler - httpsHandlers map[string]http.Handler - tlsManager *traefiktls.Manager - conf *runtime.Configuration + serviceManager *tcpservice.Manager + middlewaresBuilder middlewareBuilder + httpHandlers map[string]http.Handler + httpsHandlers map[string]http.Handler + tlsManager *traefiktls.Manager + conf *runtime.Configuration + providersPrecedence []string } // NewManager Creates a new Manager. @@ -44,14 +46,16 @@ func NewManager(conf *runtime.Configuration, httpHandlers map[string]http.Handler, httpsHandlers map[string]http.Handler, tlsManager *traefiktls.Manager, + providersPrecedence []string, ) *Manager { return &Manager{ - serviceManager: serviceManager, - middlewaresBuilder: middlewaresBuilder, - httpHandlers: httpHandlers, - httpsHandlers: httpsHandlers, - tlsManager: tlsManager, - conf: conf, + serviceManager: serviceManager, + middlewaresBuilder: middlewaresBuilder, + httpHandlers: httpHandlers, + httpsHandlers: httpsHandlers, + tlsManager: tlsManager, + conf: conf, + providersPrecedence: providersPrecedence, } } @@ -100,7 +104,7 @@ type nameAndConfig struct { func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string]*runtime.TCPRouterInfo, configsHTTP map[string]*runtime.RouterInfo, handlerHTTP, handlerHTTPS http.Handler) (*Router, error) { // Build a new Router. - router, err := NewRouter() + router, err := NewRouter(m.providersPrecedence) if err != nil { return nil, err } @@ -179,7 +183,9 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string // # When a request for "/foo" comes, even though it won't be routed by httpRouter2, // # if its SNI is set to foo.com, myTLSOptions will be used for the TLS connection. // # Otherwise, it will fallback to the default TLS config. - logger.Warn().Msgf("No domain found in rule %v, the TLS options applied for this router will depend on the SNI of each request", routerHTTPConfig.Rule) + if tlsOptionsName != traefiktls.DefaultTLSConfigName { + logger.Warn().Msgf("No domain found in rule %v, the TLS option %s cannot be applied", routerHTTPConfig.Rule, tlsOptionsName) + } } // Even though the error is seemingly ignored (aside from logging it), @@ -321,7 +327,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.muxerTCP.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCP.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, providerName(routerName), handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -331,7 +337,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.RuleSyntax, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, providerName(routerName), handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -339,7 +345,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim } for _, domain := range domains { - if httpmuxer.IsASCII(domain) { + if muxer.IsASCII(domain) { continue } @@ -365,7 +371,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.RuleSyntax, routerConfig.Priority, &brokenTLSRouter{}); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, providerName(routerName), &brokenTLSRouter{}); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -399,7 +405,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.RuleSyntax, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, providerName(routerName), handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue @@ -427,3 +433,11 @@ func (m *Manager) buildTCPHandler(ctx context.Context, router *runtime.TCPRouter return tcp.NewChain().Extend(*mHandler).Then(sHandler) } + +func providerName(routerName string) string { + parts := strings.Split(routerName, "@") + if len(parts) == 2 { + return parts[1] + } + return "" +} diff --git a/pkg/server/router/tcp/manager_test.go b/pkg/server/router/tcp/manager_test.go index 32b3218e07..adcff5ef04 100644 --- a/pkg/server/router/tcp/manager_test.go +++ b/pkg/server/router/tcp/manager_test.go @@ -367,7 +367,7 @@ func TestRuntimeConfiguration(t *testing.T) { middlewaresBuilder := tcpmiddleware.NewBuilder(conf.TCPMiddlewares) routerManager := NewManager(conf, serviceManager, middlewaresBuilder, - nil, nil, tlsManager) + nil, nil, tlsManager, nil) _ = routerManager.BuildHandlers(t.Context(), entryPoints) @@ -668,7 +668,7 @@ func TestDomainFronting(t *testing.T) { middlewaresBuilder := tcpmiddleware.NewBuilder(conf.TCPMiddlewares) - routerManager := NewManager(conf, serviceManager, middlewaresBuilder, nil, httpsHandler, tlsManager) + routerManager := NewManager(conf, serviceManager, middlewaresBuilder, nil, httpsHandler, tlsManager, nil) routers := routerManager.BuildHandlers(t.Context(), entryPoints) diff --git a/pkg/server/router/tcp/router.go b/pkg/server/router/tcp/router.go index 8fa150dcc4..0cce26e6fe 100644 --- a/pkg/server/router/tcp/router.go +++ b/pkg/server/router/tcp/router.go @@ -51,18 +51,18 @@ type Router struct { } // NewRouter returns a new TCP router. -func NewRouter() (*Router, error) { - muxTCP, err := tcpmuxer.NewMuxer() +func NewRouter(providersPrecedence []string) (*Router, error) { + muxTCP, err := tcpmuxer.NewMuxer(providersPrecedence) if err != nil { return nil, err } - muxTCPTLS, err := tcpmuxer.NewMuxer() + muxTCPTLS, err := tcpmuxer.NewMuxer(providersPrecedence) if err != nil { return nil, err } - muxHTTPS, err := tcpmuxer.NewMuxer() + muxHTTPS, err := tcpmuxer.NewMuxer(providersPrecedence) if err != nil { return nil, err } @@ -230,8 +230,8 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) { } // 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) +func (r *Router) AddTCPRoute(rule string, priority int, providerName string, target tcp.Handler) error { + return r.muxerTCP.AddRoute(rule, "", priority, providerName, target) } // AddHTTPTLSConfig defines a handler for a given sniHost and sets the matching tlsConfig. @@ -273,8 +273,10 @@ func (r *Router) SetHTTPSForwarder(handler tcp.Handler) { } } - rule := "HostSNI(`" + sniHost + "`)" - if err := r.muxerHTTPS.AddRoute(rule, "", tcpmuxer.GetRulePriority(rule), tcpHandler); err != nil { + rule := fmt.Sprintf(`HostSNI(%q)`, sniHost) + // As the hostHTTPTLSConfig contains only one TLS config per SNI, + // there is no conflict thus the provider name can be passed as empty as no tie-break is needed. + 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 f5d7cb1b65..a7d7034994 100644 --- a/pkg/server/router/tcp/router_test.go +++ b/pkg/server/router/tcp/router_test.go @@ -27,47 +27,6 @@ import ( "github.com/traefik/traefik/v3/pkg/types" ) -type applyRouter func(conf *runtime.Configuration) - -type checkRouter func(addr string, timeout time.Duration) error - -type httpForwarder struct { - net.Listener - - connChan chan net.Conn - errChan chan error -} - -func newHTTPForwarder(ln net.Listener) *httpForwarder { - return &httpForwarder{ - Listener: ln, - connChan: make(chan net.Conn), - errChan: make(chan error), - } -} - -// Close closes the Listener. -func (h *httpForwarder) Close() error { - h.errChan <- http.ErrServerClosed - - return nil -} - -// ServeTCP uses the connection to serve it later in "Accept". -func (h *httpForwarder) ServeTCP(conn tcp2.WriteCloser) { - h.connChan <- conn -} - -// Accept retrieves a served connection in ServeTCP. -func (h *httpForwarder) Accept() (net.Conn, error) { - select { - case conn := <-h.connChan: - return conn, nil - case err := <-h.errChan: - return nil, err - } -} - // Test_Routing aims to settle the behavior between routers of different types on the same TCP entryPoint. // It has been introduced as a regression test following a fix on the v2.7 TCP Muxer. // @@ -202,7 +161,7 @@ func Test_Routing(t *testing.T) { middlewaresBuilder := tcpmiddleware.NewBuilder(conf.TCPMiddlewares) manager := NewManager(conf, serviceManager, middlewaresBuilder, - nil, nil, tlsManager) + nil, nil, tlsManager, nil) type checkCase struct { checkRouter @@ -701,7 +660,7 @@ func Test_Routing(t *testing.T) { } func Test_Router_acmeTLSALPNHandlerTimeout(t *testing.T) { - router, err := NewRouter() + router, err := NewRouter(nil) require.NoError(t, err) router.httpsTLSConfig = &tls.Config{} @@ -758,6 +717,272 @@ func Test_Router_acmeTLSALPNHandlerTimeout(t *testing.T) { } } +// Test_clientHelloInfo_oversizedRecordLength verifies that clientHelloInfo +// does not block or allocate excessive memory when a client sends a TLS +// record header with a maliciously large record length (up to 0xFFFF). +// +// Without the fix, clientHelloInfo allocates a ~65KB bufio.Reader and blocks +// on Peek(65540), waiting for bytes that never arrive (until readTimeout). +// With the fix, records exceeding the TLS maximum plaintext size (16384) +// are rejected immediately. +func Test_clientHelloInfo_oversizedRecordLength(t *testing.T) { + testCases := []struct { + desc string + recLen uint16 + }{ + { + desc: "max uint16 record length (0xFFFF)", + recLen: 0xFFFF, + }, + { + desc: "just above TLS maximum (18433)", + recLen: 18433, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + type result struct { + hello *clientHello + err error + } + resultCh := make(chan result, 1) + + go func() { + pConn := &peekConn{reader: bufio.NewReader(serverConn)} + hello, err := clientHelloInfo(pConn) + resultCh <- result{hello, err} + }() + + // Send a TLS record header with an oversized record length. + // Only the 5-byte header is sent; the client then stalls. + hdr := []byte{ + 0x16, // Content Type: Handshake + 0x03, 0x03, // Version: TLS 1.2 + byte(test.recLen >> 8), // Length high byte + byte(test.recLen & 0xFF), // Length low byte + } + _, err := clientConn.Write(hdr) + require.NoError(t, err) + + // Without the fix, clientHelloInfo blocks on Peek(recLen+5) + // since only 5 bytes are available. The test would time out. + // With the fix, it returns immediately. + select { + case r := <-resultCh: + require.Error(t, r.err) + case <-time.After(5 * time.Second): + t.Fatal("clientHelloInfo blocked on oversized TLS record length — recLen is not capped") + } + }) + } +} + +// Test_clientHelloInfo_tlsRecordFragmentation documents a known limitation: +// clientHelloInfo only reads a single TLS record. When a ClientHello handshake +// message is split across multiple TLS records (RFC 5246 §6.2.1), the SNI cannot +// be extracted, leaving serverName empty and allowing SNI-based routing to be bypassed. +func Test_clientHelloInfo_tlsRecordFragmentation(t *testing.T) { + serverName := "foo.example.com" + record := buildClientHelloRecord(t, serverName) + + const hdrLen = 5 + payload := record[hdrLen:] + + ver1, ver2 := record[1], record[2] + + var recordsData bytes.Buffer + for _, part := range [][]byte{payload[:len(serverName)/2], payload[len(serverName)/2:]} { + recordsData.WriteByte(0x16) + recordsData.WriteByte(ver1) + recordsData.WriteByte(ver2) + recordsData.WriteByte(byte(len(part) >> 8)) + recordsData.WriteByte(byte(len(part))) + recordsData.Write(part) + } + + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + + type result struct { + hello *clientHello + err error + } + resultCh := make(chan result, 1) + + go func() { + pConn := &peekConn{reader: bufio.NewReader(serverConn)} + hello, err := clientHelloInfo(pConn) + resultCh <- result{hello, err} + }() + + _, err := clientConn.Write(recordsData.Bytes()) + require.NoError(t, err) + _ = clientConn.Close() + + select { + case r := <-resultCh: + require.NoError(t, r.err) + require.NotNil(t, r.hello) + assert.True(t, r.hello.isTLS) + assert.Equal(t, serverName, r.hello.serverName) + case <-time.After(5 * time.Second): + t.Fatal("clientHelloInfo blocked") + } +} + +func TestPostgresTLSTermination(t *testing.T) { + certPEM, keyPEM, err := generate.KeyPair("test.localhost", time.Time{}) + require.NoError(t, err) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + router, err := NewRouter(nil) + require.NoError(t, err) + + // Register a TCPTLS route (TLS termination, not passthrough) with a TLSHandler. + // The TLSHandler wraps the actual handler, performing the TLS handshake. + err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, "", &tcp2.TLSHandler{ + Config: tlsConf, + Next: tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { + _, _ = conn.Write([]byte("OK")) + _ = conn.Close() + }), + }) + require.NoError(t, err) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + conn, err := ln.Accept() + require.NoError(t, err) + + tcpConn := conn.(*net.TCPConn) + router.ServeTCP(tcpConn) + }() + + clientConn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = clientConn.Close() }) + + // Step 1: Client sends PostgresStartTLSMsg (SSLRequest). + _, err = clientConn.Write(PostgresStartTLSMsg) + require.NoError(t, err) + + // Step 2: Client receives PostgresStartTLSReply ('S'). + reply := make([]byte, 1) + _, err = io.ReadFull(clientConn, reply) + require.NoError(t, err) + require.Equal(t, PostgresStartTLSReply, reply) + + // Step 3: Client performs TLS handshake. + tlsClient := tls.Client(clientConn, &tls.Config{ + ServerName: "test.localhost", + InsecureSkipVerify: true, + }) + require.NoError(t, tlsClient.Handshake()) + t.Cleanup(func() { _ = tlsClient.Close() }) + + // Step 4: Read the response from the handler through the TLS connection. + buf := make([]byte, 256) + n, err := tlsClient.Read(buf) + require.NoError(t, err) + assert.Equal(t, "OK", string(buf[:n])) +} + +func TestPostgresTLSPassthrough(t *testing.T) { + certPEM, keyPEM, err := generate.KeyPair("test.localhost", time.Time{}) + require.NoError(t, err) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + + tlsConf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + router, err := NewRouter(nil) + require.NoError(t, err) + + // Register a TCPTLS route (TLS passthrough) with a tcp.Handler. + err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, "", tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { + // First we should receive the PostgresStartTLSMsg. + buf := make([]byte, len(PostgresStartTLSMsg)) + _, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, PostgresStartTLSMsg, buf) + + // Next we should answer with the PostgresStartTLSReply. + _, err = conn.Write(PostgresStartTLSReply) + require.NoError(t, err) + + // Then we should do the TLS handshake. + tlsConn := tls.Server(conn, tlsConf) + require.NoError(t, tlsConn.Handshake()) + + // Finally we write the response through the TLS connection. + _, err = tlsConn.Write([]byte("OK")) + require.NoError(t, err) + })) + require.NoError(t, err) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + conn, err := ln.Accept() + require.NoError(t, err) + + tcpConn := conn.(*net.TCPConn) + router.ServeTCP(tcpConn) + }() + + clientConn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = clientConn.Close() }) + + // Step 1: Client sends PostgresStartTLSMsg (SSLRequest). + _, err = clientConn.Write(PostgresStartTLSMsg) + require.NoError(t, err) + + // Step 2: Client receives PostgresStartTLSReply ('S'). + reply := make([]byte, 1) + _, err = io.ReadFull(clientConn, reply) + require.NoError(t, err) + require.Equal(t, PostgresStartTLSReply, reply) + + // Step 3: Client performs TLS handshake. + tlsClient := tls.Client(clientConn, &tls.Config{ + ServerName: "test.localhost", + InsecureSkipVerify: true, + }) + require.NoError(t, tlsClient.Handshake()) + t.Cleanup(func() { _ = tlsClient.Close() }) + + // Step 4: Read the response from the handler through the TLS connection. + buf := make([]byte, 256) + n, err := tlsClient.Read(buf) + require.NoError(t, err) + assert.Equal(t, "OK", string(buf[:n])) +} + // routerTCPCatchAll configures a TCP CatchAll No TLS - HostSNI(`*`) router. func routerTCPCatchAll(conf *runtime.Configuration) { conf.TCPRouters["tcp-catchall"] = &runtime.TCPRouterInfo{ @@ -1084,129 +1309,6 @@ func checkHTTPSTLS12(addr string, timeout time.Duration) error { return checkHTTPS(addr, timeout, tls.VersionTLS12) } -// Test_clientHelloInfo_oversizedRecordLength verifies that clientHelloInfo -// does not block or allocate excessive memory when a client sends a TLS -// record header with a maliciously large record length (up to 0xFFFF). -// -// Without the fix, clientHelloInfo allocates a ~65KB bufio.Reader and blocks -// on Peek(65540), waiting for bytes that never arrive (until readTimeout). -// With the fix, records exceeding the TLS maximum plaintext size (16384) -// are rejected immediately. -func Test_clientHelloInfo_oversizedRecordLength(t *testing.T) { - testCases := []struct { - desc string - recLen uint16 - }{ - { - desc: "max uint16 record length (0xFFFF)", - recLen: 0xFFFF, - }, - { - desc: "just above TLS maximum (18433)", - recLen: 18433, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - serverConn, clientConn := net.Pipe() - defer serverConn.Close() - defer clientConn.Close() - - type result struct { - hello *clientHello - err error - } - resultCh := make(chan result, 1) - - go func() { - pConn := &peekConn{reader: bufio.NewReader(serverConn)} - hello, err := clientHelloInfo(pConn) - resultCh <- result{hello, err} - }() - - // Send a TLS record header with an oversized record length. - // Only the 5-byte header is sent; the client then stalls. - hdr := []byte{ - 0x16, // Content Type: Handshake - 0x03, 0x03, // Version: TLS 1.2 - byte(test.recLen >> 8), // Length high byte - byte(test.recLen & 0xFF), // Length low byte - } - _, err := clientConn.Write(hdr) - require.NoError(t, err) - - // Without the fix, clientHelloInfo blocks on Peek(recLen+5) - // since only 5 bytes are available. The test would time out. - // With the fix, it returns immediately. - select { - case r := <-resultCh: - require.Error(t, r.err) - case <-time.After(5 * time.Second): - t.Fatal("clientHelloInfo blocked on oversized TLS record length — recLen is not capped") - } - }) - } -} - -// Test_clientHelloInfo_tlsRecordFragmentation documents a known limitation: -// clientHelloInfo only reads a single TLS record. When a ClientHello handshake -// message is split across multiple TLS records (RFC 5246 §6.2.1), the SNI cannot -// be extracted, leaving serverName empty and allowing SNI-based routing to be bypassed. -func Test_clientHelloInfo_tlsRecordFragmentation(t *testing.T) { - serverName := "foo.example.com" - record := buildClientHelloRecord(t, serverName) - - const hdrLen = 5 - payload := record[hdrLen:] - - ver1, ver2 := record[1], record[2] - - var recordsData bytes.Buffer - for _, part := range [][]byte{payload[:len(serverName)/2], payload[len(serverName)/2:]} { - recordsData.WriteByte(0x16) - recordsData.WriteByte(ver1) - recordsData.WriteByte(ver2) - recordsData.WriteByte(byte(len(part) >> 8)) - recordsData.WriteByte(byte(len(part))) - recordsData.Write(part) - } - - serverConn, clientConn := net.Pipe() - t.Cleanup(func() { - _ = serverConn.Close() - _ = clientConn.Close() - }) - - type result struct { - hello *clientHello - err error - } - resultCh := make(chan result, 1) - - go func() { - pConn := &peekConn{reader: bufio.NewReader(serverConn)} - hello, err := clientHelloInfo(pConn) - resultCh <- result{hello, err} - }() - - _, err := clientConn.Write(recordsData.Bytes()) - require.NoError(t, err) - _ = clientConn.Close() - - select { - case r := <-resultCh: - require.NoError(t, r.err) - require.NotNil(t, r.hello) - assert.True(t, r.hello.isTLS) - assert.Equal(t, serverName, r.hello.serverName) - case <-time.After(5 * time.Second): - t.Fatal("clientHelloInfo blocked") - } -} - // buildClientHelloRecord captures a real TLS ClientHello record from Go's TLS stack // for the given serverName. // It returns the raw record bytes and the byte offset of the SNI value within those bytes. @@ -1237,145 +1339,43 @@ func buildClientHelloRecord(t *testing.T, serverName string) []byte { return record } -func TestPostgresTLSTermination(t *testing.T) { - certPEM, keyPEM, err := generate.KeyPair("test.localhost", time.Time{}) - require.NoError(t, err) +type applyRouter func(conf *runtime.Configuration) - cert, err := tls.X509KeyPair(certPEM, keyPEM) - require.NoError(t, err) +type checkRouter func(addr string, timeout time.Duration) error - tlsConf := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } +type httpForwarder struct { + net.Listener - router, err := NewRouter() - require.NoError(t, err) - - // Register a TCPTLS route (TLS termination, not passthrough) with a TLSHandler. - // The TLSHandler wraps the actual handler, performing the TLS handshake. - err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, &tcp2.TLSHandler{ - Config: tlsConf, - Next: tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { - _, _ = conn.Write([]byte("OK")) - _ = conn.Close() - }), - }) - require.NoError(t, err) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { _ = ln.Close() }) - - go func() { - conn, err := ln.Accept() - require.NoError(t, err) - - tcpConn := conn.(*net.TCPConn) - router.ServeTCP(tcpConn) - }() - - clientConn, err := net.Dial("tcp", ln.Addr().String()) - require.NoError(t, err) - t.Cleanup(func() { _ = clientConn.Close() }) - - // Step 1: Client sends PostgresStartTLSMsg (SSLRequest). - _, err = clientConn.Write(PostgresStartTLSMsg) - require.NoError(t, err) - - // Step 2: Client receives PostgresStartTLSReply ('S'). - reply := make([]byte, 1) - _, err = io.ReadFull(clientConn, reply) - require.NoError(t, err) - require.Equal(t, PostgresStartTLSReply, reply) - - // Step 3: Client performs TLS handshake. - tlsClient := tls.Client(clientConn, &tls.Config{ - ServerName: "test.localhost", - InsecureSkipVerify: true, - }) - require.NoError(t, tlsClient.Handshake()) - t.Cleanup(func() { _ = tlsClient.Close() }) - - // Step 4: Read the response from the handler through the TLS connection. - buf := make([]byte, 256) - n, err := tlsClient.Read(buf) - require.NoError(t, err) - assert.Equal(t, "OK", string(buf[:n])) + connChan chan net.Conn + errChan chan error } -func TestPostgresTLSPassthrough(t *testing.T) { - certPEM, keyPEM, err := generate.KeyPair("test.localhost", time.Time{}) - require.NoError(t, err) - - cert, err := tls.X509KeyPair(certPEM, keyPEM) - require.NoError(t, err) - - tlsConf := &tls.Config{ - Certificates: []tls.Certificate{cert}, +func newHTTPForwarder(ln net.Listener) *httpForwarder { + return &httpForwarder{ + Listener: ln, + connChan: make(chan net.Conn), + errChan: make(chan error), + } +} + +// Close closes the Listener. +func (h *httpForwarder) Close() error { + h.errChan <- http.ErrServerClosed + + return nil +} + +// ServeTCP uses the connection to serve it later in "Accept". +func (h *httpForwarder) ServeTCP(conn tcp2.WriteCloser) { + h.connChan <- conn +} + +// Accept retrieves a served connection in ServeTCP. +func (h *httpForwarder) Accept() (net.Conn, error) { + select { + case conn := <-h.connChan: + return conn, nil + case err := <-h.errChan: + return nil, err } - - router, err := NewRouter() - require.NoError(t, err) - - // Register a TCPTLS route (TLS passthrough) with a tcp.Handler. - err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { - // First we should receive the PostgresStartTLSMsg. - buf := make([]byte, len(PostgresStartTLSMsg)) - _, err := conn.Read(buf) - require.NoError(t, err) - assert.Equal(t, PostgresStartTLSMsg, buf) - - // Next we should answer with the PostgresStartTLSReply. - _, err = conn.Write(PostgresStartTLSReply) - require.NoError(t, err) - - // Then we should do the TLS handshake. - tlsConn := tls.Server(conn, tlsConf) - require.NoError(t, tlsConn.Handshake()) - - // Finally we write the response through the TLS connection. - _, err = tlsConn.Write([]byte("OK")) - require.NoError(t, err) - })) - require.NoError(t, err) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { _ = ln.Close() }) - - go func() { - conn, err := ln.Accept() - require.NoError(t, err) - - tcpConn := conn.(*net.TCPConn) - router.ServeTCP(tcpConn) - }() - - clientConn, err := net.Dial("tcp", ln.Addr().String()) - require.NoError(t, err) - t.Cleanup(func() { _ = clientConn.Close() }) - - // Step 1: Client sends PostgresStartTLSMsg (SSLRequest). - _, err = clientConn.Write(PostgresStartTLSMsg) - require.NoError(t, err) - - // Step 2: Client receives PostgresStartTLSReply ('S'). - reply := make([]byte, 1) - _, err = io.ReadFull(clientConn, reply) - require.NoError(t, err) - require.Equal(t, PostgresStartTLSReply, reply) - - // Step 3: Client performs TLS handshake. - tlsClient := tls.Client(clientConn, &tls.Config{ - ServerName: "test.localhost", - InsecureSkipVerify: true, - }) - require.NoError(t, tlsClient.Handshake()) - t.Cleanup(func() { _ = tlsClient.Close() }) - - // Step 4: Read the response from the handler through the TLS connection. - buf := make([]byte, 256) - n, err := tlsClient.Read(buf) - require.NoError(t, err) - assert.Equal(t, "OK", string(buf[:n])) } diff --git a/pkg/server/routerfactory.go b/pkg/server/routerfactory.go index 503a5c23a4..3207992185 100644 --- a/pkg/server/routerfactory.go +++ b/pkg/server/routerfactory.go @@ -39,7 +39,8 @@ type RouterFactory struct { cancelPrevState func() - parser httpmuxer.SyntaxParser + parser httpmuxer.SyntaxParser + providersPrecedence []string } // NewRouterFactory creates a new RouterFactory. @@ -77,16 +78,22 @@ func NewRouterFactory(staticConfiguration static.Configuration, managerFactory * return nil, fmt.Errorf("creating parser: %w", err) } + var providersPrecedence []string + if staticConfiguration.Providers != nil { + providersPrecedence = staticConfiguration.Providers.Precedence + } + return &RouterFactory{ - entryPointsTCP: entryPointsTCP, - entryPointsUDP: entryPointsUDP, - managerFactory: managerFactory, - observabilityMgr: observabilityMgr, - tlsManager: tlsManager, - pluginBuilder: pluginBuilder, - dialerManager: dialerManager, - allowACMEByPass: allowACMEByPass, - parser: parser, + entryPointsTCP: entryPointsTCP, + entryPointsUDP: entryPointsUDP, + managerFactory: managerFactory, + observabilityMgr: observabilityMgr, + tlsManager: tlsManager, + pluginBuilder: pluginBuilder, + dialerManager: dialerManager, + allowACMEByPass: allowACMEByPass, + parser: parser, + providersPrecedence: providersPrecedence, }, nil } @@ -106,7 +113,7 @@ func (f *RouterFactory) CreateRouters(rtConf *runtime.Configuration) (map[string serviceManager.SetMiddlewareChainBuilder(middlewaresBuilder) - routerManager := router.NewManager(rtConf, serviceManager, middlewaresBuilder, f.observabilityMgr, f.tlsManager, f.parser) + routerManager := router.NewManager(rtConf, serviceManager, middlewaresBuilder, f.observabilityMgr, f.tlsManager, f.parser, f.providersPrecedence) routerManager.ParseRouterTree() @@ -120,7 +127,7 @@ func (f *RouterFactory) CreateRouters(rtConf *runtime.Configuration) (map[string middlewaresTCPBuilder := tcpmiddleware.NewBuilder(rtConf.TCPMiddlewares) - rtTCPManager := tcprouter.NewManager(rtConf, svcTCPManager, middlewaresTCPBuilder, handlersNonTLS, handlersTLS, f.tlsManager) + rtTCPManager := tcprouter.NewManager(rtConf, svcTCPManager, middlewaresTCPBuilder, handlersNonTLS, handlersTLS, f.tlsManager, f.providersPrecedence) routersTCP := rtTCPManager.BuildHandlers(ctx, f.entryPointsTCP) for ep, r := range routersTCP { diff --git a/pkg/server/routerfactory_test.go b/pkg/server/routerfactory_test.go index a287c22eef..11f9dd44cb 100644 --- a/pkg/server/routerfactory_test.go +++ b/pkg/server/routerfactory_test.go @@ -54,7 +54,7 @@ func TestReuseService(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -180,7 +180,7 @@ func TestServerResponseEmptyBackend(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -226,7 +226,7 @@ func TestInternalServices(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -275,8 +275,8 @@ func TestRecursionService(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) tlsManager := tls.NewManager(nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, tlsManager) dialerManager := tcp.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) diff --git a/pkg/server/server_entrypoint_tcp.go b/pkg/server/server_entrypoint_tcp.go index fe82cc7dff..c4f8f2098f 100644 --- a/pkg/server/server_entrypoint_tcp.go +++ b/pkg/server/server_entrypoint_tcp.go @@ -187,7 +187,7 @@ func NewTCPEntryPoint(ctx context.Context, name string, config *static.EntryPoin return nil, fmt.Errorf("building listener: %w", err) } - rt, err := tcprouter.NewRouter() + rt, err := tcprouter.NewRouter(nil) if err != nil { return nil, fmt.Errorf("creating TCP router: %w", err) } diff --git a/pkg/server/server_entrypoint_tcp_http3_test.go b/pkg/server/server_entrypoint_tcp_http3_test.go index 7468219581..b4d982303c 100644 --- a/pkg/server/server_entrypoint_tcp_http3_test.go +++ b/pkg/server/server_entrypoint_tcp_http3_test.go @@ -97,7 +97,7 @@ func TestHTTP3AdvertisedPort(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.AddHTTPTLSConfig("*", &tls.Config{ @@ -159,7 +159,7 @@ func TestHTTP30RTT(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.AddHTTPTLSConfig("example.com", &tls.Config{ diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index 7e3e0b1125..1417ff219e 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -24,7 +24,7 @@ import ( ) func TestShutdownHijacked(t *testing.T) { - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -40,7 +40,7 @@ func TestShutdownHijacked(t *testing.T) { } func TestShutdownHTTP(t *testing.T) { - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -52,10 +52,10 @@ func TestShutdownHTTP(t *testing.T) { } func TestShutdownTCP(t *testing.T) { - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) - err = router.AddTCPRoute("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 @@ -177,7 +177,7 @@ func TestReadTimeoutWithoutFirstByte(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -216,7 +216,7 @@ func TestReadTimeoutWithFirstByte(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -258,7 +258,7 @@ func TestKeepAliveMaxRequests(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -306,7 +306,7 @@ func TestKeepAliveMaxTime(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { @@ -350,7 +350,7 @@ func TestKeepAliveH2c(t *testing.T) { }, nil, nil) require.NoError(t, err) - router, err := tcprouter.NewRouter() + router, err := tcprouter.NewRouter(nil) require.NoError(t, err) router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { diff --git a/pkg/server/service/managerfactory.go b/pkg/server/service/managerfactory.go index a850ec745c..3d22ed38d1 100644 --- a/pkg/server/service/managerfactory.go +++ b/pkg/server/service/managerfactory.go @@ -12,6 +12,7 @@ import ( "github.com/traefik/traefik/v3/pkg/observability/metrics" "github.com/traefik/traefik/v3/pkg/safe" "github.com/traefik/traefik/v3/pkg/server/middleware" + "github.com/traefik/traefik/v3/pkg/tls" ) // ManagerFactory a factory of service manager. @@ -32,7 +33,7 @@ type ManagerFactory struct { } // NewManagerFactory creates a new ManagerFactory. -func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler) *ManagerFactory { +func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler, tlsManager *tls.Manager) *ManagerFactory { factory := &ManagerFactory{ observabilityMgr: observabilityMgr, routinesPool: routinesPool, @@ -42,7 +43,7 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s } if staticConfiguration.API != nil { - apiRouterBuilder := api.NewBuilder(staticConfiguration) + apiRouterBuilder := api.NewBuilder(staticConfiguration, tlsManager) if staticConfiguration.API.Dashboard { factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath} diff --git a/pkg/tls/certificate_store.go b/pkg/tls/certificate_store.go index 5321f98aa4..5f7478f311 100644 --- a/pkg/tls/certificate_store.go +++ b/pkg/tls/certificate_store.go @@ -281,12 +281,6 @@ func matchDomain(serverName, certDomain string) bool { } labels := strings.Split(serverName, ".") - for i := range labels { - labels[i] = "*" - candidate := strings.Join(labels, ".") - if certDomain == candidate { - return true - } - } - return false + labels[0] = "*" + return certDomain == strings.Join(labels, ".") } diff --git a/pkg/tls/tlsmanager.go b/pkg/tls/tlsmanager.go index 9003c21f34..cf58922a28 100644 --- a/pkg/tls/tlsmanager.go +++ b/pkg/tls/tlsmanager.go @@ -2,8 +2,10 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "errors" "fmt" "hash/fnv" @@ -295,8 +297,11 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) { // GetServerCertificates returns all certificates from the default store, // as well as the user-defined default certificate (if it exists). -func (m *Manager) GetServerCertificates() []*x509.Certificate { - var certificates []*x509.Certificate +func (m *Manager) GetServerCertificates() map[string]*x509.Certificate { + m.lock.RLock() + defer m.lock.RUnlock() + + certificates := make(map[string]*x509.Certificate) // The default store is the only relevant, because it is the only one configurable. defaultStore, ok := m.stores[DefaultTLSStoreName] @@ -306,28 +311,35 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate { // We iterate over all the certificates. if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil { - for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) { - x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0]) - if err != nil { - continue + certs, ok := defaultStore.DynamicCerts.Get().(map[string]*CertificateData) + if ok { + for _, cert := range certs { + // Use Leaf if available (it should always be populated by parseCertificate) + if cert.Certificate.Leaf == nil { + log.Warn().Msg("TLS: certificate Leaf is nil, skipping certificate in API response") + continue + } + hash := sha256.Sum256(cert.Certificate.Leaf.Raw) + fingerprint := hex.EncodeToString(hash[:]) + certificates[fingerprint] = cert.Certificate.Leaf } - - certificates = append(certificates, x509Cert) } } if defaultStore.DefaultCertificate != nil { - x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0]) - if err != nil { + if defaultStore.DefaultCertificate.Certificate.Leaf == nil { + log.Warn().Msg("TLS: default certificate Leaf is nil, skipping in API response") return certificates } // Excluding the generated Traefik default certificate. - if x509Cert.Subject.CommonName == generate.DefaultDomain { + if defaultStore.DefaultCertificate.Certificate.Leaf.Subject.CommonName == generate.DefaultDomain { return certificates } - certificates = append(certificates, x509Cert) + hash := sha256.Sum256(defaultStore.DefaultCertificate.Certificate.Leaf.Raw) + fingerprint := hex.EncodeToString(hash[:]) + certificates[fingerprint] = defaultStore.DefaultCertificate.Certificate.Leaf } return certificates diff --git a/script/gcg/traefik-bugfix.toml b/script/gcg/traefik-bugfix.toml index ab78d053f9..f54f5962b4 100644 --- a/script/gcg/traefik-bugfix.toml +++ b/script/gcg/traefik-bugfix.toml @@ -4,11 +4,11 @@ RepositoryName = "traefik" OutputType = "file" FileName = "traefik_changelog.md" -# example new bugfix v3.6.12 +# example new bugfix v3.6.13 CurrentRef = "v3.6" -PreviousRef = "v3.6.11" +PreviousRef = "v3.6.12" BaseBranch = "v3.6" -FutureCurrentRefName = "v3.6.12" +FutureCurrentRefName = "v3.6.13" ThresholdPreviousRef = 10000 ThresholdCurrentRef = 10000 diff --git a/script/gcg/traefik-rc-first.toml b/script/gcg/traefik-rc-first.toml index 7be62de85f..67d815f655 100644 --- a/script/gcg/traefik-rc-first.toml +++ b/script/gcg/traefik-rc-first.toml @@ -4,11 +4,11 @@ RepositoryName = "traefik" OutputType = "file" FileName = "traefik_changelog.md" -# example RC1 of v3.7.0-ea.1 +# example RC1 of v3.7.0-rc.1 CurrentRef = "master" PreviousRef = "v3.6.0-rc1" BaseBranch = "master" -FutureCurrentRefName = "v3.7.0-ea.1" +FutureCurrentRefName = "v3.7.0-rc.1" ThresholdPreviousRef = 10 ThresholdCurrentRef = 10 diff --git a/webui/package.json b/webui/package.json index 4d6e64f3cc..3de488ab6c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -49,7 +49,7 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", - "@traefiklabs/faency": "12.0.4", + "@traefik-labs/faency": "12.0.7", "@types/lodash": "^4.17.16", "@types/node": "^22.15.18", "@types/react": "^18.2.0", @@ -71,7 +71,7 @@ "globals": "^16.0.0", "jest-extended": "^4.0.2", "jsdom": "^24.0.0", - "lodash": "^4.17.21", + "lodash": "4.18.1", "msw": "^2.1.7", "query-string": "^6.9.0", "react": "^18.2.0", @@ -101,5 +101,9 @@ "public" ] }, - "packageManager": "yarn@4.12.0" + "packageManager": "yarn@4.13.0", + "resolutions": { + "lodash": "4.18.1", + "lodash-es": "4.18.1" + } } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8e256437f3..9eef5bfc8b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,4 +1,4 @@ -import { globalCss, Box, darkTheme, FaencyProvider, lightTheme } from '@traefiklabs/faency' +import { globalCss, Box, darkTheme, FaencyProvider, lightTheme } from '@traefik-labs/faency' import { Suspense, useContext, useEffect } from 'react' import { HelmetProvider } from 'react-helmet-async' import { HashRouter, Navigate, Route, Routes as RouterRoutes, useLocation } from 'react-router-dom' @@ -10,7 +10,7 @@ import fetch from './libs/fetch' import { VersionProvider } from 'contexts/version' import { useIsDarkMode } from 'hooks/use-theme' import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper' -import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages } from 'pages' +import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages, CertificatesPages } from 'pages' import { DashboardSkeleton } from 'pages/dashboard/Dashboard' import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext' @@ -48,6 +48,8 @@ export const Routes = () => { } /> + } /> + } /> } /> } /> } /> diff --git a/webui/src/components/CopyableText.tsx b/webui/src/components/CopyableText.tsx index 42c8f1bf2d..8b5a1dd298 100644 --- a/webui/src/components/CopyableText.tsx +++ b/webui/src/components/CopyableText.tsx @@ -1,4 +1,4 @@ -import { CSS, Text } from '@traefiklabs/faency' +import { CSS, Text } from '@traefik-labs/faency' import { useContext } from 'react' import CopyButton from 'components/buttons/CopyButton' diff --git a/webui/src/components/ScrollableCard.tsx b/webui/src/components/ScrollableCard.tsx index cb2fc1403c..451178c837 100644 --- a/webui/src/components/ScrollableCard.tsx +++ b/webui/src/components/ScrollableCard.tsx @@ -1,4 +1,4 @@ -import { Card, styled } from '@traefiklabs/faency' +import { Card, styled } from '@traefik-labs/faency' const ScrollableCard = styled(Card, { width: '100%', diff --git a/webui/src/components/SpinnerLoader.tsx b/webui/src/components/SpinnerLoader.tsx index e336fc4369..7a70b3e7b9 100644 --- a/webui/src/components/SpinnerLoader.tsx +++ b/webui/src/components/SpinnerLoader.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { motion } from 'framer-motion' import { FiLoader } from 'react-icons/fi' diff --git a/webui/src/components/ThemeSwitcher.tsx b/webui/src/components/ThemeSwitcher.tsx index 6ac173c39d..674cad664b 100644 --- a/webui/src/components/ThemeSwitcher.tsx +++ b/webui/src/components/ThemeSwitcher.tsx @@ -1,4 +1,4 @@ -import { AccessibleIcon, Button } from '@traefiklabs/faency' +import { AccessibleIcon, Button } from '@traefik-labs/faency' import { FiMoon, FiSun } from 'react-icons/fi' import { AutoThemeIcon } from 'components/icons/AutoThemeIcon' diff --git a/webui/src/components/Toast.tsx b/webui/src/components/Toast.tsx index 30a836cec3..c1d2a406e9 100644 --- a/webui/src/components/Toast.tsx +++ b/webui/src/components/Toast.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, styled, Text } from '@traefiklabs/faency' +import { Box, Button, Flex, styled, Text } from '@traefik-labs/faency' import { AnimatePresence, motion } from 'framer-motion' import { ReactNode, useEffect } from 'react' import { FiX } from 'react-icons/fi' diff --git a/webui/src/components/ToastPool.tsx b/webui/src/components/ToastPool.tsx index 5d372910bd..43d0e0f58a 100644 --- a/webui/src/components/ToastPool.tsx +++ b/webui/src/components/ToastPool.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { useContext } from 'react' import { Toast } from './Toast' diff --git a/webui/src/components/Tooltip.tsx b/webui/src/components/Tooltip.tsx index a34264a2db..29af507f9f 100644 --- a/webui/src/components/Tooltip.tsx +++ b/webui/src/components/Tooltip.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, Text, Tooltip as FaencyTooltip } from '@traefiklabs/faency' +import { Button, Flex, Text, Tooltip as FaencyTooltip } from '@traefik-labs/faency' import { MouseEvent, ReactNode, useMemo, useState } from 'react' import { FiCheck, FiCopy } from 'react-icons/fi' diff --git a/webui/src/components/TooltipText.tsx b/webui/src/components/TooltipText.tsx index 441b515f81..66d766766c 100644 --- a/webui/src/components/TooltipText.tsx +++ b/webui/src/components/TooltipText.tsx @@ -1,4 +1,4 @@ -import { CSS, Text } from '@traefiklabs/faency' +import { CSS, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import Tooltip from 'components/Tooltip' diff --git a/webui/src/components/buttons/CopyButton.tsx b/webui/src/components/buttons/CopyButton.tsx index 08f60240e7..f2f8b124a7 100644 --- a/webui/src/components/buttons/CopyButton.tsx +++ b/webui/src/components/buttons/CopyButton.tsx @@ -1,4 +1,4 @@ -import { Flex, Button, CSS, AccessibleIcon } from '@traefiklabs/faency' +import { Flex, Button, CSS, AccessibleIcon } from '@traefik-labs/faency' import React, { useState } from 'react' import { FiCheck, FiCopy } from 'react-icons/fi' diff --git a/webui/src/components/buttons/IconButton.tsx b/webui/src/components/buttons/IconButton.tsx index 42a02b929b..0d96f79126 100644 --- a/webui/src/components/buttons/IconButton.tsx +++ b/webui/src/components/buttons/IconButton.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, Text } from '@traefiklabs/faency' +import { Button, Flex, Text } from '@traefik-labs/faency' import { ComponentProps, ReactNode } from 'react' type IconButtonProps = ComponentProps & { diff --git a/webui/src/components/buttons/ScrollTopButton.tsx b/webui/src/components/buttons/ScrollTopButton.tsx index b61f41d4f6..2c1cc54156 100644 --- a/webui/src/components/buttons/ScrollTopButton.tsx +++ b/webui/src/components/buttons/ScrollTopButton.tsx @@ -1,4 +1,4 @@ -import { Button } from '@traefiklabs/faency' +import { Button } from '@traefik-labs/faency' import { useCallback, useEffect, useState } from 'react' export const ScrollTopButton = () => { diff --git a/webui/src/components/buttons/SortButton.tsx b/webui/src/components/buttons/SortButton.tsx index 2d10ea1754..fb4a5817ce 100644 --- a/webui/src/components/buttons/SortButton.tsx +++ b/webui/src/components/buttons/SortButton.tsx @@ -1,4 +1,4 @@ -import { styled, Flex, Label } from '@traefiklabs/faency' +import { styled, Flex, Label } from '@traefik-labs/faency' import { ComponentProps } from 'react' import SortIcon from 'components/icons/SortIcon' diff --git a/webui/src/components/certificates/CertExpiryBadge.tsx b/webui/src/components/certificates/CertExpiryBadge.tsx new file mode 100644 index 0000000000..07e4912188 --- /dev/null +++ b/webui/src/components/certificates/CertExpiryBadge.tsx @@ -0,0 +1,29 @@ +import { Badge } from '@traefik-labs/faency' + +type ExpiryStatus = { + variant: 'red' | 'orange' | 'green' + label: string +} + +export const getCertExpiryStatus = (daysLeft: number): ExpiryStatus => { + if (daysLeft < 0) return { variant: 'red', label: 'EXPIRED' } + if (daysLeft < 30) return { variant: 'orange', label: 'Expiring Soon' } + return { variant: 'green', label: 'Valid' } +} + +type CertExpiryBadgeProps = { + daysLeft: number + size?: 'small' | 'large' +} + +const CertExpiryBadge = ({ daysLeft, size = 'large' }: CertExpiryBadgeProps) => { + const { variant } = getCertExpiryStatus(daysLeft) + + return ( + + {daysLeft < 0 ? 'EXPIRED' : `${daysLeft} days`} + + ) +} + +export default CertExpiryBadge diff --git a/webui/src/components/certificates/CertificateDetails.tsx b/webui/src/components/certificates/CertificateDetails.tsx new file mode 100644 index 0000000000..166e08042d --- /dev/null +++ b/webui/src/components/certificates/CertificateDetails.tsx @@ -0,0 +1,106 @@ +import { Badge, Box, Flex, Link } from '@traefik-labs/faency' +import { type ReactElement, useMemo } from 'react' + +import CertExpiryBadge, { getCertExpiryStatus } from 'components/certificates/CertExpiryBadge' +import DetailsCard, { ValText } from 'components/resources/DetailsCard' + +const isLinkableHostname = (value?: string) => { + if (!value) { + return false + } + + return !value.startsWith('*.') && !/\s/.test(value) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(value) && !value.includes(':') +} + +export const CertificateDetails = ({ certificate }: { certificate: Certificate.Info }) => { + const validFrom = new Date(certificate.notBefore) + const validUntil = new Date(certificate.notAfter) + const certStatus = useMemo(() => getCertExpiryStatus(certificate.daysLeft), [certificate.daysLeft]) + + const issuedToItems = [ + { + key: 'Common Name', + val: isLinkableHostname(certificate.commonName) ? ( + + {certificate.commonName} + + ) : ( + {certificate.commonName || '-'} + ), + }, + { + key: 'Status', + val: ( + + {certStatus.label} + + ), + }, + { + key: 'Subject Alternative Names', + val: ( + + {certificate.sans.map((san) => ( + + {isLinkableHostname(san) ? ( + + {san} + + ) : ( + {san} + )} + + ))} + + ), + }, + { key: 'Organization', val: certificate.organization || '-' }, + { key: 'Country', val: certificate.country || '-' }, + ] + + const issuedByItems = [ + { key: 'Common Name', val: certificate.issuerCN || '-' }, + { key: 'Organization', val: certificate.issuerOrg || '-' }, + { key: 'Country', val: certificate.issuerCountry || '-' }, + ] + + const validityItems = [ + { key: 'Valid From', val: validFrom.toLocaleString() }, + { key: 'Valid Until', val: validUntil.toLocaleString() }, + { + key: 'Expiry', + val: , + }, + ] + + const technicalItems = [ + certificate.version && { key: 'Version', val: certificate.version }, + { key: 'Serial Number', val: certificate.serialNumber || 'N/A' }, + { key: 'Key Type', val: certificate.keyType || 'Unknown' }, + { key: 'Key Size', val: `${certificate.keySize || 0} bits` }, + { key: 'Signature Algorithm', val: certificate.signatureAlgorithm || 'Unknown' }, + ].filter(Boolean) as { key: string; val: string | ReactElement }[] + + const fingerprintItems = [ + { key: 'Certificate', val: certificate.certFingerprint || 'N/A' }, + { key: 'Public Key', val: certificate.publicKeyFingerprint || 'N/A' }, + ] + + return ( + + + + + + + + ) +} + +export default CertificateDetails diff --git a/webui/src/components/icons/SortIcon.tsx b/webui/src/components/icons/SortIcon.tsx index 38fb59405d..bc3559e1d9 100644 --- a/webui/src/components/icons/SortIcon.tsx +++ b/webui/src/components/icons/SortIcon.tsx @@ -1,4 +1,4 @@ -import { config, Flex } from '@traefiklabs/faency' +import { config, Flex } from '@traefik-labs/faency' import { useEffect, useState } from 'react' import { CustomIconProps } from 'components/icons' diff --git a/webui/src/components/icons/index.tsx b/webui/src/components/icons/index.tsx index 345660e20e..0f87c8f72b 100644 --- a/webui/src/components/icons/index.tsx +++ b/webui/src/components/icons/index.tsx @@ -1,4 +1,4 @@ -import { CSS, Flex, VariantProps } from '@traefiklabs/faency' +import { CSS, Flex, VariantProps } from '@traefik-labs/faency' import { HTMLAttributes } from 'react' export type CustomIconProps = HTMLAttributes & { diff --git a/webui/src/components/icons/providers/index.tsx b/webui/src/components/icons/providers/index.tsx index cb65ebb274..b9215ec0d9 100644 --- a/webui/src/components/icons/providers/index.tsx +++ b/webui/src/components/icons/providers/index.tsx @@ -1,4 +1,4 @@ -import { Box } from '@traefiklabs/faency' +import { Box } from '@traefik-labs/faency' import { HTMLAttributes, useMemo } from 'react' import Consul from 'components/icons/providers/Consul' diff --git a/webui/src/components/middlewares/MiddlewareDetail.tsx b/webui/src/components/middlewares/MiddlewareDetail.tsx index b93940cbc1..37aba66bbc 100644 --- a/webui/src/components/middlewares/MiddlewareDetail.tsx +++ b/webui/src/components/middlewares/MiddlewareDetail.tsx @@ -1,4 +1,4 @@ -import { Card, Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { Card, Flex, H1, Skeleton, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import MiddlewareDefinition from './MiddlewareDefinition' diff --git a/webui/src/components/resources/DetailItemComponents.tsx b/webui/src/components/resources/DetailItemComponents.tsx index b4891216aa..253c7c3139 100644 --- a/webui/src/components/resources/DetailItemComponents.tsx +++ b/webui/src/components/resources/DetailItemComponents.tsx @@ -1,4 +1,4 @@ -import { Badge, CSS, Flex, styled, Text } from '@traefiklabs/faency' +import { Badge, CSS, Flex, styled, Text } from '@traefik-labs/faency' import { ReactNode } from 'react' import { BsToggleOff, BsToggleOn } from 'react-icons/bs' diff --git a/webui/src/components/resources/DetailsCard.tsx b/webui/src/components/resources/DetailsCard.tsx index 463e0c0956..18f0b023e0 100644 --- a/webui/src/components/resources/DetailsCard.tsx +++ b/webui/src/components/resources/DetailsCard.tsx @@ -1,4 +1,4 @@ -import { Card, CSS, Flex, Grid, H2, Skeleton, styled, Text } from '@traefiklabs/faency' +import { Card, CSS, Flex, Grid, H2, Skeleton, styled, Text } from '@traefik-labs/faency' import { Fragment, ReactNode, useMemo } from 'react' import ScrollableCard from 'components/ScrollableCard' diff --git a/webui/src/components/resources/FeatureCard.tsx b/webui/src/components/resources/FeatureCard.tsx index 5804c17c1d..9b93a3a33b 100644 --- a/webui/src/components/resources/FeatureCard.tsx +++ b/webui/src/components/resources/FeatureCard.tsx @@ -1,4 +1,4 @@ -import { Box, Card, Flex, Grid, Skeleton as FaencySkeleton, Text } from '@traefiklabs/faency' +import { Box, Card, Flex, Grid, Skeleton as FaencySkeleton, Text } from '@traefik-labs/faency' import ResourceCard from 'components/resources/ResourceCard' diff --git a/webui/src/components/resources/GenericTable.tsx b/webui/src/components/resources/GenericTable.tsx index 7dce66af59..590e9123f7 100644 --- a/webui/src/components/resources/GenericTable.tsx +++ b/webui/src/components/resources/GenericTable.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import Status from './Status' diff --git a/webui/src/components/resources/IpStrategyTable.tsx b/webui/src/components/resources/IpStrategyTable.tsx index bbee8d6a16..5660bda330 100644 --- a/webui/src/components/resources/IpStrategyTable.tsx +++ b/webui/src/components/resources/IpStrategyTable.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTr, Badge, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTr, Badge, Flex, Text } from '@traefik-labs/faency' import Tooltip from 'components/Tooltip' diff --git a/webui/src/components/resources/ResourceCard.tsx b/webui/src/components/resources/ResourceCard.tsx index 4e6b040be0..8bd77d8b42 100644 --- a/webui/src/components/resources/ResourceCard.tsx +++ b/webui/src/components/resources/ResourceCard.tsx @@ -1,4 +1,4 @@ -import { Card, CSS, Flex, Text } from '@traefiklabs/faency' +import { Card, CSS, Flex, Text } from '@traefik-labs/faency' import { ReactNode } from 'react' type ResourceCardProps = { diff --git a/webui/src/components/resources/ResourceErrors.tsx b/webui/src/components/resources/ResourceErrors.tsx index e4ba60aa2a..85e30ecd90 100644 --- a/webui/src/components/resources/ResourceErrors.tsx +++ b/webui/src/components/resources/ResourceErrors.tsx @@ -1,4 +1,4 @@ -import { Card, Flex, Skeleton } from '@traefiklabs/faency' +import { Card, Flex, Skeleton } from '@traefik-labs/faency' import { FiAlertTriangle } from 'react-icons/fi' import { SectionTitle } from './DetailsCard' diff --git a/webui/src/components/resources/ResourceStatus.tsx b/webui/src/components/resources/ResourceStatus.tsx index d50040b5bc..8080f6e128 100644 --- a/webui/src/components/resources/ResourceStatus.tsx +++ b/webui/src/components/resources/ResourceStatus.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, styled, Text } from '@traefiklabs/faency' +import { Box, Flex, styled, Text } from '@traefik-labs/faency' import { ReactNode } from 'react' import { colorByStatus, iconByStatus } from 'components/resources/Status' @@ -51,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props) icon: iconByStatus.disabled, label: 'Error', }, + expired: { + color: colorByStatus.expired, + icon: iconByStatus.expired, + label: 'Expired', + }, loading: { color: colorByStatus.loading, icon: iconByStatus.loading, diff --git a/webui/src/components/resources/Status.tsx b/webui/src/components/resources/Status.tsx index d90799f580..6fef9947b8 100644 --- a/webui/src/components/resources/Status.tsx +++ b/webui/src/components/resources/Status.tsx @@ -1,4 +1,4 @@ -import { Box, CSS } from '@traefiklabs/faency' +import { Box, CSS } from '@traefik-labs/faency' import { ReactNode } from 'react' import { FiAlertCircle, FiAlertTriangle, FiCheckCircle, FiLoader } from 'react-icons/fi' @@ -9,6 +9,7 @@ export const iconByStatus: { [key in Resource.Status]: ReactNode } = { error: , enabled: , disabled: , + expired: , loading: , } @@ -20,6 +21,7 @@ export const colorByStatus: { [key in Resource.Status]: string } = { error: 'hsl(347, 100%, 60.0%)', enabled: '#30A46C', disabled: 'hsl(347, 100%, 60.0%)', + expired: 'hsl(347, 100%, 60.0%)', loading: 'hsla(0, 0%, 100%, 0.51)', } @@ -45,6 +47,8 @@ export default function Status({ css = {}, size = 20, status, color = 'white' }: return case 'disabled': return + case 'expired': + return default: return null } diff --git a/webui/src/components/resources/TraefikResourceStatsCard.tsx b/webui/src/components/resources/TraefikResourceStatsCard.tsx index 60c59812dd..2d5764fc04 100644 --- a/webui/src/components/resources/TraefikResourceStatsCard.tsx +++ b/webui/src/components/resources/TraefikResourceStatsCard.tsx @@ -1,4 +1,4 @@ -import { Box, Card, Flex, H3, Skeleton, styled, Text } from '@traefiklabs/faency' +import { Box, Card, Flex, H3, Skeleton, styled, Text } from '@traefik-labs/faency' import { Chart as ChartJs, ArcElement, Tooltip } from 'chart.js' import { ReactNode, useEffect, useMemo, useState } from 'react' import { Doughnut } from 'react-chartjs-2' diff --git a/webui/src/components/resources/UsedByRoutersSection.tsx b/webui/src/components/resources/UsedByRoutersSection.tsx index a80c630ee4..032f7d37bd 100644 --- a/webui/src/components/resources/UsedByRoutersSection.tsx +++ b/webui/src/components/resources/UsedByRoutersSection.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { orderBy } from 'lodash' import { useContext, useEffect, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/components/routers/RouterDetail.tsx b/webui/src/components/routers/RouterDetail.tsx index 766acc3f94..76fbb18daf 100644 --- a/webui/src/components/routers/RouterDetail.tsx +++ b/webui/src/components/routers/RouterDetail.tsx @@ -1,4 +1,4 @@ -import { Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { Flex, H1, Skeleton, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import { DetailsCardSkeleton } from 'components/resources/DetailsCard' diff --git a/webui/src/components/routers/RouterFlowDiagram.tsx b/webui/src/components/routers/RouterFlowDiagram.tsx index 870f69cd88..cecd3a91c6 100644 --- a/webui/src/components/routers/RouterFlowDiagram.tsx +++ b/webui/src/components/routers/RouterFlowDiagram.tsx @@ -1,4 +1,4 @@ -import { Card, Flex, styled, Link, Tooltip, Box, Text, Skeleton } from '@traefiklabs/faency' +import { Card, Flex, styled, Link, Tooltip, Box, Text, Skeleton } from '@traefik-labs/faency' import { useMemo } from 'react' import { FiArrowRight, FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi' diff --git a/webui/src/components/routers/TlsSection.tsx b/webui/src/components/routers/TlsSection.tsx index 7938b87a69..8edefc14c0 100644 --- a/webui/src/components/routers/TlsSection.tsx +++ b/webui/src/components/routers/TlsSection.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Card, Flex } from '@traefiklabs/faency' +import { Badge, Box, Card, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import TlsIcon from './TlsIcon' diff --git a/webui/src/components/services/MirrorServices.tsx b/webui/src/components/services/MirrorServices.tsx index b551afbc70..aeed621c65 100644 --- a/webui/src/components/services/MirrorServices.tsx +++ b/webui/src/components/services/MirrorServices.tsx @@ -1,4 +1,4 @@ -import { Flex, Text } from '@traefiklabs/faency' +import { Flex, Text } from '@traefik-labs/faency' import { FiGlobe } from 'react-icons/fi' import { getProviderFromName } from './Servers' diff --git a/webui/src/components/services/Servers.tsx b/webui/src/components/services/Servers.tsx index 684a7d1438..d0775d5ff2 100644 --- a/webui/src/components/services/Servers.tsx +++ b/webui/src/components/services/Servers.tsx @@ -1,4 +1,4 @@ -import { Flex, Text } from '@traefiklabs/faency' +import { Flex, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import { FiGlobe } from 'react-icons/fi' @@ -16,29 +16,18 @@ type ServersProps = { type Server = { url?: string address?: string + weight?: number } -type ServerStatus = { - [server: string]: string -} - -function getServerStatusList(data: Service.Details): ServerStatus { - const serversList: ServerStatus = {} - - data.loadBalancer?.servers?.forEach((server: Server) => { - const serverKey = server.address || server.url - if (serverKey) { - serversList[serverKey] = 'DOWN' - } - }) - - if (data.serverStatus) { - Object.entries(data.serverStatus).forEach(([server, status]) => { - serversList[server] = status - }) +function getServerStatusList(data: Service.Details) { + if (!data?.loadBalancer?.servers) { + return [] } - - return serversList + return data.loadBalancer?.servers?.map((server: Server) => ({ + url: server.address || server.url, + status: data.serverStatus?.[server.address || server.url || '-'] || 'DOWN', + weight: server.weight ?? 1, + })) } export const getProviderFromName = (serviceName: string, defaultProvider: string): string => { @@ -47,7 +36,7 @@ export const getProviderFromName = (serviceName: string, defaultProvider: string } const Servers = ({ data, protocol }: ServersProps) => { - const serversList = getServerStatusList(data) + const serversList = useMemo(() => getServerStatusList(data), [data]) const isTcp = useMemo(() => protocol === 'tcp', [protocol]) const isUdp = useMemo(() => protocol === 'udp', [protocol]) @@ -57,35 +46,39 @@ const Servers = ({ data, protocol }: ServersProps) => { return ( } title="Servers" /> - ({ - server, - status, - }))} - columns={[ - ...(isUdp ? [] : [{ key: 'status' as const, header: 'Status' }]), - { key: 'server' as const, header: isTcp ? 'Address' : 'URL' }, - ]} - testId="servers-list" - renderCell={(key, value) => { - if (key === 'status') { - return ( - - - {value} - - ) - } - if (key === 'server') { - return ( - - {value} - - ) - } - return {value} - }} - /> + {serversList?.length > 0 && ( + ({ + server: url, + status, + weight, + }))} + columns={[ + ...(isUdp ? [] : [{ key: 'status' as const, header: 'Status' }]), + { key: 'server' as const, header: isTcp ? 'Address' : 'URL' }, + ...(isUdp ? [] : [{ key: 'weight' as const, header: 'Weight' }]), + ]} + testId={`${protocol}-servers-list`} + renderCell={(key, value) => { + if (key === 'status') { + return ( + + + {value} + + ) + } + if (key === 'server') { + return ( + + {value} + + ) + } + return {value} + }} + /> + )} ) } diff --git a/webui/src/components/services/ServiceDefinition.tsx b/webui/src/components/services/ServiceDefinition.tsx index 3cd3b16c5d..000ca408bf 100644 --- a/webui/src/components/services/ServiceDefinition.tsx +++ b/webui/src/components/services/ServiceDefinition.tsx @@ -1,4 +1,4 @@ -import { Badge } from '@traefiklabs/faency' +import { Badge } from '@traefik-labs/faency' import { useMemo } from 'react' import ProviderIcon from 'components/icons/providers' diff --git a/webui/src/components/services/ServiceDetail.tsx b/webui/src/components/services/ServiceDetail.tsx index 66642bc7b8..13dd5f3c6e 100644 --- a/webui/src/components/services/ServiceDetail.tsx +++ b/webui/src/components/services/ServiceDetail.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { Box, Flex, H1, Skeleton, Text } from '@traefik-labs/faency' import MirrorServices from './MirrorServices' import Servers from './Servers' diff --git a/webui/src/components/services/WeightedServices.tsx b/webui/src/components/services/WeightedServices.tsx index 6ddae63b1d..ecf8eefe4f 100644 --- a/webui/src/components/services/WeightedServices.tsx +++ b/webui/src/components/services/WeightedServices.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { FiGlobe } from 'react-icons/fi' import { getProviderFromName } from './utils' diff --git a/webui/src/components/tables/AriaTableSkeleton.tsx b/webui/src/components/tables/AriaTableSkeleton.tsx index f9f30d8ed8..92bb30044a 100644 --- a/webui/src/components/tables/AriaTableSkeleton.tsx +++ b/webui/src/components/tables/AriaTableSkeleton.tsx @@ -9,7 +9,7 @@ import { VariantProps, AriaThead, AriaTh, -} from '@traefiklabs/faency' +} from '@traefik-labs/faency' import { ReactNode } from 'react' type AriaTableSkeletonProps = { diff --git a/webui/src/components/tables/ClickableRow.tsx b/webui/src/components/tables/ClickableRow.tsx index aecffaccdb..59f9e6e1e2 100644 --- a/webui/src/components/tables/ClickableRow.tsx +++ b/webui/src/components/tables/ClickableRow.tsx @@ -1,4 +1,4 @@ -import { AriaTr, VariantProps, styled } from '@traefiklabs/faency' +import { AriaTr, VariantProps, styled } from '@traefik-labs/faency' import { ComponentProps, forwardRef, ReactNode } from 'react' import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to' diff --git a/webui/src/components/tables/PaginatedTable.tsx b/webui/src/components/tables/PaginatedTable.tsx index 2d403dfd83..ecbabd1402 100644 --- a/webui/src/components/tables/PaginatedTable.tsx +++ b/webui/src/components/tables/PaginatedTable.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaThead, AriaTr, Box, Button, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaThead, AriaTr, Box, Button, Flex, Text } from '@traefik-labs/faency' import { ReactNode, useEffect, useRef, useState } from 'react' import { FiChevronLeft, FiChevronRight, FiChevronsLeft, FiChevronsRight } from 'react-icons/fi' diff --git a/webui/src/components/tables/SortableTh.tsx b/webui/src/components/tables/SortableTh.tsx index 831e9df0a3..92d81cf613 100644 --- a/webui/src/components/tables/SortableTh.tsx +++ b/webui/src/components/tables/SortableTh.tsx @@ -1,4 +1,4 @@ -import { AriaTh, CSS, Flex, Label } from '@traefiklabs/faency' +import { AriaTh, CSS, Flex, Label } from '@traefik-labs/faency' import { useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/components/tables/TableFilter.tsx b/webui/src/components/tables/TableFilter.tsx index ab8c01938a..ce7e369f06 100644 --- a/webui/src/components/tables/TableFilter.tsx +++ b/webui/src/components/tables/TableFilter.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, TextField, InputHandle } from '@traefiklabs/faency' +import { Box, Button, Flex, TextField, InputHandle } from '@traefik-labs/faency' import { isUndefined, omitBy } from 'lodash' import { useCallback, useRef, useState } from 'react' import { FiSearch, FiXCircle } from 'react-icons/fi' diff --git a/webui/src/hooks/use-certificates.ts b/webui/src/hooks/use-certificates.ts new file mode 100644 index 0000000000..7198ca269a --- /dev/null +++ b/webui/src/hooks/use-certificates.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react' +import useSWR from 'swr' + +export const computeDaysLeft = (notAfter: string): number => + Math.floor((new Date(notAfter).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + +export const useCertificates = () => { + const { data, error } = useSWR('/certificates') + + const certificates: Certificate.Info[] = useMemo(() => { + if (!data) return [] + + return data.map((cert) => ({ + ...cert, + daysLeft: computeDaysLeft(cert.notAfter), + })) + }, [data]) + + return { + certificates, + error, + isLoading: !error && !data, + } +} + +export const useCertificate = (certId: string) => { + const { data, error } = useSWR(certId ? `/certificates/${certId}` : null) + + const certificate: Certificate.Info | null = useMemo(() => { + if (!data) return null + + return { + ...data, + daysLeft: computeDaysLeft(data.notAfter), + } + }, [data]) + + return { + certificate, + error, + isLoading: !!certId && !error && !data, + } +} diff --git a/webui/src/hooks/use-fetch-with-pagination.tsx b/webui/src/hooks/use-fetch-with-pagination.tsx index 83f0a2e6f1..518166021b 100644 --- a/webui/src/hooks/use-fetch-with-pagination.tsx +++ b/webui/src/hooks/use-fetch-with-pagination.tsx @@ -1,4 +1,4 @@ -import { AriaTd, AriaTr } from '@traefiklabs/faency' +import { AriaTd, AriaTr } from '@traefik-labs/faency' import { stringify } from 'query-string' import { ReactNode } from 'react' import useSWRInfinite, { SWRInfiniteConfiguration } from 'swr/infinite' diff --git a/webui/src/hooks/use-overview-totals.tsx b/webui/src/hooks/use-overview-totals.tsx index 42d896cf4c..b3b7412a5f 100644 --- a/webui/src/hooks/use-overview-totals.tsx +++ b/webui/src/hooks/use-overview-totals.tsx @@ -10,6 +10,7 @@ type TotalsResult = { http: TotalsResultItem tcp: TotalsResultItem udp: TotalsResultItem + certificates: number } const useTotals = (): TotalsResult => { @@ -30,6 +31,7 @@ const useTotals = (): TotalsResult => { routers: data?.udp?.routers?.total, services: data?.udp?.services?.total, }, + certificates: data?.certificates?.total, } } diff --git a/webui/src/layout/Container.tsx b/webui/src/layout/Container.tsx index b3330c6520..b218aa6aa4 100644 --- a/webui/src/layout/Container.tsx +++ b/webui/src/layout/Container.tsx @@ -1,4 +1,4 @@ -import { Flex, styled } from '@traefiklabs/faency' +import { Flex, styled } from '@traefik-labs/faency' import breakpoints from 'utils/breakpoints' diff --git a/webui/src/layout/EmptyPlaceholder.tsx b/webui/src/layout/EmptyPlaceholder.tsx index e62fcb112f..b4ba0f5749 100644 --- a/webui/src/layout/EmptyPlaceholder.tsx +++ b/webui/src/layout/EmptyPlaceholder.tsx @@ -1,4 +1,4 @@ -import { AriaTd, Flex, Text } from '@traefiklabs/faency' +import { AriaTd, Flex, Text } from '@traefik-labs/faency' import { FiAlertTriangle } from 'react-icons/fi' type EmptyPlaceholderProps = { diff --git a/webui/src/layout/ErrorFallback.tsx b/webui/src/layout/ErrorFallback.tsx index f5a591ea9c..60363ac36e 100644 --- a/webui/src/layout/ErrorFallback.tsx +++ b/webui/src/layout/ErrorFallback.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Text } from '@traefiklabs/faency' +import { Box, Button, Text } from '@traefik-labs/faency' import { FallbackProps } from 'react-error-boundary' const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { diff --git a/webui/src/layout/Page.tsx b/webui/src/layout/Page.tsx index b9549ba5a8..2ed339d0f7 100644 --- a/webui/src/layout/Page.tsx +++ b/webui/src/layout/Page.tsx @@ -1,4 +1,4 @@ -import { Flex, globalCss, styled } from '@traefiklabs/faency' +import { Flex, globalCss, styled } from '@traefik-labs/faency' import { ReactNode, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' diff --git a/webui/src/layout/navigation/SideNavBar.tsx b/webui/src/layout/navigation/SideNavBar.tsx index 648b6681f7..855c76c961 100644 --- a/webui/src/layout/navigation/SideNavBar.tsx +++ b/webui/src/layout/navigation/SideNavBar.tsx @@ -10,7 +10,7 @@ import { Text, Tooltip, VisuallyHidden, -} from '@traefiklabs/faency' +} from '@traefik-labs/faency' import { useContext, useEffect, useMemo, useState } from 'react' import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs' import { matchPath, useHref } from 'react-router' @@ -135,7 +135,7 @@ export const SideNav = ({ const windowSize = useWindowSize() const { version } = useContext(VersionContext) - const { http, tcp, udp } = useTotals() + const { http, tcp, udp, certificates } = useTotals() const [isSmallScreen, setIsSmallScreen] = useState(false) @@ -155,8 +155,9 @@ export const SideNav = ({ '/tcp/middlewares': tcp?.middlewares as number, '/udp/routers': udp?.routers, '/udp/services': udp?.services, + '/certificates': certificates, }), - [http, tcp, udp], + [http, tcp, udp, certificates], ) return ( diff --git a/webui/src/layout/navigation/TopNavBar.tsx b/webui/src/layout/navigation/TopNavBar.tsx index 902573d0f1..5a17c479ad 100644 --- a/webui/src/layout/navigation/TopNavBar.tsx +++ b/webui/src/layout/navigation/TopNavBar.tsx @@ -12,7 +12,7 @@ import { Link, Text, Tooltip, -} from '@traefiklabs/faency' +} from '@traefik-labs/faency' import { useContext, useMemo } from 'react' import { Helmet } from 'react-helmet-async' import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi' diff --git a/webui/src/mocks/data/api-certificates.json b/webui/src/mocks/data/api-certificates.json new file mode 100644 index 0000000000..577197966a --- /dev/null +++ b/webui/src/mocks/data/api-certificates.json @@ -0,0 +1,210 @@ +[ + { + "name": "a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60", + "sans": [ + "example.com", + "foo.example.com", + "bar.example.com" + ], + "notAfter": "2026-06-15T10:00:00Z", + "notBefore": "2024-01-15T10:00:00Z", + "serialNumber": "03:E7:12:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC", + "commonName": "example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Example Corporation", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60", + "publicKeyFingerprint": "b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "sans": [ + "domain.com", + "foo.domain.com", + "bar.domain.com" + ], + "notAfter": "2026-05-20T10:00:00Z", + "notBefore": "2024-02-20T10:00:00Z", + "serialNumber": "04:A8:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD", + "commonName": "domain.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Domain Services Inc", + "country": "GB", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "publicKeyFingerprint": "d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "sans": [ + "my.domain.com", + "foo.my.domain.com", + "bar.my.domain.com" + ], + "notAfter": "2026-04-10T10:00:00Z", + "notBefore": "2024-03-10T10:00:00Z", + "serialNumber": "05:B9:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE", + "commonName": "my.domain.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "My Domain Hosting", + "country": "DE", + "version": "v3", + "keyType": "RSA", + "keySize": 4096, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "publicKeyFingerprint": "f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6", + "status": "enabled", + "resolver": "zerossl" + }, + { + "name": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "sans": [ + "api.example.com", + "*.api.example.com" + ], + "notAfter": "2026-03-20T10:00:00Z", + "notBefore": "2024-02-20T10:00:00Z", + "serialNumber": "04:A8:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD", + "commonName": "api.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Example API Services", + "country": "GB", + "version": "v3", + "keyType": "RSA", + "keySize": 4096, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "publicKeyFingerprint": "2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b", + "sans": [ + "test.example.com" + ], + "notAfter": "2026-02-01T10:00:00Z", + "notBefore": "2024-03-10T10:00:00Z", + "serialNumber": "05:B9:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE", + "commonName": "test.example.com", + "issuerCN": "ZeroSSL RSA Domain Secure Site CA", + "issuerOrg": "ZeroSSL", + "issuerCountry": "AT", + "organization": "Test Environment", + "country": "DE", + "version": "v3", + "keyType": "ECDSA", + "keySize": 256, + "signatureAlgorithm": "SHA384-ECDSA", + "certFingerprint": "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b", + "publicKeyFingerprint": "4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c", + "status": "warning", + "resolver": "letsencrypt" + }, + { + "name": "d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + "sans": [ + "db.example.com" + ], + "notAfter": "2026-06-01T10:00:00Z", + "notBefore": "2024-06-01T10:00:00Z", + "serialNumber": "05:D2:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE:F0", + "commonName": "db.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Database Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + "publicKeyFingerprint": "e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "08192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7", + "sans": [ + "old.example.com" + ], + "notAfter": "2025-12-01T10:00:00Z", + "notBefore": "2023-12-01T10:00:00Z", + "serialNumber": "02:C1:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF", + "commonName": "old.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Legacy Systems", + "country": "CA", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "08192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7", + "publicKeyFingerprint": "192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708", + "status": "expired" + }, + { + "name": "f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4", + "sans": [], + "notAfter": "2027-01-15T10:00:00Z", + "notBefore": "2024-01-15T10:00:00Z", + "serialNumber": "06:F3:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01", + "commonName": "legacy.internal.com", + "issuerCN": "Internal CA", + "issuerOrg": "Internal Certificate Authority", + "issuerCountry": "US", + "organization": "Internal Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4", + "publicKeyFingerprint": "a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5", + "status": "enabled" + }, + { + "name": "b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", + "sans": [], + "notAfter": "2026-08-20T10:00:00Z", + "notBefore": "2024-05-20T10:00:00Z", + "serialNumber": "07:A5:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE:F0:12", + "commonName": "app.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Application Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", + "publicKeyFingerprint": "c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7", + "status": "enabled", + "resolver": "letsencrypt" + } +] \ No newline at end of file diff --git a/webui/src/mocks/data/api-overview.json b/webui/src/mocks/data/api-overview.json index d01f3b403c..6af278889e 100644 --- a/webui/src/mocks/data/api-overview.json +++ b/webui/src/mocks/data/api-overview.json @@ -45,6 +45,11 @@ "errors": 0 } }, + "certificates": { + "total": 9, + "warnings": 1, + "errors": 1 + }, "features": { "tracing": "Prometheus", "metrics": "", diff --git a/webui/src/mocks/data/api-tcp_routers.json b/webui/src/mocks/data/api-tcp_routers.json index f908f66a81..2ec00a3421 100644 --- a/webui/src/mocks/data/api-tcp_routers.json +++ b/webui/src/mocks/data/api-tcp_routers.json @@ -14,5 +14,23 @@ ], "priority": 10, "provider": "docker" + }, + { + "entryPoints": [ + "websecure-tcp" + ], + "service": "postgres", + "rule": "HostSNI(`db.example.com`)", + "tls": { + "options": "default", + "certResolver": "letsencrypt" + }, + "status": "enabled", + "name": "postgres@docker", + "using": [ + "websecure-tcp" + ], + "priority": 20, + "provider": "docker" } ] diff --git a/webui/src/mocks/handlers.ts b/webui/src/mocks/handlers.ts index 5055067306..417704c621 100644 --- a/webui/src/mocks/handlers.ts +++ b/webui/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { http, passthrough } from 'msw' +import apiCertificates from './data/api-certificates.json' import apiEntrypoints from './data/api-entrypoints.json' import apiHttpMiddlewares from './data/api-http_middlewares.json' import apiHttpRouters from './data/api-http_routers.json' @@ -15,6 +16,7 @@ import eeApiErrors from './data/ee-api-errors.json' import { listHandlers } from './utils' export const getHandlers = (noDelay: boolean = false) => [ + ...listHandlers('/api/certificates', apiCertificates, noDelay), ...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true), ...listHandlers('/api/errors', eeApiErrors, noDelay), ...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay), diff --git a/webui/src/pages/NotFound.tsx b/webui/src/pages/NotFound.tsx index 55ba8e127a..e008aa3b69 100644 --- a/webui/src/pages/NotFound.tsx +++ b/webui/src/pages/NotFound.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Flex, H1, Text } from '@traefiklabs/faency' +import { Box, Button, Flex, H1, Text } from '@traefik-labs/faency' import { useNavigate } from 'react-router-dom' import PageTitle from 'layout/PageTitle' diff --git a/webui/src/pages/certificates/Certificate.spec.tsx b/webui/src/pages/certificates/Certificate.spec.tsx new file mode 100644 index 0000000000..a3dbc157dc --- /dev/null +++ b/webui/src/pages/certificates/Certificate.spec.tsx @@ -0,0 +1,90 @@ +import * as useCertificates from '../../hooks/use-certificates' + +import { Certificate } from './Certificate' + +import { renderWithProviders } from 'utils/test' + +describe('', () => { + it('should render the loading state initially', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: null, + isLoading: true, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/dW5rbm93bi1jZXJ0LWtleQ==', + withPage: true, + }) + + expect(getByTestId('skeleton')).toBeInTheDocument() + }) + + it('should render error message when API returns error', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: new Error('Internal Server Error'), + isLoading: false, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/c29tZS1jZXJ0', + withPage: true, + }) + + expect(getByTestId('error-text')).toBeInTheDocument() + }) + + it('should render not found page when certificate is null', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: null, + isLoading: false, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/bm90Zm91bmQ=', + withPage: true, + }) + + expect(getByTestId('Not found page')).toBeInTheDocument() + }) + + it('should render certificate details successfully', () => { + const mockCertificate = { + name: 'dGVzdC1jZXJ0', + commonName: 'test.com', + sans: ['test.com', 'www.test.com'], + issuerOrg: 'Test CA', + issuerCN: 'Test Root CA', + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled' as const, + serialNumber: '123456', + version: '3', + keyType: 'RSA', + signatureAlgorithm: 'SHA256WithRSA', + certFingerprint: 'a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60', + publicKeyFingerprint: 'b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071', + } + + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: { ...mockCertificate, daysLeft: 365 }, + error: null, + isLoading: false, + })) + + const { getByText } = renderWithProviders(, { + route: '/certificates/dGVzdC1jZXJ0', + withPage: true, + }) + + // Check for actual rendered content + expect(getByText('Certificate')).toBeInTheDocument() + expect(getByText('Issued To')).toBeInTheDocument() + expect(getByText('Issued By')).toBeInTheDocument() + expect(getByText('www.test.com')).toBeInTheDocument() + expect(getByText('Test CA')).toBeInTheDocument() + expect(getByText('Test Root CA')).toBeInTheDocument() + }) +}) diff --git a/webui/src/pages/certificates/Certificate.tsx b/webui/src/pages/certificates/Certificate.tsx new file mode 100644 index 0000000000..b07449361b --- /dev/null +++ b/webui/src/pages/certificates/Certificate.tsx @@ -0,0 +1,51 @@ +import { Box, Flex, H1, Skeleton, Text } from '@traefik-labs/faency' +import { useParams } from 'react-router-dom' + +import { CertificateDetails } from '../../components/certificates/CertificateDetails' +import { useCertificate } from '../../hooks/use-certificates' + +import { DetailsCardSkeleton } from 'components/resources/DetailsCard' +import PageTitle from 'layout/PageTitle' +import { NotFound } from 'pages/NotFound' + +export const Certificate = () => { + const { name } = useParams<{ name: string }>() + const { certificate, isLoading, error } = useCertificate(name || '') + + if (isLoading) { + return ( + + + + + + + + + + ) + } + + if (error) { + return ( + <> + + + Sorry, we could not fetch detail information for this Certificate right now. Please, try again later. + + + ) + } + + if (!certificate) { + return + } + + return ( + <> + +

{certificate.commonName || 'Certificate'}

+ + + ) +} diff --git a/webui/src/pages/certificates/Certificates.spec.tsx b/webui/src/pages/certificates/Certificates.spec.tsx new file mode 100644 index 0000000000..25898e4c24 --- /dev/null +++ b/webui/src/pages/certificates/Certificates.spec.tsx @@ -0,0 +1,155 @@ +import { CertificateRenderRow, Certificates as CertificatesPage, CertificatesRender } from './Certificates' + +import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination' +import { useFetchWithPaginationMock } from 'utils/mocks' +import { renderWithProviders } from 'utils/test' + +describe('', () => { + it('should render the certificates list', () => { + const pages = [ + { + name: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + commonName: 'example.com', + sans: ['example.com', '127.0.0.1', '::1'], + issuerOrg: 'Acme Co', + issuerCN: 'Acme Root CA', + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled', + }, + { + name: 'b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3', + commonName: 'warning.com', + sans: ['warning.com', 'www.warning.com'], + issuerOrg: 'Warning CA', + issuerCN: '', + notAfter: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + status: 'warning', + }, + { + name: 'c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6', + commonName: 'expired.com', + sans: ['expired.com'], + issuerOrg: 'Expired CA', + notAfter: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), + status: 'expired', + }, + ].map(CertificateRenderRow) + const mock = vi + .spyOn(useFetchWithPagination, 'default') + .mockImplementation(() => useFetchWithPaginationMock({ pages })) + + const { container, getByTestId } = renderWithProviders(, { + route: '/certificates', + withPage: true, + }) + + expect(mock).toHaveBeenCalled() + expect(getByTestId('/certificates page')).toBeInTheDocument() + const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1] + expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3) + + // First certificate (enabled) + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('example.com') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('Acme Co') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('days') + + // Second certificate (warning) + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="warning"') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('warning.com') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('Warning CA') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('days') + + // Third certificate (expired) + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="expired"') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('expired.com') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('Expired CA') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('EXPIRED') + }) + + it('should render "No data available" when the API returns empty array', async () => { + const { container, getByTestId } = renderWithProviders( + {}} + pageCount={1} + pages={[]} + />, + { route: '/certificates', withPage: true }, + ) + expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]') + const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2] + expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1) + expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available') + }) + + it('should render "Failed to fetch data" when the API returns an error', async () => { + const { container } = renderWithProviders( + {}} + pageCount={1} + pages={[]} + />, + { route: '/certificates', withPage: true }, + ) + const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2] + expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1) + expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('Failed to fetch data') + }) + + it('should display certificate with expiry colors', () => { + // Test different expiry colors + const pages = [ + { + name: 'd4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5', + commonName: 'green.com', + sans: ['green.com'], + issuerOrg: 'Test CA', + notAfter: new Date(Date.now() + 100 * 24 * 60 * 60 * 1000).toISOString(), // 100 days = green + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled', + }, + { + name: 'e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6', + commonName: 'orange.com', + sans: ['orange.com'], + issuerOrg: 'Test CA', + notAfter: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days = orange + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'warning', + }, + ].map(CertificateRenderRow) + + const { container } = renderWithProviders( + {}} + pageCount={1} + pages={pages} + />, + { route: '/certificates', withPage: true }, + ) + + const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1] + expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(2) + + // Green badge for >14 days + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('green.com') + + // Orange badge for <14 days + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('orange.com') + }) +}) diff --git a/webui/src/pages/certificates/Certificates.tsx b/webui/src/pages/certificates/Certificates.tsx new file mode 100644 index 0000000000..0e80dbd75f --- /dev/null +++ b/webui/src/pages/certificates/Certificates.tsx @@ -0,0 +1,123 @@ +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency' +import { useMemo } from 'react' +import useInfiniteScroll from 'react-infinite-scroll-hook' +import { useSearchParams } from 'react-router-dom' + +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import CertExpiryBadge from 'components/certificates/CertExpiryBadge' +import { ResourceStatus } from 'components/resources/ResourceStatus' +import { SpinnerLoader } from 'components/SpinnerLoader' +import ClickableRow from 'components/tables/ClickableRow' +import SortableTh from 'components/tables/SortableTh' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' +import TooltipText from 'components/TooltipText' +import { computeDaysLeft } from 'hooks/use-certificates' +import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' +import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' +import PageTitle from 'layout/PageTitle' + +export const CertificateRenderRow: RenderRowType = (row: unknown) => { + const cert = row as Certificate.Raw + const daysLeft = computeDaysLeft(cert.notAfter) + const validUntil = new Date(cert.notAfter).toLocaleDateString() + + return ( + + + + + + + + + + {cert.sans?.length > 0 ? cert.sans.join(', ') : '-'} + + + + + + + {validUntil} + + + + + + ) +} + +export const CertificatesRender = ({ + error, + isEmpty, + isLoadingMore, + isReachingEnd, + loadMore, + pageCount, + pages, +}: pagesResponseInterface) => { + const [infiniteRef] = useInfiniteScroll({ + loading: isLoadingMore, + hasNextPage: !isReachingEnd && !error, + onLoadMore: loadMore, + }) + + return ( + <> + + + + + + + + + + + + {pages} + {(isEmpty || !!error) && ( + + + + + + )} + + + {isLoadingMore ? : isReachingEnd && pageCount > 1 && } + + + ) +} + +export const Certificates = () => { + const [searchParams] = useSearchParams() + + const query = useMemo(() => searchParamsToState(searchParams), [searchParams]) + const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination( + '/certificates', + { + listContextKey: JSON.stringify(query), + renderRow: CertificateRenderRow, + renderLoader: () => null, + query, + }, + ) + + return ( + <> + + + + + ) +} diff --git a/webui/src/pages/certificates/index.ts b/webui/src/pages/certificates/index.ts new file mode 100644 index 0000000000..87a0a571b2 --- /dev/null +++ b/webui/src/pages/certificates/index.ts @@ -0,0 +1,2 @@ +export { Certificates } from './Certificates' +export { Certificate } from './Certificate' diff --git a/webui/src/pages/dashboard/Dashboard.tsx b/webui/src/pages/dashboard/Dashboard.tsx index 06ac71de89..2ae16b1d95 100644 --- a/webui/src/pages/dashboard/Dashboard.tsx +++ b/webui/src/pages/dashboard/Dashboard.tsx @@ -1,4 +1,4 @@ -import { Card, CSS, Flex, Grid, H2, Text } from '@traefiklabs/faency' +import { Card, CSS, Flex, Grid, H2, Text } from '@traefik-labs/faency' import { ReactNode, useMemo } from 'react' import useSWR from 'swr' diff --git a/webui/src/pages/http/HttpMiddlewares.tsx b/webui/src/pages/http/HttpMiddlewares.tsx index a0c67745db..2a82a9d9f5 100644 --- a/webui/src/pages/http/HttpMiddlewares.tsx +++ b/webui/src/pages/http/HttpMiddlewares.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/http/HttpRouters.tsx b/webui/src/pages/http/HttpRouters.tsx index 2c3df85a74..a20e66fecd 100644 --- a/webui/src/pages/http/HttpRouters.tsx +++ b/webui/src/pages/http/HttpRouters.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/http/HttpService.spec.tsx b/webui/src/pages/http/HttpService.spec.tsx index d4b98500a4..dca017f214 100644 --- a/webui/src/pages/http/HttpService.spec.tsx +++ b/webui/src/pages/http/HttpService.spec.tsx @@ -89,9 +89,10 @@ describe('', () => { expect(serviceDetails.innerHTML).toContain('Pass host header') expect(serviceDetails.innerHTML).toContain('True') - const serversList = getByTestId('servers-list') + const serversList = getByTestId('http-servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:80') + expect(serversList.innerHTML).toContain('1') const routersTable = getByTestId('routers-table') expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2) @@ -107,6 +108,47 @@ describe('', () => { }).toThrow('Unable to find an element by: [data-testid="mirror-services"]') }) + it('should render a service with server weights', async () => { + const mockData = { + loadBalancer: { + servers: [ + { + url: 'http://10.0.1.12:80', + weight: 3, + }, + { + url: 'http://10.0.1.13:80', + weight: 5, + }, + ], + passHostHeader: true, + }, + status: 'enabled', + usedBy: [], + serverStatus: { + 'http://10.0.1.12:80': 'UP', + 'http://10.0.1.13:80': 'UP', + }, + name: 'service-weighted', + provider: 'docker', + type: 'loadbalancer', + routers: [], + } + + const { getByTestId } = renderWithProviders( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + , + { route: '/http/services/mock-service', withPage: true }, + ) + + const serversList = getByTestId('http-servers-list') + expect(serversList.childNodes.length).toBe(2) + expect(serversList.innerHTML).toContain('http://10.0.1.12:80') + expect(serversList.innerHTML).toContain('http://10.0.1.13:80') + expect(serversList.innerHTML).toContain('3') + expect(serversList.innerHTML).toContain('5') + }) + it('should render a service with health check', async () => { const mockData = { loadBalancer: { @@ -217,7 +259,7 @@ describe('', () => { }).toThrow('Unable to find an element by: [data-testid="health-check"]') expect(() => { - getByTestId('servers-list') - }).toThrow('Unable to find an element by: [data-testid="servers-list"]') + getByTestId('http-servers-list') + }).toThrow('Unable to find an element by: [data-testid="http-servers-list"]') }) }) diff --git a/webui/src/pages/http/HttpServices.tsx b/webui/src/pages/http/HttpServices.tsx index 482c4f8f72..eac845a135 100644 --- a/webui/src/pages/http/HttpServices.tsx +++ b/webui/src/pages/http/HttpServices.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/hub-demo/HubDashboard.tsx b/webui/src/pages/hub-demo/HubDashboard.tsx index 8ef777bb86..592d834551 100644 --- a/webui/src/pages/hub-demo/HubDashboard.tsx +++ b/webui/src/pages/hub-demo/HubDashboard.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Image, Link, Text } from '@traefiklabs/faency' +import { Box, Flex, Image, Link, Text } from '@traefik-labs/faency' import { useMemo, useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' diff --git a/webui/src/pages/hub-demo/HubDemoNav.tsx b/webui/src/pages/hub-demo/HubDemoNav.tsx index 6689ed36d8..12f0c62775 100644 --- a/webui/src/pages/hub-demo/HubDemoNav.tsx +++ b/webui/src/pages/hub-demo/HubDemoNav.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Flex, Text } from '@traefiklabs/faency' +import { Badge, Box, Flex, Text } from '@traefik-labs/faency' import { useContext, useState } from 'react' import { BsChevronRight } from 'react-icons/bs' diff --git a/webui/src/pages/hub-demo/icons/api.tsx b/webui/src/pages/hub-demo/icons/api.tsx index 04b51da6f9..733669675d 100644 --- a/webui/src/pages/hub-demo/icons/api.tsx +++ b/webui/src/pages/hub-demo/icons/api.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { useId } from 'react' import { CustomIconProps } from 'components/icons' diff --git a/webui/src/pages/hub-demo/icons/dashboard.tsx b/webui/src/pages/hub-demo/icons/dashboard.tsx index 44ab47aa6c..e5e8119c80 100644 --- a/webui/src/pages/hub-demo/icons/dashboard.tsx +++ b/webui/src/pages/hub-demo/icons/dashboard.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { CustomIconProps } from 'components/icons' diff --git a/webui/src/pages/hub-demo/icons/gateway.tsx b/webui/src/pages/hub-demo/icons/gateway.tsx index d1f682bf44..d9c6949143 100644 --- a/webui/src/pages/hub-demo/icons/gateway.tsx +++ b/webui/src/pages/hub-demo/icons/gateway.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { useId } from 'react' import { CustomIconProps } from 'components/icons' diff --git a/webui/src/pages/hub-demo/icons/portal.tsx b/webui/src/pages/hub-demo/icons/portal.tsx index a21413ce81..cba38534c7 100644 --- a/webui/src/pages/hub-demo/icons/portal.tsx +++ b/webui/src/pages/hub-demo/icons/portal.tsx @@ -1,4 +1,4 @@ -import { Flex } from '@traefiklabs/faency' +import { Flex } from '@traefik-labs/faency' import { useId } from 'react' import { CustomIconProps } from 'components/icons' diff --git a/webui/src/pages/index.ts b/webui/src/pages/index.ts index 3a84749a96..add2071487 100644 --- a/webui/src/pages/index.ts +++ b/webui/src/pages/index.ts @@ -1,7 +1,8 @@ +import * as CertificatesPages from './certificates' import * as HTTPPages from './http' import * as TCPPages from './tcp' import * as UDPPages from './udp' export { Dashboard } from './dashboard/Dashboard' export { NotFound } from './NotFound' -export { HTTPPages, TCPPages, UDPPages } +export { HTTPPages, TCPPages, UDPPages, CertificatesPages } diff --git a/webui/src/pages/tcp/TcpMiddlewares.tsx b/webui/src/pages/tcp/TcpMiddlewares.tsx index b1f2831345..574eea7cc3 100644 --- a/webui/src/pages/tcp/TcpMiddlewares.tsx +++ b/webui/src/pages/tcp/TcpMiddlewares.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/tcp/TcpRouters.tsx b/webui/src/pages/tcp/TcpRouters.tsx index 3a56f5a649..fff21f6ca0 100644 --- a/webui/src/pages/tcp/TcpRouters.tsx +++ b/webui/src/pages/tcp/TcpRouters.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/tcp/TcpService.spec.tsx b/webui/src/pages/tcp/TcpService.spec.tsx index ea001e4573..4fa72cac3c 100644 --- a/webui/src/pages/tcp/TcpService.spec.tsx +++ b/webui/src/pages/tcp/TcpService.spec.tsx @@ -101,9 +101,10 @@ describe('', () => { expect(healthCheck.innerHTML).toContain('Expect') expect(healthCheck.innerHTML).toContain('PONG') - const serversList = getByTestId('servers-list') + const serversList = getByTestId('tcp-servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:80') + expect(serversList.innerHTML).toContain('1') const routersTable = getByTestId('routers-table') expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) @@ -113,6 +114,11 @@ describe('', () => { it('should render the service servers from the serverStatus property', async () => { const mockData = { loadBalancer: { + servers: [ + { + address: 'http://10.0.1.12:81', + }, + ], terminationDelay: 10, }, status: 'enabled', @@ -154,7 +160,7 @@ describe('', () => { { route: '/tcp/services/mock-service', withPage: true }, ) - const serversList = getByTestId('servers-list') + const serversList = getByTestId('tcp-servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:81') @@ -185,6 +191,47 @@ describe('', () => { }).toThrow('Unable to find an element by: [data-testid="routers-table"]') }) + it('should render the service with server weights', async () => { + const mockData = { + loadBalancer: { + servers: [ + { + address: '10.0.1.12:80', + weight: 3, + }, + { + address: '10.0.1.13:80', + weight: 7, + }, + ], + terminationDelay: 10, + }, + serverStatus: { + '10.0.1.12:80': 'UP', + '10.0.1.13:80': 'UP', + }, + status: 'enabled', + usedBy: [], + name: 'service-weighted-servers', + provider: 'docker', + type: 'loadbalancer', + routers: [], + } + + const { getByTestId } = renderWithProviders( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + , + { route: '/tcp/services/mock-service', withPage: true }, + ) + + const serversList = getByTestId('tcp-servers-list') + expect(serversList.childNodes.length).toBe(2) + expect(serversList.innerHTML).toContain('10.0.1.12:80') + expect(serversList.innerHTML).toContain('10.0.1.13:80') + expect(serversList.innerHTML).toContain('3') + expect(serversList.innerHTML).toContain('7') + }) + it('should render weighted services', async () => { const mockData = { weighted: { diff --git a/webui/src/pages/tcp/TcpServices.tsx b/webui/src/pages/tcp/TcpServices.tsx index b48b6cb61e..36aa9680f8 100644 --- a/webui/src/pages/tcp/TcpServices.tsx +++ b/webui/src/pages/tcp/TcpServices.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/udp/UdpRouters.tsx b/webui/src/pages/udp/UdpRouters.tsx index 9f6190d0d0..b4c2e4ef61 100644 --- a/webui/src/pages/udp/UdpRouters.tsx +++ b/webui/src/pages/udp/UdpRouters.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/pages/udp/UdpService.spec.tsx b/webui/src/pages/udp/UdpService.spec.tsx index 6f3dd1c474..40d56a9d27 100644 --- a/webui/src/pages/udp/UdpService.spec.tsx +++ b/webui/src/pages/udp/UdpService.spec.tsx @@ -79,7 +79,7 @@ describe('', () => { expect(serviceDetails.innerHTML).toContain('Termination delay') expect(serviceDetails.innerHTML).toContain('10 ms') - const serversList = getByTestId('servers-list') + const serversList = getByTestId('udp-servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:80') @@ -91,6 +91,11 @@ describe('', () => { it('should render the service servers from the serverStatus property', async () => { const mockData = { loadBalancer: { + servers: [ + { + address: 'http://10.0.1.12:81', + }, + ], terminationDelay: 10, }, status: 'enabled', @@ -132,7 +137,7 @@ describe('', () => { { route: '/udp/services/mock-service', withPage: true }, ) - const serversList = getByTestId('servers-list') + const serversList = getByTestId('udp-servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:81') diff --git a/webui/src/pages/udp/UdpServices.tsx b/webui/src/pages/udp/UdpServices.tsx index fbd667235e..55b35da171 100644 --- a/webui/src/pages/udp/UdpServices.tsx +++ b/webui/src/pages/udp/UdpServices.tsx @@ -1,4 +1,4 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency' import { useMemo } from 'react' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' diff --git a/webui/src/routes.tsx b/webui/src/routes.tsx index 2495848489..f546d1038d 100644 --- a/webui/src/routes.tsx +++ b/webui/src/routes.tsx @@ -1,5 +1,11 @@ import { ReactNode } from 'react' -import { LiaProjectDiagramSolid, LiaServerSolid, LiaCogsSolid, LiaHomeSolid } from 'react-icons/lia' +import { + LiaProjectDiagramSolid, + LiaServerSolid, + LiaCogsSolid, + LiaHomeSolid, + LiaCertificateSolid, +} from 'react-icons/lia' export type Route = { path: string @@ -91,4 +97,16 @@ export const ROUTES: RouteSections[] = [ }, ], }, + { + section: 'certificates', + sectionLabel: 'Certificates', + items: [ + { + path: '/certificates', + activeMatches: ['/certificates/:name'], + label: 'Certificates', + icon: , + }, + ], + }, ] diff --git a/webui/src/types/resources.d.ts b/webui/src/types/resources.d.ts index 7bed033e00..42e486e896 100644 --- a/webui/src/types/resources.d.ts +++ b/webui/src/types/resources.d.ts @@ -1,5 +1,5 @@ declare namespace Resource { - type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading' + type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'expired' | 'loading' type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData } @@ -19,10 +19,10 @@ declare namespace Router { } type TLS = { - options: string + options?: string certResolver: string domains: TlsDomain[] - passthrough: boolean + passthrough?: boolean } type Details = { @@ -121,3 +121,32 @@ declare namespace Middleware { routers?: Router.Details[] } } + +declare namespace Certificate { + /** Raw API response shape */ + type Raw = { + name: string + commonName?: string + sans: string[] + issuerOrg?: string + issuerCN?: string + issuerCountry?: string + organization?: string + country?: string + serialNumber: string + notBefore: string + notAfter: string + version: string + keyType: string + keySize?: number + signatureAlgorithm: string + certFingerprint: string + publicKeyFingerprint: string + status: 'enabled' | 'warning' | 'expired' + } + + /** Enriched certificate with computed fields */ + type Info = Raw & { + daysLeft: number + } +} diff --git a/webui/src/utils/test.tsx b/webui/src/utils/test.tsx index 995908e9fe..b02da4fce3 100644 --- a/webui/src/utils/test.tsx +++ b/webui/src/utils/test.tsx @@ -1,5 +1,5 @@ import { cleanup, render } from '@testing-library/react' -import { FaencyProvider } from '@traefiklabs/faency' +import { FaencyProvider } from '@traefik-labs/faency' import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter } from 'react-router-dom' import { SWRConfig } from 'swr' diff --git a/webui/yarn.lock b/webui/yarn.lock index efb7038fd7..0780ffd6c3 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -71,7 +71,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.15.4, @babel/core@npm:^7.18.9, @babel/core@npm:^7.26.0": +"@babel/core@npm:^7.15.4": version: 7.26.10 resolution: "@babel/core@npm:7.26.10" dependencies: @@ -94,7 +94,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.23.9": +"@babel/core@npm:^7.23.9, @babel/core@npm:^7.28.5": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -1123,17 +1123,6 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-self@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-react-jsx-self@npm:7.25.9" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/ce0e289f6af93d7c4dc6b385512199c5bb138ae61507b4d5117ba88b6a6b5092f704f1bdf80080b7d69b1b8c36649f2a0b250e8198667d4d30c08bbb1546bd99 - languageName: node - linkType: hard - "@babel/plugin-transform-react-jsx-self@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-react-jsx-self@npm:7.27.1" @@ -1145,17 +1134,6 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-source@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/plugin-transform-react-jsx-source@npm:7.25.9" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.25.9" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/fc9ee08efc9be7cbd2cc6788bbf92579adf3cab37912481f1b915221be3d22b0613b5b36a721df5f4c0ab65efe8582fcf8673caab83e6e1ce4cc04ceebf57dfa - languageName: node - linkType: hard - "@babel/plugin-transform-react-jsx-source@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-react-jsx-source@npm:7.27.1" @@ -1470,7 +1448,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.8.4": +"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.8.4": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: @@ -1501,7 +1479,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.26.10, @babel/traverse@npm:^7.26.5, @babel/traverse@npm:^7.26.8, @babel/traverse@npm:^7.27.0": +"@babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.26.10, @babel/traverse@npm:^7.26.5, @babel/traverse@npm:^7.26.8, @babel/traverse@npm:^7.27.0": version: 7.27.0 resolution: "@babel/traverse@npm:7.27.0" dependencies: @@ -1546,7 +1524,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0, @babel/types@npm:^7.4.4": version: 7.27.0 resolution: "@babel/types@npm:7.27.0" dependencies: @@ -2744,20 +2722,20 @@ __metadata: languageName: node linkType: hard -"@joshwooding/vite-plugin-react-docgen-typescript@npm:0.5.0": - version: 0.5.0 - resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.5.0" +"@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.1": + version: 0.6.1 + resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.1" dependencies: glob: "npm:^10.0.0" - magic-string: "npm:^0.27.0" + magic-string: "npm:^0.30.0" react-docgen-typescript: "npm:^2.2.2" peerDependencies: typescript: ">= 4.3.x" - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/dd5bcd01c685c67bcfb4676639f15319937867ad5af0dc083991fe9ae9e66302c72fec53d12e0616a45eadb0ae715bea144d0302f408a44f1eeab14c5160ad4a + checksum: 10c0/0bcc2adbb49158018102bd9d84cd8572c770daee3d46733157933ef0330953bd5b9e102c26f2338ee7dfb8f21a7bb937134d23f8a7935d5dc88525a253557467 languageName: node linkType: hard @@ -2806,7 +2784,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.13, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.0 resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 @@ -4453,6 +4431,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.53" + checksum: 10c0/e8b0a7eb76be22f6f103171f28072de821525a4e400454850516da91a7381957932ff0ce495f227bcb168e86815788b0c1d249ca9e34dca366a82c8825b714ce + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.0.2": version: 5.1.4 resolution: "@rollup/pluginutils@npm:5.1.4" @@ -5002,238 +4987,72 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-actions@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-actions@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - "@types/uuid": "npm:^9.0.1" - dequal: "npm:^2.0.2" - polished: "npm:^4.2.2" - uuid: "npm:^9.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/f05a876966f170a65d51405f0908e7db74daba033c2468f7de35e17d800960b0201d8edfe822508346c1e7f2f664c9e601cadf9673a17a41e4afafd1af922241 - languageName: node - linkType: hard - -"@storybook/addon-backgrounds@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-backgrounds@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - memoizerific: "npm:^1.11.3" - ts-dedent: "npm:^2.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/220adbe8e5b1120de449eb74a307b8ebe44e018138a676f9bafa7bb7adae00ceee9d0b9619dc55bff2ff9a261f932d992cb43dbe79f25e1fc249e2a0ae02d4e2 - languageName: node - linkType: hard - -"@storybook/addon-controls@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-controls@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - dequal: "npm:^2.0.2" - ts-dedent: "npm:^2.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/6521a98f31d5cd436795428884085b766424e9f71d1add34dc4d5470985500145dd90a7e57282affd3c1b31dfc3e6e4582640347f876acdf0be880b7734aca3b - languageName: node - linkType: hard - -"@storybook/addon-docs@npm:8.6.12, @storybook/addon-docs@npm:^8.2.5": - version: 8.6.12 - resolution: "@storybook/addon-docs@npm:8.6.12" +"@storybook/addon-docs@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/addon-docs@npm:10.0.8" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/blocks": "npm:8.6.12" - "@storybook/csf-plugin": "npm:8.6.12" - "@storybook/react-dom-shim": "npm:8.6.12" + "@storybook/csf-plugin": "npm:10.0.8" + "@storybook/icons": "npm:^1.6.0" + "@storybook/react-dom-shim": "npm:10.0.8" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/6a973bcdb4a1fdf369078d7a2e5b527756f982f6652868bf15f1fc0c7da472d15f385079b1b012ec4cda1c7e7940238a4210d7bd729fee92c20661c8f3ace32c + storybook: ^10.0.8 + checksum: 10c0/2d963b559c9725127917aa92367de2304a3865eb16de89e1c71ec9821e8f0477c092b78e822de7d698a4a3d9dc49dbc1737348961dfe8c984ae88de3349787c9 languageName: node linkType: hard -"@storybook/addon-essentials@npm:^8.2.5": - version: 8.6.12 - resolution: "@storybook/addon-essentials@npm:8.6.12" - dependencies: - "@storybook/addon-actions": "npm:8.6.12" - "@storybook/addon-backgrounds": "npm:8.6.12" - "@storybook/addon-controls": "npm:8.6.12" - "@storybook/addon-docs": "npm:8.6.12" - "@storybook/addon-highlight": "npm:8.6.12" - "@storybook/addon-measure": "npm:8.6.12" - "@storybook/addon-outline": "npm:8.6.12" - "@storybook/addon-toolbars": "npm:8.6.12" - "@storybook/addon-viewport": "npm:8.6.12" - ts-dedent: "npm:^2.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/ce018694d1ee07ab8b8efcebfe3efdf1c2163068a3907b46591b040e1876b84f68fe78bb0a43f23b50b824ea6c410aacef416d03833a77fe359b2e81b3be5b03 - languageName: node - linkType: hard - -"@storybook/addon-highlight@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-highlight@npm:8.6.12" +"@storybook/addon-links@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/addon-links@npm:10.0.8" dependencies: "@storybook/global": "npm:^5.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/c2b31583fff2cd54a85b1138a62c61b86db95704db815f0396e75ca6f1317329cfae1c6ed630914a058da2d386078d7934f21063e6d4e55ed1baf2632cfee3cb - languageName: node - linkType: hard - -"@storybook/addon-links@npm:^8.2.2": - version: 8.6.12 - resolution: "@storybook/addon-links@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - ts-dedent: "npm:^2.0.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.12 - peerDependenciesMeta: - react: - optional: true - checksum: 10c0/c90e6e81c486b94a172ebd9fa40d32c02cfe498bc1bb9536fe437842d513668ea015c328a49836de289c20801ee330457868793a7c70fd053dfc7441bf86df61 - languageName: node - linkType: hard - -"@storybook/addon-measure@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-measure@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - tiny-invariant: "npm:^1.3.1" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/1247ebf398b6297400d710a00d423c9d285c8af6f9bf7dd98a7734f54cc5689d7d3a3bf5a1e93847f5eb13d7edfe75900ac28b27932555292f09efe0c4093c28 - languageName: node - linkType: hard - -"@storybook/addon-outline@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-outline@npm:8.6.12" - dependencies: - "@storybook/global": "npm:^5.0.0" - ts-dedent: "npm:^2.0.0" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/2e1c448b932dea10d1d13b8375e154d4f8bbd1144d7e4b35a909f773c72dd041995915becfd438c02b6611e57929ee61c4d4b9af59ef6fddb222baa8c9a66e6f - languageName: node - linkType: hard - -"@storybook/addon-toolbars@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-toolbars@npm:8.6.12" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/6a7cde7eb84f8f533e96371bec7a37b55aa3e462518bc37c1762cabbd37e2dc45ff48c9708ca6034ea55d272f8b9b3a28f2e94b63056d2ab3855458b664c60bc - languageName: node - linkType: hard - -"@storybook/addon-viewport@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/addon-viewport@npm:8.6.12" - dependencies: - memoizerific: "npm:^1.11.3" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/72a570f4f45ba5c0d1515a14d2e03d04bb510ffc4b8181237f7c787c8d2a6eb6429e4cd048256dafec75bb9a764c4a155c022eed0d6476e7fd7da27f01949db4 - languageName: node - linkType: hard - -"@storybook/blocks@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/blocks@npm:8.6.12" - dependencies: - "@storybook/icons": "npm:^1.2.12" - ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^8.6.12 + storybook: ^10.0.8 peerDependenciesMeta: react: optional: true - react-dom: - optional: true - checksum: 10c0/ce15861061888b73a2f05e2fa1dd8947dd37904e61a978299f96c19f3a45b7a65eca265bd10ba101b2e56dcb24f5ff1871cdaff86640142fe46d8491b7b4ac12 + checksum: 10c0/af169f2abe2addcd9b166c531baf087f241a85fb383629ceacadbcff5974c35d8c38f179d28f4220a2d17e23fab97e531fe31968c512f22abc762eebf973bca5 languageName: node linkType: hard -"@storybook/builder-vite@npm:8.6.12, @storybook/builder-vite@npm:^8.2.5": - version: 8.6.12 - resolution: "@storybook/builder-vite@npm:8.6.12" +"@storybook/builder-vite@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/builder-vite@npm:10.0.8" dependencies: - "@storybook/csf-plugin": "npm:8.6.12" - browser-assert: "npm:^1.2.1" + "@storybook/csf-plugin": "npm:10.0.8" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.6.12 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - checksum: 10c0/cf02c9095a7cf12ac1e372f5e8dc01193c4ae298f16416538de514687b9776a4eda478ff01e5ba73e87e4f3603d8453a6a374dde1673fa22abea103135524892 + storybook: ^10.0.8 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10c0/12aae758e00caeee4d983fb2a2029ce897a138e9879a1ac12beb1651891c5dd9bcc05ecaeb344b34608bcdaea24b37abe54a6d5515d3338e73bf6523431ebe78 languageName: node linkType: hard -"@storybook/components@npm:8.6.12, @storybook/components@npm:^8.0.0": - version: 8.6.12 - resolution: "@storybook/components@npm:8.6.12" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/f443f41354d382307734f0507989ffd78d9b3fb9413122487d5e01927057d34b9526bb9ee6b5343cee806a650d6eef2aecf5112af5b0817eeb3204b1ac4fdc3d - languageName: node - linkType: hard - -"@storybook/core-events@npm:^8.0.0": - version: 8.6.12 - resolution: "@storybook/core-events@npm:8.6.12" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/2f0427afb97cd445e7dde5cde9022ae65ef4a9b2c79e2d6f51757d7bd53fb844b4167a85d21d3904ea5f6b95f46df4ca34fca0ead0ae6e992884123ebabc4af0 - languageName: node - linkType: hard - -"@storybook/core@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/core@npm:8.6.12" +"@storybook/csf-plugin@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/csf-plugin@npm:10.0.8" dependencies: - "@storybook/theming": "npm:8.6.12" - better-opn: "npm:^3.0.2" - browser-assert: "npm:^1.2.1" - esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" - esbuild-register: "npm:^3.5.0" - jsdoc-type-pratt-parser: "npm:^4.0.0" - process: "npm:^0.11.10" - recast: "npm:^0.23.5" - semver: "npm:^7.6.2" - util: "npm:^0.12.5" - ws: "npm:^8.2.3" + unplugin: "npm:^2.3.5" peerDependencies: - prettier: ^2 || ^3 + esbuild: "*" + rollup: "*" + storybook: ^10.0.8 + vite: "*" + webpack: "*" peerDependenciesMeta: - prettier: + esbuild: optional: true - checksum: 10c0/e21f2408c3fdd125033dbbbdd91d264a9cf0bd60e6f5c047b74306fed2ad8d32e39d3dad3a6bafc4b7a8f0b25451a328569f921d82de5d07b004f150e1973840 - languageName: node - linkType: hard - -"@storybook/csf-plugin@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/csf-plugin@npm:8.6.12" - dependencies: - unplugin: "npm:^1.3.1" - peerDependencies: - storybook: ^8.6.12 - checksum: 10c0/8bb5b9612178ff997cb21bd957b7918a6a7cd58fb5f3249e6ec2f3a4a039d3ff4f40b873360f202a56cf64d1235bb88a32ef5e308d3a663f294f925257943472 + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + checksum: 10c0/f915c158da53f4357774731ad0663c2edffece95d601ad9fca49c56e97a161b089af05d940bfd12407d8170e0015f330396d049985201a5c5aeb274c0c6fdabe languageName: node linkType: hard @@ -5244,102 +5063,64 @@ __metadata: languageName: node linkType: hard -"@storybook/icons@npm:^1.2.12, @storybook/icons@npm:^1.2.5": - version: 1.4.0 - resolution: "@storybook/icons@npm:1.4.0" +"@storybook/icons@npm:^1.6.0": + version: 1.6.0 + resolution: "@storybook/icons@npm:1.6.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - checksum: 10c0/fd0514fb3fa431a8b5939fe1d9fc336b253ef2c25b34792d2d4ee59e13321108d34f8bf223a0981482f54f83c5ef47ffd1a98c376ca9071011c1b8afe2b01d43 + checksum: 10c0/bbec9201a78a730195f9cf377b15856dc414a54d04e30d16c379d062425cc617bfd0d6586ba1716012cfbdab461f0c9693a6a52920f9bd09c7b4291fb116f59c languageName: node linkType: hard -"@storybook/manager-api@npm:8.6.12, @storybook/manager-api@npm:^8.0.0": - version: 8.6.12 - resolution: "@storybook/manager-api@npm:8.6.12" +"@storybook/react-dom-shim@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/react-dom-shim@npm:10.0.8" peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/88a0d361c27c53f0f7cd32564d404a5e5a3fa129136449003e8ecaecd63fd8e38ddeeda30f189fffddf24a14b674e7d0400003b4dbbdafedfae7d37bbc32272f + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + checksum: 10c0/206086a25d340eca12c5897c487a45bf25f5a8c083e61ed127a6937b883719cca20293365d7572e21a65745f7243fcc546b99f5a28c9c6ce46428a0404a323fa languageName: node linkType: hard -"@storybook/preview-api@npm:8.6.12, @storybook/preview-api@npm:^8.2.2": - version: 8.6.12 - resolution: "@storybook/preview-api@npm:8.6.12" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/38044f40a0ac060ab33ed84eff62da1a99cdb5a2f73e6786b58da4cf5c4295d4ef060373f1fdaa1bfe6cccea8e123768d046555adf98a4acf1abda40fa3e9781 - languageName: node - linkType: hard - -"@storybook/react-dom-shim@npm:8.6.12": - version: 8.6.12 - resolution: "@storybook/react-dom-shim@npm:8.6.12" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.12 - checksum: 10c0/feb0447599c2728039ed46a0fbd7fa3f8644b80518bc7e94b3687125317ce7c9aa13acb6a8279a50f1cd63aefcc7a1e9cbe64d1a9e71afbe3c3d33656063b814 - languageName: node - linkType: hard - -"@storybook/react-vite@npm:^8.2.5": - version: 8.6.12 - resolution: "@storybook/react-vite@npm:8.6.12" +"@storybook/react-vite@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/react-vite@npm:10.0.8" dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.5.0" + "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.6.1" "@rollup/pluginutils": "npm:^5.0.2" - "@storybook/builder-vite": "npm:8.6.12" - "@storybook/react": "npm:8.6.12" - find-up: "npm:^5.0.0" + "@storybook/builder-vite": "npm:10.0.8" + "@storybook/react": "npm:10.0.8" + empathic: "npm:^2.0.0" magic-string: "npm:^0.30.0" - react-docgen: "npm:^7.0.0" + react-docgen: "npm:^8.0.0" resolve: "npm:^1.22.8" tsconfig-paths: "npm:^4.2.0" peerDependencies: - "@storybook/test": 8.6.12 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.12 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 - peerDependenciesMeta: - "@storybook/test": - optional: true - checksum: 10c0/77e8e3c32d2687c2f4a41f0d83a418413cb8b634d63d8092983036f897a06140ad3c06328f80c88815d858c070b5952963004e3d4cc2a748828c0e97339c7d53 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10c0/af5cf3d8b47f9d669bc86e0c77852c5269cd7668f5bf4d53ce9e2747f28230aa614654beb9958f4fd4d75908967e35110c4c712554c6839b31277ad21547b11a languageName: node linkType: hard -"@storybook/react@npm:8.6.12, @storybook/react@npm:^8.2.5": - version: 8.6.12 - resolution: "@storybook/react@npm:8.6.12" +"@storybook/react@npm:10.0.8": + version: 10.0.8 + resolution: "@storybook/react@npm:10.0.8" dependencies: - "@storybook/components": "npm:8.6.12" "@storybook/global": "npm:^5.0.0" - "@storybook/manager-api": "npm:8.6.12" - "@storybook/preview-api": "npm:8.6.12" - "@storybook/react-dom-shim": "npm:8.6.12" - "@storybook/theming": "npm:8.6.12" + "@storybook/react-dom-shim": "npm:10.0.8" peerDependencies: - "@storybook/test": 8.6.12 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.12 - typescript: ">= 4.2.x" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.0.8 + typescript: ">= 4.9.x" peerDependenciesMeta: - "@storybook/test": - optional: true typescript: optional: true - checksum: 10c0/62d44f6c310577520d1c400cf80001c53d3db995dca6845e1b4e749422705e80825d337d1ba42c196453b2b5d66aa6d402127037546cf9f51afed5fce095e152 - languageName: node - linkType: hard - -"@storybook/theming@npm:8.6.12, @storybook/theming@npm:^8.0.0, @storybook/theming@npm:^8.2.2": - version: 8.6.12 - resolution: "@storybook/theming@npm:8.6.12" - peerDependencies: - storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/cd7033dbc9415d765fd15a60c058ea039ce02a84c7cdbe6d7e597adb418694f28ac7cacf849cccef1e8b4374e7fa0df5010f801e6b55844c2fa391968eecba3c + checksum: 10c0/53c00cba6ebf8b452fc892f04e8641d27a9567dc0a3584d4c642bb3b365342338c3e42e371c2f0502c41b3ad46b31d3908969c3a59e829a8891aad6aa516ffbd languageName: node linkType: hard @@ -5408,6 +5189,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.6.3": + version: 6.9.1 + resolution: "@testing-library/jest-dom@npm:6.9.1" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + picocolors: "npm:^1.1.1" + redent: "npm:^3.0.0" + checksum: 10c0/4291ebd2f0f38d14cefac142c56c337941775a5807e2a3d6f1a14c2fbd6be76a18e498ed189e95bedc97d9e8cf1738049bc76c85b5bc5e23fae7c9e10f7b3a12 + languageName: node + linkType: hard + "@testing-library/react@npm:^14.2.1": version: 14.3.1 resolution: "@testing-library/react@npm:14.3.1" @@ -5422,7 +5217,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.5.2": +"@testing-library/user-event@npm:^14.5.2, @testing-library/user-event@npm:^14.6.1": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" peerDependencies: @@ -5438,9 +5233,9 @@ __metadata: languageName: node linkType: hard -"@traefiklabs/faency@npm:12.0.4": - version: 12.0.4 - resolution: "@traefiklabs/faency@npm:12.0.4" +"@traefik-labs/faency@npm:12.0.7": + version: 12.0.7 + resolution: "@traefik-labs/faency@npm:12.0.7" dependencies: "@babel/core": "npm:^7.15.4" "@babel/plugin-transform-react-pure-annotations": "npm:^7.16.7" @@ -5483,18 +5278,14 @@ __metadata: "@semantic-release/npm": "npm:^9.0.0" "@semantic-release/release-notes-generator": "npm:^10.0.3" "@stitches/react": "npm:1.2.8" - "@storybook/addon-docs": "npm:^8.2.5" - "@storybook/addon-essentials": "npm:^8.2.5" - "@storybook/addon-links": "npm:^8.2.2" - "@storybook/builder-vite": "npm:^8.2.5" - "@storybook/preview-api": "npm:^8.2.2" - "@storybook/react": "npm:^8.2.5" - "@storybook/react-vite": "npm:^8.2.5" - "@storybook/theming": "npm:^8.2.2" + "@storybook/addon-docs": "npm:10.0.8" + "@storybook/addon-links": "npm:10.0.8" + "@storybook/builder-vite": "npm:10.0.8" + "@storybook/react-vite": "npm:10.0.8" "@types/jest": "npm:^27.4.1" "@types/jest-axe": "npm:^3.5.3" "@types/lodash.merge": "npm:^4.6.6" - "@types/node": "npm:^20.10.0" + "@types/node": "npm:^24.10.1" "@types/react": "npm:18.2.0" "@types/react-dom": "npm:18.2.0" "@types/tinycolor2": "npm:^1.4.3" @@ -5502,7 +5293,8 @@ __metadata: "@vanilla-extract/dynamic": "npm:^2.1.5" "@vanilla-extract/recipes": "npm:^0.5.7" "@vanilla-extract/vite-plugin": "npm:^5.1.1" - "@vitejs/plugin-react": "npm:^4.3.1" + "@vitejs/plugin-react": "npm:^5.1.1" + "@vueless/storybook-dark-mode": "npm:^10.0.3" babel-loader: "npm:^8.2.2" conventional-changelog-conventionalcommits: "npm:^4.6.3" cross-env: "npm:^7.0.3" @@ -5516,17 +5308,16 @@ __metadata: react: "npm:18.2.0" react-dom: "npm:18.2.0" semantic-release: "npm:^19.0.2" - storybook: "npm:^8.2.5" - storybook-dark-mode: "npm:^4.0.2" + storybook: "npm:10.0.8" tinycolor2: "npm:^1.4.2" typescript: "npm:^5.8.3" use-debounce: "npm:9.0.2" - vite: "npm:^5.4.19" + vite: "npm:7.1.3" vite-plugin-dts: "npm:^4.5.4" peerDependencies: react: ">=18" react-dom: ">=18" - checksum: 10c0/3ad37330aebe01ff674acc8d37799dbce6ac9b9971b6dcdb015be10699ce4386e181a53667bd6e2311dd0ffc5880afacd0b9140d308c63255d2338f1e2a7c08d + checksum: 10c0/73d8b1c3f31da60a86a1711f73f81caa0e3b3203a7bd265f1a71711a8d93fe77da29ff6fbe84ff7aa375b749c3f3ab26fd64469e03cfaf5f70578f39882beab6 languageName: node linkType: hard @@ -5553,7 +5344,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -5585,7 +5376,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.18.0": +"@types/babel__traverse@npm:*": version: 7.20.7 resolution: "@types/babel__traverse@npm:7.20.7" dependencies: @@ -5594,6 +5385,15 @@ __metadata: languageName: node linkType: hard +"@types/babel__traverse@npm:^7.20.7": + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" + dependencies: + "@babel/types": "npm:^7.28.2" + checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -5798,15 +5598,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.10.0": - version: 20.17.30 - resolution: "@types/node@npm:20.17.30" - dependencies: - undici-types: "npm:~6.19.2" - checksum: 10c0/649782c7822367d751472d70c948bcc50cded1a4744610f706f81cd54e1fc015523567d7e3e17f6b19e3e2797f6f23b653e898bdb4a2f21f8759ceba49976310 - languageName: node - linkType: hard - "@types/node@npm:^22.15.18": version: 22.15.18 resolution: "@types/node@npm:22.15.18" @@ -5816,6 +5607,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.10.1": + version: 24.10.4 + resolution: "@types/node@npm:24.10.4" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/069639cb7233ee747df1897b5e784f6b6c5da765c96c94773c580aac888fa1a585048d2a6e95eb8302d89c7a9df75801c8b5a0b7d0221d4249059cf09a5f4228 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0, @types/normalize-package-data@npm:^2.4.1": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -5957,13 +5757,6 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^9.0.1": - version: 9.0.8 - resolution: "@types/uuid@npm:9.0.8" - checksum: 10c0/b411b93054cb1d4361919579ef3508a1f12bf15b5fdd97337d3d351bece6c921b52b6daeef89b62340fd73fd60da407878432a1af777f40648cbe53a01723489 - languageName: node - linkType: hard - "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -6348,21 +6141,6 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:^4.3.1": - version: 4.3.4 - resolution: "@vitejs/plugin-react@npm:4.3.4" - dependencies: - "@babel/core": "npm:^7.26.0" - "@babel/plugin-transform-react-jsx-self": "npm:^7.25.9" - "@babel/plugin-transform-react-jsx-source": "npm:^7.25.9" - "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.14.2" - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 - checksum: 10c0/38a47a1dbafae0b97142943d83ee3674cb3331153a60b1a3fd29d230c12c9dfe63b7c345b231a3450168ed8a9375a9a1a253c3d85e9efdc19478c0d56b98496c - languageName: node - linkType: hard - "@vitejs/plugin-react@npm:^4.7.0": version: 4.7.0 resolution: "@vitejs/plugin-react@npm:4.7.0" @@ -6379,6 +6157,22 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:^5.1.1": + version: 5.1.2 + resolution: "@vitejs/plugin-react@npm:5.1.2" + dependencies: + "@babel/core": "npm:^7.28.5" + "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" + "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" + "@rolldown/pluginutils": "npm:1.0.0-beta.53" + "@types/babel__core": "npm:^7.20.5" + react-refresh: "npm:^0.18.0" + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10c0/d788f269cdf7474425071ba7c4ea7013f174ddaef12b758defe809a551a03ac62a4a80cd858872deb618e7936ccc7cffe178bc12b62e9c836a467e13f15b9390 + languageName: node + linkType: hard + "@vitest/coverage-v8@npm:^3.2.4": version: 3.2.4 resolution: "@vitest/coverage-v8@npm:3.2.4" @@ -6406,6 +6200,19 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/expect@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.3": version: 4.0.3 resolution: "@vitest/expect@npm:4.0.3" @@ -6420,6 +6227,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/mocker@npm:3.2.4" + dependencies: + "@vitest/spy": "npm:3.2.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/f7a4aea19bbbf8f15905847ee9143b6298b2c110f8b64789224cb0ffdc2e96f9802876aa2ca83f1ec1b6e1ff45e822abb34f0054c24d57b29ab18add06536ccd + languageName: node + linkType: hard + "@vitest/mocker@npm:4.0.3": version: 4.0.3 resolution: "@vitest/mocker@npm:4.0.3" @@ -6439,6 +6265,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/pretty-format@npm:3.2.4" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.0.3": version: 4.0.3 resolution: "@vitest/pretty-format@npm:4.0.3" @@ -6469,6 +6304,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/spy@npm:3.2.4" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 + languageName: node + linkType: hard + "@vitest/spy@npm:4.0.3": version: 4.0.3 resolution: "@vitest/spy@npm:4.0.3" @@ -6476,6 +6320,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/utils@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 + languageName: node + linkType: hard + "@vitest/utils@npm:4.0.3": version: 4.0.3 resolution: "@vitest/utils@npm:4.0.3" @@ -6585,6 +6440,18 @@ __metadata: languageName: node linkType: hard +"@vueless/storybook-dark-mode@npm:^10.0.3": + version: 10.0.4 + resolution: "@vueless/storybook-dark-mode@npm:10.0.4" + dependencies: + "@storybook/global": "npm:^5.0.0" + lodash-es: "npm:^4.17.21" + peerDependencies: + storybook: ^10.0.0 + checksum: 10c0/d1c138f99cd5cabdb8bb8da6c1fc2624a2e31d33e3ae70c4413868f43590fec9f23807d9328d3a70bfa0943e8dc93b3affdb23403fd5161a1925a524d1a15a02 + languageName: node + linkType: hard + "@yarnpkg/lockfile@npm:^1.1.0": version: 1.1.0 resolution: "@yarnpkg/lockfile@npm:1.1.0" @@ -7154,6 +7021,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "ast-types-flow@npm:^0.0.8": version: 0.0.8 resolution: "ast-types-flow@npm:0.0.8" @@ -7311,15 +7185,6 @@ __metadata: languageName: node linkType: hard -"better-opn@npm:^3.0.2": - version: 3.0.2 - resolution: "better-opn@npm:3.0.2" - dependencies: - open: "npm:^8.0.4" - checksum: 10c0/911ef25d44da75aabfd2444ce7a4294a8000ebcac73068c04a60298b0f7c7506b60421aa4cd02ac82502fb42baaff7e4892234b51e6923eded44c5a11185f2f5 - languageName: node - linkType: hard - "big-integer@npm:^1.6.44": version: 1.6.52 resolution: "big-integer@npm:1.6.52" @@ -7426,13 +7291,6 @@ __metadata: languageName: node linkType: hard -"browser-assert@npm:^1.2.1": - version: 1.2.1 - resolution: "browser-assert@npm:1.2.1" - checksum: 10c0/902abf999f92c9c951fdb6d7352c09eea9a84706258699655f7e7906e42daa06a1ae286398a755872740e05a6a71c43c5d1a0c0431d67a8cdb66e5d859a3fc0c - languageName: node - linkType: hard - "browserslist@npm:^4.24.0, browserslist@npm:^4.24.4": version: 4.24.4 resolution: "browserslist@npm:4.24.4" @@ -7694,6 +7552,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.2.0": + version: 5.3.3 + resolution: "chai@npm:5.3.3" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 + languageName: node + linkType: hard + "chai@npm:^6.0.1": version: 6.2.0 resolution: "chai@npm:6.2.0" @@ -7768,6 +7639,13 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -8587,6 +8465,13 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + "deep-equal@npm:^2.0.5": version: 2.2.3 resolution: "deep-equal@npm:2.2.3" @@ -8690,13 +8575,6 @@ __metadata: languageName: node linkType: hard -"define-lazy-prop@npm:^2.0.0": - version: 2.0.0 - resolution: "define-lazy-prop@npm:2.0.0" - checksum: 10c0/db6c63864a9d3b7dc9def55d52764968a5af296de87c1b2cc71d8be8142e445208071953649e0386a8cc37cfcf9a2067a47207f1eb9ff250c2a269658fdae422 - languageName: node - linkType: hard - "define-lazy-prop@npm:^3.0.0": version: 3.0.0 resolution: "define-lazy-prop@npm:3.0.0" @@ -8784,7 +8662,7 @@ __metadata: languageName: node linkType: hard -"dequal@npm:^2.0.2, dequal@npm:^2.0.3": +"dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 @@ -8966,6 +8844,13 @@ __metadata: languageName: node linkType: hard +"empathic@npm:^2.0.0": + version: 2.0.0 + resolution: "empathic@npm:2.0.0" + checksum: 10c0/7d3b14b04a93b35c47bcc950467ec914fd241cd9acc0269b0ea160f13026ec110f520c90fae64720fde72cc1757b57f3f292fb606617b7fccac1f4d008a76506 + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -9249,17 +9134,6 @@ __metadata: languageName: node linkType: hard -"esbuild-register@npm:^3.5.0": - version: 3.6.0 - resolution: "esbuild-register@npm:3.6.0" - dependencies: - debug: "npm:^4.3.4" - peerDependencies: - esbuild: ">=0.12 <1" - checksum: 10c0/77193b7ca32ba9f81b35ddf3d3d0138efb0b1429d71b39480cfee932e1189dd2e492bd32bf04a4d0bc3adfbc7ec7381ceb5ffd06efe35f3e70904f1f686566d5 - languageName: node - linkType: hard - "esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0": version: 0.25.2 resolution: "esbuild@npm:0.25.2" @@ -11524,7 +11398,7 @@ __metadata: languageName: node linkType: hard -"is-arguments@npm:^1.0.4, is-arguments@npm:^1.1.1": +"is-arguments@npm:^1.1.1": version: 1.2.0 resolution: "is-arguments@npm:1.2.0" dependencies: @@ -11657,7 +11531,7 @@ __metadata: languageName: node linkType: hard -"is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": +"is-docker@npm:^2.0.0": version: 2.2.1 resolution: "is-docker@npm:2.2.1" bin: @@ -11721,7 +11595,7 @@ __metadata: languageName: node linkType: hard -"is-generator-function@npm:^1.0.10, is-generator-function@npm:^1.0.7": +"is-generator-function@npm:^1.0.10": version: 1.1.0 resolution: "is-generator-function@npm:1.1.0" dependencies: @@ -12014,7 +11888,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -12390,13 +12264,6 @@ __metadata: languageName: node linkType: hard -"jsdoc-type-pratt-parser@npm:^4.0.0": - version: 4.1.0 - resolution: "jsdoc-type-pratt-parser@npm:4.1.0" - checksum: 10c0/7700372d2e733a32f7ea0a1df9cec6752321a5345c11a91b2ab478a031a426e934f16d5c1f15c8566c7b2c10af9f27892a29c2c789039f595470e929a4aa60ea - languageName: node - linkType: hard - "jsdom@npm:^24.0.0": version: 24.1.3 resolution: "jsdom@npm:24.1.3" @@ -13012,6 +12879,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:4.18.1": + version: 4.18.1 + resolution: "lodash-es@npm:4.18.1" + checksum: 10c0/35d4dcf87ef07f8d090f409447575800108057e360b445f590d0d25d09e3d1e33a163d2fc100d4d072b0f901d5e2fc533cd7c4bfd8eeb38a06abec693823c8b8 + languageName: node + linkType: hard + "lodash.capitalize@npm:^4.2.1": version: 4.2.1 resolution: "lodash.capitalize@npm:4.2.1" @@ -13082,10 +12956,10 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.12, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.15": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c +"lodash@npm:4.18.1": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 languageName: node linkType: hard @@ -13161,6 +13035,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.0, loupe@npm:^3.1.4": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 + languageName: node + linkType: hard + "lowercase-keys@npm:^2.0.0": version: 2.0.0 resolution: "lowercase-keys@npm:2.0.0" @@ -13216,15 +13097,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.27.0": - version: 0.27.0 - resolution: "magic-string@npm:0.27.0" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.4.13" - checksum: 10c0/cddacfea14441ca57ae8a307bc3cf90bac69efaa4138dd9a80804cffc2759bf06f32da3a293fb13eaa96334b7d45b7768a34f1d226afae25d2f05b05a3bb37d8 - languageName: node - linkType: hard - "magic-string@npm:^0.30.0, magic-string@npm:^0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" @@ -13329,13 +13201,6 @@ __metadata: languageName: node linkType: hard -"map-or-similar@npm:^1.5.0": - version: 1.5.0 - resolution: "map-or-similar@npm:1.5.0" - checksum: 10c0/33c6ccfdc272992e33e4e99a69541a3e7faed9de3ac5bc732feb2500a9ee71d3f9d098980a70b7746e7eeb7f859ff7dfb8aa9b5ecc4e34170a32ab78cfb18def - languageName: node - linkType: hard - "marked-terminal@npm:^5.0.0": version: 5.2.0 resolution: "marked-terminal@npm:5.2.0" @@ -13377,15 +13242,6 @@ __metadata: languageName: node linkType: hard -"memoizerific@npm:^1.11.3": - version: 1.11.3 - resolution: "memoizerific@npm:1.11.3" - dependencies: - map-or-similar: "npm:^1.5.0" - checksum: 10c0/661bf69b7afbfad57f0208f0c63324f4c96087b480708115b78ee3f0237d86c7f91347f6db31528740b2776c2e34c709bcb034e1e910edee2270c9603a0a469e - languageName: node - linkType: hard - "meow@npm:^12.0.1": version: 12.1.1 resolution: "meow@npm:12.1.1" @@ -14533,17 +14389,6 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.4": - version: 8.4.2 - resolution: "open@npm:8.4.2" - dependencies: - define-lazy-prop: "npm:^2.0.0" - is-docker: "npm:^2.1.1" - is-wsl: "npm:^2.2.0" - checksum: 10c0/bb6b3a58401dacdb0aad14360626faf3fb7fba4b77816b373495988b724fb48941cad80c1b65d62bb31a17609b2cd91c41a181602caea597ca80dfbcc27e84c9 - languageName: node - linkType: hard - "open@npm:^9.1.0": version: 9.1.0 resolution: "open@npm:9.1.0" @@ -15104,6 +14949,13 @@ __metadata: languageName: node linkType: hard +"pathval@npm:^2.0.0": + version: 2.0.1 + resolution: "pathval@npm:2.0.1" + checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 + languageName: node + linkType: hard + "picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -15207,15 +15059,6 @@ __metadata: languageName: node linkType: hard -"polished@npm:^4.2.2": - version: 4.3.1 - resolution: "polished@npm:4.3.1" - dependencies: - "@babel/runtime": "npm:^7.17.8" - checksum: 10c0/45480d4c7281a134281cef092f6ecc202a868475ff66a390fee6e9261386e16f3047b4de46a2f2e1cf7fb7aa8f52d30b4ed631a1e3bcd6f303ca31161d4f07fe - languageName: node - linkType: hard - "possible-typed-array-names@npm:^1.0.0": version: 1.1.0 resolution: "possible-typed-array-names@npm:1.1.0" @@ -15323,13 +15166,6 @@ __metadata: languageName: node linkType: hard -"process@npm:^0.11.10": - version: 0.11.10 - resolution: "process@npm:0.11.10" - checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 - languageName: node - linkType: hard - "progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" @@ -15526,21 +15362,21 @@ __metadata: languageName: node linkType: hard -"react-docgen@npm:^7.0.0": - version: 7.1.1 - resolution: "react-docgen@npm:7.1.1" +"react-docgen@npm:^8.0.0": + version: 8.0.2 + resolution: "react-docgen@npm:8.0.2" dependencies: - "@babel/core": "npm:^7.18.9" - "@babel/traverse": "npm:^7.18.9" - "@babel/types": "npm:^7.18.9" - "@types/babel__core": "npm:^7.18.0" - "@types/babel__traverse": "npm:^7.18.0" + "@babel/core": "npm:^7.28.0" + "@babel/traverse": "npm:^7.28.0" + "@babel/types": "npm:^7.28.2" + "@types/babel__core": "npm:^7.20.5" + "@types/babel__traverse": "npm:^7.20.7" "@types/doctrine": "npm:^0.0.9" "@types/resolve": "npm:^1.20.2" doctrine: "npm:^3.0.0" resolve: "npm:^1.22.1" strip-indent: "npm:^4.0.0" - checksum: 10c0/961e69487f6acbd9110afbda31f5a0c7fa7ab8b1ebe09fc0138c17efd297fa0b69518df873e937cac108732cd8125433bf939115d23ff99c1c171844140705a7 + checksum: 10c0/25e2dd48957c52749cf44bdcf172f3b47d42d8bb8c51000bceb136ff018cbe0a78610d04f12d8bbb882df0d86884e8d05b1d7a1cc39586de356ef5bb9fceab71 languageName: node linkType: hard @@ -15660,13 +15496,6 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:^0.14.2": - version: 0.14.2 - resolution: "react-refresh@npm:0.14.2" - checksum: 10c0/875b72ef56b147a131e33f2abd6ec059d1989854b3ff438898e4f9310bfcc73acff709445b7ba843318a953cb9424bcc2c05af2b3d80011cee28f25aef3e2ebb - languageName: node - linkType: hard - "react-refresh@npm:^0.17.0": version: 0.17.0 resolution: "react-refresh@npm:0.17.0" @@ -15674,6 +15503,13 @@ __metadata: languageName: node linkType: hard +"react-refresh@npm:^0.18.0": + version: 0.18.0 + resolution: "react-refresh@npm:0.18.0" + checksum: 10c0/34a262f7fd803433a534f50deb27a148112a81adcae440c7d1cbae7ef14d21ea8f2b3d783e858cb7698968183b77755a38b4d4b5b1d79b4f4689c2f6d358fff2 + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8" @@ -17162,37 +16998,29 @@ __metadata: languageName: node linkType: hard -"storybook-dark-mode@npm:^4.0.2": - version: 4.0.2 - resolution: "storybook-dark-mode@npm:4.0.2" +"storybook@npm:10.0.8": + version: 10.0.8 + resolution: "storybook@npm:10.0.8" dependencies: - "@storybook/components": "npm:^8.0.0" - "@storybook/core-events": "npm:^8.0.0" "@storybook/global": "npm:^5.0.0" - "@storybook/icons": "npm:^1.2.5" - "@storybook/manager-api": "npm:^8.0.0" - "@storybook/theming": "npm:^8.0.0" - fast-deep-equal: "npm:^3.1.3" - memoizerific: "npm:^1.11.3" - checksum: 10c0/d4fc652ff080f6cc9f0effab0c989b66ead3372b267c2c328eef608f27c9822bf47aaa177405e42768b2de22f8a3e9a0280af50430efd0cf78bd6ed1f12c8b29 - languageName: node - linkType: hard - -"storybook@npm:^8.2.5": - version: 8.6.12 - resolution: "storybook@npm:8.6.12" - dependencies: - "@storybook/core": "npm:8.6.12" + "@storybook/icons": "npm:^1.6.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/expect": "npm:3.2.4" + "@vitest/mocker": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" + recast: "npm:^0.23.5" + semver: "npm:^7.6.2" + ws: "npm:^8.18.0" peerDependencies: prettier: ^2 || ^3 peerDependenciesMeta: prettier: optional: true bin: - getstorybook: ./bin/index.cjs - sb: ./bin/index.cjs - storybook: ./bin/index.cjs - checksum: 10c0/9e52fed104fe9b0e8baad84651f5ea13d37ad885f1cfaf3fb27858c928920abbc05f624516545c360975c5bb86c1107ca8cdf484725fc8ddb540e55a6d536cb6 + storybook: ./dist/bin/dispatcher.js + checksum: 10c0/8389f682840646dfb435af80c2a1470f2c6dda6620f987ad481691fb0e91dafa8fe9f81343c21b66d830321ced9e0f3a7463fec1f6cc507ccad8da53e6523433 languageName: node linkType: hard @@ -17684,7 +17512,7 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3": +"tiny-invariant@npm:^1.3.3": version: 1.3.3 resolution: "tiny-invariant@npm:1.3.3" checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a @@ -17763,6 +17591,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^4.0.3": + version: 4.0.4 + resolution: "tinyspy@npm:4.0.4" + checksum: 10c0/a8020fc17799251e06a8398dcc352601d2770aa91c556b9531ecd7a12581161fd1c14e81cbdaff0c1306c93bfdde8ff6d1c1a3f9bbe6d91604f0fd4e01e2f1eb + languageName: node + linkType: hard + "titleize@npm:^3.0.0": version: 3.0.0 resolution: "titleize@npm:3.0.0" @@ -17827,7 +17662,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^14.2.1" "@testing-library/user-event": "npm:^14.5.2" - "@traefiklabs/faency": "npm:12.0.4" + "@traefik-labs/faency": "npm:12.0.7" "@types/lodash": "npm:^4.17.16" "@types/node": "npm:^22.15.18" "@types/react": "npm:^18.2.0" @@ -17851,7 +17686,7 @@ __metadata: jest-extended: "npm:^4.0.2" jsdom: "npm:^24.0.0" lint-staged: "npm:^9.5.0" - lodash: "npm:^4.17.21" + lodash: "npm:4.18.1" msw: "npm:^2.1.7" prettier: "npm:^3.5.3" query-string: "npm:^6.9.0" @@ -18225,13 +18060,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.19.2": - version: 6.19.8 - resolution: "undici-types@npm:6.19.8" - checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 - languageName: node - linkType: hard - "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" @@ -18239,6 +18067,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -18345,13 +18180,15 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^1.3.1": - version: 1.16.1 - resolution: "unplugin@npm:1.16.1" +"unplugin@npm:^2.3.5": + version: 2.3.11 + resolution: "unplugin@npm:2.3.11" dependencies: - acorn: "npm:^8.14.0" + "@jridgewell/remapping": "npm:^2.3.5" + acorn: "npm:^8.15.0" + picomatch: "npm:^4.0.3" webpack-virtual-modules: "npm:^0.6.2" - checksum: 10c0/dd5f8c5727d0135847da73cf03fb199107f1acf458167034886fda3405737dab871ad3926431b4f70e1e82cdac482ac1383cea4019d782a68515c8e3e611b6cc + checksum: 10c0/273c1eab0eca4470c7317428689295c31dbe8ab0b306504de9f03cd20c156debb4131bef24b27ac615862958c5dd950a3951d26c0723ea774652ab3624149cff languageName: node linkType: hard @@ -18558,28 +18395,6 @@ __metadata: languageName: node linkType: hard -"util@npm:^0.12.5": - version: 0.12.5 - resolution: "util@npm:0.12.5" - dependencies: - inherits: "npm:^2.0.3" - is-arguments: "npm:^1.0.4" - is-generator-function: "npm:^1.0.7" - is-typed-array: "npm:^1.1.3" - which-typed-array: "npm:^1.1.2" - checksum: 10c0/c27054de2cea2229a66c09522d0fa1415fb12d861d08523a8846bf2e4cbf0079d4c3f725f09dcb87493549bcbf05f5798dce1688b53c6c17201a45759e7253f3 - languageName: node - linkType: hard - -"uuid@npm:^9.0.0": - version: 9.0.1 - resolution: "uuid@npm:9.0.1" - bin: - uuid: dist/bin/uuid - checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b - languageName: node - linkType: hard - "vali-date@npm:^1.0.0": version: 1.0.0 resolution: "vali-date@npm:1.0.0" @@ -18669,6 +18484,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:7.1.3": + version: 7.1.3 + resolution: "vite@npm:7.1.3" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.14" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/a0aa418beab80673dc9a3e9d1fa49472955d6ef9d41a4c9c6bd402953f411346f612864dae267adfb2bb8ceeb894482369316ffae5816c84fd45990e352b727d + languageName: node + linkType: hard + "vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0, vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": version: 7.2.2 resolution: "vite@npm:7.2.2" @@ -19027,7 +18897,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.19": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: @@ -19191,7 +19061,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0, ws@npm:^8.2.3": +"ws@npm:^8.18.0": version: 8.18.1 resolution: "ws@npm:8.18.1" peerDependencies: