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
+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{{