feat(rate-limit): 站点级请求频率限制支持继承与自定义

在站点详情流量限制中配置 limit_req_per_ip;渲染按 effective rate 生成多 limit_req_zone,并以站点+IP 隔离计数。
This commit is contained in:
ryan
2026-07-20 15:02:38 +08:00
parent a261c01a9c
commit 80c47f6ff3
8 changed files with 330 additions and 15 deletions
+4
View File
@@ -22,6 +22,10 @@ sidebar: false
## [unreleased]
### 新增
- 反代站点「流量限制」支持配置单 IP 请求频率:空或 0 继承全局默认,-1 关闭,填写如 10r/s、100r/m 为站点自定义;不同站点可使用不同频率并按站点隔离计数。
## [v3.4.3-beta.3] - 2026-07-20
### 新增
@@ -0,0 +1,159 @@
# 站点级访问频率限制设计
日期:2026-07-20
状态:已评审待实现
方案:站点详情 Limits 暴露 `limit_req_per_ip`;渲染时按 effective rate 生成多 `limit_req_zone`,并用站点键隔离 IP 计数
## 背景
全局默认已有:
- `openresty_default_limit_conn_per_server`
- `openresty_default_limit_conn_per_ip`
- `openresty_default_limit_rate`
- `openresty_default_limit_req_per_ip`
站点级并发/带宽已在「反代站点详情 → 流量限制」中配置,语义为:空/`0` 继承、`-1` 关闭、自定义覆盖。
请求频率(`limit_req`)后端字段与 merge 已存在,但:
1. 前端站点详情未暴露 `limit_req_per_ip`
2. 渲染侧仅在全局默认非空时输出**单一** `limit_req_zone ... rate=全局值`,站点自定义 rate 无法真正独立生效(nginx 的 rate 写在 zone 上,不能仅靠 location 覆盖)
## 目标
1. 在**仅站点详情「流量限制」区块**配置单 IP 请求频率。
2. 语义与现有三项一致:空/`0` 继承全局;`-1` 关闭;合法 `Nr/s` / `Nr/m` 为站点自定义。
3. 站点自定义 rate **真正按该 rate 生效**(A 站 5r/s、B 站 10r/s 互不影响)。
4. 同 IP 在不同站点的频率配额**按站点隔离**。
5. 修改后仍需发布配置版本;Agent 使用与 Server 同源的 render 路径。
## 非目标
- 在「安全性 → 限流」页增加按站点列表编辑
- 新建站点表单中的频率字段
- 按路径 / URI 差异化频率限制
- 改变 `limit_conn_*` / `limit_rate` 的现有 zone 与合并模型
- 业务 Zone(顶级域 + 二级域名资源)模型变更
## 语义
### 站点字段 `limit_req_per_ip`(字符串)
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 继承全局 `openresty_default_limit_req_per_ip` |
| `"-1"` | 本站显式关闭频率限制 |
| `^\d+r/[sm]$`(大小写不敏感,存小写) | 本站自定义 rate |
### 全局默认
| 值 | 含义 |
|----|------|
| 空 / `"0"` | 默认关闭;继承方亦不输出 `limit_req` |
| 合法 rate | 未配置站点的 effective rate |
### 合并(与现有 `mergeLimitRate` 一致)
```
if route == -1: effective = off
else if route is set: effective = route // 合法 rate
else: effective = global // route 空/0
// global 空/0 → off
```
## 渲染
### 问题
nginx `limit_req_zone` 的 `rate=` 在 zone 声明时固定;多个站点若 effective rate 不同,必须使用不同 zone。
### 步骤
1. 在 `RenderRouteConfig` / main 配置生成前,对全部 route 计算 effective `LimitReqPerIP`。
2. 收集非空 effective rate 的**去重集合**,在 `http {}`(`renderOpenRestyLimitZoneBlock` 扩展,需能访问 routes 或 precomputed rates)输出:
```nginx
# 变量键:站点名 + IP,保证跨站点计数隔离
# 实现可用 map 或在 server 内 set 后引用;zone key 采用组合键
limit_req_zone $openflare_req_key zone=openflare_req_<rate_token>:10m rate=<rate>;
```
`rate_token` 由 rate 规范化生成(如 `10r/s` → `10rs`,`100r/m` → `100rm`),仅作 zone 名片段,合法 nginx zone 名。
3. 每个业务 server 在 access 相关位置之前设置:
```nginx
set $openflare_req_key "$openflare_waf_site$binary_remote_addr";
```
(与现有 `set $openflare_waf_site "..."` 同源 site_name;若某 server 无 waf site 变量则用同一 displayName/site_name。)
4. `renderRouteLimitBlock` 在 effective rate 非空时输出:
```nginx
limit_req zone=openflare_req_<rate_token> burst=<calculateBurst> nodelay;
limit_req_status 429;
```
5. **无任何** effective rate 时:不输出任何 `limit_req_zone` / `limit_req`(避免引用不存在的 zone)。
6. 应用范围与现有 limit 块一致:HTTP/HTTPS 反代 `location /`、Pages 相关 location;不含 HTTP→HTTPS 重定向-only server。
### 与旧行为差异
| 项 | 旧 | 新 |
|----|----|----|
| zone 数量 | 全局最多 1 个 | 按不同 effective rate 多个 |
| zone key | `$binary_remote_addr` | `$openflare_req_key`(站点+IP) |
| 站点自定义 rate | 无法真正独立 | 引用对应 rate 的 zone |
快照 JSON **仍保留站点原始值**(含空/`-1`),不把 merge 结果写回 route。
## 数据与 API
- 列 `of_proxy_routes.limit_req_per_ip` 已存在;无新迁移(若环境已跑过既有迁移)。
- API `Input` / `View` 已有字段;normalize / 校验已存在。
- 前端类型与详情表单补齐即可。
## 前端
仅改站点详情 `limits-section.tsx`:
- 增加「单 IP 请求频率」输入
- 校验:空、`0`、`-1`、或 `^\d+r/[sm]$i`
- 规范化:trim + lower;`0` → `""`
- `ProxyRouteItem` / `ProxyRouteMutationPayload` 增加 `limit_req_per_ip`
- `buildPayloadFromRoute` 带上该字段,避免其它区块保存时丢失
文案:与并发/带宽一致(空或 0 继承;-1 关闭;例如 10r/s、100r/m 自定义)。
## Agent / 发布
- 配置保存后须**发布配置版本**
- Agent **本地** `RenderJSON`;必须部署含本设计 render 的 Agent,否则 source 有字段但 conf 无指令
- 若 Agent 已记录同 version/checksum,升级二进制后需触发重新 apply(重启或强制重同步)
## 测试
- `mergeRouteLimitConfig`:继承 / 覆盖 / `-1`(已有则补 rate 断言)
- 多站点不同 effective rate:main conf 含多个 `limit_req_zone`,各 location 引用正确 zone 名
- 全关闭:无 `limit_req` 相关指令
- 仅全局有值:一个 zone + 未自定义站点引用该 zone
- 前端类型与表单校验(手工或既有模式)
## 验收
1. 全局 `10r/s`,站点空 → 该站 location 有 limit_req,zone rate=10r/s
2. 站点改 `5r/s` 并发布 → 该站引用 5r/s zone
3. 站点 `-1` → 该站无 limit_req
4. 两站不同 rate,同 IP 压测互不抢同一配额
## 实现边界
| 层 | 工作量 |
|----|--------|
| 渲染 `pkg/render/openresty` | 多 zone + 站点键 + location 引用 |
| 前端详情 Limits + types + payload | 补字段 |
| 后端 API/DB | 已具备,仅回归 |
| 文档/changelog | 用户可见变更记中文 changelog |
@@ -278,6 +278,25 @@ export function normalizeLimitRate(value: string) {
return normalized;
}
const limitReqPattern = /^\d+r\/[sm]$/i;
export function validateLimitReqPerIP(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitReqPattern.test(normalized)) {
return '请求频率格式不合法,请使用 10r/s、100r/m,或 -1 关闭';
}
return null;
}
export function normalizeLimitReqPerIP(value: string) {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === '0') return '';
return normalized;
}
export function validateCacheRules(
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
@@ -335,6 +354,7 @@ export function buildPayloadFromRoute(
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
limit_req_per_ip: route.limit_req_per_ip,
cache_enabled: route.cache_enabled,
cache_policy: (() => {
if (!route.cache_enabled) {
@@ -228,6 +228,7 @@ export function ProxyRouteCreateSheet({
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
limit_req_per_ip: '',
cache_enabled: true,
cache_policy: 'static',
cache_rules: [],
@@ -19,7 +19,9 @@ import type { ProxyRouteItem } from '@/lib/services/openflare';
import {
normalizeLimitRate,
normalizeLimitReqPerIP,
validateLimitRate,
validateLimitReqPerIP,
} from '../../components/helpers';
import { proxyRouteFormIds } from '../helpers';
import { useRouteSectionSave } from '../hooks/use-route-section-save';
@@ -30,6 +32,7 @@ const rateLimitSchema = z
limit_conn_per_server: z.string(),
limit_conn_per_ip: z.string(),
limit_rate: z.string(),
limit_req_per_ip: z.string(),
})
.superRefine((value, context) => {
for (const field of [
@@ -57,6 +60,15 @@ const rateLimitSchema = z
message: limitRateError,
});
}
const limitReqError = validateLimitReqPerIP(value.limit_req_per_ip);
if (limitReqError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['limit_req_per_ip'],
message: limitReqError,
});
}
});
type RateLimitValues = z.infer<typeof rateLimitSchema>;
@@ -99,6 +111,7 @@ export function LimitsSection({
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
limit_req_per_ip: route.limit_req_per_ip || '',
},
});
@@ -107,6 +120,7 @@ export function LimitsSection({
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
limit_req_per_ip: route.limit_req_per_ip || '',
});
}, [form, route]);
@@ -129,6 +143,9 @@ export function LimitsSection({
),
limit_conn_per_ip: parseConnValue(values.limit_conn_per_ip),
limit_rate: normalizeLimitRate(values.limit_rate),
limit_req_per_ip: normalizeLimitReqPerIP(
values.limit_req_per_ip,
),
},
'流量限制已保存',
);
@@ -172,7 +189,7 @@ export function LimitsSection({
control={form.control}
name='limit_rate'
render={({ field }) => (
<FormItem className='md:col-span-2'>
<FormItem>
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder='512k/1m 或 -1' {...field} />
@@ -184,6 +201,24 @@ export function LimitsSection({
</FormItem>
)}
/>
<FormField
control={form.control}
name='limit_req_per_ip'
render={({ field }) => (
<FormItem>
<FormLabel>单 IP 请求频率</FormLabel>
<FormControl>
<Input placeholder='10r/s / 100r/m 或 -1' {...field} />
</FormControl>
<FormDescription>
空或 0 继承全局默认;-1 关闭;例如 10r/s、100r/m
为自定义频率。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
+2
View File
@@ -233,6 +233,7 @@ export interface ProxyRouteItem {
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
limit_req_per_ip: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
@@ -269,6 +270,7 @@ export interface ProxyRouteMutationPayload {
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
limit_req_per_ip?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
+47 -13
View File
@@ -35,7 +35,7 @@ func RenderJSON(sourceJSON string, certificateFiles []SupportFile) (*Result, err
// Render produces a complete OpenResty configuration Result from a Document and
// a set of certificate support files.
func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
mainConfig := RenderMainConfig(doc.OpenRestyConfig)
mainConfig := RenderMainConfig(doc)
routeConfig, err := RenderRouteConfig(doc, certificateFiles)
if err != nil {
return nil, err
@@ -56,13 +56,15 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
}
// RenderMainConfig renders the nginx main configuration string from the given
// ConfigSnapshot, falling back to the built-in default template when none is set.
func RenderMainConfig(cfg ConfigSnapshot) string {
// Document, falling back to the built-in default template when none is set.
// Limit-req zones are derived from each route's effective rate after merge.
func RenderMainConfig(doc Document) string {
cfg := doc.OpenRestyConfig
templateText := cfg.MainConfigTemplate
if strings.TrimSpace(templateText) == "" {
templateText = defaultMainConfigTemplate
}
return renderMainConfigTemplate(templateText, cfg)
return renderMainConfigTemplate(templateText, cfg, collectEffectiveLimitReqRates(doc.Routes, cfg))
}
// ValidateMainConfigTemplate checks that the provided template text is non-empty
@@ -157,7 +159,7 @@ func DedupeSupportFiles(files []SupportFile) []SupportFile {
return result
}
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string {
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqRates []string) string {
replacer := strings.NewReplacer(
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
@@ -187,7 +189,7 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string {
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
"{{OpenRestyResolverDirective}}", renderTemplateDirective(cfg.Resolvers != "", fmt.Sprintf("resolver %s;", cfg.Resolvers)),
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg, limitReqRates),
"{{OpenRestyRouteConfigInclude}}", RouteConfigPlaceholder,
)
return replacer.Replace(templateText)
@@ -200,8 +202,8 @@ func renderTemplateDirective(enabled bool, statement string) string {
return fmt.Sprintf(" %s\n", statement)
}
func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
lines := []string{renderOpenRestyLimitZoneBlock(cfg)}
func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot, limitReqRates []string) string {
lines := []string{renderOpenRestyLimitZoneBlock(limitReqRates)}
if !cfg.CacheEnabled {
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
@@ -222,16 +224,47 @@ func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
return strings.Join(lines, "")
}
func renderOpenRestyLimitZoneBlock(cfg ConfigSnapshot) string {
func renderOpenRestyLimitZoneBlock(limitReqRates []string) 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))
for _, rate := range limitReqRates {
fmt.Fprintf(
&builder,
" limit_req_zone $openflare_waf_site$binary_remote_addr zone=%s:10m rate=%s;\n",
limitReqZoneName(rate),
rate,
)
}
return builder.String()
}
func collectEffectiveLimitReqRates(routes []Route, cfg ConfigSnapshot) []string {
seen := make(map[string]struct{}, len(routes))
for _, route := range routes {
rate := strings.TrimSpace(mergeRouteLimitConfig(route, cfg).LimitReqPerIP)
if rate == "" {
continue
}
seen[rate] = struct{}{}
}
if len(seen) == 0 {
return nil
}
rates := make([]string, 0, len(seen))
for rate := range seen {
rates = append(rates, rate)
}
sort.Strings(rates)
return rates
}
func limitReqZoneName(rate string) string {
normalized := strings.ToLower(strings.TrimSpace(rate))
normalized = strings.ReplaceAll(normalized, "/", "")
return "openflare_req_" + normalized
}
func renderOpenRestyObservabilityTemplateBlock() string {
return fmt.Sprintf(" lua_shared_dict openflare_observability 10m;\n lua_shared_dict openflare_pow_challenges 10m;\n lua_shared_dict openflare_pow_sessions 10m;\n lua_shared_dict openflare_pow_config 1m;\n lua_shared_dict openflare_waf_config 1m;\n lua_shared_dict openflare_waf_ip_groups 64m;\n init_worker_by_lua_file %s/observability/init.lua;\n log_by_lua_file %s/observability/log.lua;\n\n server {\n listen %s;\n server_name openflare-observability;\n access_log off;\n\n location = /openflare/stub_status {\n stub_status;\n }\n\n location = /openflare/observability {\n default_type application/json;\n content_by_lua_file %s/observability/read.lua;\n }\n }\n\n", LuaDirPlaceholder, LuaDirPlaceholder, ObservabilityListenPlaceholder, LuaDirPlaceholder)
}
@@ -482,8 +515,9 @@ func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
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)
rate := strings.TrimSpace(limitConfig.LimitReqPerIP)
burst := calculateBurst(rate)
fmt.Fprintf(&builder, " limit_req zone=%s burst=%d nodelay;\n", limitReqZoneName(rate), burst)
fmt.Fprintf(&builder, " limit_req_status 429;\n")
}
return builder.String()
+61 -1
View File
@@ -537,7 +537,7 @@ 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 zone=openflare_req_10rs burst=20 nodelay;",
"limit_req_status 429;",
} {
if !strings.Contains(rendered, want) {
@@ -546,6 +546,66 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
}
}
func TestRenderMainConfigEmitsLimitReqZonesByEffectiveRate(t *testing.T) {
doc := Document{
Routes: []Route{
{
SiteName: "a.example.com",
Domains: []string{"a.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
},
{
SiteName: "b.example.com",
Domains: []string{"b.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8081",
Upstreams: []string{"http://127.0.0.1:8081"},
LimitReqPerIP: "5r/s",
},
{
SiteName: "c.example.com",
Domains: []string{"c.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8082",
Upstreams: []string{"http://127.0.0.1:8082"},
LimitReqPerIP: "-1",
},
},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitReqPerIP: "10r/s",
},
}
mainConfig := RenderMainConfig(doc)
for _, want := range []string{
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_10rs:10m rate=10r/s;",
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_5rs:10m rate=5r/s;",
} {
if !strings.Contains(mainConfig, want) {
t.Fatalf("expected %q in main config, got:\n%s", want, mainConfig)
}
}
if strings.Contains(mainConfig, "openflare_req_per_ip") {
t.Fatalf("unexpected legacy zone name in main config:\n%s", mainConfig)
}
routeConfig, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_10rs burst=20 nodelay;") {
t.Fatalf("expected inherited zone on route a, got:\n%s", routeConfig)
}
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_5rs burst=10 nodelay;") {
t.Fatalf("expected custom zone on route b, got:\n%s", routeConfig)
}
// route c is off: count limit_req lines should equal 2 routes * (http+https? depends) — assert c server has no limit_req by site name block is hard; ensure -1 route does not force extra zones
if strings.Count(mainConfig, "limit_req_zone") != 2 {
t.Fatalf("expected exactly 2 limit_req_zone lines, got main:\n%s", mainConfig)
}
}
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{