From 80c47f6ff3615cb87d4219bcca547b2242bee799 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 20 Jul 2026 15:02:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(rate-limit):=20=E7=AB=99=E7=82=B9=E7=BA=A7?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=A2=91=E7=8E=87=E9=99=90=E5=88=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BB=A7=E6=89=BF=E4=B8=8E=E8=87=AA=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在站点详情流量限制中配置 limit_req_per_ip;渲染按 effective rate 生成多 limit_req_zone,并以站点+IP 隔离计数。 --- docs/changelog/index.md | 4 + .../specs/2026-07-20-site-limit-req-design.md | 159 ++++++++++++++++++ .../(main)/proxy-routes/components/helpers.ts | 20 +++ .../components/proxy-route-create-sheet.tsx | 1 + .../detail/components/limits-section.tsx | 37 +++- frontend/lib/services/openflare/types.ts | 2 + pkg/render/openresty/render.go | 60 +++++-- pkg/render/openresty/render_test.go | 62 ++++++- 8 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-20-site-limit-req-design.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 121effc6..e5b01931 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -22,6 +22,10 @@ sidebar: false ## [unreleased] +### 新增 + +- 反代站点「流量限制」支持配置单 IP 请求频率:空或 0 继承全局默认,-1 关闭,填写如 10r/s、100r/m 为站点自定义;不同站点可使用不同频率并按站点隔离计数。 + ## [v3.4.3-beta.3] - 2026-07-20 ### 新增 diff --git a/docs/superpowers/specs/2026-07-20-site-limit-req-design.md b/docs/superpowers/specs/2026-07-20-site-limit-req-design.md new file mode 100644 index 00000000..3c3ef02f --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-site-limit-req-design.md @@ -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_:10m 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_ burst= 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 | diff --git a/frontend/app/(main)/proxy-routes/components/helpers.ts b/frontend/app/(main)/proxy-routes/components/helpers.ts index 5345dd00..f3102643 100644 --- a/frontend/app/(main)/proxy-routes/components/helpers.ts +++ b/frontend/app/(main)/proxy-routes/components/helpers.ts @@ -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) { diff --git a/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx b/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx index 7e7efe79..d967b650 100644 --- a/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx +++ b/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx @@ -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: [], diff --git a/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx b/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx index 5bf0eceb..924b64a6 100644 --- a/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx +++ b/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx @@ -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; @@ -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 }) => ( - + 限速 @@ -184,6 +201,24 @@ export function LimitsSection({ )} /> + + ( + + 单 IP 请求频率 + + + + + 空或 0 继承全局默认;-1 关闭;例如 10r/s、100r/m + 为自定义频率。 + + + + )} + /> diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 10d53109..2eced25f 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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[]; diff --git a/pkg/render/openresty/render.go b/pkg/render/openresty/render.go index 41c1d0e9..6b6770f0 100644 --- a/pkg/render/openresty/render.go +++ b/pkg/render/openresty/render.go @@ -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() diff --git a/pkg/render/openresty/render_test.go b/pkg/render/openresty/render_test.go index d5bc1f3d..98f4f457 100644 --- a/pkg/render/openresty/render_test.go +++ b/pkg/render/openresty/render_test.go @@ -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{{