From a1a997bcda1968a378b464601c2df79ac185c08f Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 13 Jul 2026 14:16:55 +0800 Subject: [PATCH] feat(waf): complete composable rule orchestration Add the React Flow rule editor, ordered graph APIs and runtime DAG execution.\n\nPublish rules only on OpenResty reload and reconcile checksum-driven IP group snapshots in bounded shared memory. --- cmd/agent/main.go | 20 +- cmd/agent/main_test.go | 24 + docs/changelog/index.md | 12 + docs/design/waf-design.md | 133 +-- docs/design/waf-orchestration-design.md | 4 +- docs/docs.go | 480 ++++----- docs/guide/waf-usage.md | 181 +--- docs/plan/20260713-waf-orchestration.md | 8 +- docs/reference/configuration.md | 4 + docs/swagger.json | 480 ++++----- docs/swagger.yaml | 362 +++---- .../waf/components/rule-groups-table.tsx | 10 +- .../waf/components/site-binding-sheet.tsx | 139 --- frontend/app/(main)/waf/page.tsx | 41 +- .../editor/components/editor-behavior.test.ts | 66 ++ .../editor/components/editor-behavior.ts | 73 ++ .../components/graph-validation.test.ts | 116 +++ .../editor/components/graph-validation.ts | 146 +++ .../rules/editor/components/node-library.tsx | 14 + .../components/node-properties.test.tsx | 34 + .../editor/components/node-properties.tsx | 39 + .../editor/components/rule-flow-canvas.tsx | 68 ++ .../waf/rules/editor/components/rule-node.tsx | 39 + .../components/unsaved-changes.test.tsx | 46 + .../editor/components/unsaved-changes.tsx | 42 + .../app/(main)/waf/rules/editor/page.test.tsx | 124 +++ frontend/app/(main)/waf/rules/editor/page.tsx | 123 +++ frontend/lib/services/openflare/index.ts | 1 + frontend/lib/services/openflare/types.ts | 5 + .../lib/services/openflare/waf.service.ts | 12 +- frontend/tests/unit/waf-rule-service.test.ts | 30 +- internal/apps/agent/config/config.go | 15 + internal/apps/agent/config/config_test.go | 30 + internal/apps/agent/geoipupdate/updater.go | 75 +- .../apps/agent/geoipupdate/updater_test.go | 77 ++ internal/apps/agent/nginx/manager.go | 203 +++- internal/apps/agent/nginx/manager_test.go | 354 ++++++- internal/apps/agent/nginx/pow_assets.go | 108 +- internal/apps/agent/nginx/waf_assets.go | 279 +---- internal/apps/agent/nginx/waf_assets_test.go | 44 + internal/apps/agent/nginx/waf_ip_groups.lua | 150 +++ .../apps/agent/nginx/waf_ip_groups_spec.lua | 356 +++++++ internal/apps/agent/nginx/waf_runtime.lua | 404 ++++++++ .../apps/agent/nginx/waf_runtime_spec.lua | 535 ++++++++++ internal/apps/agent/sync/service.go | 79 +- internal/apps/agent/sync/service_test.go | 161 ++- internal/apps/openflare/agent/waf_ip_group.go | 103 +- .../apps/openflare/agent/waf_ip_group_test.go | 111 ++ .../openflare/config_version/logics_test.go | 63 +- .../apps/openflare/config_version/snapshot.go | 144 ++- .../config_version/waf_graph_snapshot_test.go | 135 +++ .../openflare/integration/security_test.go | 18 +- internal/apps/openflare/waf/graph_types.go | 27 +- internal/apps/openflare/waf/graph_validate.go | 259 +++-- internal/apps/openflare/waf/ip_group_sync.go | 2 +- internal/apps/openflare/waf/logics.go | 981 +++++------------- internal/apps/openflare/waf/logics_test.go | 33 +- internal/apps/openflare/waf/pow_helpers.go | 92 -- internal/apps/openflare/waf/routers.go | 189 +--- internal/apps/openflare/waf/rule_logics.go | 185 ++++ .../apps/openflare/waf/rule_logics_test.go | 181 ++++ internal/apps/openflare/waf/rule_routers.go | 186 ++++ ...2607150003_drop_legacy_waf_rule_fields.sql | 27 + ...2607150003_drop_legacy_waf_rule_fields.sql | 27 + internal/model/openflare_waf.go | 46 +- internal/model/openflare_waf_graph_test.go | 18 +- internal/router/v1/openflare/register_waf.go | 12 +- pkg/protocol/waf_ip_group_snapshot.go | 39 + pkg/protocol/waf_ip_group_snapshot_test.go | 57 + pkg/render/openresty/render.go | 127 +-- pkg/render/openresty/render_test.go | 117 ++- pkg/render/openresty/types.go | 52 +- 72 files changed, 5897 insertions(+), 3080 deletions(-) create mode 100644 cmd/agent/main_test.go delete mode 100644 frontend/app/(main)/waf/components/site-binding-sheet.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/graph-validation.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/node-library.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/node-properties.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/rule-node.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/unsaved-changes.test.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/components/unsaved-changes.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/page.test.tsx create mode 100644 frontend/app/(main)/waf/rules/editor/page.tsx create mode 100644 internal/apps/agent/nginx/waf_assets_test.go create mode 100644 internal/apps/agent/nginx/waf_ip_groups.lua create mode 100644 internal/apps/agent/nginx/waf_ip_groups_spec.lua create mode 100644 internal/apps/agent/nginx/waf_runtime.lua create mode 100644 internal/apps/agent/nginx/waf_runtime_spec.lua create mode 100644 internal/apps/openflare/config_version/waf_graph_snapshot_test.go delete mode 100644 internal/apps/openflare/waf/pow_helpers.go create mode 100644 internal/apps/openflare/waf/rule_logics.go create mode 100644 internal/apps/openflare/waf/rule_logics_test.go create mode 100644 internal/apps/openflare/waf/rule_routers.go create mode 100644 internal/db/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql create mode 100644 internal/db/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql create mode 100644 pkg/protocol/waf_ip_group_snapshot.go create mode 100644 pkg/protocol/waf_ip_group_snapshot_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 49303854..73bc2a2f 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -66,6 +66,7 @@ func main() { "lua_dir", cfg.LuaDir, "runtime_config_dir", cfg.RuntimeConfigDir, "mmdb_path", cfg.MMDBPath, + "city_mmdb_path", cfg.CityMMDBPath, ) client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) @@ -81,6 +82,8 @@ func main() { LuaDir: cfg.LuaDir, NginxLuaDir: cfg.OpenrestyLuaDir, RuntimeConfigDir: cfg.RuntimeConfigDir, + MMDBPath: cfg.MMDBPath, + CityMMDBPath: cfg.CityMMDBPath, PagesDir: cfg.PagesDir, OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort), OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, @@ -122,10 +125,9 @@ func main() { } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - geoIPUpdater := &geoipupdate.Updater{ - MMDBPath: cfg.MMDBPath, - DownloadURL: cfg.MMDBDownloadURL, - UpdateInterval: cfg.MMDBUpdateInterval.Duration(), + geoIPUpdater := newGeoIPUpdater(cfg) + if err = geoIPUpdater.EnsureInitialDatabases(ctx); err != nil { + slog.Warn("failed to prepare GeoIP databases before agent startup", "error", err) } go geoIPUpdater.Run(ctx) slog.Info("agent process started") @@ -138,3 +140,13 @@ func main() { stop() slog.Info("agent process stopped") } + +func newGeoIPUpdater(cfg *config.Config) *geoipupdate.Updater { + return &geoipupdate.Updater{ + MMDBPath: cfg.MMDBPath, + DownloadURL: cfg.MMDBDownloadURL, + CityMMDBPath: cfg.CityMMDBPath, + CityDownloadURL: cfg.CityMMDBDownloadURL, + UpdateInterval: cfg.MMDBUpdateInterval.Duration(), + } +} diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go new file mode 100644 index 00000000..2a99adf9 --- /dev/null +++ b/cmd/agent/main_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "testing" + "time" + + "github.com/Rain-kl/Wavelet/internal/apps/agent/config" +) + +func TestNewGeoIPUpdaterWiresCountryAndCity(t *testing.T) { + cfg := &config.Config{ + MMDBPath: "/data/GeoLite2-Country.mmdb", + MMDBDownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + CityMMDBPath: "/data/GeoLite2-City.mmdb", + CityMMDBDownloadURL: "https://geo.example/GeoLite2-City.mmdb", + MMDBUpdateInterval: config.MillisecondDuration(time.Hour), + } + updater := newGeoIPUpdater(cfg) + if updater.MMDBPath != cfg.MMDBPath || updater.DownloadURL != cfg.MMDBDownloadURL || + updater.CityMMDBPath != cfg.CityMMDBPath || updater.CityDownloadURL != cfg.CityMMDBDownloadURL || + updater.UpdateInterval != time.Hour { + t.Fatalf("GeoIP updater wiring incomplete: %#v", updater) + } +} diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 9c07cc11..28d1b78e 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,8 +21,20 @@ sidebar: false ## [unreleased] +### 新增 + +- WAF 规则支持可视化 DAG 编排、版本冲突保护和有序路由绑定,发布时编译为 OpenResty 纯内存运行图。 +- WAF IP 组支持 checksum 驱动的 Worker 内存热刷新,并补充 City MMDB 地区匹配数据源。 + +### 变更 + +- 移除 WAF 规则旧固定黑白名单、地域名单与 PoW 数据库字段;升级后需在发布前重新编排规则。 + ### 修复 +- WAF 规则编辑器新增启用/停用控制,并移除已废弃的规则组侧站点绑定入口;站点规则顺序统一在反代路由详情中管理。 +- 修复 Agent 心跳无法从活动 WAF 运行图发现 IP 组引用的问题,并对发布与同步的完整 IP 组快照增加 20 MiB 聚合容量保护。 +- 修复 Agent 增量同步长期保留已取消引用的 WAF IP 组、最终阻塞合法热更新的问题;配置同步现按活动引用集合权威收敛,实时广播仅更新本地现存组。 - 修复 Pages 部署文件清单前端请求路径与后端路由不一致导致的 404 错误。 ## [v3.2.0] - 2026-07-12 diff --git a/docs/design/waf-design.md b/docs/design/waf-design.md index 9658b98b..973f39ab 100644 --- a/docs/design/waf-design.md +++ b/docs/design/waf-design.md @@ -1,132 +1,15 @@ -# WAF 设计文档 +# WAF 设计 -> WAF 规则正在从固定的白名单、黑名单、PoW 判定链演进为可视化 DAG。新的图模型、执行顺序、发布加载和迁移边界以 [WAF 可编排规则设计](./waf-orchestration-design.md) 为准;本文保留 IP 组、GeoIP 与现有运行时背景说明。 +OpenFlare WAF 的现行规则模型是可视化 DAG。节点语义、图约束、多规则顺序、发布编译与迁移边界统一以 [WAF 可编排规则设计](./waf-orchestration-design.md) 为准。 -你会学到:OpenFlare 边缘 Web 应用防火墙(WAF)的核心架构、动态 IP 组异步差分同步模型、OpenResty Lua 高性能缓存方案以及完整的请求过滤与判定逻辑。 +## 系统边界 ---- +Server 保存带坐标和修订号的编辑图,发布时再次校验并编译为紧凑运行图;Agent 原子写入快照并 reload OpenResty;请求热路径只遍历 Worker 内存中的不可变图。 -## 需求分析 +IP 组独立于规则拓扑更新。手动、订阅和自动 IP 组由控制面维护,Agent 先原子替换 JSON、最后更新 checksum。协调 Worker 每 5 秒检查 checksum,仅变化时读取完整快照并分发给其它 Worker;失败时保留上一份有效数据。完整运行时快照上限为 20 MiB,Server 发布/同步与 Agent 落盘使用同一序列化校验;OpenResty 使用独立的 64 MiB 共享字典和非淘汰写入,容量不足时拒绝新版本而不破坏已提交快照。 -在互联网公开环境中,Web 应用程序面临着各种各样的安全威胁(如扫描器踩点、刷接口、针对特定地域的恶意网络爬虫、勒索攻击及 CC 攻击等)。如果直接把恶意请求放行给源站(Origin Server),会导致: -1. **源站负载飙升**:高频的数据库查询与 CPU 运算极易耗尽服务器资源。 -2. **敏感接口被刷**:登录、注册、短信验证码接口容易被恶意滥用导致财产损失。 -3. **数据泄露风险**:恶意的通用漏洞探测行为无法被提前拦截。 +地域节点使用 Country 与 City MMDB。数据库不可用时地域匹配返回 `false` 并限频告警,不允许因数据损坏意外放行其它执行错误。 -因此,OpenFlare 需要在最前端的数据面(OpenResty)构建一套 **高性能、可弹性伸缩的 WAF 过滤引擎**。该引擎能够在最接近用户的边缘层以毫秒级的极低开销对恶意请求进行深度过滤,减轻源站压力,并提供防 CC(PoW 挑战)、IP 黑白名单与地域级别拦截等核心安全防护能力。 +## 安全顺序 ---- - -## 核心功能 - -OpenFlare WAF 包含以下核心防护维度: - -* **IP 级拦截(IP 黑白名单)**:支持单 IP、CIDR 网段过滤,支持将上万 IP 聚合为 IP 组进行高效比对。 -* **地域黑白名单(GeoIP 限制)**:集成 MaxMind 数据库,支持针对国家(Country)和省份/地区(Region)执行精准准入控制。 -* **自定义拦截响应**:支持针对不同的过滤规则自定义阻断状态码(如 403, 418)以及个性化的 HTML 拦截页面。 -* **人机挑战(PoW CC 防护)**:支持无感人机挑战,通过计算 Hash 碰撞防止自动化脚本和僵尸网络(Botnet)对接口进行并发冲击。 - ---- - -## IP 组设计与动态异步同步 - -IP 组是 WAF 进行高效黑白名单管控的核心容器。OpenFlare 将 IP 组根据更新频率与产生渠道分为三类: - -### 1. IP 组类型 -* **手动 IP 组(Manual)**:由管理员在控制面板上手动输入 IP 或 CIDR 列表。主要用于静态的信任 IP 或长期的封禁。 -* **订阅 IP 组(Subscription)**:配置远程文本(按行分隔)或标准的 JSON 订阅地址。Server 侧的定时任务会周期性抓取远程订阅源并自动解析导入。主要用于集成开源的威胁情报库、云厂商的 IP 范围等。 -* **自动 IP 组(Automatic)**:**最具弹性的动态防护通道**。控制面的定时扫描任务会读取所有节点的访问日志,按照设定的 Expr 规则(例如:“5分钟内请求 `/api/login` 接口触发 401 超过 50 次”)进行聚合分析,一旦匹配,自动将该恶意源 IP 写入封禁组,并指定封禁时长。 - -### 2. 异步差分同步设计 (不触发 Nginx Reload) -在传统的 Nginx WAF 设计中,IP 黑名单的更新通常需要重写配置并 reload。如果恶意 IP 封禁以秒级或分钟级高频触发,频繁 reload 会导致 Nginx 频繁新建 Worker 进程并销毁老进程,导致性能骤降。 - -OpenFlare 采用 **动态 IP 组异步差分同步设计**: - -```text -WAF IP 成员更新 (手动/订阅/自动自动触发) - | - v -Server 更新数据库并计算该 IP 组的全新 MD5 Checksum - | - +----------------------------------------+ - | (WebSocket 实时广播) | (心跳兜底比对) - v v -Server 立即向所有 Agent 推送变更组的完整成员 Agent 心跳上报本地所有 IP 组的 Checksum 映射表 - | | - | v - | Server 发现 Checksum 不一致,下发变更的 IP 组成员 - v | -Agent 接收成员数据,将其以 JSON 形式写入本地磁盘路径:waf_ip_groups.json - | - v (Lua 内存感知) -OpenResty Lua 引擎通过 MD5 校验和秒级感知文件变化并热更新内存,无需 reload 进程 -``` - -通过这一架构,上万个高频变动的动态黑名单 IP 的落地和生效,**全程无需 reload 任何 Nginx 进程**,极大地保护了网关的高并发性能。 - ---- - -## 规则组与网站绑定 - -* **WAF 规则组(Rule Group)**:WAF 过滤政策的最小逻辑集合。一条规则组内可以包含 IP 黑白名单、IP 组引用、地域限制及防 CC 挑战配置。 -* **全局规则组(Global)**:当规则组被标记为 `is_global = true` 时,该规则组对节点上托管的**所有网站路由**默认生效。 -* **网站绑定绑定(Site Binding)**:网站路由(Proxy Route)可以绑定一个或多个非全局规则组。判定时,会执行 `全局规则组 + 绑定规则组` 的并集逻辑。 - ---- - -## 实现方案与高性能缓存 - -WAF 在 OpenResty 的 `access_by_lua` 阶段被触发,核心由 Lua 文件与本地落地的 JSON 配置构成。 - -### 1. 物理结构 -* `waf_config.json`:包含所有规则组的元数据、国家地域限制、以及网站(Site)与规则组的关联映射。 -* `waf_ip_groups.json`:包含所有同步下来的 IP 组与对应的 IP 列表。 -* `waf/runtime.lua`:WAF 规则比对的实际运行时引擎。 -* `waf/check.lua`:接入层入口,负责包引入与 check() 触发。 - -### 2. 共享内存字典 (ngx.shared) 高性能缓存设计 -在每次 Web 请求进来时都读取磁盘上的 JSON 文件并进行解码,会导致磁盘 I/O 成为严重的性能瓶颈。 - -OpenFlare 利用 **OpenResty 共享内存字典 (ngx.shared.openflare_waf_config)** 设计了二级缓存机制: - -1. **零文件 I/O 路径**: - 在 Lua 中,每次执行 `check()` 时,首先利用 `ngx.md5` 瞬间计算本地磁盘 JSON 文件的 MD5 哈希(这一操作几乎为零耗时,因为文件已被操作系统 Page Cache 缓存)。 -2. **哈希比对与热加载**: - 比对共享内存中存储的缓存哈希键(`_config_hash`)。 - * **若哈希未发生变化**:直接从共享内存字典中读取已解码、存在内存中的 Lua Table 配置,整个校验过程完全基于**共享内存操作**,耗时在 **微秒级** 级别。 - * **若哈希不一致**:说明 Agent 刚刚落地了新的 WAF 规则或 IP 组,Lua 自动读取磁盘文件并使用 `cjson.decode` 解码,解码后的数据及全新的 MD5 写入共享内存,供后续 Worker 进程无缝读取。 - ---- - -## 应用流程与判定判定控制逻辑 - -当一个 HTTP/HTTPS 请求到达 OpenResty 后,WAF 会在 `access` 阶段按下图所示的漏斗判决链进行逐步匹配拦截: - -### 1. WAF 判定流程图 - -```mermaid -flowchart TD - A[请求进入 access 阶段] --> B[获取当前请求的 Site Name] - B --> C[在共享内存中加载与此 Site 绑定的所有活跃规则组] - C --> D{匹配到 IP 白名单 / 白名单 IP 组?} - D -- 是 (匹配成功) --> E[放行请求 - ALLOW] - D -- 否 --> F{匹配到国家/地区地域白名单?} - F -- 是 (匹配成功) --> E - F -- 否 --> G{匹配到 IP 黑名单 / 黑名单 IP 组?} - G -- 是 (匹配成功) --> H[阻断请求 - BLOCK] - G -- 否 --> I{匹配到国家/地区地域黑名单?} - I -- 是 (匹配成功) --> H - I -- 否 --> J{是否启用了防 CC PoW 验证?} - J -- 是 --> K[转交防 CC 模块处理] - J -- 否 --> L[无安全风险,正常放行] - - H --> M[退出并返回规则组配置的自定义状态码与拦截响应体] -``` - -### 2. 判决步骤细则 -1. **白名单前置**: - 为了防止误杀以及保障核心回源流量(如搜索引擎蜘蛛、CDN 回源 IP、办公区出口)的顺畅,WAF **优先匹配 IP 白名单与地域白名单**。一旦白名单匹配成功,直接绕过后续的所有黑名单检测和 CC 挑战,立刻放行。如果请求未命中白名单,则继续向下进行黑名单检测及其他后续判定。 -2. **黑名单强力阻断**: - 如果在白名单判定中未被捕获,请求将进入黑名单漏斗。一旦请求源 IP 命中 IP 黑名单、命中引用的黑名单 IP 组、或是处于被禁止的国家/地区范围内,Lua 引擎立即将 `ngx.ctx.openflare_waf_blocked` 标记设为 `true`。 -3. **输出响应**: - 命中黑名单后,Lua 提取匹配到规则组的 `block_status_code`(默认返回 418 / 403)和 `block_response_body`(拦截页面 HTML),通过 `ngx.say()` 输出响应体并执行 `ngx.exit(status)` 平滑退出请求,防止请求继续向后透传。 +启用的全局规则固定前置;路由规则按绑定 sequence 执行。阻止节点立即终止,通过节点仅结束当前规则,全部规则通过后才进入回源链路。未知节点、缺失出口或步数超限一律阻止请求。 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 6cc622fd..9bb979ae 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -70,11 +70,11 @@ IP 组采用协调 Worker、共享快照和 Worker 本地对象的两级缓存 1. 请求始终读取当前 Worker 内存中的 IP 组对象,不访问文件或共享字典中的 JSON。 2. 每 5 秒只有一个取得共享锁的 Worker 读取轻量 checksum 文件。 3. checksum 未变化时立即结束,不读取完整 `waf_ip_groups.json`。 -4. checksum 变化时,协调 Worker 读取并验证一次完整 JSON,再把原始快照与新版本写入 `ngx.shared`。 +4. checksum 变化时,协调 Worker 读取并验证一次完整 JSON,再把原始快照按 checksum 写入独立的 64 MiB `ngx.shared.openflare_waf_ip_groups`,最后更新提交指针。 5. 其他 Worker 发现共享版本变化后,从共享内存取得快照、解析并原子替换各自的本地对象,不重复读取磁盘。 6. 刷新失败时继续使用上一份有效对象,限频记录错误,并在下一周期重试。 -Agent 必须先原子替换 IP 组 JSON,最后原子更新 checksum,使 Worker 永远不会把半写入文件识别为新版本。 +Agent 必须先原子替换 IP 组 JSON,最后原子更新 checksum,使 Worker 永远不会把半写入文件识别为新版本。Server 发布/同步和 Agent 落盘共同执行 20 MiB 聚合快照上限;共享字典使用不会强制淘汰旧键的安全写入,失败时保留当前与上一代不可变快照。 ## API 与编辑器 diff --git a/docs/docs.go b/docs/docs.go index b3ecc49f..1a25dfa5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -10249,17 +10249,16 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "返回全部 WAF 规则组,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "列出 WAF 规则组", + "summary": "列出 WAF 规则", "responses": { "200": { - "description": "规则组列表", + "description": "规则列表", "schema": { "allOf": [ { @@ -10271,7 +10270,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10279,12 +10278,6 @@ const docTemplate = `{ ] } }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, "401": { "description": "未登录", "schema": { @@ -10311,7 +10304,6 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "创建新的 WAF 规则组,需要管理员权限", "consumes": [ "application/json" ], @@ -10321,21 +10313,21 @@ const docTemplate = `{ "tags": [ "openflare-waf" ], - "summary": "创建 WAF 规则组", + "summary": "创建 WAF 规则", "parameters": [ { - "description": "规则组参数", + "description": "规则名称", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" + "$ref": "#/definitions/waf.CreateRuleInput" } } ], "responses": { "200": { - "description": "创建成功的规则组", + "description": "创建成功", "schema": { "allOf": [ { @@ -10345,7 +10337,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10386,18 +10378,17 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "获取 WAF 规则组详情", + "summary": "获取 WAF 规则详情", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true @@ -10405,7 +10396,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "规则组详情", + "description": "规则详情", "schema": { "allOf": [ { @@ -10415,7 +10406,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10435,7 +10426,7 @@ const docTemplate = `{ } }, "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -10456,18 +10447,17 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "按 ID 删除 WAF 规则组,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "删除 WAF 规则组", + "summary": "删除 WAF 规则", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true @@ -10493,7 +10483,7 @@ const docTemplate = `{ } }, "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -10507,14 +10497,13 @@ const docTemplate = `{ } } }, - "/api/v1/d/waf/rule-groups/{id}/sites": { + "/api/v1/d/waf/rule-groups/{id}/graph": { "post": { "security": [ { "SessionCookie": [] } ], - "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", "consumes": [ "application/json" ], @@ -10524,28 +10513,28 @@ const docTemplate = `{ "tags": [ "openflare-waf" ], - "summary": "替换规则组站点绑定", + "summary": "保存 WAF 规则图", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true }, { - "description": "站点 ID 列表", + "description": "规则图和修订号", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/waf.IDsRequest" + "$ref": "#/definitions/waf.SaveRuleGraphInput" } } ], "responses": { "200": { - "description": "更新后的规则组", + "description": "保存成功", "schema": { "allOf": [ { @@ -10555,7 +10544,94 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数或规则图错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "修订冲突", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/meta": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则元数据", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则元数据", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.UpdateRuleMetaInput" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" } } } @@ -10575,89 +10651,7 @@ const docTemplate = `{ } }, "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -18531,6 +18525,14 @@ const docTemplate = `{ } } }, + "waf.CreateRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "waf.IDsRequest": { "type": "object", "properties": { @@ -18716,139 +18718,97 @@ const docTemplate = `{ } } }, - "waf.PoWConfig": { + "waf.RuleEdge": { "type": "object", "properties": { - "algorithm": { + "id": { "type": "string" }, - "blacklist": { - "$ref": "#/definitions/waf.PoWListConfig" + "source": { + "type": "string" }, - "challenge_ttl": { - "type": "integer" + "source_handle": { + "type": "string" }, - "difficulty": { - "type": "integer" - }, - "session_ttl": { - "type": "integer" - }, - "whitelist": { - "$ref": "#/definitions/waf.PoWListConfig" + "target": { + "type": "string" } } }, - "waf.PoWListConfig": { + "waf.RuleGraph": { "type": "object", "properties": { - "ip_cidrs": { + "edges": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/waf.RuleEdge" } }, - "ips": { + "nodes": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/waf.RuleNode" } }, - "path_regexes": { - "type": "array", - "items": { - "type": "string" - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - } - }, - "user_agents": { - "type": "array", - "items": { - "type": "string" - } + "schema_version": { + "type": "integer" } } }, - "waf.RuleGroupInput": { + "waf.RuleNode": { "type": "object", "properties": { - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { + "config": { "type": "array", "items": { "type": "integer" } }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "name": { + "id": { "type": "string" }, - "pow_config": { - "type": "array", - "items": { - "type": "integer" - } + "label": { + "type": "string" }, - "pow_enabled": { - "type": "boolean" + "position": { + "$ref": "#/definitions/waf.RulePosition" }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } + "type": { + "$ref": "#/definitions/waf.RuleNodeType" } } }, - "waf.RuleGroupView": { + "waf.RuleNodeType": { + "type": "string", + "enum": [ + "start", + "allow", + "block", + "ip_match", + "geo_match", + "pow" + ], + "x-enum-varnames": [ + "RuleNodeStart", + "RuleNodeAllow", + "RuleNodeBlock", + "RuleNodeIPMatch", + "RuleNodeGeoMatch", + "RuleNodePoW" + ] + }, + "waf.RulePosition": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "waf.RuleView": { "type": "object", "properties": { "applied_site_count": { @@ -18860,86 +18820,43 @@ const docTemplate = `{ "type": "integer" } }, - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, "created_at": { "type": "string" }, "enabled": { "type": "boolean" }, + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, "id": { "type": "integer" }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "is_global": { "type": "boolean" }, "name": { "type": "string" }, - "pow_config": { - "$ref": "#/definitions/waf.PoWConfig" - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } + "revision": { + "type": "integer" }, "updated_at": { "type": "string" } } }, + "waf.SaveRuleGraphInput": { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "revision": { + "type": "integer" + } + } + }, "waf.SiteRuleGroupsView": { "type": "object", "properties": { @@ -18952,11 +18869,11 @@ const docTemplate = `{ "applied_rule_groups": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } }, "global_rule_group": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" }, "route_id": { "type": "integer" @@ -18964,11 +18881,22 @@ const docTemplate = `{ "rule_groups": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } }, + "waf.UpdateRuleMetaInput": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, "zone.DomainInput": { "type": "object", "properties": { diff --git a/docs/guide/waf-usage.md b/docs/guide/waf-usage.md index 0196900d..c9c31e66 100644 --- a/docs/guide/waf-usage.md +++ b/docs/guide/waf-usage.md @@ -1,176 +1,25 @@ # WAF 安全防护使用 -你会学到:OpenFlare 边缘 Web 应用防火墙 (WAF) 的工作原理、防护维度,如何管理与引用三类 IP 组(手动、订阅与基于 Expr 的自动 IP 组),配置防 CC 挑战(PoW 人机验证)与地域级拦截,以及如何在不 reload 进程的情况下实现 IP 组成员的秒级热更新。 +OpenFlare WAF 使用可视化有向无环图编排规则。新建规则时只填写名称,系统创建默认的“开始 → 通过”图并进入编辑器。 ---- +## 节点与连线 -## 核心概念 +- **开始**:每条规则唯一,沿 `next` 进入图。 +- **通过**:结束当前规则;若路由仍有后续规则则继续执行。 +- **阻止**:立即按配置的状态码和 HTML 响应终止请求。 +- **IP 匹配**:配置 IP、CIDR 或 IP 组,分别连接 `true`、`false`。 +- **地域匹配**:按国家或地区代码分支;City MMDB 不可用时按未匹配处理。 +- **PoW**:未完成挑战时接管请求,验证通过后沿 `next` 继续。 -在配置安全策略前,你需要理解 WAF 的几个核心组成部分: +服务端会拒绝循环、悬空出口、不可达节点、重复端口连接和无效配置。保存时携带页面加载得到的 `revision`;发生 409 冲突时应重新加载,避免覆盖他人修改。 -| 概念 | 说明 | 作用范围与生效方式 | -| --- | --- | --- | -| **WAF 规则组 (Rule Group)** | 安全规则的逻辑集合。包括:IP 黑白名单(直接录入或引用 IP 组)、国家/地区地域限制、防 CC 挑战(PoW)以及自定义拦截响应。 | 支持全局生效或绑定到单个/多个网站。**修改规则组定义必须发布并激活配置版本**。 | -| **IP 组 (IP Group)** | 存放单个 IP 或 CIDR 网段的列表容器。分为**手动**、**订阅**与**自动**三类。WAF 规则组可通过 ID 引用 IP 组。 | 属于动态资源。**IP 组成员的增减支持 WebSocket 秒级无缝热同步,无需 reload 进程**。 | -| **人机挑战 (CC PoW)** | 基于 Proof of Work (工作量证明) 的人机验证挑战。通过让浏览器计算特定难度的哈希碰撞,静默阻断恶意刷接口的自动化脚本与 Bot,保障正常用户体验。 | 位于规则组内的配置 Tab。**修改 PoW 参数必须发布并激活配置版本**。 | +## 绑定与生效 ---- +启用的全局规则固定最先执行;路由绑定的自定义规则严格按列表顺序执行。调整顺序后需要发布配置版本,规则拓扑才会随 OpenResty reload 生效。 -## 推荐配置顺序 +IP 组成员是动态资源。Agent 每 5 秒检查 checksum,变化后在 Worker 间更新内存快照,无需重新发布规则或 reload。手动、订阅与自动 IP 组均可被 IP 匹配节点引用。单次完整 IP 组运行时快照最多 20 MiB;超过上限时发布或同步会返回错误,并继续使用上一份有效快照。 -配置网站的安全防护时,推荐按这个顺序进行: +> [!IMPORTANT] +> 从旧固定黑白名单/地域/PoW 表单升级时,规则图会重置为“开始 → 通过”,旧策略字段不会迁移。请在发布新版本前逐条重新编排并验证规则。 -1. 进入左侧菜单 **「安全性」->「IP 组」**,创建所需的 **手动 IP 组** (如开发者白名单) 或 **自动 IP 组** (如根据 404 扫描自动封禁的 IP)。 -2. 创建或编辑 **WAF 规则组**(菜单路径 **「安全性」->「WAF」**): - * 绑定需要引用或阻断的 IP 组。 - * 配置国家或省份的地域黑白名单限制。 - * (可选) 在 `PoW` 标签页配置人机挑战参数。 - * 在 `拦截返回` 标签页设定自定义状态码(如 403, 418)和 HTML 拦截页。 -3. 将规则组关联到对应的 **路由规则**(在 **「规则管理」** 页面编辑对应规则,并在「WAF」选项卡中勾选关联规则组)。 -4. 发布并激活配置版本,使边缘节点 (Agent) 开始应用 WAF 规则过滤流量。 - ---- - -## 详细步骤指南 - -### 第一步:管理与配置 IP 组 - -IP 组是进行大批量 IP 过滤的基石。OpenFlare 提供了极富弹性的三类 IP 组: - -#### 1. 手动 IP 组 (Manual) -* **用途**:静态维护一些确定受信任或确定需长期拦截的 IP/网段。 -* **配置**:点击「创建 IP 组」-> 类型选择「手动」-> 按行直接填入 IP 或 CIDR 格式(例如 `192.168.1.100` 或 `10.0.0.0/24`)。 - -#### 2. 订阅 IP 组 (Subscription) -* **用途**:接入第三方开源威胁情报库、云厂商公布的官方网段(如 Cloudflare, GitHub Action IP 列表),或团队内部统一维护的动态 IP 源。 -* **配置参数**: - * **订阅 URL**:必须是合法的 `http` 或 `https` 链接。 - * **订阅格式**:支持 `Text` 与 `JSON` 两种数据格式: - * **Text 格式**:纯文本格式。按行分隔读取 IP/CIDR,会自动过滤掉以 `#` 开头的注释行和空白行。 - * **JSON 格式**:当订阅源是一个结构化的 JSON 响应时,需要编写 **映射规则 (Mapping Rule)** 从 JSON 数据中提取 IP 列表。 - * **映射规则**:使用类似 JSONPath 的轻量点语法定位 IP 数组,支持以 `[]` 展开数组。例如: - * 若 JSON 结构为 `{"data": {"ips": ["1.1.1.1", "2.2.2.2"]}}`,则映射规则填写 `$.data.ips[]`(或 `data.ips[]`)。 - * 若 JSON 根节点本身即为字符串数组(如 `["1.1.1.1", "2.2.2.2"]`),映射规则留空或填写 `$` 即可。 - * **同步间隔 (分钟)**:该订阅组自动同步的周期,默认为 `1440` 分钟(24小时),允许范围为 `5` 至 `43200` 分钟。 -* **安全限额与同步频率**: - * 为防止恶意或超大订阅源造成系统负担,单次抓取上限限制为 **2 MiB**,网络拉取超时为 15 秒。 - * Server 默认每 5 分钟在后台扫描一次到期的订阅 IP 组并拉取同步。 - -> [!TIP] -> 关于 WAF 的动态 IP 组异步差分同步模型(WebSocket 实时热同步、不触发 Nginx Reload 机制)以及高性能 Lua 缓存方案等底层设计细节,请参阅 [WAF 设计](../design/waf-design.md)。 - -#### 3. 自动 IP 组 (Automatic) -* **用途**:**最具杀伤力的防扫描、防爆破自动通道**。 -* **配置**:类型选择「自动」-> 编写 Expr 日志聚合逻辑。你可以直接引用系统内置的预设: - * **单 IP 404 高频扫描**:`request_count > 100 && StatusRatio(404) >= 0.8` (单个 IP 最近一小时请求超 100 次且 404 响应占比超 80%)。 - * **单 IP 直连访问异常**:`ip_host_count > 50 && ip_host_ratio > 0.5` (绕过域名直接通过 IP 地址进行高频请求)。 -* **测试与立即执行**:保存前可点击 **「测试规则」** 按钮预览当前日志窗口被命中的 IP。保存后可点击 **「立即执行」** 直接聚合日志并生成封禁名单。 - -> [!TIP] -> 自动 IP 组的详细语法和可用指标请参阅 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。 - ---- - -### 第二步:创建与配置 WAF 规则组 - -1. 导航至左侧菜单 **「安全性」->「WAF」**,点击 **「创建规则组」**。 -2. 填写规则组名称(如 `production-api-shield`),选择是否为「全局规则组」。 -3. 进入规则组详情,在下方几个配置 Tab 中依次设置: - -#### 1. 黑白名单配置 (Allow / Block Lists) -* **直录 IP**:可直接在框内按行填入临时需要白名单放行或黑名单阻断的单个 IP 或网段。 -* **IP 组引用**:点击「绑定 IP 组」,选择你在第一步中配置好的手动、自动或订阅 IP 组。白名单引用会直接放行,黑名单引用则直接阻断。 - -#### 2. 地域限制 (GeoIP) -* **说明**:OpenFlare 集成了 GeoIP 地理位置解析。 -* **配置**:可开启地域限制开关,模式可选择「仅允许」或「禁止」。 -* * 例如,若你的服务只服务于国内,可以将模式设为「仅允许」,并在国家列表中勾选 `中国`。 -* * 支持细化到具体省份/地区(Region),一键拦截特定地理区域的恶意流量。 - -#### 3. 人机挑战配置 (PoW CC 防护) -* **说明**:开启防 CC 的人机挑战。当请求触发防CC机制时,浏览器会渲染一个静默挑战页面,并在几百毫秒内完成数学计算(哈希碰撞)。通过后会被写入 Cookie,后续访问直接放行。此过程对真实用户几乎无感,但能完美拦截不支持 JS/不具备计算能力的爆破脚本与 CC 僵尸工具。 -* **核心参数**: - * **开启状态**:启用/禁用。 - * **哈希难度**:控制碰撞难度(建议设定为 `4` 或 `5`)。 - * **Cookie 有效期**:挑战通过后,在多长时间内免验证(例如 `3600` 秒)。 - * **自定义挑战 HTML**:可定制挑战中的 Loading 页面风格,让其融入你的业务设计。 - -#### 4. 拦截返回 (Block Response) -* **说明**:设定 WAF 规则拦截恶意请求时的返回行为。 -* **配置**: - * **拦截状态码**:可自定义拦截响应的 HTTP 状态码,例如标准的 `403`,或带有趣味性质的 `418 (I'm a teapot)`。 - * **拦截响应体**:可在此输入自定义的 HTML 内容,展示给被拦截的攻击者(如:“WAF 拦截:你的请求已被记录”)。 - ---- - -### 第三步:将规则组关联到路由规则 - -规则组配置完成后,并不会自动生效,你需要将其与具体的路由规则绑定。 - -* **关联配置步骤**:进入 **「规则管理」** 页面,点击进入对应反代或静态托管规则的详情,切换到 **「WAF」** 选项卡,勾选并绑定刚才创建的 WAF 规则组。 - -> [!NOTE] -> 如果规则组被标记为 **「全局规则组 (is_global)」**,它将自动应用到网关上托管的**所有网站**,无需手动执行绑定。 - ---- - -### 第四步:发布并生效配置 - -1. 如果你修改了 **规则组定义**、**GeoIP 范围**、**PoW 防CC难度** 或 **网站的绑定关系**: - * 你需要点击管理端右上角的 **「配置预览」** -> **「发布并激活」**。 - * Agent 拉取并校验新版本后,将重写本地 OpenResty 核心配置文件(`waf_config.json` 等)并平滑重载进程使策略生效。 -2. 如果你只是更新了 **IP 组的成员名单**(如:在手动 IP 组中删减了一个 IP,或者自动 IP 组定时聚合出了一批新的封禁 IP): - * **不需要做任何发布操作!** - * Server 会在数据库更新后立即计算 IP 组全新的 Checksum 摘要。 - * 控制面会通过 **WebSocket 长连接实时向所有在线的 Agent 广播** 变更的 IP 组成员,Agent 接收后会增量覆写到本地的运行时磁盘文件 `waf_ip_groups.json`。 - * OpenResty Lua 引擎在处理新请求时,会在微秒级计算文件哈希,若发现 Checksum 变更则实时重载入内存字典(`ngx.shared`),**整个过程全程不需要 reload 任何 Nginx 服务,对线上高并发业务毫无影响**。 - * 即使 WebSocket 连接意外中断,Agent 也会在每周期心跳中上报本地 Checksum,由 Server 差分补齐下发,确保万无一失。 - ---- - -## WAF 判定逻辑 (过滤漏斗) - -当一个外部请求到达 OpenResty 数据面时,WAF 运行时引擎会以微秒级的极速开销进行如下判决流检测。只要判定出明确结果,即不再向下执行: - -```text - 请求进入 access 阶段 - │ - ▼ - 获取当前请求绑定的所有规则组 (全局规则组 + 自定义规则组) - │ - ▼ - 1. 匹配 IP 白名单 / 白名单 IP 组? ──────(是)─────► [ 放行 (ALLOW) ] - │ (否) - ▼ - 2. 匹配国家 / 省份地域白名单? ────────(是)─────► [ 放行 (ALLOW) ] - │ (否) - ▼ - 3. 匹配 IP 黑名单 / 黑名单 IP 组? ──────(是)─────► [ 拦截 (BLOCK) ] ──► 返回自定义状态码与HTML拦截页 - │ (否) - ▼ - 4. 匹配国家 / 省份地域黑名单? ────────(是)─────► [ 拦截 (BLOCK) ] ──► 返回自定义状态码与HTML拦截页 - │ (否) - ▼ - 5. 该站点是否启用了 PoW CC 防护? - ├───(是)───► [ 校验 PoW Cookie ] ──(验证通过)──► [ 放行 (ALLOW) ] - │ │ - │ (未通过) - │ ▼ - │ [ 渲染 PoW 挑战页 ] ──(计算正确)──► 写入 Cookie 并放行 - ▼ - 6. 未触发任何策略,属于正常业务流量 ───────────────► [ 放行 (ALLOW) ] -``` - ---- - -## 最佳实践与调优建议 - -* **白名单放行语义**:一旦某个生效规则组配置了 IP 白名单、白名单 IP 组或地域白名单,且请求命中了其中至少一条白名单规则,该请求将被直接放行,并优先绕过后续的黑名单与 PoW 检查;未命中的请求则会继续进行黑名单等后续防护校验。 -* **白名单前置与保护**:在部署高强度黑名单或地域屏蔽前,建议首先创建一个「受信任 IP 组」,放入你团队的办公室出口 IP、本地开发 IP 以及可能访问你的第三方回调源站 IP(如微信、支付宝支付回调地址),并在规则组的**白名单**中优先引入。这可以有效防止误杀,确保信任的 IP 即使命中黑名单或 CC 限制也能无阻碍访问。 -* **合理微调 PoW 难度**:人机 CC 挑战的哈希碰撞计算(`challenge_difficulty`)是一把双刃剑。 - * 难度值 `3`:几乎瞬间完成计算,防 CC 强度低。 - * 难度值 `4`:普通手机/低端浏览器在 100~300ms 内完成计算,防护性能良好。 - * 难度值 `5`:需要 500ms~2s,防护性强,但低配端可能会感觉稍显卡顿。 - * 难度值 `6` 及以上:计算量呈指数级上升,可能导致移动端用户浏览器 CPU 持续打满卡死。**因此强烈建议在生产环境选用 `4` 或 `5`**。 -* **善用“测试规则”**:对于自动 IP 组,在点击保存之前务必点击 **「测试规则」**。通过分析当前窗口内被命中的 IP 列表,确认你的 Expr 表达式阈值(如请求数、404占比等)配置是否过宽或过紧,防止由于阈值配置不合理导致大面积误封正常用户。 -* **分离静态与动态黑名单**:不要将需要长期封禁的静态恶意 IP 填入自动封禁组(因为自动聚合的名单随时会被新的执行窗口覆盖)。应该将确定的恶意 IP 录入到一个专门的「手动封禁 IP 组」中,并让规则组同时引用该手动组与自动组。 +架构、图校验和失败回滚细节见 [WAF 可编排规则设计](../design/waf-orchestration-design.md)。 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md index b3c3c4d5..6ea1976c 100644 --- a/docs/plan/20260713-waf-orchestration.md +++ b/docs/plan/20260713-waf-orchestration.md @@ -8,6 +8,12 @@ **Tech Stack:** Go 1.25、Gin、GORM、goose、PostgreSQL/SQLite、OpenResty Lua、Next.js 16 App Router、React 19、TypeScript、`@xyflow/react`、TanStack Query、shadcn/ui、Vitest。 +## 实现状态(2026-07-13) + +Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。 + +当前工作区已完成 `go test ./...`、前端全量 Vitest(54 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 + ## Global Constraints - 每张图恰好一个 `start` 和一个 `allow`;`block` 可多个;图必须无环、无悬空、无不可达节点,所有路径必须抵达 `allow` 或 `block`。 @@ -374,7 +380,7 @@ Commit: `feat(agent): execute waf graphs from worker memory` **Interfaces:** - Produces: `waf_ip_groups.json.checksum`;Lua `ip_groups.current()` 返回 Worker 本地对象;协调刷新间隔固定 5 秒。 -- Consumes: 现有 Agent IP 组同步 payload 与 `ngx.shared.openflare_waf_config`。 +- Consumes: 现有 Agent IP 组同步 payload 与独立的 `ngx.shared.openflare_waf_ip_groups`(64 MiB);完整运行时快照上限为 20 MiB。 - [ ] **Step 1: 写失败测试** diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0b4df4c4..af66a549 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -286,6 +286,8 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb 路径,可覆盖 `agent.json` | 空 | | `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb 更新间隔,可覆盖 `agent.json` | 空 | | `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb 下载地址,可覆盖 `agent.json` | 空 | +| `OPENFLARE_CITY_MMDB_PATH` | WAF 地区匹配 City MMDB 路径,可覆盖 `agent.json` | 空 | +| `OPENFLARE_CITY_MMDB_DOWNLOAD_URL` | WAF City MMDB 下载地址,可覆盖 `agent.json` | 空 | --- @@ -315,8 +317,10 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` | | `pages_dir` | Pages 静态部署包解压与当前部署目录 | 否 | `data_dir/var/lib/openflare/pages` | | `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` | +| `city_mmdb_path` | WAF 地区匹配 City MMDB 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-City.mmdb` | | `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 (24h) | | `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 | +| `city_mmdb_download_url` | WAF City MMDB 下载地址 | 否 | 内置 GeoLite2 City 下载地址 | | `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` | | `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` | | `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` | diff --git a/docs/swagger.json b/docs/swagger.json index 5bd7ede5..e35868c6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -10242,17 +10242,16 @@ "SessionCookie": [] } ], - "description": "返回全部 WAF 规则组,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "列出 WAF 规则组", + "summary": "列出 WAF 规则", "responses": { "200": { - "description": "规则组列表", + "description": "规则列表", "schema": { "allOf": [ { @@ -10264,7 +10263,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10272,12 +10271,6 @@ ] } }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, "401": { "description": "未登录", "schema": { @@ -10304,7 +10297,6 @@ "SessionCookie": [] } ], - "description": "创建新的 WAF 规则组,需要管理员权限", "consumes": [ "application/json" ], @@ -10314,21 +10306,21 @@ "tags": [ "openflare-waf" ], - "summary": "创建 WAF 规则组", + "summary": "创建 WAF 规则", "parameters": [ { - "description": "规则组参数", + "description": "规则名称", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" + "$ref": "#/definitions/waf.CreateRuleInput" } } ], "responses": { "200": { - "description": "创建成功的规则组", + "description": "创建成功", "schema": { "allOf": [ { @@ -10338,7 +10330,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10379,18 +10371,17 @@ "SessionCookie": [] } ], - "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "获取 WAF 规则组详情", + "summary": "获取 WAF 规则详情", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true @@ -10398,7 +10389,7 @@ ], "responses": { "200": { - "description": "规则组详情", + "description": "规则详情", "schema": { "allOf": [ { @@ -10408,7 +10399,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } @@ -10428,7 +10419,7 @@ } }, "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -10449,18 +10440,17 @@ "SessionCookie": [] } ], - "description": "按 ID 删除 WAF 规则组,需要管理员权限", "produces": [ "application/json" ], "tags": [ "openflare-waf" ], - "summary": "删除 WAF 规则组", + "summary": "删除 WAF 规则", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true @@ -10486,7 +10476,7 @@ } }, "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -10500,14 +10490,13 @@ } } }, - "/api/v1/d/waf/rule-groups/{id}/sites": { + "/api/v1/d/waf/rule-groups/{id}/graph": { "post": { "security": [ { "SessionCookie": [] } ], - "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", "consumes": [ "application/json" ], @@ -10517,28 +10506,28 @@ "tags": [ "openflare-waf" ], - "summary": "替换规则组站点绑定", + "summary": "保存 WAF 规则图", "parameters": [ { "type": "integer", - "description": "规则组 ID", + "description": "规则 ID", "name": "id", "in": "path", "required": true }, { - "description": "站点 ID 列表", + "description": "规则图和修订号", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/waf.IDsRequest" + "$ref": "#/definitions/waf.SaveRuleGraphInput" } } ], "responses": { "200": { - "description": "更新后的规则组", + "description": "保存成功", "schema": { "allOf": [ { @@ -10548,7 +10537,94 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数或规则图错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "修订冲突", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/meta": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则元数据", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则元数据", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.UpdateRuleMetaInput" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" } } } @@ -10568,89 +10644,7 @@ } }, "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", + "description": "无权限或不存在", "schema": { "$ref": "#/definitions/response.Any" } @@ -18524,6 +18518,14 @@ } } }, + "waf.CreateRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "waf.IDsRequest": { "type": "object", "properties": { @@ -18709,139 +18711,97 @@ } } }, - "waf.PoWConfig": { + "waf.RuleEdge": { "type": "object", "properties": { - "algorithm": { + "id": { "type": "string" }, - "blacklist": { - "$ref": "#/definitions/waf.PoWListConfig" + "source": { + "type": "string" }, - "challenge_ttl": { - "type": "integer" + "source_handle": { + "type": "string" }, - "difficulty": { - "type": "integer" - }, - "session_ttl": { - "type": "integer" - }, - "whitelist": { - "$ref": "#/definitions/waf.PoWListConfig" + "target": { + "type": "string" } } }, - "waf.PoWListConfig": { + "waf.RuleGraph": { "type": "object", "properties": { - "ip_cidrs": { + "edges": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/waf.RuleEdge" } }, - "ips": { + "nodes": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/waf.RuleNode" } }, - "path_regexes": { - "type": "array", - "items": { - "type": "string" - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - } - }, - "user_agents": { - "type": "array", - "items": { - "type": "string" - } + "schema_version": { + "type": "integer" } } }, - "waf.RuleGroupInput": { + "waf.RuleNode": { "type": "object", "properties": { - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { + "config": { "type": "array", "items": { "type": "integer" } }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "name": { + "id": { "type": "string" }, - "pow_config": { - "type": "array", - "items": { - "type": "integer" - } + "label": { + "type": "string" }, - "pow_enabled": { - "type": "boolean" + "position": { + "$ref": "#/definitions/waf.RulePosition" }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } + "type": { + "$ref": "#/definitions/waf.RuleNodeType" } } }, - "waf.RuleGroupView": { + "waf.RuleNodeType": { + "type": "string", + "enum": [ + "start", + "allow", + "block", + "ip_match", + "geo_match", + "pow" + ], + "x-enum-varnames": [ + "RuleNodeStart", + "RuleNodeAllow", + "RuleNodeBlock", + "RuleNodeIPMatch", + "RuleNodeGeoMatch", + "RuleNodePoW" + ] + }, + "waf.RulePosition": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "waf.RuleView": { "type": "object", "properties": { "applied_site_count": { @@ -18853,86 +18813,43 @@ "type": "integer" } }, - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, "created_at": { "type": "string" }, "enabled": { "type": "boolean" }, + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, "id": { "type": "integer" }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "is_global": { "type": "boolean" }, "name": { "type": "string" }, - "pow_config": { - "$ref": "#/definitions/waf.PoWConfig" - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } + "revision": { + "type": "integer" }, "updated_at": { "type": "string" } } }, + "waf.SaveRuleGraphInput": { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "revision": { + "type": "integer" + } + } + }, "waf.SiteRuleGroupsView": { "type": "object", "properties": { @@ -18945,11 +18862,11 @@ "applied_rule_groups": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } }, "global_rule_group": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" }, "route_id": { "type": "integer" @@ -18957,11 +18874,22 @@ "rule_groups": { "type": "array", "items": { - "$ref": "#/definitions/waf.RuleGroupView" + "$ref": "#/definitions/waf.RuleView" } } } }, + "waf.UpdateRuleMetaInput": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, "zone.DomainInput": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6aed4726..97f3f440 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3641,6 +3641,11 @@ definitions: website: type: string type: object + waf.CreateRuleInput: + properties: + name: + type: string + type: object waf.IDsRequest: properties: ids: @@ -3762,94 +3767,69 @@ definitions: updated_at: type: string type: object - waf.PoWConfig: + waf.RuleEdge: properties: - algorithm: + id: type: string - blacklist: - $ref: '#/definitions/waf.PoWListConfig' - challenge_ttl: - type: integer - difficulty: - type: integer - session_ttl: - type: integer - whitelist: - $ref: '#/definitions/waf.PoWListConfig' - type: object - waf.PoWListConfig: - properties: - ip_cidrs: - items: - type: string - type: array - ips: - items: - type: string - type: array - path_regexes: - items: - type: string - type: array - paths: - items: - type: string - type: array - user_agents: - items: - type: string - type: array - type: object - waf.RuleGroupInput: - properties: - block_response_body: + source: type: string - block_status_code: + source_handle: + type: string + target: + type: string + type: object + waf.RuleGraph: + properties: + edges: + items: + $ref: '#/definitions/waf.RuleEdge' + type: array + nodes: + items: + $ref: '#/definitions/waf.RuleNode' + type: array + schema_version: type: integer - country_blacklist: - items: - type: string - type: array - country_whitelist: - items: - type: string - type: array - enabled: - type: boolean - ip_blacklist: - items: - type: string - type: array - ip_blacklist_group_ids: + type: object + waf.RuleNode: + properties: + config: items: type: integer type: array - ip_whitelist: - items: - type: string - type: array - ip_whitelist_group_ids: - items: - type: integer - type: array - name: + id: type: string - pow_config: - items: - type: integer - type: array - pow_enabled: - type: boolean - region_blacklist: - items: - type: string - type: array - region_whitelist: - items: - type: string - type: array + label: + type: string + position: + $ref: '#/definitions/waf.RulePosition' + type: + $ref: '#/definitions/waf.RuleNodeType' type: object - waf.RuleGroupView: + waf.RuleNodeType: + enum: + - start + - allow + - block + - ip_match + - geo_match + - pow + type: string + x-enum-varnames: + - RuleNodeStart + - RuleNodeAllow + - RuleNodeBlock + - RuleNodeIPMatch + - RuleNodeGeoMatch + - RuleNodePoW + waf.RulePosition: + properties: + x: + type: number + "y": + type: number + type: object + waf.RuleView: properties: applied_site_count: type: integer @@ -3857,59 +3837,30 @@ definitions: items: type: integer type: array - block_response_body: - type: string - block_status_code: - type: integer - country_blacklist: - items: - type: string - type: array - country_whitelist: - items: - type: string - type: array created_at: type: string enabled: type: boolean + graph: + $ref: '#/definitions/waf.RuleGraph' id: type: integer - ip_blacklist: - items: - type: string - type: array - ip_blacklist_group_ids: - items: - type: integer - type: array - ip_whitelist: - items: - type: string - type: array - ip_whitelist_group_ids: - items: - type: integer - type: array is_global: type: boolean name: type: string - pow_config: - $ref: '#/definitions/waf.PoWConfig' - pow_enabled: - type: boolean - region_blacklist: - items: - type: string - type: array - region_whitelist: - items: - type: string - type: array + revision: + type: integer updated_at: type: string type: object + waf.SaveRuleGraphInput: + properties: + graph: + $ref: '#/definitions/waf.RuleGraph' + revision: + type: integer + type: object waf.SiteRuleGroupsView: properties: applied_ids: @@ -3918,17 +3869,24 @@ definitions: type: array applied_rule_groups: items: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' type: array global_rule_group: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' route_id: type: integer rule_groups: items: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' type: array type: object + waf.UpdateRuleMetaInput: + properties: + enabled: + type: boolean + name: + type: string + type: object zone.DomainInput: properties: cert_id: @@ -10168,25 +10126,20 @@ paths: - openflare-waf /api/v1/d/waf/rule-groups: get: - description: 返回全部 WAF 规则组,需要管理员权限 produces: - application/json responses: "200": - description: 规则组列表 + description: 规则列表 schema: allOf: - $ref: '#/definitions/response.Any' - properties: data: items: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' type: array type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' "401": description: 未登录 schema: @@ -10201,31 +10154,30 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 列出 WAF 规则组 + summary: 列出 WAF 规则 tags: - openflare-waf post: consumes: - application/json - description: 创建新的 WAF 规则组,需要管理员权限 parameters: - - description: 规则组参数 + - description: 规则名称 in: body name: request required: true schema: - $ref: '#/definitions/waf.RuleGroupInput' + $ref: '#/definitions/waf.CreateRuleInput' produces: - application/json responses: "200": - description: 创建成功的规则组 + description: 创建成功 schema: allOf: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' type: object "400": description: 参数错误 @@ -10245,14 +10197,13 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 创建 WAF 规则组 + summary: 创建 WAF 规则 tags: - openflare-waf /api/v1/d/waf/rule-groups/{id}: get: - description: 按 ID 返回 WAF 规则组详情,需要管理员权限 parameters: - - description: 规则组 ID + - description: 规则 ID in: path name: id required: true @@ -10261,13 +10212,13 @@ paths: - application/json responses: "200": - description: 规则组详情 + description: 规则详情 schema: allOf: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' type: object "400": description: 参数错误 @@ -10278,7 +10229,7 @@ paths: schema: $ref: '#/definitions/response.Any' "404": - description: 记录不存在 + description: 无权限或不存在 schema: $ref: '#/definitions/response.Any' "500": @@ -10287,14 +10238,13 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 获取 WAF 规则组详情 + summary: 获取 WAF 规则详情 tags: - openflare-waf /api/v1/d/waf/rule-groups/{id}/delete: post: - description: 按 ID 删除 WAF 规则组,需要管理员权限 parameters: - - description: 规则组 ID + - description: 规则 ID in: path name: id required: true @@ -10315,7 +10265,7 @@ paths: schema: $ref: '#/definitions/response.Any' "404": - description: 记录不存在 + description: 无权限或不存在 schema: $ref: '#/definitions/response.Any' "500": @@ -10324,37 +10274,89 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 删除 WAF 规则组 + summary: 删除 WAF 规则 tags: - openflare-waf - /api/v1/d/waf/rule-groups/{id}/sites: + /api/v1/d/waf/rule-groups/{id}/graph: post: consumes: - application/json - description: 替换 WAF 规则组关联的代理站点列表,需要管理员权限 parameters: - - description: 规则组 ID + - description: 规则 ID in: path name: id required: true type: integer - - description: 站点 ID 列表 + - description: 规则图和修订号 in: body name: request required: true schema: - $ref: '#/definitions/waf.IDsRequest' + $ref: '#/definitions/waf.SaveRuleGraphInput' produces: - application/json responses: "200": - description: 更新后的规则组 + description: 保存成功 schema: allOf: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/waf.RuleGroupView' + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数或规则图错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 修订冲突 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 保存 WAF 规则图 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/meta: + post: + consumes: + - application/json + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + - description: 规则元数据 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.UpdateRuleMetaInput' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' type: object "400": description: 参数错误 @@ -10365,7 +10367,7 @@ paths: schema: $ref: '#/definitions/response.Any' "404": - description: 记录不存在 + description: 无权限或不存在 schema: $ref: '#/definitions/response.Any' "500": @@ -10374,57 +10376,7 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 替换规则组站点绑定 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 WAF 规则组,需要管理员权限 - parameters: - - description: 规则组 ID - in: path - name: id - required: true - type: integer - - description: 规则组参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.RuleGroupInput' - produces: - - application/json - responses: - "200": - description: 更新后的规则组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.RuleGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 WAF 规则组 + summary: 更新 WAF 规则元数据 tags: - openflare-waf /api/v1/d/waf/sites/{route_id}/rule-groups: diff --git a/frontend/app/(main)/waf/components/rule-groups-table.tsx b/frontend/app/(main)/waf/components/rule-groups-table.tsx index bfd62526..b03e1c75 100644 --- a/frontend/app/(main)/waf/components/rule-groups-table.tsx +++ b/frontend/app/(main)/waf/components/rule-groups-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2, Users} from 'lucide-react'; +import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -20,14 +20,12 @@ interface RuleGroupsTableProps { groups: WAFRule[]; onEdit: (group: WAFRule) => void; onDelete: (group: WAFRule) => void; - onBindSites: (group: WAFRule) => void; } export function RuleGroupsTable({ groups, onEdit, onDelete, - onBindSites, }: RuleGroupsTableProps) { return ( @@ -85,12 +83,6 @@ export function RuleGroupsTable({ 编排 - {!group.is_global ? ( - onBindSites(group)}> - - 绑定网站 - - ) : null} {!group.is_global ? ( <> diff --git a/frontend/app/(main)/waf/components/site-binding-sheet.tsx b/frontend/app/(main)/waf/components/site-binding-sheet.tsx deleted file mode 100644 index 456e393b..00000000 --- a/frontend/app/(main)/waf/components/site-binding-sheet.tsx +++ /dev/null @@ -1,139 +0,0 @@ -'use client'; - -import {useEffect, useMemo, useState} from 'react'; -import {Check, Search} from 'lucide-react'; - -import {Button} from '@/components/ui/button'; -import {Input} from '@/components/ui/input'; -import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet'; -import {cn} from '@/lib/utils'; -import type {ProxyRouteItem, WAFRule} from '@/lib/services/openflare'; - -interface SiteBindingSheetProps { - group: WAFRule | null; - routes: ProxyRouteItem[]; - open: boolean; - pending: boolean; - onOpenChange: (open: boolean) => void; - onSave: (ids: number[]) => void; -} - -export function SiteBindingSheet({ - group, - routes, - open, - pending, - onOpenChange, - onSave, -}: SiteBindingSheetProps) { - const [keyword, setKeyword] = useState(''); - const [selectedIDs, setSelectedIDs] = useState([]); - - useEffect(() => { - setSelectedIDs(group?.applied_site_ids ?? []); - setKeyword(''); - }, [group, open]); - - const filteredRoutes = useMemo(() => { - const normalized = keyword.trim().toLowerCase(); - if (!normalized) return routes; - return routes.filter((route) => - [ - route.site_name, - ...(route.zone_domains ?? []).map((item) => item.domain), - ] - .join(' ') - .toLowerCase() - .includes(normalized), - ); - }, [keyword, routes]); - - const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); - - const toggleID = (id: number) => { - setSelectedIDs((current) => - current.includes(id) - ? current.filter((item) => item !== id) - : [...current, id], - ); - }; - - const selectFiltered = () => { - const next = new Set(selectedIDs); - filteredRoutes.forEach((route) => next.add(route.id)); - setSelectedIDs([...next]); - }; - - return ( - - - - {group ? `绑定 ${group.name}` : '绑定规则组'} - - 选择这个自定义规则组要叠加到哪些网站。 - - - -
-
- - setKeyword(event.target.value)} - /> - -
- -
- {filteredRoutes.map((route) => ( - - ))} -
-
- - - - - -
-
- ); -} diff --git a/frontend/app/(main)/waf/page.tsx b/frontend/app/(main)/waf/page.tsx index 6591a66b..37793688 100644 --- a/frontend/app/(main)/waf/page.tsx +++ b/frontend/app/(main)/waf/page.tsx @@ -22,33 +22,25 @@ import {EmptyStateWithBorder} from '@/components/layout/empty'; import {ErrorInline} from '@/components/layout/error'; import {LoadingStateWithBorder} from '@/components/layout/loading'; import type {WAFRule} from '@/lib/services/openflare'; -import {ProxyRouteService, WafService} from '@/lib/services/openflare'; +import {WafService} from '@/lib/services/openflare'; import {CreateRuleDialog} from './components/create-rule-dialog'; import {getErrorMessage} from './components/helpers'; import {RuleGroupsTable} from './components/rule-groups-table'; -import {SiteBindingSheet} from './components/site-binding-sheet'; const ruleGroupsQueryKey = ['openflare', 'waf', 'rule-groups']; -const routesQueryKey = ['openflare', 'proxy-routes']; export default function WafPage() { const router = useRouter(); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); - const [bindingGroup, setBindingGroup] = useState(null); const groupsQuery = useQuery({ queryKey: ruleGroupsQueryKey, queryFn: () => WafService.listRuleGroups(), }); - const routesQuery = useQuery({ - queryKey: routesQueryKey, - queryFn: () => ProxyRouteService.list(), - }); - const invalidate = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey }), @@ -80,26 +72,13 @@ export default function WafPage() { }, }); - const bindMutation = useMutation({ - mutationFn: ({ id, ids }: { id: number; ids: number[] }) => - WafService.updateRuleGroupSites(id, ids), - onSuccess: async () => { - toast.success('规则组应用范围已更新'); - setBindingGroup(null); - await invalidate(); - }, - onError: (error) => { - toast.error(getErrorMessage(error)); - }, - }); - const handleRefresh = () => { void queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey }); }; const groups = groupsQuery.data ?? []; - const loading = groupsQuery.isLoading || routesQuery.isLoading; - const error = groupsQuery.error ?? routesQuery.error ?? null; + const loading = groupsQuery.isLoading; + const error = groupsQuery.error ?? null; return (
@@ -162,7 +141,6 @@ export default function WafPage() { groups={groups} onEdit={(rule) => router.push(`/waf/rules/editor?id=${rule.id}`)} onDelete={setDeleteTarget} - onBindSites={setBindingGroup} /> )} @@ -177,19 +155,6 @@ export default function WafPage() { }} /> - !open && setBindingGroup(null)} - onSave={(ids) => { - if (bindingGroup) { - bindMutation.mutate({ id: bindingGroup.id, ids }); - } - }} - /> - !open && setDeleteTarget(null)} diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts new file mode 100644 index 00000000..d42b2559 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts @@ -0,0 +1,66 @@ +import {describe, expect, it} from 'vitest'; + +import type {WAFRuleGraph} from '@/lib/services/openflare'; + +import {acceptedNodeChanges, filterRemovableNodeIds, findGraphErrorTarget, getHistoryTransition, isConnectionAllowed, isPersistentEdgeChange, isPersistentNodeChange} from './editor-behavior'; + +describe('React Flow persistence filtering', () => { + it('ignores dimensions and selection changes', () => { + expect(isPersistentNodeChange({type: 'dimensions', id: 'n', dimensions: {width: 10, height: 10}})).toBe(false); + expect(isPersistentNodeChange({type: 'select', id: 'n', selected: true})).toBe(false); + expect(isPersistentEdgeChange({type: 'select', id: 'e', selected: true})).toBe(false); + }); + + it('persists completed position changes and removals', () => { + expect(isPersistentNodeChange({type: 'position', id: 'n', position: {x: 1, y: 2}, dragging: false})).toBe(true); + expect(isPersistentNodeChange({type: 'remove', id: 'n'})).toBe(true); + expect(isPersistentEdgeChange({type: 'remove', id: 'e'})).toBe(true); + }); +}); + +describe('editor safety constraints', () => { + const graph: WAFRuleGraph = {schema_version: 1, nodes: [ + {id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}}, + {id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}}, + {id: 'allow', type: 'allow', position: {x: 0, y: 0}, config: {}}, + ], edges: [{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'}]}; + + it('protects start and allow from deletion', () => expect(filterRemovableNodeIds(graph.nodes, ['start', 'match', 'allow'])).toEqual(['match'])); + + it('does not persist a removal containing only protected nodes', () => { + expect(acceptedNodeChanges(graph.nodes, [{type: 'remove', id: 'start'}, {type: 'remove', id: 'allow'}])).toEqual({changes: [], persistent: false}); + }); + + it('rejects invalid and already-used source ports', () => { + expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'next', target: 'allow'})).toBe(false); + expect(isConnectionAllowed(graph, {source: 'start', sourceHandle: 'next', target: 'allow'})).toBe(false); + expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'true', target: 'allow'})).toBe(true); + }); +}); + +describe('server graph error targeting', () => { + const nodes = ['start', 'match-1']; + const edges = ['start-match']; + + it('uses explicit node and edge ids from nested payloads', () => { + expect(findGraphErrorTarget({details: {node_id: 'match-1'}}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'}); + expect(findGraphErrorTarget({details: {edgeId: 'start-match'}}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'}); + }); + + it('does not substring match unrelated error text', () => { + expect(findGraphErrorTarget({message: 'restart operation failed'}, nodes, edges)).toBeUndefined(); + }); + + it('parses the real API envelope with strict ID boundaries', () => { + expect(findGraphErrorTarget({error_msg: '规则图无效: 节点 match-1 的 true 出口未连接', data: null}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'}); + expect(findGraphErrorTarget(new Error('规则图无效: 边 start-match 的目标节点不存在'), nodes, edges)).toEqual({kind: 'edge', id: 'start-match'}); + expect(findGraphErrorTarget({error_msg: '节点 match-10 无效'}, nodes, edges)).toBeUndefined(); + expect(findGraphErrorTarget({error_msg: '规则图无效: 边 ID start-match 重复'}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'}); + expect(findGraphErrorTarget({error_msg: '边 ID start-matcher 重复'}, nodes, edges)).toBeUndefined(); + }); +}); + +it('calculates deterministic Back and Forward restoration deltas', () => { + expect(getHistoryTransition(4, 3)).toEqual({direction: 'back', restoreDelta: 1}); + expect(getHistoryTransition(4, 6)).toEqual({direction: 'forward', restoreDelta: -2}); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts new file mode 100644 index 00000000..0f3b0c2d --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts @@ -0,0 +1,73 @@ +import type {EdgeChange, NodeChange} from '@xyflow/react'; +import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare'; + +import {wouldCreateCycle} from './graph-validation'; + +export type GraphErrorTarget = {kind: 'node' | 'edge'; id: string}; + +export function isPersistentNodeChange(change: NodeChange): boolean { + return change.type === 'remove' || change.type === 'add' || change.type === 'replace' || (change.type === 'position' && change.dragging === false && Boolean(change.position)); +} + +export function isPersistentEdgeChange(change: EdgeChange): boolean { + return change.type === 'remove' || change.type === 'add' || change.type === 'replace'; +} + +export function filterRemovableNodeIds(nodes: WAFRuleNode[], ids: string[]): string[] { + return ids.filter((id) => !['start', 'allow'].includes(nodes.find((node) => node.id === id)?.type ?? '')); +} + +export function acceptedNodeChanges(nodes: WAFRuleNode[], changes: NodeChange[]): {changes: NodeChange[]; persistent: boolean} { + const accepted = changes.filter((change) => change.type === 'position' || (change.type === 'remove' && filterRemovableNodeIds(nodes, [change.id]).length === 1)); + return {changes: accepted, persistent: accepted.some(isPersistentNodeChange)}; +} + +export function getHistoryTransition(current: number, target: number): {direction: 'back' | 'forward'; restoreDelta: number} { + return {direction: target < current ? 'back' : 'forward', restoreDelta: current - target}; +} + +export function isConnectionAllowed(graph: WAFRuleGraph, connection: {source?: string | null; sourceHandle?: string | null; target?: string | null}): boolean { + if (!connection.source || !connection.target || !connection.sourceHandle) return false; + const source = graph.nodes.find((node) => node.id === connection.source); + const handles: Partial> = {start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next']}; + return Boolean(source && (handles[source.type] ?? []).includes(connection.sourceHandle)) && !graph.edges.some((edge) => edge.source === connection.source && edge.source_handle === connection.sourceHandle) && !wouldCreateCycle(graph, connection.source, connection.target); +} + +export function findGraphErrorTarget(payload: unknown, nodeIds: string[], edgeIds: string[]): GraphErrorTarget | undefined { + const found = collectIdFields(payload); + for (const {key, value} of found) { + if ((key === 'node_id' || key === 'nodeId') && nodeIds.includes(value)) return {kind: 'node', id: value}; + if ((key === 'edge_id' || key === 'edgeId') && edgeIds.includes(value)) return {kind: 'edge', id: value}; + } + const messages = collectMessages(payload); + for (const message of messages) { + const nodeId = findMessageId(message, '节点', nodeIds); + if (nodeId) return {kind: 'node', id: nodeId}; + const edgeId = findMessageId(message, '边', edgeIds); + if (edgeId) return {kind: 'edge', id: edgeId}; + } + return undefined; +} + +function collectMessages(value: unknown): string[] { + if (value instanceof Error) return [value.message, ...collectMessages(value.cause)]; + if (!value || typeof value !== 'object') return []; + return Object.entries(value).flatMap(([key, child]) => key === 'error_msg' || key === 'message' ? typeof child === 'string' ? [child] : [] : collectMessages(child)); +} + +function findMessageId(message: string, prefix: '节点' | '边', ids: string[]): string | undefined { + const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点'; + return [...ids].sort((a, b) => b.length - a.length).find((id) => new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(message)); +} + +function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +function collectIdFields(value: unknown): {key: string; value: string}[] { + if (!value || typeof value !== 'object') return []; + const result: {key: string; value: string}[] = []; + for (const [key, child] of Object.entries(value)) { + if (typeof child === 'string') result.push({key, value: child}); + else result.push(...collectIdFields(child)); + } + return result; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts new file mode 100644 index 00000000..a4a54cbb --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts @@ -0,0 +1,116 @@ +import {describe, expect, it} from 'vitest'; + +import type {WAFRuleGraph} from '@/lib/services/openflare'; + +import {removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation'; + +const validGraph = (): WAFRuleGraph => ({ + schema_version: 1, + nodes: [ + {id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}}, + {id: 'match', type: 'ip_match', position: {x: 240, y: 0}, config: {ips: ['127.0.0.1'], cidrs: [], ip_group_ids: []}}, + {id: 'allow', type: 'allow', position: {x: 520, y: -80}, config: {}}, + {id: 'block', type: 'block', position: {x: 520, y: 100}, config: {status_code: 403, response_body: ''}}, + ], + edges: [ + {id: 'start-match', source: 'start', source_handle: 'next', target: 'match'}, + {id: 'match-allow', source: 'match', source_handle: 'true', target: 'allow'}, + {id: 'match-block', source: 'match', source_handle: 'false', target: 'block'}, + ], +}); + +describe('validateGraph', () => { + it('accepts a complete terminating graph', () => expect(validateGraph(validGraph())).toEqual([])); + + it('requires exactly one start and allow node', () => { + const graph = validGraph(); + graph.nodes = graph.nodes.filter((node) => node.type !== 'allow'); + expect(validateGraph(graph).map((issue) => issue.code)).toContain('allow_count'); + }); + + it('requires every source handle', () => { + const graph = validGraph(); + graph.edges = graph.edges.filter((edge) => edge.source_handle !== 'false'); + expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'missing_handle', nodeId: 'match'})); + }); + + it('rejects cycles', () => { + const graph = validGraph(); + graph.edges.push({id: 'cycle', source: 'block', source_handle: 'next', target: 'match'}); + expect(validateGraph(graph).map((issue) => issue.code)).toContain('cycle'); + }); + + it('reports unreachable nodes and paths without a terminal', () => { + const graph = validGraph(); + graph.nodes.push({id: 'orphan', type: 'pow', position: {x: 0, y: 200}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}}); + expect(validateGraph(graph)).toEqual(expect.arrayContaining([ + expect.objectContaining({code: 'unreachable', nodeId: 'orphan'}), + expect.objectContaining({code: 'non_terminating', nodeId: 'orphan'}), + ])); + }); + + it('rejects duplicate identifiers and start incoming edges', () => { + const graph = validGraph(); + graph.nodes.push({...graph.nodes[1]}); + graph.edges.push({...graph.edges[0]}, {id: 'into-start', source: 'match', source_handle: 'true', target: 'start'}); + expect(validateGraph(graph)).toEqual(expect.arrayContaining([ + expect.objectContaining({code: 'duplicate_node_id', nodeId: 'match'}), + expect.objectContaining({code: 'duplicate_edge_id', edgeId: 'start-match'}), + expect.objectContaining({code: 'start_incoming', edgeId: 'into-start'}), + ])); + }); + + it('validates typed node configuration locally', () => { + const graph = validGraph(); + graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['999.1.1.1'], cidrs: ['broken'], ip_group_ids: [-1]}} : node.type === 'block' ? {...node, config: {status_code: 200, response_body: 'x'.repeat(65_537)}} : node); + expect(validateGraph(graph).filter((issue) => issue.code === 'invalid_config').map((issue) => issue.nodeId)).toEqual(expect.arrayContaining(['match', 'block'])); + }); + + it('validates PoW bounds and geography codes', () => { + const graph = validGraph(); + graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 0, session_ttl: 0, challenge_ttl: 0}}); + graph.nodes.push({id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: ['china'], regions: ['']}}); + expect(validateGraph(graph)).toEqual(expect.arrayContaining([ + expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}), + expect.objectContaining({code: 'invalid_config', nodeId: 'geo'}), + ])); + }); + + it('rejects non-finite and fractional integer configuration', () => { + const graph = validGraph(); + graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4.5, session_ttl: Number.NaN, challenge_ttl: 30}}); + graph.nodes.push({id: 'block-fraction', type: 'block', position: {x: 0, y: 0}, config: {status_code: 403.5, response_body: ''}}); + expect(validateGraph(graph)).toEqual(expect.arrayContaining([ + expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}), + expect.objectContaining({code: 'invalid_config', nodeId: 'block-fraction'}), + ])); + }); + + it('matches server IP and prefix parsing semantics', () => { + const invalid = validGraph(); + invalid.nodes = invalid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '::::'], cidrs: ['2001:db8::/32', '10.0.0.0/33'], ip_group_ids: []}} : node); + expect(validateGraph(invalid)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'})); + const valid = validGraph(); + valid.nodes = valid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '192.0.2.1'], cidrs: ['2001:db8::/32', '10.0.0.0/8'], ip_group_ids: []}} : node); + expect(validateGraph(valid)).toEqual([]); + }); + + it('requires exactly one CIDR slash and rejects scoped IPv6 addresses', () => { + for (const value of ['10.0.0.0/8/extra', 'fe80::1%en0', 'fe80::%en0/64']) { + const graph = validGraph(); + graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: value.includes('/') ? {ips: [], cidrs: [value], ip_group_ids: []} : {ips: [value], cidrs: [], ip_group_ids: []}} : node); + expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'})); + } + }); +}); + +it('removes incident edges when deleting a node', () => { + const next = removeNodeFromGraph(validGraph(), 'match'); + expect(next.nodes.some((node) => node.id === 'match')).toBe(false); + expect(next.edges).toEqual([]); +}); + +it('detects whether a new connection creates a cycle', () => { + expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true); + expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts new file mode 100644 index 00000000..cd4fcad8 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts @@ -0,0 +1,146 @@ +import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare'; + +export type GraphIssueCode = 'schema' | 'size_limit' | 'empty_id' | 'duplicate_node_id' | 'duplicate_edge_id' | 'start_count' | 'allow_count' | 'start_incoming' | 'missing_handle' | 'duplicate_handle' | 'invalid_edge' | 'invalid_config' | 'cycle' | 'unreachable' | 'non_terminating'; + +export interface GraphIssue { + code: GraphIssueCode; + message: string; + nodeId?: string; + edgeId?: string; +} + +const handles: Partial> = { + start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next'], +}; + +export function validateGraph(graph: WAFRuleGraph): GraphIssue[] { + const issues: GraphIssue[] = []; + const nodeMap = new Map(graph.nodes.map((node) => [node.id, node])); + if (graph.schema_version !== 1) issues.push({code: 'schema', message: '规则图 schema_version 必须为 1'}); + if (graph.nodes.length > 128 || graph.edges.length > 256 || new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024) issues.push({code: 'size_limit', message: '规则图超过大小限制'}); + const nodeIds = new Set(); + for (const node of graph.nodes) { + if (!node.id.trim()) issues.push({code: 'empty_id', message: '节点 ID 不能为空', nodeId: node.id}); + if (nodeIds.has(node.id)) issues.push({code: 'duplicate_node_id', message: `节点 ID ${node.id} 重复`, nodeId: node.id}); + nodeIds.add(node.id); + const configIssue = validateNodeConfig(node); + if (configIssue) issues.push({code: 'invalid_config', message: configIssue, nodeId: node.id}); + } + const edgeIds = new Set(); + for (const edge of graph.edges) { + if (!edge.id.trim()) issues.push({code: 'empty_id', message: '连线 ID 不能为空', edgeId: edge.id}); + if (edgeIds.has(edge.id)) issues.push({code: 'duplicate_edge_id', message: `连线 ID ${edge.id} 重复`, edgeId: edge.id}); + edgeIds.add(edge.id); + if (nodeMap.get(edge.target)?.type === 'start') issues.push({code: 'start_incoming', message: '开始节点不能有入边', edgeId: edge.id, nodeId: edge.target}); + } + if (graph.nodes.filter((node) => node.type === 'start').length !== 1) issues.push({code: 'start_count', message: '规则图必须恰好有一个开始节点'}); + if (graph.nodes.filter((node) => node.type === 'allow').length !== 1) issues.push({code: 'allow_count', message: '规则图必须恰好有一个通过节点'}); + + for (const edge of graph.edges) { + const source = nodeMap.get(edge.source); + if (!source || !nodeMap.has(edge.target) || !(handles[source.type] ?? []).includes(edge.source_handle)) { + issues.push({code: 'invalid_edge', message: `连线 ${edge.id} 的端点或出口无效`, edgeId: edge.id}); + } + } + for (const node of graph.nodes) { + for (const handle of handles[node.type] ?? []) { + const outgoing = graph.edges.filter((edge) => edge.source === node.id && edge.source_handle === handle); + if (outgoing.length === 0) issues.push({code: 'missing_handle', message: `节点 ${node.id} 的 ${handle} 出口未连接`, nodeId: node.id}); + if (outgoing.length > 1) issues.push({code: 'duplicate_handle', message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`, nodeId: node.id}); + } + } + + const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]])); + const reverse = new Map(graph.nodes.map((node) => [node.id, [] as string[]])); + for (const edge of graph.edges) { + adjacency.get(edge.source)?.push(edge.target); + reverse.get(edge.target)?.push(edge.source); + } + const start = graph.nodes.find((node) => node.type === 'start'); + const reachable = walk(start ? [start.id] : [], adjacency); + for (const node of graph.nodes) if (!reachable.has(node.id)) issues.push({code: 'unreachable', message: `节点 ${node.id} 无法从开始节点到达`, nodeId: node.id}); + const terminals = graph.nodes.filter((node) => node.type === 'allow' || node.type === 'block').map((node) => node.id); + const canTerminate = walk(terminals, reverse); + for (const node of graph.nodes) if (!canTerminate.has(node.id)) issues.push({code: 'non_terminating', message: `节点 ${node.id} 无法抵达终止节点`, nodeId: node.id}); + if (hasCycle(graph)) issues.push({code: 'cycle', message: '规则图不能包含循环'}); + return issues; +} + +function validateNodeConfig(node: WAFRuleNode): string | undefined { + if (node.type === 'ip_match') { + if (node.config.ips.some((value) => !isIP(value))) return `节点 ${node.id} 包含无效 IP`; + if (node.config.cidrs.some((value) => { const parts = value.split('/'); if (parts.length !== 2) return true; const [ip, bits] = parts; return !isIP(ip) || !/^\d+$/.test(bits) || Number(bits) > (ip.includes(':') ? 128 : 32); })) return `节点 ${node.id} 包含无效 CIDR`; + if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0)) return `节点 ${node.id} 包含无效 IP 组`; + } + if (node.type === 'geo_match' && (node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) || node.config.regions.some((code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code)))) return `节点 ${node.id} 包含无效地域代码`; + if (node.type === 'pow' && (!['fast', 'slow'].includes(node.config.algorithm) || !isIntegerInRange(node.config.difficulty, 1, 16) || !isIntegerInRange(node.config.session_ttl, 60) || !isIntegerInRange(node.config.challenge_ttl, 30))) return `节点 ${node.id} 的 PoW 配置超出范围`; + if (node.type === 'block' && (!isIntegerInRange(node.config.status_code, 400, 599) || new TextEncoder().encode(node.config.response_body).length > 16 * 1024)) return `节点 ${node.id} 的阻止响应配置无效`; + return undefined; +} + +function isIntegerInRange(value: number, min: number, max = Number.MAX_SAFE_INTEGER): boolean { return Number.isFinite(value) && Number.isInteger(value) && value >= min && value <= max; } + +function isIP(value: string): boolean { + if (value.includes(':')) return isIPv6(value); + const parts = value.split('.'); + return parts.length === 4 && parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255); +} + +function isIPv6(value: string): boolean { + if (!/^[0-9a-f:.]+$/i.test(value) || value.includes(':::') || value.split('::').length > 2) return false; + const compressed = value.includes('::'); + const sections = value.split('::'); + const groups = sections.flatMap((section) => section ? section.split(':') : []); + let units = 0; + for (let index = 0; index < groups.length; index++) { + const group = groups[index]; + if (group.includes('.')) { + if (index !== groups.length - 1 || !isIP(group)) return false; + units += 2; + } else { + if (!/^[0-9a-f]{1,4}$/i.test(group)) return false; + units++; + } + } + return compressed ? units < 8 : units === 8; +} + +function walk(seeds: string[], links: Map): Set { + const seen = new Set(); + const stack = [...seeds]; + while (stack.length) { + const id = stack.pop()!; + if (seen.has(id)) continue; + seen.add(id); + stack.push(...(links.get(id) ?? [])); + } + return seen; +} + +function hasCycle(graph: WAFRuleGraph): boolean { + const indegree = new Map(graph.nodes.map((node) => [node.id, 0])); + for (const edge of graph.edges) if (indegree.has(edge.target) && indegree.has(edge.source)) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + const queue = [...indegree].filter(([, degree]) => degree === 0).map(([id]) => id); + let visited = 0; + while (queue.length) { + const id = queue.shift()!; + visited++; + for (const edge of graph.edges.filter((item) => item.source === id)) { + const next = (indegree.get(edge.target) ?? 0) - 1; + indegree.set(edge.target, next); + if (next === 0) queue.push(edge.target); + } + } + return visited !== graph.nodes.length; +} + +export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: string): boolean { + if (source === target) return true; + const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]])); + for (const edge of graph.edges) adjacency.get(edge.source)?.push(edge.target); + return walk([target], adjacency).has(source); +} + +export function removeNodeFromGraph(graph: WAFRuleGraph, nodeId: string): WAFRuleGraph { + return {...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)}; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx new file mode 100644 index 00000000..063f78de --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx @@ -0,0 +1,14 @@ +import {Ban, Fingerprint, Globe2, Plus, ShieldCheck} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import type {WAFRuleNode} from '@/lib/services/openflare'; + +type AddableType = Extract; +const items = [ + {type: 'ip_match', label: 'IP 匹配', icon: Fingerprint}, {type: 'geo_match', label: '地域匹配', icon: Globe2}, + {type: 'pow', label: 'PoW 挑战', icon: ShieldCheck}, {type: 'block', label: '阻止', icon: Ban}, +] satisfies {type: AddableType; label: string; icon: typeof Plus}[]; + +export function NodeLibrary({onAdd}: {onAdd: (type: AddableType) => void}) { + return
{items.map(({type, label, icon: Icon}) => )}
; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx new file mode 100644 index 00000000..c88e2a45 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx @@ -0,0 +1,34 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import {expect, it, vi} from 'vitest'; + +import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare'; + +import {NodeProperties} from './node-properties'; + +it('edits IP group config through a typed multi-select', async () => { + const node: WAFRuleNode = {id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}}; + const group = {id: 7, name: '办公室出口'} as WAFIPGroup; + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', {name: 'IP 组'})); + fireEvent.click(await screen.findByText('办公室出口')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({ip_group_ids: [7]})})); +}); + +it('associates numeric property labels and constrains server ranges', () => { + const node: WAFRuleNode = {id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}}; + render(); + expect(screen.getByLabelText('难度')).toHaveAttribute('min', '1'); + expect(screen.getByLabelText('难度')).toHaveAttribute('max', '16'); + expect(screen.getByLabelText('会话 TTL(秒)')).toHaveAttribute('min', '60'); +}); + +it('creates any normalized valid geography code', async () => { + const node: WAFRuleNode = {id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: [], regions: []}}; + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', {name: '国家代码'})); + fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), {target: {value: 'nz'}}); + fireEvent.click(screen.getByRole('button', {name: '添加代码'})); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({countries: ['NZ']})})); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx new file mode 100644 index 00000000..7c6f7a8f --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -0,0 +1,39 @@ +import {Settings2} from 'lucide-react'; +import {useState} from 'react'; + +import {Button} from '@/components/ui/button'; +import {Checkbox} from '@/components/ui/checkbox'; +import {Field, FieldDescription, FieldGroup, FieldLabel} from '@/components/ui/field'; +import {Input} from '@/components/ui/input'; +import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; +import {ScrollArea} from '@/components/ui/scroll-area'; +import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; +import {Separator} from '@/components/ui/separator'; +import {Textarea} from '@/components/ui/textarea'; +import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare'; + +const countries = ['CN', 'US', 'JP', 'SG', 'DE', 'FR', 'GB', 'CA', 'AU', 'BR', 'IN', 'KR'].map((value) => ({value, label: value})); +const regions = ['CN-BJ', 'CN-SH', 'CN-GD', 'CN-ZJ', 'US-CA', 'US-NY', 'US-TX', 'JP-13', 'DE-BE', 'GB-ENG'].map((value) => ({value, label: value})); + +export function NodeProperties({node, ipGroups, onChange}: {node?: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) { + return ; +} + +function PropertyFields({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) { + if (node.type === 'start' || node.type === 'allow') return

系统节点无需配置。

; + if (node.type === 'ip_match') return onChange({...node, config: {...node.config, ips}})}/> onChange({...node, config: {...node.config, cidrs}})}/> ({value: String(group.id), label: group.name}))} value={node.config.ip_group_ids.map(String)} onChange={(values) => onChange({...node, config: {...node.config, ip_group_ids: values.map(Number)}})}/>; + if (node.type === 'geo_match') return onChange({...node, config: {...node.config, countries}})}/> onChange({...node, config: {...node.config, regions}})}/>; + if (node.type === 'pow') return 算法{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map((key) => onChange({...node, config: {...node.config, [key]: value}})}/>)}; + return onChange({...node, config: {...node.config, status_code}})}/>HTML 响应体