From bc1b8618417bebcf9c183ea6e73a02e796285e2e Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 1 Jun 2026 09:33:04 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=20IP=20=E7=BB=84=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=20Expr=20=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=81=9A=E5=90=88=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20IP=20=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/config.ts | 1 + docs/design/architecture.md | 2 +- docs/design/index.md | 7 +- docs/design/release-model.md | 2 +- docs/guide/index.md | 8 +- docs/guide/usage.md | 3 +- docs/guide/waf-ip-group-expr.md | 161 +++++++++++ docs/guildline/development-constraints.md | 1 + docs/reference/api.md | 22 +- openflare_server/go.mod | 3 +- openflare_server/go.sum | 2 + openflare_server/model/node_access_log.go | 4 + openflare_server/model/waf.go | 4 +- openflare_server/service/waf_ip_group.go | 256 +++++++++++++++++- openflare_server/service/waf_test.go | 79 ++++++ .../waf/components/ip-groups-page.tsx | 119 ++++++-- .../tests/unit/waf-ip-groups-page.test.tsx | 32 +++ 17 files changed, 665 insertions(+), 41 deletions(-) create mode 100644 docs/guide/waf-ip-group-expr.md diff --git a/docs/config.ts b/docs/config.ts index 2bdd942e..36325974 100644 --- a/docs/config.ts +++ b/docs/config.ts @@ -69,6 +69,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: '概览', link: '' }, { text: '快速开始', link: 'quick-start' }, { text: '基础使用', link: 'usage' }, + { text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' }, { text: 'SSO 登录配置', link: 'sso' }, { text: '发布第一份配置', link: 'first-site' }, { text: '故障排查', link: 'troubleshooting' } diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 0b208068..e4e9b0e2 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -165,7 +165,7 @@ Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。 -WAF IP 组由 Server 管理并在发布时展开到 `waf_config.json`。手动 IP 组直接保存 IP/IP 段列表;自动 IP 组当前只保存配置;订阅 IP 组由 Server 定时任务同步远程文本或 JSON 源。OpenResty Lua 只读取 Agent 落地的运行时 JSON,不直接访问 Server 数据库或远程订阅源。 +WAF IP 组由 Server 管理并在发布时展开到 `waf_config.json`。手动 IP 组直接保存 IP/IP 段列表;自动 IP 组由 Server 定时任务读取请求日志、按单个 IP 聚合指标并执行 Expr 规则;订阅 IP 组由 Server 定时任务同步远程文本或 JSON 源。OpenResty Lua 只读取 Agent 落地的运行时 JSON,不直接访问 Server 数据库、请求日志或远程订阅源。 ## 核心对象 diff --git a/docs/design/index.md b/docs/design/index.md index 22a99144..681f0521 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -139,10 +139,15 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应 IP 组约束: * 手动 IP 组由管理端直接维护 IP/IP 段列表。 -* 自动 IP 组当前只保存结构化配置,不执行请求日志挖掘。 +* 自动 IP 组使用 Expr 语法保存自定义规则,由 Server 定时按单个 IP 聚合请求日志并更新 IP 列表。 * 订阅 IP 组由 Server 定时从 HTTP/HTTPS URL 同步,支持文本列表和 JSON 映射。 * WAF 运行时不访问数据库;发布时将规则组引用的启用 IP 组展开进完整配置版本。 +自动 IP 组首批内置预设规则: + +* 单个 IP 请求数大于 100,且 404 状态码占比不低于 80%:`request_count > 100 && status_404_ratio >= 0.8` +* 单个 IP 通过 IP 地址访问次数大于 50,且通过 IP 地址访问占比大于 50%:`ip_host_count > 50 && ip_host_ratio > 0.5` + 判定顺序: * 白名单是放行例外,任意启用规则组命中白名单即放行。 diff --git a/docs/design/release-model.md b/docs/design/release-model.md index 28b348e0..a688a2ad 100644 --- a/docs/design/release-model.md +++ b/docs/design/release-model.md @@ -18,7 +18,7 @@ Server 发布时必须: 2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。 3. 读取域名与证书绑定关系。 4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。 -5. 展开 WAF 规则组引用的启用 IP 组,渲染完整 OpenResty 配置与 WAF 运行时配置。 +5. 使用自动 IP 组最近一次执行后的 IP 列表,并展开 WAF 规则组引用的启用 IP 组,渲染完整 OpenResty 配置与 WAF 运行时配置。 6. 计算 `checksum`。 7. 写入 `config_versions`。 8. 切换激活版本。 diff --git a/docs/guide/index.md b/docs/guide/index.md index 8aa51aaa..143e1753 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -10,9 +10,10 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站 1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。 2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。 -3. [部署说明](../reference/deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。 -4. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。 -5. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。 +3. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。 +4. [部署说明](../reference/deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。 +5. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。 +6. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。 ## 按角色查找 @@ -20,6 +21,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站 | --- | --- | | 5 分钟内跑起管理端 | [快速开始](./quick-start.md) | | 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) | +| 编写自动 IP 组规则 | [WAF 自动 IP 组语法](./waf-ip-group-expr.md) | | 接入或重装节点 Agent | [接入 Agent](../reference/agent.md) | | 从源码启动 Server | [启动 Server](../reference/server.md) | | 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) | diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 9becec8d..cf8f196e 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -81,7 +81,8 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。 安全防护统一从管理端侧边栏的 **WAF** 入口进入: * WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。 -* 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组当前保存配置但暂不执行日志挖掘;订阅 IP 组可从远程文本或 JSON 源定时同步。 +* 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组使用 Expr 规则按单个 IP 聚合请求日志并定时更新名单;订阅 IP 组可从远程文本或 JSON 源定时同步。 +* 自动 IP 组页面提供两个预设:单个 IP 请求数大于 100 且 404 占比不低于 80%;单个 IP 通过 IP 地址访问次数大于 50 且该访问占比大于 50%。保存后可点击 **立即执行** 验证规则效果,语法见 [WAF 自动 IP 组规则语法](./waf-ip-group-expr.md)。 * 在 WAF 规则组的黑白名单中,IP 维度既可以直接添加 IP/IP 段,也可以引用已有 IP 组。发布时 Server 会把启用 IP 组展开到 WAF 运行时配置。 * `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。 * 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。 diff --git a/docs/guide/waf-ip-group-expr.md b/docs/guide/waf-ip-group-expr.md new file mode 100644 index 00000000..eb6052a7 --- /dev/null +++ b/docs/guide/waf-ip-group-expr.md @@ -0,0 +1,161 @@ +# WAF 自动 IP 组规则语法 + +自动 IP 组用于从请求日志中按单个客户端 IP 聚合指标,再用 Expr 表达式判断是否把该 IP 加入组内名单。自动 IP 组可以被 WAF 规则组的 IP 黑名单或白名单引用;发布配置时,Server 会把启用 IP 组展开到 `waf_config.json`。 + +## 配置结构 + +自动 IP 组的配置是一个 JSON 对象: + +```json +{ + "lookback_minutes": 60, + "rules": [ + { + "name": "单 IP 404 高频扫描", + "expr": "request_count > 100 && status_404_ratio >= 0.8" + } + ] +} +``` + +字段说明: + +| 字段 | 类型 | 作用 | +| --- | --- | --- | +| `lookback_minutes` | number | 每次执行时回看多少分钟内的请求日志。未填写时默认 60 分钟,最小 5 分钟,最大 43200 分钟。 | +| `rules` | array | 自动规则列表。任意一条规则命中时,该 IP 会进入自动 IP 组名单。 | +| `rules[].name` | string | 规则名称,只用于界面展示和错误提示。 | +| `rules[].expr` | string | Expr 表达式,必须返回布尔值。 | + +## 执行口径 + +自动规则不是逐条请求判断,而是先按单个客户端 IP 聚合: + +1. Server 读取最近 `lookback_minutes` 分钟内的请求日志。 +2. 按 `remote_addr` 归一化后的 IP 分组。 +3. 为每个 IP 计算请求数、404 数、直连 IP Host 次数等指标。 +4. 逐个 IP 执行 `rules[].expr`。 +5. 只要某个 IP 命中任意规则,就写入该自动 IP 组的 `IP / IP 段` 列表。 + +Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:如果 Host 是 IPv4 或 IPv6 字面量,例如 `203.0.113.10`、`[2001:db8::10]`、`203.0.113.10:443`,就计入 `ip_host_count`。 + +## 可用关键字 + +表达式中可以直接使用以下字段: + +| 关键字 | 类型 | 作用 | +| --- | --- | --- | +| `ip` | string | 当前正在判断的客户端 IP。 | +| `request_count` | number | 当前 IP 在回看窗口内的总请求数。 | +| `status_404_count` | number | 当前 IP 在回看窗口内返回 404 的请求数。 | +| `status_404_ratio` | number | 404 请求占比,计算方式为 `status_404_count / request_count`。 | +| `ip_host_count` | number | 当前 IP 通过 IP 地址作为 Host 访问的请求数。 | +| `ip_host_ratio` | number | 通过 IP 地址访问的占比,计算方式为 `ip_host_count / request_count`。 | +| `client_error_count` | number | 当前 IP 返回 4xx 状态码的请求数。 | +| `server_error_count` | number | 当前 IP 返回 5xx 状态码的请求数。 | +| `last_seen_unix` | number | 当前 IP 在回看窗口内最后一次请求的 Unix 秒级时间戳。 | + +比例字段都是 `0` 到 `1` 之间的小数。80% 应写成 `0.8`,50% 应写成 `0.5`。 + +## Expr 常用写法 + +自动 IP 组使用 Expr 语法,当前表达式必须返回布尔值。 + +常用运算符: + +| 写法 | 作用 | 示例 | +| --- | --- | --- | +| `>`、`>=`、`<`、`<=` | 数值比较 | `request_count > 100` | +| `==`、`!=` | 相等或不相等 | `ip != "127.0.0.1"` | +| `&&` | 并且 | `request_count > 100 && status_404_ratio >= 0.8` | +| `||` | 或者 | `status_404_ratio >= 0.8 || server_error_count > 20` | +| `!` | 取反 | `!(ip == "127.0.0.1")` | +| `in` | 判断值是否在列表中 | `ip in ["203.0.113.10", "198.51.100.20"]` | +| `not in` | 判断值是否不在列表中 | `ip not in ["127.0.0.1"]` | +| `()` | 分组控制优先级 | `(request_count > 100 && status_404_ratio >= 0.8) || server_error_count > 50` | + +## 内置预设 + +管理端内置两个预设规则,可以直接添加后再按需调整: + +```json +{ + "name": "单 IP 404 高频扫描", + "expr": "request_count > 100 && status_404_ratio >= 0.8" +} +``` + +含义:单个 IP 在回看窗口内请求数大于 100,并且 404 状态码占比不低于 80%。 + +```json +{ + "name": "单 IP 直连访问异常", + "expr": "ip_host_count > 50 && ip_host_ratio > 0.5" +} +``` + +含义:单个 IP 通过 IP 地址作为 Host 访问的次数大于 50,并且这种访问占比大于 50%。 + +## 示例 + +高频 404 扫描: + +```json +{ + "lookback_minutes": 60, + "rules": [ + { + "name": "高频 404 扫描", + "expr": "request_count > 100 && status_404_ratio >= 0.8" + } + ] +} +``` + +IP 直连访问异常: + +```json +{ + "lookback_minutes": 30, + "rules": [ + { + "name": "IP 直连访问异常", + "expr": "ip_host_count > 50 && ip_host_ratio > 0.5" + } + ] +} +``` + +同时捕获高 4xx 与高 5xx: + +```json +{ + "lookback_minutes": 120, + "rules": [ + { + "name": "异常错误率", + "expr": "(client_error_count > 80 && request_count > 100) || server_error_count > 30" + } + ] +} +``` + +排除可信 IP: + +```json +{ + "lookback_minutes": 60, + "rules": [ + { + "name": "排除可信 IP 的 404 扫描", + "expr": "ip not in [\"203.0.113.10\", \"198.51.100.20\"] && request_count > 100 && status_404_ratio >= 0.8" + } + ] +} +``` + +## 使用建议 + +先用较短的回看窗口和较高阈值观察命中结果,再逐步调整阈值。自动 IP 组执行后会覆盖该组的 IP 列表;如果要长期保留某些地址,建议放入手动 IP 组,并在 WAF 规则组中同时引用手动组和自动组。 + +自动 IP 组更新后不会立即改变 Agent 上的运行时配置。需要重新发布并激活配置版本,Agent 才会拉取新的 `waf_config.json`。 diff --git a/docs/guildline/development-constraints.md b/docs/guildline/development-constraints.md index 69b3b422..4847cb25 100644 --- a/docs/guildline/development-constraints.md +++ b/docs/guildline/development-constraints.md @@ -160,6 +160,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起 * 发布时读取全部启用的 `proxy_routes`。 * 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。 * 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。 +* 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。 * 生成完整 OpenResty 配置。 * 计算 `checksum`。 * 写入 `config_versions`。 diff --git a/docs/reference/api.md b/docs/reference/api.md index 4afdfe56..e7f5d324 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -36,9 +36,27 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。 | `POST` | `/api/waf/ip-groups` | 创建 IP 组 | | `POST` | `/api/waf/ip-groups/:id/update` | 更新 IP 组 | | `POST` | `/api/waf/ip-groups/:id/delete` | 删除 IP 组;已被规则组引用时会拒绝 | -| `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组 | +| `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组或立即执行自动型 IP 组 | -IP 组 `type` 支持 `manual`、`automatic`、`subscription`。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。 +IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组的 `auto_config` 是 JSON 对象,当前支持: + +```json +{ + "lookback_minutes": 60, + "rules": [ + { + "name": "单 IP 404 高频扫描", + "expr": "request_count > 100 && status_404_ratio >= 0.8" + }, + { + "name": "单 IP 直连访问异常", + "expr": "ip_host_count > 50 && ip_host_ratio > 0.5" + } + ] +} +``` + +自动规则使用 Expr 语法,表达式必须返回布尔值。规则按单个 IP 的请求日志聚合指标计算,可用字段包括 `ip`、`request_count`、`status_404_count`、`status_404_ratio`、`ip_host_count`、`ip_host_ratio`、`client_error_count`、`server_error_count`、`last_seen_unix`。完整语法和字段含义见 [WAF 自动 IP 组规则语法](../guide/waf-ip-group-expr.md)。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。 ## 鉴权 diff --git a/openflare_server/go.mod b/openflare_server/go.mod index 926498f8..d2f89634 100644 --- a/openflare_server/go.mod +++ b/openflare_server/go.mod @@ -6,6 +6,7 @@ go 1.25.0 require ( github.com/bwmarrin/snowflake v0.3.0 github.com/dgraph-io/ristretto/v2 v2.2.0 + github.com/expr-lang/expr v1.17.8 github.com/gin-contrib/cors v1.6.0 github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/static v0.0.1 @@ -16,6 +17,7 @@ require ( github.com/go-redis/redis/v8 v8.11.5 github.com/google/uuid v1.6.0 github.com/oschwald/maxminddb-golang v1.13.1 + github.com/robfig/cron/v3 v3.0.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.4 @@ -71,7 +73,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.7.0 // indirect diff --git a/openflare_server/go.sum b/openflare_server/go.sum index 7a07a4f8..14f8c9a3 100644 --- a/openflare_server/go.sum +++ b/openflare_server/go.sum @@ -36,6 +36,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= diff --git a/openflare_server/model/node_access_log.go b/openflare_server/model/node_access_log.go index 0973cb6b..6b41826d 100644 --- a/openflare_server/model/node_access_log.go +++ b/openflare_server/model/node_access_log.go @@ -130,6 +130,10 @@ func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err er return all[start:end], nil } +func ListNodeAccessLogsForWAFIPGroup(query NodeAccessLogQuery) ([]*NodeAccessLog, error) { + return listNodeAccessLogsAcrossShards(query) +} + func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) { all, err := listNodeAccessLogsAcrossShards(query) if err != nil { diff --git a/openflare_server/model/waf.go b/openflare_server/model/waf.go index b6994882..44e84da4 100644 --- a/openflare_server/model/waf.go +++ b/openflare_server/model/waf.go @@ -119,9 +119,9 @@ func ListWAFIPGroupsByIDs(ids []uint) ([]*WAFIPGroup, error) { return groups, err } -func ListDueSubscriptionWAFIPGroups(now time.Time) ([]*WAFIPGroup, error) { +func ListDueWAFIPGroups(now time.Time) ([]*WAFIPGroup, error) { var groups []*WAFIPGroup - err := DB.Where("type = ? AND enabled = ? AND subscription_url <> '' AND (next_sync_at IS NULL OR next_sync_at <= ?)", "subscription", true, now). + err := DB.Where("enabled = ? AND (type = ? OR (type = ? AND subscription_url <> '')) AND (next_sync_at IS NULL OR next_sync_at <= ?)", true, "automatic", "subscription", now). Order("id asc"). Find(&groups).Error return groups, err diff --git a/openflare_server/service/waf_ip_group.go b/openflare_server/service/waf_ip_group.go index ea7bdcab..0eb67d2d 100644 --- a/openflare_server/service/waf_ip_group.go +++ b/openflare_server/service/waf_ip_group.go @@ -6,13 +6,17 @@ import ( "errors" "fmt" "io" + "net" "net/http" + "net/netip" "net/url" "openflare/model" "sort" "strings" "time" + exprlang "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" "gorm.io/gorm" ) @@ -25,11 +29,44 @@ const ( WAFIPGroupSubscriptionFormatJSON = "json" defaultWAFIPGroupSyncIntervalMinutes = 1440 + defaultWAFIPGroupAutoLookbackMinutes = 60 minWAFIPGroupSyncIntervalMinutes = 5 maxWAFIPGroupSyncIntervalMinutes = 43200 maxWAFIPGroupSubscriptionBytes = 2 * 1024 * 1024 ) +type wafIPGroupAutoConfig struct { + LookbackMinutes int `json:"lookback_minutes"` + Rules []wafIPGroupAutoRule `json:"rules"` +} + +type wafIPGroupAutoRule struct { + Name string `json:"name"` + Expr string `json:"expr"` +} + +type wafIPGroupAutoRuleEnv struct { + IP string `expr:"ip"` + RequestCount int `expr:"request_count"` + Status404Count int `expr:"status_404_count"` + Status404Ratio float64 `expr:"status_404_ratio"` + IPHostCount int `expr:"ip_host_count"` + IPHostRatio float64 `expr:"ip_host_ratio"` + ClientErrorCount int `expr:"client_error_count"` + ServerErrorCount int `expr:"server_error_count"` + LastSeenUnix int64 `expr:"last_seen_unix"` +} + +type wafIPGroupAutoAccumulator struct { + ip string + requestCount int + status404Count int + ipHostCount int + clientErrorCount int + serverErrorCount int + lastSeen time.Time +} + type WAFIPGroupInput struct { Name string `json:"name"` Type string `json:"type"` @@ -160,7 +197,7 @@ func SyncWAFIPGroup(id uint) (*WAFIPGroupSyncResult, error) { func SyncDueWAFIPGroups() error { now := time.Now().UTC() - groups, err := model.ListDueSubscriptionWAFIPGroups(now) + groups, err := model.ListDueWAFIPGroups(now) if err != nil { return err } @@ -193,14 +230,11 @@ func buildWAFIPGroup(group *model.WAFIPGroup, input WAFIPGroupInput) (*model.WAF subscriptionFormat = WAFIPGroupSubscriptionFormatText mappingRule = "" case WAFIPGroupTypeAutomatic: - raw := strings.TrimSpace(string(input.AutoConfig)) - if raw == "" { - raw = "{}" + normalizedConfig, err := normalizeWAFIPGroupAutoConfig(input.AutoConfig) + if err != nil { + return nil, err } - if !json.Valid([]byte(raw)) || strings.HasPrefix(raw, "[") { - return nil, errors.New("自动 IP 组配置必须是 JSON 对象") - } - autoConfig = raw + autoConfig = normalizedConfig subscriptionFormat = WAFIPGroupSubscriptionFormatText mappingRule = "" case WAFIPGroupTypeSubscription: @@ -278,9 +312,17 @@ func syncWAFIPGroup(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResu if group == nil { return nil, errors.New("IP 组不存在") } - if group.Type != WAFIPGroupTypeSubscription { - return nil, errors.New("只有订阅类型 IP 组支持同步") + switch group.Type { + case WAFIPGroupTypeSubscription: + return syncWAFIPGroupSubscription(group, now) + case WAFIPGroupTypeAutomatic: + return syncWAFIPGroupAutomatic(group, now) + default: + return nil, errors.New("只有自动和订阅类型 IP 组支持同步") } +} + +func syncWAFIPGroupSubscription(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) { content, err := downloadWAFIPGroupSubscription(group.SubscriptionURL) if err != nil { recordWAFIPGroupSyncFailure(group, now, err) @@ -315,6 +357,36 @@ func syncWAFIPGroup(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResu }, nil } +func syncWAFIPGroupAutomatic(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) { + ips, err := evaluateWAFIPGroupAutoConfig(group.AutoConfig, now) + if err != nil { + recordWAFIPGroupSyncFailure(group, now, err) + return nil, err + } + ipListJSON, _ := json.Marshal(ips) + nextSyncAt := now.Add(time.Duration(normalizeWAFIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute) + group.IPList = string(ipListJSON) + group.LastSyncedAt = &now + group.NextSyncAt = &nextSyncAt + group.LastSyncStatus = "success" + group.LastSyncMessage = fmt.Sprintf("自动规则执行成功,共命中 %d 个 IP", len(ips)) + if err := group.UpdateSyncResult(); err != nil { + return nil, err + } + view, err := GetWAFIPGroup(group.ID) + if err != nil { + return nil, err + } + return &WAFIPGroupSyncResult{ + Group: *view, + IPCount: len(ips), + SyncedAt: now.Format(time.RFC3339), + NextSyncAt: nextSyncAt.Format(time.RFC3339), + Status: group.LastSyncStatus, + Message: group.LastSyncMessage, + }, nil +} + func recordWAFIPGroupSyncFailure(group *model.WAFIPGroup, now time.Time, syncErr error) { nextSyncAt := now.Add(time.Duration(normalizeWAFIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute) group.LastSyncedAt = &now @@ -324,6 +396,168 @@ func recordWAFIPGroupSyncFailure(group *model.WAFIPGroup, now time.Time, syncErr _ = group.UpdateSyncResult() } +func normalizeWAFIPGroupAutoConfig(raw json.RawMessage) (string, error) { + text := strings.TrimSpace(string(raw)) + if text == "" { + text = "{}" + } + var config wafIPGroupAutoConfig + if err := json.Unmarshal([]byte(text), &config); err != nil { + return "", errors.New("自动 IP 组配置必须是 JSON 对象") + } + var object map[string]any + if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil { + return "", errors.New("自动 IP 组配置必须是 JSON 对象") + } + if config.LookbackMinutes <= 0 { + config.LookbackMinutes = defaultWAFIPGroupAutoLookbackMinutes + } + if config.LookbackMinutes < 5 { + config.LookbackMinutes = 5 + } + if config.LookbackMinutes > 43200 { + config.LookbackMinutes = 43200 + } + if config.Rules == nil { + config.Rules = []wafIPGroupAutoRule{} + } + for i, rule := range config.Rules { + rule.Name = strings.TrimSpace(rule.Name) + rule.Expr = strings.TrimSpace(rule.Expr) + if rule.Expr == "" { + return "", fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1) + } + if _, err := exprlang.Compile(rule.Expr, exprlang.Env(wafIPGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil { + return "", fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) + } + config.Rules[i] = rule + } + normalized, _ := json.Marshal(config) + return string(normalized), nil +} + +func evaluateWAFIPGroupAutoConfig(raw string, now time.Time) ([]string, error) { + normalized, err := normalizeWAFIPGroupAutoConfig(json.RawMessage(raw)) + if err != nil { + return nil, err + } + var config wafIPGroupAutoConfig + if err := json.Unmarshal([]byte(normalized), &config); err != nil { + return nil, err + } + if len(config.Rules) == 0 { + return []string{}, nil + } + programs := make([]*vm.Program, 0, len(config.Rules)) + for i, rule := range config.Rules { + program, err := exprlang.Compile(rule.Expr, exprlang.Env(wafIPGroupAutoRuleEnv{}), exprlang.AsBool()) + if err != nil { + return nil, fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) + } + programs = append(programs, program) + } + logs, err := model.ListNodeAccessLogsForWAFIPGroup(model.NodeAccessLogQuery{ + Since: now.Add(-time.Duration(config.LookbackMinutes) * time.Minute), + Until: now, + }) + if err != nil { + return nil, err + } + accumulators := make(map[string]*wafIPGroupAutoAccumulator) + for _, item := range logs { + if item == nil { + continue + } + ip, ok := normalizeIPLiteral(item.RemoteAddr) + if !ok { + continue + } + acc := accumulators[ip] + if acc == nil { + acc = &wafIPGroupAutoAccumulator{ip: ip} + accumulators[ip] = acc + } + acc.requestCount++ + if item.StatusCode == http.StatusNotFound { + acc.status404Count++ + } + if item.StatusCode >= 400 && item.StatusCode < 500 { + acc.clientErrorCount++ + } + if item.StatusCode >= 500 { + acc.serverErrorCount++ + } + if hostIsIPLiteral(item.Host) { + acc.ipHostCount++ + } + if item.LoggedAt.After(acc.lastSeen) { + acc.lastSeen = item.LoggedAt + } + } + matched := make([]string, 0) + for _, acc := range accumulators { + env := acc.toExprEnv() + for _, program := range programs { + output, err := exprlang.Run(program, env) + if err != nil { + return nil, fmt.Errorf("执行自动规则失败: %w", err) + } + if matchedRule, ok := output.(bool); ok && matchedRule { + matched = append(matched, acc.ip) + break + } + } + } + return normalizeWAFIPList(matched) +} + +func (acc *wafIPGroupAutoAccumulator) toExprEnv() wafIPGroupAutoRuleEnv { + env := wafIPGroupAutoRuleEnv{ + IP: acc.ip, + RequestCount: acc.requestCount, + Status404Count: acc.status404Count, + IPHostCount: acc.ipHostCount, + ClientErrorCount: acc.clientErrorCount, + ServerErrorCount: acc.serverErrorCount, + } + if acc.requestCount > 0 { + env.Status404Ratio = float64(acc.status404Count) / float64(acc.requestCount) + env.IPHostRatio = float64(acc.ipHostCount) / float64(acc.requestCount) + } + if !acc.lastSeen.IsZero() { + env.LastSeenUnix = acc.lastSeen.Unix() + } + return env +} + +func displayWAFIPGroupAutoRuleName(rule wafIPGroupAutoRule, index int) string { + if rule.Name != "" { + return rule.Name + } + return fmt.Sprintf("#%d", index+1) +} + +func normalizeIPLiteral(value string) (string, bool) { + host := strings.TrimSpace(value) + if host == "" { + return "", false + } + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + host = strings.Trim(host, "[]") + addr, err := netip.ParseAddr(host) + if err != nil { + return "", false + } + return addr.String(), true +} + +func hostIsIPLiteral(value string) bool { + _, ok := normalizeIPLiteral(value) + return ok +} + func downloadWAFIPGroupSubscription(rawURL string) ([]byte, error) { if err := validateSubscriptionURL(rawURL); err != nil { return nil, err @@ -493,7 +727,7 @@ func normalizeWAFIPGroupSyncInterval(value int) int { } func nextWAFIPGroupSyncAt(groupType string, enabled bool, interval int, current *time.Time) *time.Time { - if groupType != WAFIPGroupTypeSubscription || !enabled { + if (groupType != WAFIPGroupTypeSubscription && groupType != WAFIPGroupTypeAutomatic) || !enabled { return nil } if current != nil && current.After(time.Now().UTC()) { diff --git a/openflare_server/service/waf_test.go b/openflare_server/service/waf_test.go index a264651f..ff676a62 100644 --- a/openflare_server/service/waf_test.go +++ b/openflare_server/service/waf_test.go @@ -4,8 +4,10 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "openflare/model" "strings" "testing" + "time" ) func TestWAFRuleGroupValidationAndNormalization(t *testing.T) { @@ -211,6 +213,63 @@ func TestSyncWAFIPGroupDownloadsSubscription(t *testing.T) { } } +func TestSyncWAFIPGroupAutomaticExprRules(t *testing.T) { + setupServiceTestDB(t) + + now := time.Now().UTC() + seedWAFNodeAccessLogs(t, now, "203.0.113.10", "app.example.com", 101, 81) + seedWAFNodeAccessLogs(t, now, "203.0.113.11", "198.51.100.10", 60, 0) + seedWAFNodeAccessLogs(t, now, "203.0.113.12", "app.example.com", 120, 10) + + group, err := CreateWAFIPGroup(WAFIPGroupInput{ + Name: "auto blacklist", + Type: WAFIPGroupTypeAutomatic, + Enabled: true, + AutoConfig: json.RawMessage(`{ + "lookback_minutes": 60, + "rules": [ + {"name":"单 IP 404 高频扫描","expr":"request_count > 100 && status_404_ratio >= 0.8"}, + {"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"} + ] + }`), + }) + if err != nil { + t.Fatalf("CreateWAFIPGroup failed: %v", err) + } + result, err := SyncWAFIPGroup(group.ID) + if err != nil { + t.Fatalf("SyncWAFIPGroup failed: %v", err) + } + if result.IPCount != 2 { + t.Fatalf("expected two matched IPs, got %#v", result) + } + want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true} + for _, item := range result.Group.IPList { + if !want[item] { + t.Fatalf("unexpected matched IP %s in %#v", item, result.Group.IPList) + } + delete(want, item) + } + if len(want) != 0 { + t.Fatalf("missing matched IPs: %#v", want) + } +} + +func TestWAFIPGroupAutomaticRejectsInvalidExpr(t *testing.T) { + setupServiceTestDB(t) + + if _, err := CreateWAFIPGroup(WAFIPGroupInput{ + Name: "bad auto", + Type: WAFIPGroupTypeAutomatic, + Enabled: true, + AutoConfig: json.RawMessage(`{ + "rules": [{"name":"bad","expr":"request_count > "}] + }`), + }); err == nil { + t.Fatal("expected invalid Expr to be rejected") + } +} + func TestPublishConfigVersionExpandsWAFIPGroupReferences(t *testing.T) { setupServiceTestDB(t) @@ -265,3 +324,23 @@ func TestPublishConfigVersionExpandsWAFIPGroupReferences(t *testing.T) { t.Fatalf("expected expanded IP group in waf_config.json, got %#v", files) } } + +func seedWAFNodeAccessLogs(t *testing.T, loggedAt time.Time, remoteAddr string, host string, total int, notFound int) { + t.Helper() + for i := 0; i < total; i++ { + statusCode := http.StatusOK + if i < notFound { + statusCode = http.StatusNotFound + } + if err := model.DB.Create(&model.NodeAccessLog{ + NodeID: "node-waf-auto", + LoggedAt: loggedAt.Add(-time.Duration(i%30) * time.Second), + RemoteAddr: remoteAddr, + Host: host, + Path: "/probe", + StatusCode: statusCode, + }).Error; err != nil { + t.Fatalf("failed to seed access log: %v", err) + } + } +} diff --git a/openflare_server/web/features/waf/components/ip-groups-page.tsx b/openflare_server/web/features/waf/components/ip-groups-page.tsx index b1b0b49d..6dd934cf 100644 --- a/openflare_server/web/features/waf/components/ip-groups-page.tsx +++ b/openflare_server/web/features/waf/components/ip-groups-page.tsx @@ -69,6 +69,17 @@ const typeLabels: Record = { subscription: '订阅', }; +const automaticPresetRules = [ + { + name: '单 IP 404 高频扫描', + expr: 'request_count > 100 && status_404_ratio >= 0.8', + }, + { + name: '单 IP 直连访问异常', + expr: 'ip_host_count > 50 && ip_host_ratio > 0.5', + }, +]; + function buildDraft(group: WAFIPGroup | null): IPGroupDraft { if (!group) { return { ...emptyIPGroupDraft }; @@ -112,6 +123,38 @@ function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload { }; } +function appendAutomaticPresetRule( + autoConfigText: string, + rule: (typeof automaticPresetRules)[number], +) { + const parsed = JSON.parse(autoConfigText || '{}') as unknown; + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('自动配置必须是 JSON 对象。'); + } + const config = parsed as Record; + const rules = Array.isArray(config.rules) ? config.rules : []; + const exists = rules.some( + (item) => + item && + typeof item === 'object' && + 'expr' in item && + (item as { expr?: unknown }).expr === rule.expr, + ); + const nextRules = exists ? rules : [...rules, rule]; + return JSON.stringify( + { + lookback_minutes: + typeof config.lookback_minutes === 'number' + ? config.lookback_minutes + : 60, + ...config, + rules: nextRules, + }, + null, + 2, + ); +} + export function WAFIPGroupsPage() { const router = useRouter(); const queryClient = useQueryClient(); @@ -280,19 +323,24 @@ export function WAFIPGroupsPage() { title={selectedGroup ? selectedGroup.name : '新建 IP 组'} description={ draft.type === 'automatic' - ? '自动 IP 组第一版仅保存配置,暂不执行日志挖掘。' + ? '自动 IP 组会按 Expr 规则定时从请求日志中聚合命中 IP。' : '保存后可在 WAF 规则组黑白名单中引用。' } action={
- {selectedGroup?.type === 'subscription' ? ( + {selectedGroup?.type === 'subscription' || + selectedGroup?.type === 'automatic' ? ( syncMutation.mutate(selectedGroup.id)} > - {syncMutation.isPending ? '同步中...' : '立即同步'} + {syncMutation.isPending + ? '执行中...' + : selectedGroup.type === 'automatic' + ? '立即执行' + : '立即同步'} ) : null} - - setDraft((current) => ({ - ...current, - auto_config_text: event.target.value, - })) - } - /> - +
+ +
+ {automaticPresetRules.map((rule) => ( + { + try { + setDraft((current) => ({ + ...current, + auto_config_text: appendAutomaticPresetRule( + current.auto_config_text, + rule, + ), + })); + } catch (error) { + setFeedback({ + tone: 'danger', + message: getErrorMessage(error), + }); + } + }} + > + + {rule.name} + + ))} +
+
+ + + setDraft((current) => ({ + ...current, + auto_config_text: event.target.value, + })) + } + /> + +
) : ( { await waitFor(() => expect(groups).toHaveLength(1)); }); + it('adds automatic Expr preset rules', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/waf/ip-groups')) { + return Promise.resolve( + new Response( + JSON.stringify({ success: true, message: '', data: [] }), + ), + ); + } + + return Promise.reject(new Error(`Unhandled fetch: ${url}`)); + }), + ); + + renderWithProviders(); + + await screen.findByText('暂无 IP 组'); + await userEvent.click(screen.getByRole('button', { name: /新建 IP 组/ })); + await userEvent.selectOptions(screen.getByLabelText('类型'), 'automatic'); + await userEvent.click(screen.getByText('单 IP 404 高频扫描')); + await userEvent.click(screen.getByText('单 IP 直连访问异常')); + + const textarea = screen.getByLabelText(/自动配置 JSON/); + const value = (textarea as HTMLTextAreaElement).value; + expect(value).toContain('request_count > 100 && status_404_ratio >= 0.8'); + expect(value).toContain('ip_host_count > 50 && ip_host_ratio > 0.5'); + }); + it('opens IP group management from WAF page and references an IP group', async () => { vi.stubGlobal( 'fetch',