From 351e8ce78c20e83e6a03865c62e5715473920c6b Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 20 Jul 2026 15:39:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(waf):=20StatusRatio/StatusCount=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=202xx/4xx/5xx=20=E7=B1=BB=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动 IP 组表达式可按状态码类汇总占比与计数,兼容原有精确状态码。 --- docs/changelog/index.md | 4 + docs/guide/waf-ip-group-expr.md | 24 ++++- internal/apps/openflare/waf/ip_group_sync.go | 92 ++++++++++++++++++- .../apps/openflare/waf/ip_group_sync_test.go | 59 ++++++++++++ 4 files changed, 173 insertions(+), 6 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 7184ce6b..c8da7b27 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -26,6 +26,10 @@ sidebar: false - 反代站点「流量限制」支持配置单 IP 请求频率:空或 0 继承全局默认,-1 关闭,填写如 10r/s、100r/m 为站点自定义;不同站点可使用不同频率并按站点隔离计数。 +### 改进 + +- IP 组自动规则中的 `StatusCount` / `StatusRatio` 支持状态码类写法(如 `"2xx"`、`"4xx"`、`"5xx"`),便于按整类错误率匹配。 + ### 修复 - 修复 IP 组自动抓取使用预设规则时未写入 `ttl` 字段的问题,避免配置 JSON 缺少封禁时长。 diff --git a/docs/guide/waf-ip-group-expr.md b/docs/guide/waf-ip-group-expr.md index 044ed5f6..d453b977 100644 --- a/docs/guide/waf-ip-group-expr.md +++ b/docs/guide/waf-ip-group-expr.md @@ -61,8 +61,14 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断: 如果内置的 `status_404_count` 和 `status_404_ratio` 不能满足您的需求,您可以使用以下内置方法来匹配任意状态码的请求数与占比: -* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数(如 `StatusCount(403) > 10`) -* **`StatusRatio(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数占该 IP 总请求数的比例(如 `StatusRatio(502) >= 0.5`) +* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码(或状态码类)的请求数。 + * 精确状态码:`StatusCount(403) > 10` + * 状态码类(`1xx`–`5xx`,大小写不敏感):`StatusCount("4xx") > 50` +* **`StatusRatio(code)`**: 获取上述计数占该 IP 总请求数的比例。 + * 精确状态码:`StatusRatio(502) >= 0.5` + * 状态码类:`StatusRatio("4xx") >= 0.8`、`StatusRatio("5xx") >= 0.3` + +状态码类会汇总该百位区间内全部状态码,例如 `"4xx"` 包含 400–499,`"2xx"` 包含 200–299。 ## Expr 常用写法 @@ -147,6 +153,20 @@ IP 直连访问异常: } ``` +使用状态码类写法(与 `client_error_count` / `server_error_count` 等价思路): + +```json +{ + "lookback_minutes": 120, + "rules": [ + { + "name": "高 4xx 或 5xx 占比", + "expr": "request_count > 100 && (StatusRatio(\"4xx\") >= 0.8 || StatusRatio(\"5xx\") >= 0.3)" + } + ] +} +``` + 排除可信 IP: ```json diff --git a/internal/apps/openflare/waf/ip_group_sync.go b/internal/apps/openflare/waf/ip_group_sync.go index b8979119..28d83bf9 100644 --- a/internal/apps/openflare/waf/ip_group_sync.go +++ b/internal/apps/openflare/waf/ip_group_sync.go @@ -40,18 +40,102 @@ type ipGroupAutoRuleEnv struct { statusCounts map[int]int } -func (env ipGroupAutoRuleEnv) StatusCount(code int) int { +func (env ipGroupAutoRuleEnv) StatusCount(code any) int { if env.statusCounts == nil { return 0 } - return env.statusCounts[code] + return countStatusMatches(env.statusCounts, code) } -func (env ipGroupAutoRuleEnv) StatusRatio(code int) float64 { +func (env ipGroupAutoRuleEnv) StatusRatio(code any) float64 { if env.RequestCount <= 0 || env.statusCounts == nil { return 0.0 } - return float64(env.statusCounts[code]) / float64(env.RequestCount) + return float64(countStatusMatches(env.statusCounts, code)) / float64(env.RequestCount) +} + +// countStatusMatches sums status counts for an exact code or class token. +// Accepted forms: +// - int / int64 / float64: exact status code (e.g. 404) +// - string digits: exact status code (e.g. "404") +// - string class: "1xx".."5xx" (case-insensitive), matching that hundred range +func countStatusMatches(statusCounts map[int]int, code any) int { + if statusCounts == nil { + return 0 + } + switch v := code.(type) { + case int: + return statusCounts[v] + case int8: + return statusCounts[int(v)] + case int16: + return statusCounts[int(v)] + case int32: + return statusCounts[int(v)] + case int64: + return statusCounts[int(v)] + case uint: + return statusCounts[int(v)] + case uint8: + return statusCounts[int(v)] + case uint16: + return statusCounts[int(v)] + case uint32: + return statusCounts[int(v)] + case uint64: + return statusCounts[int(v)] + case float32: + if v != float32(int(v)) { + return 0 + } + return statusCounts[int(v)] + case float64: + if v != float64(int(v)) { + return 0 + } + return statusCounts[int(v)] + case string: + return countStatusMatchesString(statusCounts, v) + default: + return 0 + } +} + +func countStatusMatchesString(statusCounts map[int]int, raw string) int { + token := strings.TrimSpace(strings.ToLower(raw)) + if token == "" { + return 0 + } + if len(token) == 3 && token[1] == 'x' && token[2] == 'x' { + classDigit := token[0] + if classDigit < '1' || classDigit > '5' { + return 0 + } + base := int(classDigit-'0') * 100 + total := 0 + for code, count := range statusCounts { + if code >= base && code < base+100 { + total += count + } + } + return total + } + // exact numeric string, e.g. "404" + var code int + for _, ch := range token { + if ch < '0' || ch > '9' { + return 0 + } + code = code*10 + int(ch-'0') + if code > 999 { + return 0 + } + } + if code < 100 || code > 599 { + // still allow lookup for non-standard codes if present + return statusCounts[code] + } + return statusCounts[code] } type ipGroupAutoAccumulator struct { diff --git a/internal/apps/openflare/waf/ip_group_sync_test.go b/internal/apps/openflare/waf/ip_group_sync_test.go index e2e9f0ff..e5fc13a2 100644 --- a/internal/apps/openflare/waf/ip_group_sync_test.go +++ b/internal/apps/openflare/waf/ip_group_sync_test.go @@ -209,3 +209,62 @@ func seedWAFAccessLogs(t *testing.T, ctx context.Context, loggedAt time.Time, re } require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, records)) } + +func TestCountStatusMatchesSupportsClassTokens(t *testing.T) { + counts := map[int]int{ + 200: 10, + 201: 5, + 404: 20, + 403: 10, + 500: 4, + 502: 1, + } + cases := []struct { + name string + code any + want int + }{ + {name: "exact int", code: 404, want: 20}, + {name: "exact string", code: "403", want: 10}, + {name: "2xx class", code: "2xx", want: 15}, + {name: "4xx class upper", code: "4XX", want: 30}, + {name: "5xx class", code: "5xx", want: 5}, + {name: "unknown class", code: "9xx", want: 0}, + {name: "invalid token", code: "abc", want: 0}, + {name: "float exact", code: float64(200), want: 10}, + {name: "float non-int", code: 200.5, want: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := countStatusMatches(counts, tc.code) + if got != tc.want { + t.Errorf("countStatusMatches(%v) = %d, want %d", tc.code, got, tc.want) + } + }) + } +} + +func TestStatusRatioClassTokenInExpr(t *testing.T) { + cleanup := setupIPGroupSyncTestDB(t) + defer cleanup() + ctx := context.Background() + + now := time.Now().UTC() + // 100 requests, 80 of which are 404 → 4xx ratio 0.8 + seedWAFAccessLogs(t, ctx, now, "203.0.113.40", "app.example.com", 100, 80) + // mostly OK → should not match + seedWAFAccessLogs(t, ctx, now, "203.0.113.41", "app.example.com", 100, 10) + + result, err := TestIPGroupAutoConfig(ctx, IPGroupAutoTestInput{ + AutoConfig: json.RawMessage(`{ + "lookback_minutes": 60, + "rules": [ + {"name":"高 4xx 占比","expr":"request_count >= 100 && StatusRatio(\"4xx\") >= 0.8"} + ] + }`), + }) + require.NoError(t, err) + assert.Equal(t, 1, result.MatchedCount) + require.Len(t, result.MatchedIPs, 1) + assert.Equal(t, "203.0.113.40", result.MatchedIPs[0]) +}