diff --git a/docs/content/middlewares/http/ratelimit.md b/docs/content/middlewares/http/ratelimit.md index dc03dc9a3..8759e9255 100644 --- a/docs/content/middlewares/http/ratelimit.md +++ b/docs/content/middlewares/http/ratelimit.md @@ -496,3 +496,718 @@ http: [http.middlewares.test-ratelimit.rateLimit.sourceCriterion] requestHost = true ``` + +### `redis` + +Enables distributed rate limit using `redis` to store the tokens. +If not set, Traefik's in-memory storage is used by default. + +#### `redis.endpoints` + +_Required, Default="127.0.0.1:6379"_ + +Defines how to connect to the Redis server. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.endpoints=127.0.0.1:6379" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + endpoints: + - "127.0.0.1:6379" +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.endpoints=127.0.0.1:6379" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + endpoints: + - "127.0.0.1:6379" +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + endpoints = ["127.0.0.1:6379"] +``` + +#### `redis.username` + +_Optional, Default=""_ + +Defines the username used to authenticate with the Redis server. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.username=user" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + secret: mysecret + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mysecret + namespace: default + +data: + username: dXNlcm5hbWU= + password: cGFzc3dvcmQ= +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.username=user" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + username: user +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + username = "user" +``` + +#### `redis.password` + +_Optional, Default=""_ + +Defines the password to authenticate against the Redis server. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.password=password" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + secret: mysecret + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mysecret + namespace: default + +data: + username: dXNlcm5hbWU= + password: cGFzc3dvcmQ= +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.password=password" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + password: password +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + password = "password" +``` + +#### `redis.db` + +_Optional, Default=0_ + +Defines the database to select after connecting to the Redis. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.db=0" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + db: 0 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.db=0" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + db: 0 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + db = 0 +``` + +#### `redis.tls` + +Same as this [config](https://doc.traefik.io/traefik/providers/redis/#tls) + +_Optional_ + +Defines the TLS configuration used for the secure connection to Redis. + +##### `redis.tls.ca` + +_Optional_ + +`ca` is the path to the certificate authority used for the secure connection to Redis, +it defaults to the system bundle. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.ca=path/to/ca.crt" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + tls: + caSecret: mycasercret + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mycasercret + namespace: default + +data: + # Must contain a certificate under either a `tls.ca` or a `ca.crt` key. + tls.ca: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.ca=path/to/ca.crt" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + rateLimit: + # ... + redis: + tls: + ca: path/to/ca.crt +``` + +```toml tab="File (TOML)" +[providers.redis.tls] + ca = "path/to/ca.crt" +``` + +##### `redis.tls.cert` + +_Optional_ + +`cert` is the path to the public certificate used for the secure connection to Redis. +When this option is set, the `key` option is required. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.cert=path/to/foo.cert" + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.key=path/to/foo.key" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + tls: + certSecret: mytlscert + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mytlscert + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0= +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.cert=path/to/foo.cert" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.key=path/to/foo.key" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + redis: + tls: + cert: path/to/foo.cert + key: path/to/foo.key +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + [http.middlewares.test-ratelimit.rateLimit.redis.tls] + cert = "path/to/foo.cert" + key = "path/to/foo.key" +``` + +##### `redis.tls.key` + +_Optional_ + +`key` is the path to the private key used for the secure connection to Redis. +When this option is set, the `cert` option is required. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.cert=path/to/foo.cert" + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.key=path/to/foo.key" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + tls: + certSecret: mytlscert + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mytlscert + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0= +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.cert=path/to/foo.cert" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.key=path/to/foo.key" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + redis: + tls: + cert: path/to/foo.cert + key: path/to/foo.key +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + [http.middlewares.test-ratelimit.rateLimit.redis.tls] + cert = "path/to/foo.cert" + key = "path/to/foo.key" +``` + +##### `redis.tls.insecureSkipVerify` + +_Optional, Default=false_ + +If `insecureSkipVerify` is `true`, the TLS connection to Redis accepts any certificate presented by the server regardless of the hostnames it covers. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.insecureSkipVerify=true" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + tls: + insecureSkipVerify: true +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.tls.insecureSkipVerify=true" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + tls: + insecureSkipVerify: true +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + [http.middlewares.test-ratelimit.rateLimit.redis.tls] + insecureSkipVerify = true +``` + +#### `redis.poolSize` + +_Optional, Default=0_ + +Defines the base number of socket connections. + +If there are not enough connections in the pool, new connections will be allocated beyond `redis.poolSize`. +You can limit this using `redis.maxActiveConns`. + +Zero means 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.poolSize=42" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + poolSize: 42 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.poolSize=42" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + poolSize: 42 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + poolSize = 42 +``` + +#### `redis.minIdleConns` + +_Optional, Default=0_ + +Defines the minimum number of idle connections, which is useful when establishing new connections is slow. +Zero means that idle connections are not closed. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.minIdleConns=42" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + minIdleConns: 42 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.minIdleConns=42" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + minIdleConns: 42 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + minIdleConns = 42 +``` + +#### `redis.maxActiveConns` + +_Optional, Default=0_ + +Defines the maximum number of connections the pool can allocate at a given time. +Zero means no limit. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.maxActiveConns=42" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + maxActiveConns: 42 +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.maxActiveConns=42" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + maxActiveConns: 42 +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + maxActiveConns = 42 +``` + +#### `redis.readTimeout` + +_Optional, Default=3s_ + +Defines the timeout for socket reads. +If reached, commands will fail with a timeout instead of blocking. +Zero means no timeout. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.readTimeout=42s" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + readTimeout: 42s +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.readTimeout=42s" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + readTimeout: 42s +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + readTimeout = "42s" +``` + +#### `redis.writeTimeout` + +_Optional, Default=3s_ + +Defines the timeout for socket writes. +If reached, commands will fail with a timeout instead of blocking. +Zero means no timeout. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.writeTimeout=42s" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + writeTimeout: 42s +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.writeTimeout=42s" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + writeTimeout: 42s +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + writeTimeout = "42s" +``` + +#### `redis.dialTimeout` + +_Optional, Default=5s_ + +Defines the dial timeout for establishing new connections. +Zero means no timeout. + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.middlewares.test-ratelimit.ratelimit.redis.dialTimeout=42s" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: test-ratelimit +spec: + rateLimit: + # ... + redis: + dialTimeout: 42s +``` + +```yaml tab="Consul Catalog" +- "traefik.http.middlewares.test-ratelimit.ratelimit.redis.dialTimeout=42s" +``` + +```yaml tab="File (YAML)" +http: + middlewares: + test-ratelimit: + rateLimit: + # ... + redis: + dialTimeout: 42s +``` + +```toml tab="File (TOML)" +[http.middlewares] + [http.middlewares.test-ratelimit.rateLimit] + [http.middlewares.test-ratelimit.rateLimit.redis] + dialTimeout = "42s" +``` diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index baf1c4186..bc8b268f9 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -132,6 +132,20 @@ - "traefik.http.middlewares.middleware18.ratelimit.average=42" - "traefik.http.middlewares.middleware18.ratelimit.burst=42" - "traefik.http.middlewares.middleware18.ratelimit.period=42s" +- "traefik.http.middlewares.middleware18.ratelimit.redis.db=42" +- "traefik.http.middlewares.middleware18.ratelimit.redis.dialtimeout=42s" +- "traefik.http.middlewares.middleware18.ratelimit.redis.endpoints=foobar, foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.maxactiveconns=42" +- "traefik.http.middlewares.middleware18.ratelimit.redis.minidleconns=42" +- "traefik.http.middlewares.middleware18.ratelimit.redis.password=foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.poolsize=42" +- "traefik.http.middlewares.middleware18.ratelimit.redis.readtimeout=42s" +- "traefik.http.middlewares.middleware18.ratelimit.redis.tls.ca=foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.tls.cert=foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.tls.insecureskipverify=true" +- "traefik.http.middlewares.middleware18.ratelimit.redis.tls.key=foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.username=foobar" +- "traefik.http.middlewares.middleware18.ratelimit.redis.writetimeout=42s" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.depth=42" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.excludedips=foobar, foobar" - "traefik.http.middlewares.middleware18.ratelimit.sourcecriterion.ipstrategy.ipv6subnet=42" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a5d45447b..0c5ae3bbd 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -311,6 +311,22 @@ depth = 42 excludedIPs = ["foobar", "foobar"] ipv6Subnet = 42 + [http.middlewares.Middleware18.rateLimit.redis] + endpoints = ["foobar", "foobar"] + username = "foobar" + password = "foobar" + db = 42 + poolSize = 42 + minIdleConns = 42 + maxActiveConns = 42 + readTimeout = "42s" + writeTimeout = "42s" + dialTimeout = "42s" + [http.middlewares.Middleware18.rateLimit.redis.tls] + ca = "foobar" + cert = "foobar" + key = "foobar" + insecureSkipVerify = true [http.middlewares.Middleware19] [http.middlewares.Middleware19.redirectRegex] regex = "foobar" diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index c7daacc11..7c0766810 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -360,6 +360,24 @@ http: ipv6Subnet: 42 requestHeaderName: foobar requestHost: true + redis: + endpoints: + - foobar + - foobar + tls: + ca: foobar + cert: foobar + key: foobar + insecureSkipVerify: true + username: foobar + password: foobar + db: 42 + poolSize: 42 + minIdleConns: 42 + maxActiveConns: 42 + readTimeout: 42s + writeTimeout: 42s + dialTimeout: 42s Middleware19: redirectRegex: regex: foobar diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 43b14722c..0b5982e20 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -1790,6 +1790,90 @@ spec: Period, in combination with Average, defines the actual maximum rate, such as: r = Average / Period. It defaults to a second. x-kubernetes-int-or-string: true + redis: + description: Redis hold the configs of Redis as bucket in rate + limiter. + properties: + db: + description: DB defines the Redis database that will be selected + after connecting to the server. + type: integer + dialTimeout: + anyOf: + - type: integer + - type: string + description: |- + DialTimeout sets the timeout for establishing new connections. + Default value is 5 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + endpoints: + description: |- + Endpoints contains either a single address or a seed list of host:port addresses. + Default value is ["localhost:6379"]. + items: + type: string + type: array + maxActiveConns: + description: |- + MaxActiveConns defines the maximum number of connections allocated by the pool at a given time. + Default value is 0, meaning there is no limit. + type: integer + minIdleConns: + description: |- + MinIdleConns defines the minimum number of idle connections. + Default value is 0, and idle connections are not closed by default. + type: integer + poolSize: + description: |- + PoolSize defines the initial number of socket connections. + If the pool runs out of available connections, additional ones will be created beyond PoolSize. + This can be limited using MaxActiveConns. + // Default value is 0, meaning 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + type: integer + readTimeout: + anyOf: + - type: integer + - type: string + description: |- + ReadTimeout defines the timeout for socket read operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + secret: + description: Secret defines the name of the referenced Kubernetes + Secret containing Redis credentials. + type: string + tls: + description: |- + TLS defines TLS-specific configurations, including the CA, certificate, and key, + which can be provided as a file path or file content. + properties: + caSecret: + description: |- + CASecret is the name of the referenced Kubernetes Secret containing the CA to validate the server certificate. + The CA certificate is extracted from key `tls.ca` or `ca.crt`. + type: string + certSecret: + description: |- + CertSecret is the name of the referenced Kubernetes Secret containing the client certificate. + The client certificate is extracted from the keys `tls.crt` and `tls.key`. + type: string + insecureSkipVerify: + description: InsecureSkipVerify defines whether the server + certificates should be validated. + type: boolean + type: object + writeTimeout: + anyOf: + - type: integer + - type: string + description: |- + WriteTimeout defines the timeout for socket write operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + type: object sourceCriterion: description: |- SourceCriterion defines what criterion is used to group requests as originating from a common source. diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index e04f5b84e..c1a080925 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -153,6 +153,21 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/middlewares/Middleware18/rateLimit/average` | `42` | | `traefik/http/middlewares/Middleware18/rateLimit/burst` | `42` | | `traefik/http/middlewares/Middleware18/rateLimit/period` | `42s` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/db` | `42` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/dialTimeout` | `42s` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/endpoints/0` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/endpoints/1` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/maxActiveConns` | `42` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/minIdleConns` | `42` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/password` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/poolSize` | `42` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/readTimeout` | `42s` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/tls/ca` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/tls/cert` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/tls/insecureSkipVerify` | `true` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/tls/key` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/username` | `foobar` | +| `traefik/http/middlewares/Middleware18/rateLimit/redis/writeTimeout` | `42s` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/depth` | `42` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/excludedIPs/0` | `foobar` | | `traefik/http/middlewares/Middleware18/rateLimit/sourceCriterion/ipStrategy/excludedIPs/1` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml index c5fe517a9..cf0da0719 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml @@ -1027,6 +1027,90 @@ spec: Period, in combination with Average, defines the actual maximum rate, such as: r = Average / Period. It defaults to a second. x-kubernetes-int-or-string: true + redis: + description: Redis hold the configs of Redis as bucket in rate + limiter. + properties: + db: + description: DB defines the Redis database that will be selected + after connecting to the server. + type: integer + dialTimeout: + anyOf: + - type: integer + - type: string + description: |- + DialTimeout sets the timeout for establishing new connections. + Default value is 5 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + endpoints: + description: |- + Endpoints contains either a single address or a seed list of host:port addresses. + Default value is ["localhost:6379"]. + items: + type: string + type: array + maxActiveConns: + description: |- + MaxActiveConns defines the maximum number of connections allocated by the pool at a given time. + Default value is 0, meaning there is no limit. + type: integer + minIdleConns: + description: |- + MinIdleConns defines the minimum number of idle connections. + Default value is 0, and idle connections are not closed by default. + type: integer + poolSize: + description: |- + PoolSize defines the initial number of socket connections. + If the pool runs out of available connections, additional ones will be created beyond PoolSize. + This can be limited using MaxActiveConns. + // Default value is 0, meaning 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + type: integer + readTimeout: + anyOf: + - type: integer + - type: string + description: |- + ReadTimeout defines the timeout for socket read operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + secret: + description: Secret defines the name of the referenced Kubernetes + Secret containing Redis credentials. + type: string + tls: + description: |- + TLS defines TLS-specific configurations, including the CA, certificate, and key, + which can be provided as a file path or file content. + properties: + caSecret: + description: |- + CASecret is the name of the referenced Kubernetes Secret containing the CA to validate the server certificate. + The CA certificate is extracted from key `tls.ca` or `ca.crt`. + type: string + certSecret: + description: |- + CertSecret is the name of the referenced Kubernetes Secret containing the client certificate. + The client certificate is extracted from the keys `tls.crt` and `tls.key`. + type: string + insecureSkipVerify: + description: InsecureSkipVerify defines whether the server + certificates should be validated. + type: boolean + type: object + writeTimeout: + anyOf: + - type: integer + - type: string + description: |- + WriteTimeout defines the timeout for socket write operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + type: object sourceCriterion: description: |- SourceCriterion defines what criterion is used to group requests as originating from a common source. diff --git a/go.mod b/go.mod index 6e9528a40..7493f9f50 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 github.com/quic-go/quic-go v0.48.2 + github.com/redis/go-redis/v9 v9.7.1 github.com/rs/zerolog v1.33.0 github.com/sirupsen/logrus v1.9.3 github.com/spiffe/go-spiffe/v2 v2.4.0 @@ -70,6 +71,7 @@ require ( github.com/valyala/fasthttp v1.58.0 github.com/vulcand/oxy/v2 v2.0.0 github.com/vulcand/predicate v1.2.0 + github.com/yuin/gopher-lua v1.1.1 go.opentelemetry.io/collector/pdata v1.10.0 go.opentelemetry.io/contrib/bridges/otellogrus v0.7.0 go.opentelemetry.io/contrib/propagators/autoprop v0.53.0 @@ -301,7 +303,6 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect - github.com/redis/go-redis/v9 v9.6.1 // indirect github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect github.com/rs/cors v1.7.0 // indirect github.com/sacloud/api-client-go v0.2.10 // indirect diff --git a/go.sum b/go.sum index 6c0789d4c..5ed12b428 100644 --- a/go.sum +++ b/go.sum @@ -1030,8 +1030,8 @@ github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KW github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= -github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/redis/go-redis/v9 v9.7.1 h1:4LhKRCIduqXqtvCUlaq9c8bdHOkICjDMrr1+Zb3osAc= +github.com/redis/go-redis/v9 v9.7.1/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -1256,6 +1256,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 43b14722c..0b5982e20 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -1790,6 +1790,90 @@ spec: Period, in combination with Average, defines the actual maximum rate, such as: r = Average / Period. It defaults to a second. x-kubernetes-int-or-string: true + redis: + description: Redis hold the configs of Redis as bucket in rate + limiter. + properties: + db: + description: DB defines the Redis database that will be selected + after connecting to the server. + type: integer + dialTimeout: + anyOf: + - type: integer + - type: string + description: |- + DialTimeout sets the timeout for establishing new connections. + Default value is 5 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + endpoints: + description: |- + Endpoints contains either a single address or a seed list of host:port addresses. + Default value is ["localhost:6379"]. + items: + type: string + type: array + maxActiveConns: + description: |- + MaxActiveConns defines the maximum number of connections allocated by the pool at a given time. + Default value is 0, meaning there is no limit. + type: integer + minIdleConns: + description: |- + MinIdleConns defines the minimum number of idle connections. + Default value is 0, and idle connections are not closed by default. + type: integer + poolSize: + description: |- + PoolSize defines the initial number of socket connections. + If the pool runs out of available connections, additional ones will be created beyond PoolSize. + This can be limited using MaxActiveConns. + // Default value is 0, meaning 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + type: integer + readTimeout: + anyOf: + - type: integer + - type: string + description: |- + ReadTimeout defines the timeout for socket read operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + secret: + description: Secret defines the name of the referenced Kubernetes + Secret containing Redis credentials. + type: string + tls: + description: |- + TLS defines TLS-specific configurations, including the CA, certificate, and key, + which can be provided as a file path or file content. + properties: + caSecret: + description: |- + CASecret is the name of the referenced Kubernetes Secret containing the CA to validate the server certificate. + The CA certificate is extracted from key `tls.ca` or `ca.crt`. + type: string + certSecret: + description: |- + CertSecret is the name of the referenced Kubernetes Secret containing the client certificate. + The client certificate is extracted from the keys `tls.crt` and `tls.key`. + type: string + insecureSkipVerify: + description: InsecureSkipVerify defines whether the server + certificates should be validated. + type: boolean + type: object + writeTimeout: + anyOf: + - type: integer + - type: string + description: |- + WriteTimeout defines the timeout for socket write operations. + Default value is 3 seconds. + pattern: ^([0-9]+(ns|us|µs|ms|s|m|h)?)+$ + x-kubernetes-int-or-string: true + type: object sourceCriterion: description: |- SourceCriterion defines what criterion is used to group requests as originating from a common source. diff --git a/integration/fixtures/ratelimit/simple_redis.toml b/integration/fixtures/ratelimit/simple_redis.toml new file mode 100644 index 000000000..cf3f08b65 --- /dev/null +++ b/integration/fixtures/ratelimit/simple_redis.toml @@ -0,0 +1,39 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[api] + insecure = true + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8081" + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + service = "service1" + middlewares = [ "ratelimit" ] + rule = "Path(`/`)" + +[http.middlewares] + [http.middlewares.ratelimit.rateLimit] + average = 100 + burst = 1 + [http.middlewares.ratelimit.rateLimit.redis] + endpoints = ["{{ .RedisEndpoint }}"] + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + passHostHeader = true + [[http.services.service1.loadBalancer.servers]] + url = "http://{{.Server1}}:80" diff --git a/integration/ratelimit_test.go b/integration/ratelimit_test.go index b0254564d..c0f0c96b3 100644 --- a/integration/ratelimit_test.go +++ b/integration/ratelimit_test.go @@ -1,6 +1,7 @@ package integration import ( + "net" "net/http" "testing" "time" @@ -12,7 +13,8 @@ import ( type RateLimitSuite struct { BaseSuite - ServerIP string + ServerIP string + RedisEndpoint string } func TestRateLimitSuite(t *testing.T) { @@ -26,6 +28,7 @@ func (s *RateLimitSuite) SetupSuite() { s.composeUp() s.ServerIP = s.getComposeServiceIP("whoami1") + s.RedisEndpoint = net.JoinHostPort(s.getComposeServiceIP("redis"), "6379") } func (s *RateLimitSuite) TearDownSuite() { @@ -58,3 +61,34 @@ func (s *RateLimitSuite) TestSimpleConfiguration() { s.T().Fatalf("requests throughput was too fast wrt to rate limiting: 100 requests in %v", elapsed) } } + +func (s *RateLimitSuite) TestRedisRateLimitSimpleConfiguration() { + file := s.adaptFile("fixtures/ratelimit/simple_redis.toml", struct { + Server1 string + RedisEndpoint string + }{ + Server1: s.ServerIP, + RedisEndpoint: s.RedisEndpoint, + }) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("ratelimit", "redis")) + require.NoError(s.T(), err) + + start := time.Now() + count := 0 + for { + err = try.GetRequest("http://127.0.0.1:8081/", 500*time.Millisecond, try.StatusCodeIs(http.StatusOK)) + require.NoError(s.T(), err) + count++ + if count > 100 { + break + } + } + stop := time.Now() + elapsed := stop.Sub(start) + if elapsed < time.Second*99/100 { + s.T().Fatalf("requests throughput was too fast wrt to rate limiting: 100 requests in %v", elapsed) + } +} diff --git a/integration/resources/compose/ratelimit.yml b/integration/resources/compose/ratelimit.yml index 251568165..5d9d6ec1e 100644 --- a/integration/resources/compose/ratelimit.yml +++ b/integration/resources/compose/ratelimit.yml @@ -2,3 +2,10 @@ version: "3.8" services: whoami1: image: traefik/whoami + + redis: + image: redis:5.0 + command: + - redis-server + - --port + - 6379 diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index e1fc3a73b..501b75c01 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -7,6 +7,7 @@ import ( ptypes "github.com/traefik/paerser/types" "github.com/traefik/traefik/v3/pkg/ip" + "github.com/traefik/traefik/v3/pkg/types" ) // ForwardAuthDefaultMaxBodySize is the ForwardAuth.MaxBodySize option default value. @@ -566,6 +567,10 @@ type RateLimit struct { // If several strategies are defined at the same time, an error will be raised. // If none are set, the default is to use the request's remote address field (as an ipStrategy). SourceCriterion *SourceCriterion `json:"sourceCriterion,omitempty" toml:"sourceCriterion,omitempty" yaml:"sourceCriterion,omitempty" export:"true"` + + // Redis stores the configuration for using Redis as a bucket in the rate-limiting algorithm. + // If not specified, Traefik will default to an in-memory bucket for the algorithm. + Redis *Redis `json:"redis,omitempty" toml:"redis,omitempty" yaml:"redis,omitempty" export:"true"` } // SetDefaults sets the default values on a RateLimit. @@ -576,6 +581,58 @@ func (r *RateLimit) SetDefaults() { // +k8s:deepcopy-gen=true +// Redis holds the Redis configuration. +type Redis struct { + // Endpoints contains either a single address or a seed list of host:port addresses. + // Default value is ["localhost:6379"]. + Endpoints []string `json:"endpoints,omitempty" toml:"endpoints,omitempty" yaml:"endpoints,omitempty"` + // TLS defines TLS-specific configurations, including the CA, certificate, and key, + // which can be provided as a file path or file content. + TLS *types.ClientTLS `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" export:"true"` + // Username defines the username to connect to the Redis server. + Username string `json:"username,omitempty" toml:"username,omitempty" yaml:"username,omitempty" loggable:"false"` + // Password defines the password to connect to the Redis server. + Password string `json:"password,omitempty" toml:"password,omitempty" yaml:"password,omitempty" loggable:"false"` + // DB defines the Redis database that will be selected after connecting to the server. + DB int `json:"db,omitempty" toml:"db,omitempty" yaml:"db,omitempty"` + // PoolSize defines the initial number of socket connections. + // If the pool runs out of available connections, additional ones will be created beyond PoolSize. + // This can be limited using MaxActiveConns. + // Default value is 0, meaning 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + PoolSize int `json:"poolSize,omitempty" toml:"poolSize,omitempty" yaml:"poolSize,omitempty" export:"true"` + // MinIdleConns defines the minimum number of idle connections. + // Default value is 0, and idle connections are not closed by default. + MinIdleConns int `json:"minIdleConns,omitempty" toml:"minIdleConns,omitempty" yaml:"minIdleConns,omitempty" export:"true"` + // MaxActiveConns defines the maximum number of connections allocated by the pool at a given time. + // Default value is 0, meaning there is no limit. + MaxActiveConns int `json:"maxActiveConns,omitempty" toml:"maxActiveConns,omitempty" yaml:"maxActiveConns,omitempty" export:"true"` + // ReadTimeout defines the timeout for socket read operations. + // Default value is 3 seconds. + ReadTimeout *ptypes.Duration `json:"readTimeout,omitempty" toml:"readTimeout,omitempty" yaml:"readTimeout,omitempty" export:"true"` + // WriteTimeout defines the timeout for socket write operations. + // Default value is 3 seconds. + WriteTimeout *ptypes.Duration `json:"writeTimeout,omitempty" toml:"writeTimeout,omitempty" yaml:"writeTimeout,omitempty" export:"true"` + // DialTimeout sets the timeout for establishing new connections. + // Default value is 5 seconds. + DialTimeout *ptypes.Duration `json:"dialTimeout,omitempty" toml:"dialTimeout,omitempty" yaml:"dialTimeout,omitempty" export:"true"` +} + +// SetDefaults sets the default values on a RateLimit. +func (r *Redis) SetDefaults() { + r.Endpoints = []string{"localhost:6379"} + + defaultReadTimeout := ptypes.Duration(3 * time.Second) + r.ReadTimeout = &defaultReadTimeout + + defaultWriteTimeout := ptypes.Duration(3 * time.Second) + r.WriteTimeout = &defaultWriteTimeout + + defaultDialTimeout := ptypes.Duration(5 * time.Second) + r.DialTimeout = &defaultDialTimeout +} + +// +k8s:deepcopy-gen=true + // RedirectRegex holds the redirect regex middleware configuration. // This middleware redirects a request using regex matching and replacement. // More info: https://doc.traefik.io/traefik/v3.3/middlewares/http/redirectregex/#regex diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 2f0e8dd4d..2a680897c 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -30,6 +30,7 @@ THE SOFTWARE. package dynamic import ( + paersertypes "github.com/traefik/paerser/types" tls "github.com/traefik/traefik/v3/pkg/tls" types "github.com/traefik/traefik/v3/pkg/types" ) @@ -1094,6 +1095,11 @@ func (in *RateLimit) DeepCopyInto(out *RateLimit) { *out = new(SourceCriterion) (*in).DeepCopyInto(*out) } + if in.Redis != nil { + in, out := &in.Redis, &out.Redis + *out = new(Redis) + (*in).DeepCopyInto(*out) + } return } @@ -1139,6 +1145,47 @@ func (in *RedirectScheme) DeepCopy() *RedirectScheme { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Redis) DeepCopyInto(out *Redis) { + *out = *in + if in.Endpoints != nil { + in, out := &in.Endpoints, &out.Endpoints + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(types.ClientTLS) + **out = **in + } + if in.ReadTimeout != nil { + in, out := &in.ReadTimeout, &out.ReadTimeout + *out = new(paersertypes.Duration) + **out = **in + } + if in.WriteTimeout != nil { + in, out := &in.WriteTimeout, &out.WriteTimeout + *out = new(paersertypes.Duration) + **out = **in + } + if in.DialTimeout != nil { + in, out := &in.DialTimeout, &out.DialTimeout + *out = new(paersertypes.Duration) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Redis. +func (in *Redis) DeepCopy() *Redis { + if in == nil { + return nil + } + out := new(Redis) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ReplacePath) DeepCopyInto(out *ReplacePath) { *out = *in diff --git a/pkg/middlewares/ratelimiter/in_memory_limiter.go b/pkg/middlewares/ratelimiter/in_memory_limiter.go new file mode 100644 index 000000000..bec67aa07 --- /dev/null +++ b/pkg/middlewares/ratelimiter/in_memory_limiter.go @@ -0,0 +1,72 @@ +package ratelimiter + +import ( + "context" + "fmt" + "time" + + "github.com/mailgun/ttlmap" + "github.com/rs/zerolog" + "golang.org/x/time/rate" +) + +type inMemoryRateLimiter struct { + rate rate.Limit // reqs/s + burst int64 + // maxDelay is the maximum duration we're willing to wait for a bucket reservation to become effective, in nanoseconds. + // For now it is somewhat arbitrarily set to 1/(2*rate). + maxDelay time.Duration + // Each rate limiter for a given source is stored in the buckets ttlmap. + // To keep this ttlmap constrained in size, + // each ratelimiter is "garbage collected" when it is considered expired. + // It is considered expired after it hasn't been used for ttl seconds. + ttl int + buckets *ttlmap.TtlMap // actual buckets, keyed by source. + + logger *zerolog.Logger +} + +func newInMemoryRateLimiter(rate rate.Limit, burst int64, maxDelay time.Duration, ttl int, logger *zerolog.Logger) (*inMemoryRateLimiter, error) { + buckets, err := ttlmap.NewConcurrent(maxSources) + if err != nil { + return nil, fmt.Errorf("creating ttlmap: %w", err) + } + + return &inMemoryRateLimiter{ + rate: rate, + burst: burst, + maxDelay: maxDelay, + ttl: ttl, + logger: logger, + buckets: buckets, + }, nil +} + +func (i *inMemoryRateLimiter) Allow(_ context.Context, source string) (*time.Duration, error) { + // Get bucket which contains limiter information. + var bucket *rate.Limiter + if rlSource, exists := i.buckets.Get(source); exists { + bucket = rlSource.(*rate.Limiter) + } else { + bucket = rate.NewLimiter(i.rate, int(i.burst)) + } + + // We Set even in the case where the source already exists, + // because we want to update the expiryTime everytime we get the source, + // as the expiryTime is supposed to reflect the activity (or lack thereof) on that source. + if err := i.buckets.Set(source, bucket, i.ttl); err != nil { + return nil, fmt.Errorf("setting buckets: %w", err) + } + + res := bucket.Reserve() + if !res.OK() { + return nil, nil + } + + delay := res.Delay() + if delay > i.maxDelay { + res.Cancel() + } + + return &delay, nil +} diff --git a/pkg/middlewares/ratelimiter/lua.go b/pkg/middlewares/ratelimiter/lua.go new file mode 100644 index 000000000..47100629e --- /dev/null +++ b/pkg/middlewares/ratelimiter/lua.go @@ -0,0 +1,66 @@ +package ratelimiter + +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +type Rediser interface { + Eval(ctx context.Context, script string, keys []string, args ...interface{}) *redis.Cmd + EvalSha(ctx context.Context, sha1 string, keys []string, args ...interface{}) *redis.Cmd + ScriptExists(ctx context.Context, hashes ...string) *redis.BoolSliceCmd + ScriptLoad(ctx context.Context, script string) *redis.StringCmd + Del(ctx context.Context, keys ...string) *redis.IntCmd + + EvalRO(ctx context.Context, script string, keys []string, args ...interface{}) *redis.Cmd + EvalShaRO(ctx context.Context, sha1 string, keys []string, args ...interface{}) *redis.Cmd +} + +//nolint:dupword +var AllowTokenBucketRaw = ` +local key = KEYS[1] +local limit, burst, ttl, t, max_delay = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4]), + tonumber(ARGV[5]) + +local bucket = { + limit = limit, + burst = burst, + tokens = 0, + last = 0 +} + +local rl_source = redis.call('hgetall', key) + +if table.maxn(rl_source) == 4 then + -- Get bucket state from redis + bucket.last = tonumber(rl_source[2]) + bucket.tokens = tonumber(rl_source[4]) +end + +local last = bucket.last +if t < last then + last = t +end + +local elapsed = t - last +local delta = bucket.limit * elapsed +local tokens = bucket.tokens + delta +tokens = math.min(tokens, bucket.burst) +tokens = tokens - 1 + +local wait_duration = 0 +if tokens < 0 then + wait_duration = (tokens * -1) / bucket.limit + if wait_duration > max_delay then + tokens = tokens + 1 + tokens = math.min(tokens, burst) + end +end + +redis.call('hset', key, 'last', t, 'tokens', tokens) +redis.call('expire', key, ttl) + +return {tostring(true), tostring(wait_duration),tostring(tokens)}` + +var AllowTokenBucketScript = redis.NewScript(AllowTokenBucketRaw) diff --git a/pkg/middlewares/ratelimiter/rate_limiter.go b/pkg/middlewares/ratelimiter/rate_limiter.go old mode 100644 new mode 100755 index d22b99a42..5798fd87a --- a/pkg/middlewares/ratelimiter/rate_limiter.go +++ b/pkg/middlewares/ratelimiter/rate_limiter.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/mailgun/ttlmap" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/middlewares" @@ -23,24 +23,23 @@ const ( maxSources = 65536 ) +type limiter interface { + Allow(ctx context.Context, token string) (*time.Duration, error) +} + // rateLimiter implements rate limiting and traffic shaping with a set of token buckets; // one for each traffic source. The same parameters are applied to all the buckets. type rateLimiter struct { - name string - rate rate.Limit // reqs/s - burst int64 + name string + rate rate.Limit // reqs/s // maxDelay is the maximum duration we're willing to wait for a bucket reservation to become effective, in nanoseconds. // For now it is somewhat arbitrarily set to 1/(2*rate). - maxDelay time.Duration - // each rate limiter for a given source is stored in the buckets ttlmap. - // To keep this ttlmap constrained in size, - // each ratelimiter is "garbage collected" when it is considered expired. - // It is considered expired after it hasn't been used for ttl seconds. - ttl int + maxDelay time.Duration sourceMatcher utils.SourceExtractor next http.Handler + logger *zerolog.Logger - buckets *ttlmap.TtlMap // actual buckets, keyed by source. + limiter limiter } // New returns a rate limiter middleware. @@ -60,12 +59,7 @@ func New(ctx context.Context, next http.Handler, config dynamic.RateLimit, name sourceMatcher, err := middlewares.GetSourceExtractor(ctxLog, config.SourceCriterion) if err != nil { - return nil, err - } - - buckets, err := ttlmap.NewConcurrent(maxSources) - if err != nil { - return nil, err + return nil, fmt.Errorf("getting source extractor: %w", err) } burst := config.Burst @@ -109,16 +103,27 @@ func New(ctx context.Context, next http.Handler, config dynamic.RateLimit, name } else if rtl > 0 { ttl += int(1 / rtl) } + var limiter limiter + if config.Redis != nil { + limiter, err = newRedisLimiter(ctx, rate.Limit(rtl), burst, maxDelay, ttl, config, logger) + if err != nil { + return nil, fmt.Errorf("creating redis limiter: %w", err) + } + } else { + limiter, err = newInMemoryRateLimiter(rate.Limit(rtl), burst, maxDelay, ttl, logger) + if err != nil { + return nil, fmt.Errorf("creating in-memory limiter: %w", err) + } + } return &rateLimiter{ + logger: logger, name: name, rate: rate.Limit(rtl), - burst: burst, maxDelay: maxDelay, next: next, sourceMatcher: sourceMatcher, - buckets: buckets, - ttl: ttl, + limiter: limiter, }, nil } @@ -141,38 +146,34 @@ func (rl *rateLimiter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { logger.Info().Msgf("ignoring token bucket amount > 1: %d", amount) } - var bucket *rate.Limiter - if rlSource, exists := rl.buckets.Get(source); exists { - bucket = rlSource.(*rate.Limiter) - } else { - bucket = rate.NewLimiter(rl.rate, int(rl.burst)) - } - - // We Set even in the case where the source already exists, - // because we want to update the expiryTime every time we get the source, - // as the expiryTime is supposed to reflect the activity (or lack thereof) on that source. - if err := rl.buckets.Set(source, bucket, rl.ttl); err != nil { - logger.Error().Err(err).Msg("Could not insert/update bucket") - observability.SetStatusErrorf(req.Context(), "Could not insert/update bucket") - http.Error(rw, "could not insert/update bucket", http.StatusInternalServerError) + delay, err := rl.limiter.Allow(ctx, source) + if err != nil { + rl.logger.Error().Err(err).Msg("Could not insert/update bucket") + observability.SetStatusErrorf(ctx, "Could not insert/update bucket") + http.Error(rw, "Could not insert/update bucket", http.StatusInternalServerError) return } - res := bucket.Reserve() - if !res.OK() { - observability.SetStatusErrorf(req.Context(), "No bursty traffic allowed") + if delay == nil { + observability.SetStatusErrorf(ctx, "No bursty traffic allowed") http.Error(rw, "No bursty traffic allowed", http.StatusTooManyRequests) return } - delay := res.Delay() - if delay > rl.maxDelay { - res.Cancel() - rl.serveDelayError(ctx, rw, delay) + if *delay > rl.maxDelay { + rl.serveDelayError(ctx, rw, *delay) return } - time.Sleep(delay) + select { + case <-ctx.Done(): + observability.SetStatusErrorf(ctx, "Context canceled") + http.Error(rw, "context canceled", http.StatusInternalServerError) + return + + case <-time.After(*delay): + } + rl.next.ServeHTTP(rw, req) } diff --git a/pkg/middlewares/ratelimiter/rate_limiter_test.go b/pkg/middlewares/ratelimiter/rate_limiter_test.go index d61e4243c..b16dccf00 100644 --- a/pkg/middlewares/ratelimiter/rate_limiter_test.go +++ b/pkg/middlewares/ratelimiter/rate_limiter_test.go @@ -2,19 +2,25 @@ package ratelimiter import ( "context" + "errors" "fmt" + "math/rand" "net/http" "net/http/httptest" "os" + "strconv" "testing" "time" + "github.com/mailgun/ttlmap" + "github.com/redis/go-redis/v9" "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/testhelpers" "github.com/vulcand/oxy/v2/utils" + lua "github.com/yuin/gopher-lua" "golang.org/x/time/rate" ) @@ -84,7 +90,17 @@ func TestNewRateLimiter(t *testing.T) { RequestHeaderName: "Foo", }, }, - expectedError: "iPStrategy and RequestHeaderName are mutually exclusive", + expectedError: "getting source extractor: iPStrategy and RequestHeaderName are mutually exclusive", + }, + { + desc: "Use Redis", + config: dynamic.RateLimit{ + Average: 200, + Burst: 10, + Redis: &dynamic.Redis{ + Endpoints: []string{"localhost:6379"}, + }, + }, }, } @@ -138,7 +154,7 @@ func TestNewRateLimiter(t *testing.T) { } } -func TestRateLimit(t *testing.T) { +func TestInMemoryRateLimit(t *testing.T) { testCases := []struct { desc string config dynamic.RateLimit @@ -326,15 +342,357 @@ func TestRateLimit(t *testing.T) { minCount := computeMinCount(wantCount) if reqCount < minCount { - t.Fatalf("rate was slower than expected: %d requests (wanted > %d) in %v", reqCount, minCount, elapsed) + t.Fatalf("rate was slower than expected: %d requests (wanted > %d) (dropped %d) in %v", reqCount, minCount, dropped, elapsed) } if reqCount > maxCount { - t.Fatalf("rate was faster than expected: %d requests (wanted < %d) in %v", reqCount, maxCount, elapsed) + t.Fatalf("rate was faster than expected: %d requests (wanted < %d) (dropped %d) in %v", reqCount, maxCount, dropped, elapsed) } }) } } +func TestRedisRateLimit(t *testing.T) { + testCases := []struct { + desc string + config dynamic.RateLimit + loadDuration time.Duration + incomingLoad int // in reqs/s + burst int + }{ + { + desc: "Average is respected", + config: dynamic.RateLimit{ + Average: 100, + Burst: 1, + }, + loadDuration: 2 * time.Second, + incomingLoad: 400, + }, + { + desc: "burst allowed, no bursty traffic", + config: dynamic.RateLimit{ + Average: 100, + Burst: 100, + }, + loadDuration: 2 * time.Second, + incomingLoad: 200, + }, + { + desc: "burst allowed, initial burst, under capacity", + config: dynamic.RateLimit{ + Average: 100, + Burst: 100, + }, + loadDuration: 2 * time.Second, + incomingLoad: 200, + burst: 50, + }, + { + desc: "burst allowed, initial burst, over capacity", + config: dynamic.RateLimit{ + Average: 100, + Burst: 100, + }, + loadDuration: 2 * time.Second, + incomingLoad: 200, + burst: 150, + }, + { + desc: "burst over average, initial burst, over capacity", + config: dynamic.RateLimit{ + Average: 100, + Burst: 200, + }, + loadDuration: 2 * time.Second, + incomingLoad: 200, + burst: 300, + }, + { + desc: "lower than 1/s", + config: dynamic.RateLimit{ + // Bug on gopher-lua on parsing the string to number "5e-07" => 0.0000005 + // See https://github.com/yuin/gopher-lua/issues/491 + // Average: 5, + Average: 1, + Period: ptypes.Duration(10 * time.Second), + }, + loadDuration: 2 * time.Second, + incomingLoad: 100, + burst: 0, + }, + { + desc: "lower than 1/s, longer", + config: dynamic.RateLimit{ + // Bug on gopher-lua on parsing the string to number "5e-07" => 0.0000005 + // See https://github.com/yuin/gopher-lua/issues/491 + // Average: 5, + Average: 1, + Period: ptypes.Duration(10 * time.Second), + }, + loadDuration: time.Minute, + incomingLoad: 100, + burst: 0, + }, + { + desc: "lower than 1/s, longer, harsher", + config: dynamic.RateLimit{ + Average: 1, + Period: ptypes.Duration(time.Minute), + }, + loadDuration: time.Minute, + incomingLoad: 100, + burst: 0, + }, + { + desc: "period below 1 second", + config: dynamic.RateLimit{ + Average: 50, + Period: ptypes.Duration(500 * time.Millisecond), + }, + loadDuration: 2 * time.Second, + incomingLoad: 300, + burst: 0, + }, + // TODO Try to disambiguate when it fails if it is because of too high a load. + // { + // desc: "Zero average ==> no rate limiting", + // config: dynamic.RateLimit{ + // Average: 0, + // Burst: 1, + // }, + // incomingLoad: 1000, + // loadDuration: time.Second, + // }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + randPort := rand.Int() + if test.loadDuration >= time.Minute && testing.Short() { + t.Skip("skipping test in short mode.") + } + t.Parallel() + + reqCount := 0 + dropped := 0 + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqCount++ + }) + test.config.Redis = &dynamic.Redis{ + Endpoints: []string{"localhost:6379"}, + } + h, err := New(context.Background(), next, test.config, "rate-limiter") + require.NoError(t, err) + + l := h.(*rateLimiter) + + limiter := l.limiter.(*redisLimiter) + limiter.client = newMockRedisClient(limiter.ttl) + + h = l + + loadPeriod := time.Duration(1e9 / test.incomingLoad) + start := time.Now() + end := start.Add(test.loadDuration) + ticker := time.NewTicker(loadPeriod) + defer ticker.Stop() + for { + if time.Now().After(end) { + break + } + + req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil) + req.RemoteAddr = "127.0.0." + strconv.Itoa(randPort) + ":" + strconv.Itoa(randPort) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + if w.Result().StatusCode != http.StatusOK { + dropped++ + } + if test.burst > 0 && reqCount < test.burst { + // if a burst is defined we first hammer the server with test.burst requests as fast as possible + continue + } + <-ticker.C + } + stop := time.Now() + elapsed := stop.Sub(start) + + burst := test.config.Burst + if burst < 1 { + // actual default value + burst = 1 + } + + period := time.Duration(test.config.Period) + if period == 0 { + period = time.Second + } + + if test.config.Average == 0 { + if reqCount < 75*test.incomingLoad/100 { + t.Fatalf("we (arbitrarily) expect at least 75%% of the requests to go through with no rate limiting, and yet only %d/%d went through", reqCount, test.incomingLoad) + } + if dropped != 0 { + t.Fatalf("no request should have been dropped if rate limiting is disabled, and yet %d were", dropped) + } + return + } + + // Note that even when there is no bursty traffic, + // we take into account the configured burst, + // because it also helps absorbing non-bursty traffic. + rate := float64(test.config.Average) / float64(period) + + wantCount := int(int64(rate*float64(test.loadDuration)) + burst) + + // Allow for a 2% leeway + maxCount := wantCount * 102 / 100 + + // With very high CPU loads, + // we can expect some extra delay in addition to the rate limiting we already do, + // so we allow for some extra leeway there. + // Feel free to adjust wrt to the load on e.g. the CI. + minCount := computeMinCount(wantCount) + + if reqCount < minCount { + t.Fatalf("rate was slower than expected: %d requests (wanted > %d) (dropped %d) in %v", reqCount, minCount, dropped, elapsed) + } + if reqCount > maxCount { + t.Fatalf("rate was faster than expected: %d requests (wanted < %d) (dropped %d) in %v", reqCount, maxCount, dropped, elapsed) + } + }) + } +} + +type mockRedisClient struct { + ttl int + keys *ttlmap.TtlMap +} + +func newMockRedisClient(ttl int) Rediser { + buckets, _ := ttlmap.NewConcurrent(65536) + return &mockRedisClient{ + ttl: ttl, + keys: buckets, + } +} + +func (m *mockRedisClient) EvalSha(ctx context.Context, _ string, keys []string, args ...interface{}) *redis.Cmd { + state := lua.NewState() + defer state.Close() + + tableKeys := state.NewTable() + for _, key := range keys { + tableKeys.Append(lua.LString(key)) + } + state.SetGlobal("KEYS", tableKeys) + + tableArgv := state.NewTable() + for _, arg := range args { + tableArgv.Append(lua.LString(fmt.Sprint(arg))) + } + state.SetGlobal("ARGV", tableArgv) + + mod := state.SetFuncs(state.NewTable(), map[string]lua.LGFunction{ + "call": func(state *lua.LState) int { + switch state.Get(1).String() { + case "hset": + key := state.Get(2).String() + keyLast := state.Get(3).String() + last := state.Get(4).String() + keyTokens := state.Get(5).String() + tokens := state.Get(6).String() + table := []string{keyLast, last, keyTokens, tokens} + _ = m.keys.Set(key, table, m.ttl) + case "hgetall": + key := state.Get(2).String() + value, ok := m.keys.Get(key) + table := state.NewTable() + if !ok { + state.Push(table) + } else { + switch v := value.(type) { + case []string: + if len(v) != 4 { + break + } + for i := range v { + table.Append(lua.LString(v[i])) + } + default: + fmt.Printf("Unknown type: %T\n", v) + } + state.Push(table) + } + case "expire": + default: + return 0 + } + + return 1 + }, + }) + state.SetGlobal("redis", mod) + state.Push(mod) + + cmd := redis.NewCmd(ctx) + if err := state.DoString(AllowTokenBucketRaw); err != nil { + cmd.SetErr(err) + return cmd + } + + result := state.Get(2) + resultTable, ok := result.(*lua.LTable) + if !ok { + cmd.SetErr(errors.New("unexpected response type: " + result.String())) + return cmd + } + + var resultSlice []interface{} + resultTable.ForEach(func(_ lua.LValue, value lua.LValue) { + valueNbr, ok := value.(lua.LNumber) + if !ok { + valueStr, ok := value.(lua.LString) + if !ok { + cmd.SetErr(errors.New("unexpected response value type " + value.String())) + } + resultSlice = append(resultSlice, string(valueStr)) + return + } + + resultSlice = append(resultSlice, int64(valueNbr)) + }) + + cmd.SetVal(resultSlice) + + return cmd +} + +func (m *mockRedisClient) Eval(ctx context.Context, script string, keys []string, args ...interface{}) *redis.Cmd { + return m.EvalSha(ctx, script, keys, args...) +} + +func (m *mockRedisClient) ScriptExists(ctx context.Context, hashes ...string) *redis.BoolSliceCmd { + return nil +} + +func (m *mockRedisClient) ScriptLoad(ctx context.Context, script string) *redis.StringCmd { + return nil +} + +func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd { + return nil +} + +func (m *mockRedisClient) EvalRO(ctx context.Context, script string, keys []string, args ...interface{}) *redis.Cmd { + return nil +} + +func (m *mockRedisClient) EvalShaRO(ctx context.Context, sha1 string, keys []string, args ...interface{}) *redis.Cmd { + return nil +} + func computeMinCount(wantCount int) int { if os.Getenv("CI") != "" { return wantCount * 60 / 100 diff --git a/pkg/middlewares/ratelimiter/redis_limiter.go b/pkg/middlewares/ratelimiter/redis_limiter.go new file mode 100644 index 000000000..6bdf6c8fc --- /dev/null +++ b/pkg/middlewares/ratelimiter/redis_limiter.go @@ -0,0 +1,118 @@ +package ratelimiter + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog" + ptypes "github.com/traefik/paerser/types" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "golang.org/x/time/rate" +) + +const redisPrefix = "rate:" + +type redisLimiter struct { + rate rate.Limit // reqs/s + burst int64 + maxDelay time.Duration + period ptypes.Duration + logger *zerolog.Logger + ttl int + client Rediser +} + +func newRedisLimiter(ctx context.Context, rate rate.Limit, burst int64, maxDelay time.Duration, ttl int, config dynamic.RateLimit, logger *zerolog.Logger) (limiter, error) { + options := &redis.UniversalOptions{ + Addrs: config.Redis.Endpoints, + Username: config.Redis.Username, + Password: config.Redis.Password, + DB: config.Redis.DB, + PoolSize: config.Redis.PoolSize, + MinIdleConns: config.Redis.MinIdleConns, + MaxActiveConns: config.Redis.MaxActiveConns, + } + + if config.Redis.DialTimeout != nil && *config.Redis.DialTimeout > 0 { + options.DialTimeout = time.Duration(*config.Redis.DialTimeout) + } + + if config.Redis.ReadTimeout != nil { + if *config.Redis.ReadTimeout > 0 { + options.ReadTimeout = time.Duration(*config.Redis.ReadTimeout) + } else { + options.ReadTimeout = -1 + } + } + + if config.Redis.WriteTimeout != nil { + if *config.Redis.ReadTimeout > 0 { + options.WriteTimeout = time.Duration(*config.Redis.WriteTimeout) + } else { + options.WriteTimeout = -1 + } + } + + if config.Redis.TLS != nil { + var err error + options.TLSConfig, err = config.Redis.TLS.CreateTLSConfig(ctx) + if err != nil { + return nil, fmt.Errorf("creating TLS config: %w", err) + } + } + + return &redisLimiter{ + rate: rate, + burst: burst, + period: config.Period, + maxDelay: maxDelay, + logger: logger, + ttl: ttl, + client: redis.NewUniversalClient(options), + }, nil +} + +func (r *redisLimiter) Allow(ctx context.Context, source string) (*time.Duration, error) { + ok, delay, err := r.evaluateScript(ctx, source) + if err != nil { + return nil, fmt.Errorf("evaluating script: %w", err) + } + if !ok { + return nil, nil + } + return delay, nil +} + +func (r *redisLimiter) evaluateScript(ctx context.Context, key string) (bool, *time.Duration, error) { + if r.rate == rate.Inf { + return true, nil, nil + } + + params := []interface{}{ + float64(r.rate / 1000000), + r.burst, + r.ttl, + time.Now().UnixMicro(), + r.maxDelay.Microseconds(), + } + v, err := AllowTokenBucketScript.Run(ctx, r.client, []string{redisPrefix + key}, params...).Result() + if err != nil { + return false, nil, fmt.Errorf("running script: %w", err) + } + + values := v.([]interface{}) + ok, err := strconv.ParseBool(values[0].(string)) + if err != nil { + return false, nil, fmt.Errorf("parsing ok value from redis rate lua script: %w", err) + } + delay, err := strconv.ParseFloat(values[1].(string), 64) + if err != nil { + return false, nil, fmt.Errorf("parsing delay value from redis rate lua script: %w", err) + } + + microDelay := time.Duration(delay * float64(time.Microsecond)) + return ok, µDelay, nil +} diff --git a/pkg/provider/kubernetes/crd/fixtures/with_auth.yml b/pkg/provider/kubernetes/crd/fixtures/with_auth.yml index 16d167e6c..4a6c16e13 100644 --- a/pkg/provider/kubernetes/crd/fixtures/with_auth.yml +++ b/pkg/provider/kubernetes/crd/fixtures/with_auth.yml @@ -8,6 +8,7 @@ data: users: |2 dGVzdDokYXByMSRINnVza2trVyRJZ1hMUDZld1RyU3VCa1RycUU4d2ovCnRlc3QyOiRhcHIxJGQ5 aHI5SEJCJDRIeHdnVWlyM0hQNEVzZ2dQL1FObzAK + --- apiVersion: v1 kind: Secret diff --git a/pkg/provider/kubernetes/crd/fixtures/with_ratelimit.yml b/pkg/provider/kubernetes/crd/fixtures/with_ratelimit.yml new file mode 100644 index 000000000..0ada06322 --- /dev/null +++ b/pkg/provider/kubernetes/crd/fixtures/with_ratelimit.yml @@ -0,0 +1,82 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit + namespace: default + +spec: + rateLimit: + period: 1m + average: 6 + burst: 12 + sourceCriterion: + ipStrategy: + excludedIPs: + - 127.0.0.1/32 + - 192.168.1.7 + + redis: + secret: redissecret + endpoints: + - "127.0.0.1:6379" + tls: + certSecret: tlssecret + caSecret: casecret + db: 0 + poolSize: 42 + maxActiveConns: 42 + readTimeout: 42s + writeTimeout: 42s + dialTimeout: 42s + +--- +apiVersion: v1 +kind: Secret +metadata: + name: redissecret + namespace: default +data: + username: dXNlcg== # username: user + password: cGFzc3dvcmQ= # password: password + +--- +apiVersion: v1 +kind: Secret +metadata: + name: casecret + namespace: default + +data: + ca: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + +--- +apiVersion: v1 +kind: Secret +metadata: + name: tlssecret + namespace: default + +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0= + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0= + +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: test2.route + namespace: default + +spec: + entryPoints: + - web + + routes: + - match: Host(`foo.com`) && PathPrefix(`/will-be-limited`) + priority: 12 + kind: Rule + services: + - name: whoami + port: 80 + middlewares: + - name: ratelimit diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index 5e435346b..f9a8809c1 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -266,7 +266,7 @@ func (p *Provider) loadConfigurationFromCRD(ctx context.Context, client Client) continue } - rateLimit, err := createRateLimitMiddleware(middleware.Spec.RateLimit) + rateLimit, err := createRateLimitMiddleware(client, middleware.Namespace, middleware.Spec.RateLimit) if err != nil { logger.Error().Err(err).Msg("Error while reading rateLimit middleware") continue @@ -686,7 +686,7 @@ func createCompressMiddleware(compress *traefikv1alpha1.Compress) *dynamic.Compr return c } -func createRateLimitMiddleware(rateLimit *traefikv1alpha1.RateLimit) (*dynamic.RateLimit, error) { +func createRateLimitMiddleware(client Client, namespace string, rateLimit *traefikv1alpha1.RateLimit) (*dynamic.RateLimit, error) { if rateLimit == nil { return nil, nil } @@ -713,9 +713,97 @@ func createRateLimitMiddleware(rateLimit *traefikv1alpha1.RateLimit) (*dynamic.R rl.SourceCriterion = rateLimit.SourceCriterion } + if rateLimit.Redis != nil { + rl.Redis = &dynamic.Redis{ + DB: rateLimit.Redis.DB, + PoolSize: rateLimit.Redis.PoolSize, + MinIdleConns: rateLimit.Redis.MinIdleConns, + MaxActiveConns: rateLimit.Redis.MaxActiveConns, + } + rl.Redis.SetDefaults() + + if len(rateLimit.Redis.Endpoints) > 0 { + rl.Redis.Endpoints = rateLimit.Redis.Endpoints + } + + if rateLimit.Redis.TLS != nil { + rl.Redis.TLS = &types.ClientTLS{ + InsecureSkipVerify: rateLimit.Redis.TLS.InsecureSkipVerify, + } + + if len(rateLimit.Redis.TLS.CASecret) > 0 { + caSecret, err := loadCASecret(namespace, rateLimit.Redis.TLS.CASecret, client) + if err != nil { + return nil, fmt.Errorf("failed to load auth ca secret: %w", err) + } + rl.Redis.TLS.CA = caSecret + } + + if len(rateLimit.Redis.TLS.CertSecret) > 0 { + authSecretCert, authSecretKey, err := loadAuthTLSSecret(namespace, rateLimit.Redis.TLS.CertSecret, client) + if err != nil { + return nil, fmt.Errorf("failed to load auth secret: %w", err) + } + rl.Redis.TLS.Cert = authSecretCert + rl.Redis.TLS.Key = authSecretKey + } + } + + if rateLimit.Redis.DialTimeout != nil { + err := rl.Redis.DialTimeout.Set(rateLimit.Redis.DialTimeout.String()) + if err != nil { + return nil, err + } + } + + if rateLimit.Redis.ReadTimeout != nil { + err := rl.Redis.ReadTimeout.Set(rateLimit.Redis.ReadTimeout.String()) + if err != nil { + return nil, err + } + } + + if rateLimit.Redis.WriteTimeout != nil { + err := rl.Redis.WriteTimeout.Set(rateLimit.Redis.WriteTimeout.String()) + if err != nil { + return nil, err + } + } + + if rateLimit.Redis.Secret != "" { + var err error + rl.Redis.Username, rl.Redis.Password, err = loadRedisCredentials(namespace, rateLimit.Redis.Secret, client) + if err != nil { + return nil, err + } + } + } + return rl, nil } +func loadRedisCredentials(namespace, secretName string, k8sClient Client) (string, string, error) { + secret, exists, err := k8sClient.GetSecret(namespace, secretName) + if err != nil { + return "", "", fmt.Errorf("failed to fetch secret '%s/%s': %w", namespace, secretName, err) + } + + if !exists { + return "", "", fmt.Errorf("secret '%s/%s' not found", namespace, secretName) + } + + if secret == nil { + return "", "", fmt.Errorf("data for secret '%s/%s' must not be nil", namespace, secretName) + } + + username, usernameExists := secret.Data["username"] + password, passwordExists := secret.Data["password"] + if !usernameExists || !passwordExists { + return "", "", fmt.Errorf("secret '%s/%s' must contain both username and password keys", secret.Namespace, secret.Name) + } + return string(username), string(password), nil +} + func createRetryMiddleware(retry *traefikv1alpha1.Retry) (*dynamic.Retry, error) { if retry == nil { return nil, nil diff --git a/pkg/provider/kubernetes/crd/kubernetes_test.go b/pkg/provider/kubernetes/crd/kubernetes_test.go index 3d88287ea..70b4779dc 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_test.go +++ b/pkg/provider/kubernetes/crd/kubernetes_test.go @@ -1835,6 +1835,84 @@ func TestLoadIngressRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple Ingress Route with middleware ratelimit", + allowCrossNamespace: true, + paths: []string{"services.yml", "with_ratelimit.yml"}, + 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{ + "default-test2-route-3c9bf014491ebdba74f7": { + EntryPoints: []string{"web"}, + Service: "default-test2-route-3c9bf014491ebdba74f7", + Rule: "Host(`foo.com`) && PathPrefix(`/will-be-limited`)", + Priority: 12, + Middlewares: []string{"default-ratelimit"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ratelimit": { + RateLimit: &dynamic.RateLimit{ + Average: 6, + Burst: 12, + Period: ptypes.Duration(60 * time.Second), + SourceCriterion: &dynamic.SourceCriterion{ + IPStrategy: &dynamic.IPStrategy{ + ExcludedIPs: []string{"127.0.0.1/32", "192.168.1.7"}, + }, + }, + Redis: &dynamic.Redis{ + Endpoints: []string{"127.0.0.1:6379"}, + Username: "user", + Password: "password", + TLS: &types.ClientTLS{ + CA: "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----", + Cert: "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----", + Key: "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----", + }, + DB: 0, + PoolSize: 42, + MaxActiveConns: 42, + ReadTimeout: pointer(ptypes.Duration(42 * time.Second)), + WriteTimeout: pointer(ptypes.Duration(42 * time.Second)), + DialTimeout: pointer(ptypes.Duration(42 * time.Second)), + }, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-test2-route-3c9bf014491ebdba74f7": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: pointer(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Middlewares in ingress route config are normalized", allowCrossNamespace: true, diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go index 29810587f..91d2fd962 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go @@ -170,7 +170,7 @@ type ForwardAuth struct { // If not set or empty then all request headers are passed. AuthRequestHeaders []string `json:"authRequestHeaders,omitempty"` // TLS defines the configuration used to secure the connection to the authentication server. - TLS *ClientTLS `json:"tls,omitempty"` + TLS *ClientTLSWithCAOptional `json:"tls,omitempty"` // AddAuthCookiesToResponse defines the list of cookies to copy from the authentication server response to the response. AddAuthCookiesToResponse []string `json:"addAuthCookiesToResponse,omitempty"` // HeaderField defines a header field to store the authenticated user. @@ -186,16 +186,12 @@ type ForwardAuth struct { PreserveRequestMethod bool `json:"preserveRequestMethod,omitempty"` } -// ClientTLS holds the client TLS configuration. -type ClientTLS struct { - // CASecret is the name of the referenced Kubernetes Secret containing the CA to validate the server certificate. - // The CA certificate is extracted from key `tls.ca` or `ca.crt`. - CASecret string `json:"caSecret,omitempty"` - // CertSecret is the name of the referenced Kubernetes Secret containing the client certificate. - // The client certificate is extracted from the keys `tls.crt` and `tls.key`. - CertSecret string `json:"certSecret,omitempty"` - // InsecureSkipVerify defines whether the server certificates should be validated. - InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` +// +k8s:deepcopy-gen=true + +// ClientTLSWithCAOptional holds the client TLS configuration. +// TODO: This has to be removed once the CAOptional option is removed. +type ClientTLSWithCAOptional struct { + ClientTLS `json:",inline"` // Deprecated: TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634). CAOptional *bool `json:"caOptional,omitempty"` @@ -225,6 +221,65 @@ type RateLimit struct { // If several strategies are defined at the same time, an error will be raised. // If none are set, the default is to use the request's remote address field (as an ipStrategy). SourceCriterion *dynamic.SourceCriterion `json:"sourceCriterion,omitempty"` + // Redis hold the configs of Redis as bucket in rate limiter. + Redis *Redis `json:"redis,omitempty"` +} + +// +k8s:deepcopy-gen=true + +// Redis contains the configuration for using Redis in middleware. +// In a Kubernetes setup, the username and password are stored in a Secret file within the same namespace as the middleware. +type Redis struct { + // Endpoints contains either a single address or a seed list of host:port addresses. + // Default value is ["localhost:6379"]. + Endpoints []string `json:"endpoints,omitempty"` + // TLS defines TLS-specific configurations, including the CA, certificate, and key, + // which can be provided as a file path or file content. + TLS *ClientTLS `json:"tls,omitempty"` + // Secret defines the name of the referenced Kubernetes Secret containing Redis credentials. + Secret string `json:"secret,omitempty"` + // DB defines the Redis database that will be selected after connecting to the server. + DB int `json:"db,omitempty"` + // PoolSize defines the initial number of socket connections. + // If the pool runs out of available connections, additional ones will be created beyond PoolSize. + // This can be limited using MaxActiveConns. + // // Default value is 0, meaning 10 connections per every available CPU as reported by runtime.GOMAXPROCS. + PoolSize int `json:"poolSize,omitempty"` + // MinIdleConns defines the minimum number of idle connections. + // Default value is 0, and idle connections are not closed by default. + MinIdleConns int `json:"minIdleConns,omitempty"` + // MaxActiveConns defines the maximum number of connections allocated by the pool at a given time. + // Default value is 0, meaning there is no limit. + MaxActiveConns int `json:"maxActiveConns,omitempty"` + // ReadTimeout defines the timeout for socket read operations. + // Default value is 3 seconds. + // +kubebuilder:validation:Pattern="^([0-9]+(ns|us|µs|ms|s|m|h)?)+$" + // +kubebuilder:validation:XIntOrString + ReadTimeout *intstr.IntOrString `json:"readTimeout,omitempty"` + // WriteTimeout defines the timeout for socket write operations. + // Default value is 3 seconds. + // +kubebuilder:validation:Pattern="^([0-9]+(ns|us|µs|ms|s|m|h)?)+$" + // +kubebuilder:validation:XIntOrString + WriteTimeout *intstr.IntOrString `json:"writeTimeout,omitempty"` + // DialTimeout sets the timeout for establishing new connections. + // Default value is 5 seconds. + // +kubebuilder:validation:Pattern="^([0-9]+(ns|us|µs|ms|s|m|h)?)+$" + // +kubebuilder:validation:XIntOrString + DialTimeout *intstr.IntOrString `json:"dialTimeout,omitempty"` +} + +// +k8s:deepcopy-gen=true + +// ClientTLS holds the client TLS configuration. +type ClientTLS struct { + // CASecret is the name of the referenced Kubernetes Secret containing the CA to validate the server certificate. + // The CA certificate is extracted from key `tls.ca` or `ca.crt`. + CASecret string `json:"caSecret,omitempty"` + // CertSecret is the name of the referenced Kubernetes Secret containing the client certificate. + // The client certificate is extracted from the keys `tls.crt` and `tls.key`. + CertSecret string `json:"certSecret,omitempty"` + // InsecureSkipVerify defines whether the server certificates should be validated. + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` } // +k8s:deepcopy-gen=true 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 ae83e6173..e9dd2386b 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go @@ -146,11 +146,6 @@ func (in *ClientAuth) DeepCopy() *ClientAuth { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClientTLS) DeepCopyInto(out *ClientTLS) { *out = *in - if in.CAOptional != nil { - in, out := &in.CAOptional, &out.CAOptional - *out = new(bool) - **out = **in - } return } @@ -164,6 +159,28 @@ func (in *ClientTLS) DeepCopy() *ClientTLS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClientTLSWithCAOptional) DeepCopyInto(out *ClientTLSWithCAOptional) { + *out = *in + out.ClientTLS = in.ClientTLS + if in.CAOptional != nil { + in, out := &in.CAOptional, &out.CAOptional + *out = new(bool) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClientTLSWithCAOptional. +func (in *ClientTLSWithCAOptional) DeepCopy() *ClientTLSWithCAOptional { + if in == nil { + return nil + } + out := new(ClientTLSWithCAOptional) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Compress) DeepCopyInto(out *Compress) { *out = *in @@ -265,7 +282,7 @@ func (in *ForwardAuth) DeepCopyInto(out *ForwardAuth) { } if in.TLS != nil { in, out := &in.TLS, &out.TLS - *out = new(ClientTLS) + *out = new(ClientTLSWithCAOptional) (*in).DeepCopyInto(*out) } if in.AddAuthCookiesToResponse != nil { @@ -1053,6 +1070,11 @@ func (in *RateLimit) DeepCopyInto(out *RateLimit) { *out = new(dynamic.SourceCriterion) (*in).DeepCopyInto(*out) } + if in.Redis != nil { + in, out := &in.Redis, &out.Redis + *out = new(Redis) + (*in).DeepCopyInto(*out) + } return } @@ -1066,6 +1088,47 @@ func (in *RateLimit) DeepCopy() *RateLimit { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Redis) DeepCopyInto(out *Redis) { + *out = *in + if in.Endpoints != nil { + in, out := &in.Endpoints, &out.Endpoints + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(ClientTLS) + **out = **in + } + if in.ReadTimeout != nil { + in, out := &in.ReadTimeout, &out.ReadTimeout + *out = new(intstr.IntOrString) + **out = **in + } + if in.WriteTimeout != nil { + in, out := &in.WriteTimeout, &out.WriteTimeout + *out = new(intstr.IntOrString) + **out = **in + } + if in.DialTimeout != nil { + in, out := &in.DialTimeout, &out.DialTimeout + *out = new(intstr.IntOrString) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Redis. +func (in *Redis) DeepCopy() *Redis { + if in == nil { + return nil + } + out := new(Redis) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResponseForwarding) DeepCopyInto(out *ResponseForwarding) { *out = *in