diff --git a/docs/guide/usage.md b/docs/guide/usage.md index cf8f196e..85064623 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -82,7 +82,7 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。 * WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。 * 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组使用 Expr 规则按单个 IP 聚合请求日志并定时更新名单;订阅 IP 组可从远程文本或 JSON 源定时同步。 -* 自动 IP 组页面提供两个预设:单个 IP 请求数大于 100 且 404 占比不低于 80%;单个 IP 通过 IP 地址访问次数大于 50 且该访问占比大于 50%。保存后可点击 **立即执行** 验证规则效果,语法见 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。 +* 自动 IP 组页面提供两个预设:单个 IP 请求数大于 100 且 404 占比不低于 80%;单个 IP 通过 IP 地址访问次数大于 50 且该访问占比大于 50%。保存前可点击 **测试规则** 查看当前日志窗口命中的 IP,保存后可点击 **立即执行** 更新组内名单,语法见 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。 * 在 WAF 规则组的黑白名单中,IP 维度既可以直接添加 IP/IP 段,也可以引用已有 IP 组。发布时 Server 会把启用 IP 组展开到 WAF 运行时配置。 * `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。 * 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。 diff --git a/docs/guide/waf-ip-group-expr.md b/docs/guide/waf-ip-group-expr.md index eb6052a7..c5a51e04 100644 --- a/docs/guide/waf-ip-group-expr.md +++ b/docs/guide/waf-ip-group-expr.md @@ -156,6 +156,6 @@ IP 直连访问异常: ## 使用建议 -先用较短的回看窗口和较高阈值观察命中结果,再逐步调整阈值。自动 IP 组执行后会覆盖该组的 IP 列表;如果要长期保留某些地址,建议放入手动 IP 组,并在 WAF 规则组中同时引用手动组和自动组。 +先用较短的回看窗口和较高阈值观察命中结果,再逐步调整阈值。管理端 IP 组页面支持在保存前点击 **测试规则**,直接查看当前回看窗口内命中的 IP;自动 IP 组真正执行后会覆盖该组的 IP 列表。如果要长期保留某些地址,建议放入手动 IP 组,并在 WAF 规则组中同时引用手动组和自动组。 自动 IP 组更新后不会立即改变 Agent 上的运行时配置。需要重新发布并激活配置版本,Agent 才会拉取新的 `waf_config.json`。 diff --git a/docs/reference/api.md b/docs/reference/api.md index e7f5d324..65c5c6bb 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -34,6 +34,7 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。 | `GET` | `/api/waf/ip-groups` | 查询 IP 组列表 | | `GET` | `/api/waf/ip-groups/:id` | 查询单个 IP 组 | | `POST` | `/api/waf/ip-groups` | 创建 IP 组 | +| `POST` | `/api/waf/ip-groups/test` | 测试自动 IP 组 Expr 规则,不保存配置,返回当前日志窗口内命中的 IP 列表 | | `POST` | `/api/waf/ip-groups/:id/update` | 更新 IP 组 | | `POST` | `/api/waf/ip-groups/:id/delete` | 删除 IP 组;已被规则组引用时会拒绝 | | `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组或立即执行自动型 IP 组 | diff --git a/openflare_server/controller/waf.go b/openflare_server/controller/waf.go index 6e9143a7..976fc2d7 100644 --- a/openflare_server/controller/waf.go +++ b/openflare_server/controller/waf.go @@ -199,6 +199,19 @@ func SyncWAFIPGroup(c *gin.Context) { respondSuccess(c, result) } +func TestWAFIPGroupAutoConfig(c *gin.Context) { + var input service.WAFIPGroupAutoTestInput + if !bindJSON(c, &input) { + return + } + result, err := service.TestWAFIPGroupAutoConfig(input) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + func parseUintPathParam(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil || id == 0 { diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 45acc14c..ea371c89 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -100,6 +100,7 @@ func SetApiRouter(router *gin.Engine) { wafRoute.GET("/ip-groups", controller.ListWAFIPGroups) wafRoute.GET("/ip-groups/:id", controller.GetWAFIPGroup) wafRoute.POST("/ip-groups", controller.CreateWAFIPGroup) + wafRoute.POST("/ip-groups/test", controller.TestWAFIPGroupAutoConfig) wafRoute.POST("/ip-groups/:id/update", controller.UpdateWAFIPGroup) wafRoute.POST("/ip-groups/:id/delete", controller.DeleteWAFIPGroup) wafRoute.POST("/ip-groups/:id/sync", controller.SyncWAFIPGroup) diff --git a/openflare_server/service/waf_ip_group.go b/openflare_server/service/waf_ip_group.go index 0eb67d2d..f3459f3e 100644 --- a/openflare_server/service/waf_ip_group.go +++ b/openflare_server/service/waf_ip_group.go @@ -110,6 +110,18 @@ type WAFIPGroupSyncResult struct { Message string `json:"message"` } +type WAFIPGroupAutoTestInput struct { + AutoConfig json.RawMessage `json:"auto_config"` +} + +type WAFIPGroupAutoTestResult struct { + MatchedIPs []string `json:"matched_ips"` + MatchedCount int `json:"matched_count"` + LookbackMinutes int `json:"lookback_minutes"` + RuleCount int `json:"rule_count"` + TestedAt string `json:"tested_at"` +} + func ListWAFIPGroups() ([]WAFIPGroupView, error) { groups, err := model.ListWAFIPGroups() if err != nil { @@ -195,6 +207,25 @@ func SyncWAFIPGroup(id uint) (*WAFIPGroupSyncResult, error) { return syncWAFIPGroup(group, time.Now().UTC()) } +func TestWAFIPGroupAutoConfig(input WAFIPGroupAutoTestInput) (*WAFIPGroupAutoTestResult, error) { + config, err := parseWAFIPGroupAutoConfig(input.AutoConfig) + if err != nil { + return nil, err + } + now := time.Now().UTC() + ips, err := evaluateParsedWAFIPGroupAutoConfig(config, now) + if err != nil { + return nil, err + } + return &WAFIPGroupAutoTestResult{ + MatchedIPs: ips, + MatchedCount: len(ips), + LookbackMinutes: config.LookbackMinutes, + RuleCount: len(config.Rules), + TestedAt: now.Format(time.RFC3339), + }, nil +} + func SyncDueWAFIPGroups() error { now := time.Now().UTC() groups, err := model.ListDueWAFIPGroups(now) @@ -397,17 +428,38 @@ func recordWAFIPGroupSyncFailure(group *model.WAFIPGroup, now time.Time, syncErr } func normalizeWAFIPGroupAutoConfig(raw json.RawMessage) (string, error) { + text := strings.TrimSpace(string(raw)) + if text == "" { + text = "{}" + } + config, err := parseWAFIPGroupAutoConfig(json.RawMessage(text)) + if err != nil { + return "", err + } + normalized, _ := json.Marshal(config) + return string(normalized), nil +} + +func evaluateWAFIPGroupAutoConfig(raw string, now time.Time) ([]string, error) { + config, err := parseWAFIPGroupAutoConfig(json.RawMessage(raw)) + if err != nil { + return nil, err + } + return evaluateParsedWAFIPGroupAutoConfig(config, now) +} + +func parseWAFIPGroupAutoConfig(raw json.RawMessage) (wafIPGroupAutoConfig, error) { text := strings.TrimSpace(string(raw)) if text == "" { text = "{}" } var config wafIPGroupAutoConfig if err := json.Unmarshal([]byte(text), &config); err != nil { - return "", errors.New("自动 IP 组配置必须是 JSON 对象") + return wafIPGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") } var object map[string]any if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil { - return "", errors.New("自动 IP 组配置必须是 JSON 对象") + return wafIPGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") } if config.LookbackMinutes <= 0 { config.LookbackMinutes = defaultWAFIPGroupAutoLookbackMinutes @@ -425,26 +477,17 @@ func normalizeWAFIPGroupAutoConfig(raw json.RawMessage) (string, error) { rule.Name = strings.TrimSpace(rule.Name) rule.Expr = strings.TrimSpace(rule.Expr) if rule.Expr == "" { - return "", fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1) + return wafIPGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1) } if _, err := exprlang.Compile(rule.Expr, exprlang.Env(wafIPGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil { - return "", fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) + return wafIPGroupAutoConfig{}, fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) } config.Rules[i] = rule } - normalized, _ := json.Marshal(config) - return string(normalized), nil + return config, nil } -func evaluateWAFIPGroupAutoConfig(raw string, now time.Time) ([]string, error) { - normalized, err := normalizeWAFIPGroupAutoConfig(json.RawMessage(raw)) - if err != nil { - return nil, err - } - var config wafIPGroupAutoConfig - if err := json.Unmarshal([]byte(normalized), &config); err != nil { - return nil, err - } +func evaluateParsedWAFIPGroupAutoConfig(config wafIPGroupAutoConfig, now time.Time) ([]string, error) { if len(config.Rules) == 0 { return []string{}, nil } diff --git a/openflare_server/service/waf_test.go b/openflare_server/service/waf_test.go index ff676a62..5a8a19b6 100644 --- a/openflare_server/service/waf_test.go +++ b/openflare_server/service/waf_test.go @@ -255,6 +255,41 @@ func TestSyncWAFIPGroupAutomaticExprRules(t *testing.T) { } } +func TestWAFIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) { + setupServiceTestDB(t) + + now := time.Now().UTC() + seedWAFNodeAccessLogs(t, now, "203.0.113.10", "app.example.com", 101, 81) + seedWAFNodeAccessLogs(t, now, "203.0.113.11", "198.51.100.10", 60, 0) + seedWAFNodeAccessLogs(t, now, "203.0.113.12", "app.example.com", 120, 10) + + result, err := TestWAFIPGroupAutoConfig(WAFIPGroupAutoTestInput{ + AutoConfig: json.RawMessage(`{ + "lookback_minutes": 60, + "rules": [ + {"name":"单 IP 404 高频扫描","expr":"request_count > 100 && status_404_ratio >= 0.8"}, + {"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"} + ] + }`), + }) + if err != nil { + t.Fatalf("TestWAFIPGroupAutoConfig failed: %v", err) + } + if result.MatchedCount != 2 || result.RuleCount != 2 || result.LookbackMinutes != 60 { + t.Fatalf("unexpected test result: %+v", result) + } + want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true} + for _, item := range result.MatchedIPs { + if !want[item] { + t.Fatalf("unexpected matched IP %s in %#v", item, result.MatchedIPs) + } + delete(want, item) + } + if len(want) != 0 { + t.Fatalf("missing matched IPs: %#v", want) + } +} + func TestWAFIPGroupAutomaticRejectsInvalidExpr(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/web/features/waf/api/waf.ts b/openflare_server/web/features/waf/api/waf.ts index fbc0a43b..3e69207a 100644 --- a/openflare_server/web/features/waf/api/waf.ts +++ b/openflare_server/web/features/waf/api/waf.ts @@ -4,6 +4,8 @@ import type { WAFRuleGroup, WAFRuleGroupPayload, WAFIPGroup, + WAFIPGroupAutoTestPayload, + WAFIPGroupAutoTestResult, WAFIPGroupPayload, WAFIPGroupSyncResult, WAFSiteRuleGroups, @@ -84,3 +86,10 @@ export function syncWAFIPGroup(id: number) { method: 'POST', }); } + +export function testWAFIPGroupAutoConfig(payload: WAFIPGroupAutoTestPayload) { + return apiRequest('/waf/ip-groups/test', { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/openflare_server/web/features/waf/components/ip-groups-page.tsx b/openflare_server/web/features/waf/components/ip-groups-page.tsx index 6dd934cf..d2c63b13 100644 --- a/openflare_server/web/features/waf/components/ip-groups-page.tsx +++ b/openflare_server/web/features/waf/components/ip-groups-page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Download, Plus, Save, Trash2 } from 'lucide-react'; +import { ArrowLeft, Download, Play, Plus, Save, Trash2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; @@ -16,10 +16,12 @@ import { deleteWAFIPGroup, getWAFIPGroups, syncWAFIPGroup, + testWAFIPGroupAutoConfig, updateWAFIPGroup, } from '@/features/waf/api/waf'; import type { WAFIPGroup, + WAFIPGroupAutoTestResult, WAFIPGroupPayload, WAFIPGroupSubscriptionFormat, WAFIPGroupType, @@ -101,14 +103,10 @@ function buildDraft(group: WAFIPGroup | null): IPGroupDraft { } function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload { - let autoConfig: Record = {}; - if (draft.type === 'automatic') { - const parsed = JSON.parse(draft.auto_config_text || '{}') as unknown; - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { - throw new Error('自动配置必须是 JSON 对象。'); - } - autoConfig = parsed as Record; - } + const autoConfig = + draft.type === 'automatic' + ? parseAutomaticConfig(draft.auto_config_text) + : {}; return { name: draft.name, type: draft.type, @@ -123,6 +121,14 @@ function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload { }; } +function parseAutomaticConfig(text: string): Record { + const parsed = JSON.parse(text || '{}') as unknown; + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('自动配置必须是 JSON 对象。'); + } + return parsed as Record; +} + function appendAutomaticPresetRule( autoConfigText: string, rule: (typeof automaticPresetRules)[number], @@ -161,6 +167,8 @@ export function WAFIPGroupsPage() { const [selectedID, setSelectedID] = useState(null); const [draft, setDraft] = useState(emptyIPGroupDraft); const [feedback, setFeedback] = useState(null); + const [autoTestResult, setAutoTestResult] = + useState(null); const groupsQuery = useQuery({ queryKey: ['waf', 'ip-groups'], @@ -234,6 +242,28 @@ export function WAFIPGroupsPage() { }, }); + const testMutation = useMutation({ + mutationFn: testWAFIPGroupAutoConfig, + onSuccess: (result) => { + setAutoTestResult(result); + setFeedback({ + tone: result.matched_count > 0 ? 'success' : 'info', + message: + result.matched_count > 0 + ? `规则测试完成,命中 ${result.matched_count} 个 IP。` + : '规则测试完成,当前未命中任何 IP。', + }); + }, + onError: (error) => { + setAutoTestResult(null); + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + useEffect(() => { + setAutoTestResult(null); + }, [draft.type, draft.auto_config_text, selectedID]); + if (groupsQuery.isLoading) { return ; } @@ -254,6 +284,17 @@ export function WAFIPGroupsPage() { } }; + const testDraft = () => { + try { + testMutation.mutate({ + auto_config: parseAutomaticConfig(draft.auto_config_text), + }); + } catch (error) { + setAutoTestResult(null); + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + return (
+ {draft.type === 'automatic' ? ( + + + {testMutation.isPending ? '测试中...' : '测试规则'} + + ) : null} {selectedGroup?.type === 'subscription' || selectedGroup?.type === 'automatic' ? ( + {autoTestResult ? ( +
+
+
+ 测试结果 +
+
+ 回看 {autoTestResult.lookback_minutes} 分钟 · 规则{' '} + {autoTestResult.rule_count} 条 +
+
+ 0 ? 'success' : 'info' + } + message={ + autoTestResult.matched_count > 0 + ? `命中 ${autoTestResult.matched_count} 个 IP。` + : '当前没有匹配到任何 IP。' + } + /> + {autoTestResult.matched_count > 0 ? ( +
+
+ 命中 IP 列表 +
+
+                          {autoTestResult.matched_ips.join('\n')}
+                        
+
+ ) : null} +
+ ) : null}
) : ( ; +} + +export interface WAFIPGroupAutoTestResult { + matched_ips: string[]; + matched_count: number; + lookback_minutes: number; + rule_count: number; + tested_at: string; +} diff --git a/openflare_server/web/tests/unit/waf-ip-groups-page.test.tsx b/openflare_server/web/tests/unit/waf-ip-groups-page.test.tsx index f5d30928..2c4bcef9 100644 --- a/openflare_server/web/tests/unit/waf-ip-groups-page.test.tsx +++ b/openflare_server/web/tests/unit/waf-ip-groups-page.test.tsx @@ -204,6 +204,69 @@ describe('WAF IP groups', () => { expect(value).toContain('ip_host_count > 50 && ip_host_ratio > 0.5'); }); + it('tests automatic Expr rules before saving', async () => { + const testMock = vi.fn(); + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method?.toUpperCase() ?? 'GET'; + + if (url.includes('/waf/ip-groups/test') && method === 'POST') { + testMock(JSON.parse(String(init?.body))); + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + message: '', + data: { + matched_ips: ['203.0.113.10', '203.0.113.11'], + matched_count: 2, + lookback_minutes: 60, + rule_count: 1, + tested_at: '2026-06-01T00:00:00Z', + }, + }), + ), + ); + } + + if (url.includes('/waf/ip-groups')) { + return Promise.resolve( + new Response( + JSON.stringify({ success: true, message: '', data: [] }), + ), + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }), + ); + + renderWithProviders(); + + await screen.findByText('暂无 IP 组'); + await userEvent.click(screen.getByRole('button', { name: /新建 IP 组/ })); + await userEvent.selectOptions(screen.getByLabelText('类型'), 'automatic'); + await userEvent.click(screen.getByText('单 IP 404 高频扫描')); + await userEvent.click(screen.getByRole('button', { name: /测试规则/ })); + + expect(await screen.findByText('命中 2 个 IP。')).toBeInTheDocument(); + expect(screen.getByText('203.0.113.10')).toBeInTheDocument(); + expect(screen.getByText('203.0.113.11')).toBeInTheDocument(); + expect(testMock).toHaveBeenCalledWith({ + auto_config: expect.objectContaining({ + lookback_minutes: 60, + rules: [ + expect.objectContaining({ + expr: 'request_count > 100 && status_404_ratio >= 0.8', + }), + ], + }), + }); + }); + it('opens IP group management from WAF page and references an IP group', async () => { vi.stubGlobal( 'fetch',