From ae5345c03e4f51a236a58c7e6c5d0b06eda734fe Mon Sep 17 00:00:00 2001
From: ryan
Date: Mon, 20 Jul 2026 10:58:13 +0800
Subject: [PATCH] feat(rate-limit): add default request rate limit
configuration
- Support openresty_default_limit_req_per_ip in system_configs.
- Add limit_req and limit_req_status 429 directive generation in openresty renderer.
- Implement route-level limit_req_per_ip override and explicit disable.
- Add frontend UI inputs and validation in rate limits tab config.
- Update swagger API docs and changelog for v3.4.3-beta.3.
---
docs/changelog/index.md | 6 +++
docs/docs.go | 8 +++-
docs/swagger.json | 8 +++-
docs/swagger.yaml | 6 ++-
.../rate-limits/components/config-tab.tsx | 34 ++++++++++++++
.../apps/openflare/config_version/logics.go | 2 +
.../apps/openflare/config_version/snapshot.go | 7 +++
.../openflare/option/openresty_validators.go | 13 ++++++
.../openflare/proxy_route/build_helpers.go | 3 +-
.../apps/openflare/proxy_route/helpers.go | 15 +++++++
internal/apps/openflare/proxy_route/logics.go | 8 ++++
...add_openresty_default_limit_req_per_ip.sql | 12 +++++
...add_openresty_default_limit_req_per_ip.sql | 12 +++++
internal/model/openflare_proxy_route.go | 2 +
internal/model/system_configs.go | 1 +
pkg/render/openresty/render.go | 44 +++++++++++++++++--
pkg/render/openresty/render_test.go | 19 +++++---
pkg/render/openresty/types.go | 3 ++
18 files changed, 191 insertions(+), 12 deletions(-)
create mode 100644 internal/db/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql
create mode 100644 internal/db/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 4afba152..121effc6 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -22,6 +22,12 @@ sidebar: false
## [unreleased]
+## [v3.4.3-beta.3] - 2026-07-20
+
+### 新增
+
+- 安全性限流页「配置」页签新增请求频率限制配置:支持在全局配置单 IP 的请求频率限制(例如 10r/s、100r/m),触发限制时边缘节点返回 429 状态码;同时支持站点级别单独配置或通过 `-1` 显式禁用请求频率限制。
+
### 改进
- 限流页请求压力图的 RPS 纵轴按可见时间窗口最高值的 1.5 倍动态缩放,拖动底部时间范围条时同步更新。
diff --git a/docs/docs.go b/docs/docs.go
index 00fea8aa..e970aa8f 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -5571,7 +5571,7 @@ const docTemplate = `{
},
{
"type": "integer",
- "description": "趋势桶分钟数(1、5 或 60,默认 60)",
+ "description": "趋势桶分钟数(1、3、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
@@ -18272,6 +18272,9 @@ const docTemplate = `{
"limit_rate": {
"type": "string"
},
+ "limit_req_per_ip": {
+ "type": "string"
+ },
"origin_address": {
"type": "string"
},
@@ -18388,6 +18391,9 @@ const docTemplate = `{
"limit_rate": {
"type": "string"
},
+ "limit_req_per_ip": {
+ "type": "string"
+ },
"origin_host": {
"type": "string"
},
diff --git a/docs/swagger.json b/docs/swagger.json
index 577fd4d6..b92eea7d 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -5564,7 +5564,7 @@
},
{
"type": "integer",
- "description": "趋势桶分钟数(1、5 或 60,默认 60)",
+ "description": "趋势桶分钟数(1、3、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
@@ -18265,6 +18265,9 @@
"limit_rate": {
"type": "string"
},
+ "limit_req_per_ip": {
+ "type": "string"
+ },
"origin_address": {
"type": "string"
},
@@ -18381,6 +18384,9 @@
"limit_rate": {
"type": "string"
},
+ "limit_req_per_ip": {
+ "type": "string"
+ },
"origin_host": {
"type": "string"
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 7d5b231f..aca02b01 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -2928,6 +2928,8 @@ definitions:
type: integer
limit_rate:
type: string
+ limit_req_per_ip:
+ type: string
origin_address:
type: string
origin_host:
@@ -3005,6 +3007,8 @@ definitions:
type: integer
limit_rate:
type: string
+ limit_req_per_ip:
+ type: string
origin_host:
type: string
origin_id:
@@ -7522,7 +7526,7 @@ paths:
in: query
name: hours
type: integer
- - description: 趋势桶分钟数(1、5 或 60,默认 60)
+ - description: 趋势桶分钟数(1、3、5 或 60,默认 60)
in: query
name: bucket_minutes
type: integer
diff --git a/frontend/app/(main)/rate-limits/components/config-tab.tsx b/frontend/app/(main)/rate-limits/components/config-tab.tsx
index 231f8ad2..6c76f832 100644
--- a/frontend/app/(main)/rate-limits/components/config-tab.tsx
+++ b/frontend/app/(main)/rate-limits/components/config-tab.tsx
@@ -25,6 +25,7 @@ const optionsQueryKey = ['openflare', 'options'] as const;
const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server';
const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip';
const KEY_LIMIT_RATE = 'openresty_default_limit_rate';
+const KEY_LIMIT_REQ_PER_IP = 'openresty_default_limit_req_per_ip';
const limitRatePattern = /^\d+(?:[kKmM])?$/;
@@ -32,12 +33,14 @@ type RateLimitFields = {
openresty_default_limit_conn_per_server: string;
openresty_default_limit_conn_per_ip: string;
openresty_default_limit_rate: string;
+ openresty_default_limit_req_per_ip: string;
};
const defaultFields: RateLimitFields = {
openresty_default_limit_conn_per_server: '0',
openresty_default_limit_conn_per_ip: '0',
openresty_default_limit_rate: '',
+ openresty_default_limit_req_per_ip: '',
};
function optionsToMap(options: Array<{ key: string; value: string }>) {
@@ -55,6 +58,7 @@ function mapOptionsToFields(
optionMap[KEY_CONN_PER_SERVER] ?? '0',
openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0',
openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '',
+ openresty_default_limit_req_per_ip: optionMap[KEY_LIMIT_REQ_PER_IP] ?? '',
};
}
@@ -71,6 +75,13 @@ function validateFields(fields: RateLimitFields) {
if (rate && rate !== '0' && !limitRatePattern.test(rate)) {
throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭');
}
+
+ const reqRate = fields.openresty_default_limit_req_per_ip.trim();
+ if (reqRate && reqRate !== '0' && !/^\d+r\/[sm]$/i.test(reqRate)) {
+ throw new Error(
+ '请求频率限制格式不合法,请使用类似 10r/s、100r/m,或留空关闭',
+ );
+ }
}
function normalizeConnValue(value: string) {
@@ -119,6 +130,10 @@ export function ConfigTab() {
key: KEY_LIMIT_RATE,
value: normalizeRateValue(fields.openresty_default_limit_rate),
},
+ {
+ key: KEY_LIMIT_REQ_PER_IP,
+ value: normalizeRateValue(fields.openresty_default_limit_req_per_ip),
+ },
]);
},
onSuccess: async () => {
@@ -256,6 +271,25 @@ export function ConfigTab() {
单请求带宽默认值,例如 512k 或 1m
+
+
+
+ updateField(
+ 'openresty_default_limit_req_per_ip',
+ e.target.value,
+ )
+ }
+ className='h-9 text-xs'
+ />
+
+ 单个 IP 请求频率默认值,例如 10r/s 或 100r/m,留空关闭
+
+
diff --git a/internal/apps/openflare/config_version/logics.go b/internal/apps/openflare/config_version/logics.go
index 76bb6c58..b8146af2 100644
--- a/internal/apps/openflare/config_version/logics.go
+++ b/internal/apps/openflare/config_version/logics.go
@@ -531,6 +531,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
+ appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP)
return changes
}
@@ -582,5 +583,6 @@ func openRestyOptionKeys() []string {
"OpenRestyDefaultLimitConnPerServer",
"OpenRestyDefaultLimitConnPerIP",
"OpenRestyDefaultLimitRate",
+ "OpenRestyDefaultLimitReqPerIP",
}
}
diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go
index 895c41b9..5921466f 100644
--- a/internal/apps/openflare/config_version/snapshot.go
+++ b/internal/apps/openflare/config_version/snapshot.go
@@ -54,6 +54,7 @@ type snapshotRoute struct {
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
+ LimitReqPerIP string `json:"limit_req_per_ip,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
@@ -138,6 +139,7 @@ type openRestyConfigSnapshot struct {
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
+ DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
}
type snapshotDocument struct {
@@ -285,6 +287,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
+ LimitReqPerIP: route.LimitReqPerIP,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
@@ -548,10 +551,14 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
+ DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))),
}
if snapshot.DefaultLimitRate == "0" {
snapshot.DefaultLimitRate = ""
}
+ if snapshot.DefaultLimitReqPerIP == "0" {
+ snapshot.DefaultLimitReqPerIP = ""
+ }
snapshot.CachePath = normalizeProxyCachePathForSnapshot(snapshot.CacheEnabled, snapshot.CachePath)
return snapshot
}
diff --git a/internal/apps/openflare/option/openresty_validators.go b/internal/apps/openflare/option/openresty_validators.go
index 43e54ad4..7185221c 100644
--- a/internal/apps/openflare/option/openresty_validators.go
+++ b/internal/apps/openflare/option/openresty_validators.go
@@ -53,6 +53,7 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
+ model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP,
}
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
@@ -194,3 +195,15 @@ func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
}
return nil
}
+
+var openRestyDefaultLimitReqPerIPPattern = regexp.MustCompile(`^\d+r/[sm]$`)
+
+func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
+ if trimmed == "" || trimmed == "0" {
+ return nil
+ }
+ if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) {
+ return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key)
+ }
+ return nil
+}
diff --git a/internal/apps/openflare/proxy_route/build_helpers.go b/internal/apps/openflare/proxy_route/build_helpers.go
index 9a2b5dd9..10136e65 100644
--- a/internal/apps/openflare/proxy_route/build_helpers.go
+++ b/internal/apps/openflare/proxy_route/build_helpers.go
@@ -90,7 +90,7 @@ func populateProxyRouteFields(
upstreams []string,
originHost, cachePolicy string,
limitConnPerServer, limitConnPerIP int,
- limitRate, upstreamType string,
+ limitRate, limitReqPerIP, upstreamType string,
) {
route.SiteName = siteName
route.OriginID = originID
@@ -103,6 +103,7 @@ func populateProxyRouteFields(
route.LimitConnPerServer = limitConnPerServer
route.LimitConnPerIP = limitConnPerIP
route.LimitRate = limitRate
+ route.LimitReqPerIP = limitReqPerIP
route.CacheEnabled = input.CacheEnabled
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = jsonFields.cacheRulesJSON
diff --git a/internal/apps/openflare/proxy_route/helpers.go b/internal/apps/openflare/proxy_route/helpers.go
index 1c67cec6..9e631042 100644
--- a/internal/apps/openflare/proxy_route/helpers.go
+++ b/internal/apps/openflare/proxy_route/helpers.go
@@ -23,6 +23,7 @@ import (
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
+var proxyRouteLimitReqPattern = regexp.MustCompile(`^\d+r/[sm]$`)
const (
proxyRouteCachePolicyStatic = "static"
@@ -349,6 +350,20 @@ func normalizeProxyRouteLimitRate(raw string) (string, error) {
return normalized, nil
}
+func normalizeProxyRouteLimitReqPerIP(raw string) (string, error) {
+ normalized := strings.ToLower(strings.TrimSpace(raw))
+ if normalized == "" || normalized == "0" {
+ return "", nil
+ }
+ if normalized == "-1" {
+ return "-1", nil
+ }
+ if !proxyRouteLimitReqPattern.MatchString(normalized) {
+ return "", errors.New("请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用")
+ }
+ return normalized, nil
+}
+
func hasStructuredOriginInput(input Input) bool {
return (input.OriginID != nil && *input.OriginID != 0) ||
strings.TrimSpace(input.OriginScheme) != "" ||
diff --git a/internal/apps/openflare/proxy_route/logics.go b/internal/apps/openflare/proxy_route/logics.go
index 1570be32..6f5f2801 100644
--- a/internal/apps/openflare/proxy_route/logics.go
+++ b/internal/apps/openflare/proxy_route/logics.go
@@ -40,6 +40,7 @@ type Input struct {
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
+ LimitReqPerIP string `json:"limit_req_per_ip"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
@@ -72,6 +73,7 @@ type View struct {
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
+ LimitReqPerIP string `json:"limit_req_per_ip"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules string `json:"cache_rules"`
@@ -267,6 +269,10 @@ func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input)
if err != nil {
return nil, nil, err
}
+ limitReqPerIP, err := normalizeProxyRouteLimitReqPerIP(input.LimitReqPerIP)
+ if err != nil {
+ return nil, nil, err
+ }
if err := validateProxyRouteZoneDomainCertificates(ctx, domains, input.EnableHTTPS); err != nil {
return nil, nil, err
}
@@ -307,6 +313,7 @@ func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input)
limitConnPerServer,
limitConnPerIP,
limitRate,
+ limitReqPerIP,
upstreamType,
)
if err := applyProxyRouteUpstreamType(ctx, route, upstreamType, input); err != nil {
@@ -369,6 +376,7 @@ func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, e
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
+ LimitReqPerIP: route.LimitReqPerIP,
CacheEnabled: route.CacheEnabled,
CachePolicy: displayCachePolicy(route.CacheEnabled, route.CachePolicy),
CacheRules: route.CacheRules,
diff --git a/internal/db/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql b/internal/db/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql
new file mode 100644
index 00000000..553265ef
--- /dev/null
+++ b/internal/db/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql
@@ -0,0 +1,12 @@
+-- +goose Up
+INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
+VALUES
+ ('openresty_default_limit_req_per_ip', '', 'business', 0, '默认单 IP 请求频率限制(空关闭,例如 10r/s、100r/m)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ON CONFLICT (key) DO NOTHING;
+
+ALTER TABLE w_of_proxy_routes ADD COLUMN limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '';
+
+-- +goose Down
+ALTER TABLE w_of_proxy_routes DROP COLUMN limit_req_per_ip;
+
+DELETE FROM w_system_configs WHERE key = 'openresty_default_limit_req_per_ip';
diff --git a/internal/db/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql b/internal/db/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql
new file mode 100644
index 00000000..553265ef
--- /dev/null
+++ b/internal/db/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql
@@ -0,0 +1,12 @@
+-- +goose Up
+INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
+VALUES
+ ('openresty_default_limit_req_per_ip', '', 'business', 0, '默认单 IP 请求频率限制(空关闭,例如 10r/s、100r/m)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ON CONFLICT (key) DO NOTHING;
+
+ALTER TABLE w_of_proxy_routes ADD COLUMN limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '';
+
+-- +goose Down
+ALTER TABLE w_of_proxy_routes DROP COLUMN limit_req_per_ip;
+
+DELETE FROM w_system_configs WHERE key = 'openresty_default_limit_req_per_ip';
diff --git a/internal/model/openflare_proxy_route.go b/internal/model/openflare_proxy_route.go
index b6b7d886..41da0e93 100644
--- a/internal/model/openflare_proxy_route.go
+++ b/internal/model/openflare_proxy_route.go
@@ -25,6 +25,7 @@ type ProxyRoute struct {
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
+ LimitReqPerIP string `json:"limit_req_per_ip" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
@@ -84,6 +85,7 @@ func UpdateProxyRouteRecord(ctx context.Context, route *ProxyRoute) error {
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
+ "limit_req_per_ip": route.LimitReqPerIP,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go
index fd9ba9d3..746f87d6 100644
--- a/internal/model/system_configs.go
+++ b/internal/model/system_configs.go
@@ -108,6 +108,7 @@ const (
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
+ ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制
)
const (
diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go
index e17f1963..41c1d0e9 100644
--- a/pkg/render/openresty/render.go
+++ b/pkg/render/openresty/render.go
@@ -201,7 +201,7 @@ func renderTemplateDirective(enabled bool, statement string) string {
}
func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
- lines := []string{renderOpenRestyLimitZoneBlock()}
+ lines := []string{renderOpenRestyLimitZoneBlock(cfg)}
if !cfg.CacheEnabled {
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
@@ -222,8 +222,14 @@ func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
return strings.Join(lines, "")
}
-func renderOpenRestyLimitZoneBlock() string {
- return " limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n"
+func renderOpenRestyLimitZoneBlock(cfg ConfigSnapshot) string {
+ var builder strings.Builder
+ builder.WriteString(" limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n")
+ builder.WriteString(" limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n")
+ if strings.TrimSpace(cfg.DefaultLimitReqPerIP) != "" {
+ fmt.Fprintf(&builder, " limit_req_zone $binary_remote_addr zone=openflare_req_per_ip:10m rate=%s;\n", strings.TrimSpace(cfg.DefaultLimitReqPerIP))
+ }
+ return builder.String()
}
func renderOpenRestyObservabilityTemplateBlock() string {
@@ -475,6 +481,11 @@ func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
if strings.TrimSpace(limitConfig.LimitRate) != "" {
fmt.Fprintf(&builder, " limit_rate %s;\n", limitConfig.LimitRate)
}
+ if strings.TrimSpace(limitConfig.LimitReqPerIP) != "" {
+ burst := calculateBurst(limitConfig.LimitReqPerIP)
+ fmt.Fprintf(&builder, " limit_req zone=openflare_req_per_ip burst=%d nodelay;\n", burst)
+ fmt.Fprintf(&builder, " limit_req_status 429;\n")
+ }
return builder.String()
}
@@ -483,6 +494,7 @@ func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
+ LimitReqPerIP: mergeLimitRate(route.LimitReqPerIP, cfg.DefaultLimitReqPerIP),
}
}
@@ -864,3 +876,29 @@ func buildPathExactMatchPattern(rules []string) string {
}
return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|"))
}
+
+func calculateBurst(rateStr string) int {
+ rateStr = strings.ToLower(strings.TrimSpace(rateStr))
+ if rateStr == "" {
+ return 0
+ }
+ var val int
+ var unit string
+ _, err := fmt.Sscanf(rateStr, "%dr/%s", &val, &unit)
+ if err != nil {
+ return 5
+ }
+ if val <= 0 {
+ return 5
+ }
+ if unit == "s" {
+ return val * 2
+ } else if unit == "m" {
+ b := val / 5
+ if b < 5 {
+ b = 5
+ }
+ return b
+ }
+ return 5
+}
diff --git a/pkg/render/openresty/render_test.go b/pkg/render/openresty/render_test.go
index d6dcfa7a..d5bc1f3d 100644
--- a/pkg/render/openresty/render_test.go
+++ b/pkg/render/openresty/render_test.go
@@ -464,16 +464,18 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
+ DefaultLimitReqPerIP: "10r/s",
},
- want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
+ want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k", LimitReqPerIP: "10r/s"},
},
{
name: "explicit off ignores default",
- route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
+ route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1", LimitReqPerIP: "-1"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
+ DefaultLimitReqPerIP: "10r/s",
},
want: routeLimitConfig{},
},
@@ -484,8 +486,9 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
+ DefaultLimitReqPerIP: "10r/s",
},
- want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
+ want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m", LimitReqPerIP: "10r/s"},
},
{
name: "partial inherit",
@@ -494,8 +497,9 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "256k",
+ DefaultLimitReqPerIP: "10r/s",
},
- want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
+ want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k", LimitReqPerIP: "10r/s"},
},
}
for _, tc := range cases {
@@ -522,6 +526,7 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
+ DefaultLimitReqPerIP: "10r/s",
},
}
rendered, err := RenderRouteConfig(doc, nil)
@@ -532,6 +537,8 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
"limit_conn openflare_conn_per_server 120;",
"limit_conn openflare_conn_per_ip 12;",
"limit_rate 512k;",
+ "limit_req zone=openflare_req_per_ip burst=20 nodelay;",
+ "limit_req_status 429;",
} {
if !strings.Contains(rendered, want) {
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
@@ -550,18 +557,20 @@ func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
LimitConnPerServer: -1,
LimitConnPerIP: -1,
LimitRate: "-1",
+ LimitReqPerIP: "-1",
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
+ DefaultLimitReqPerIP: "10r/s",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
- if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
+ if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") || strings.Contains(rendered, "limit_req") {
t.Fatalf("expected no limit directives, got:\n%s", rendered)
}
}
diff --git a/pkg/render/openresty/types.go b/pkg/render/openresty/types.go
index 86bb7714..5bb55e9b 100644
--- a/pkg/render/openresty/types.go
+++ b/pkg/render/openresty/types.go
@@ -165,6 +165,7 @@ type Route struct {
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
+ LimitReqPerIP string `json:"limit_req_per_ip,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
@@ -312,6 +313,7 @@ type ConfigSnapshot struct {
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
+ DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
}
// Document is the top-level input structure for the OpenResty renderer,
@@ -341,6 +343,7 @@ type routeLimitConfig struct {
LimitConnPerServer int
LimitConnPerIP int
LimitRate string
+ LimitReqPerIP string
}
type routeUpstreamConfig struct {