mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
[优化] 添加 WAF IP 组功能,包括 CRUD 接口和前端页面支持
This commit is contained in:
@@ -165,6 +165,8 @@ Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
|
|||||||
|
|
||||||
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。
|
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 数据库或远程订阅源。
|
||||||
|
|
||||||
## 核心对象
|
## 核心对象
|
||||||
|
|
||||||
当前有效实体包括:
|
当前有效实体包括:
|
||||||
@@ -186,6 +188,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
|||||||
* `traffic_analytics_rollups`
|
* `traffic_analytics_rollups`
|
||||||
* `node_health_events`
|
* `node_health_events`
|
||||||
* `waf_rule_groups`
|
* `waf_rule_groups`
|
||||||
|
* `waf_ip_groups`
|
||||||
* `waf_rule_group_bindings`
|
* `waf_rule_group_bindings`
|
||||||
* `acme_accounts`
|
* `acme_accounts`
|
||||||
* `dns_accounts`
|
* `dns_accounts`
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
|
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
|
||||||
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
|
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
|
||||||
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
|
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
|
||||||
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、国家级地域黑白名单 |
|
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、IP 组、国家级地域黑白名单 |
|
||||||
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
|
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
|
||||||
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
||||||
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
||||||
@@ -132,9 +132,17 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
|
|||||||
一期支持:
|
一期支持:
|
||||||
|
|
||||||
* IP / IP 段白名单与黑名单。
|
* IP / IP 段白名单与黑名单。
|
||||||
|
* IP 组引用,支持手动、自动、订阅三类 IP 组。
|
||||||
* 国家级地域白名单与黑名单。
|
* 国家级地域白名单与黑名单。
|
||||||
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
|
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
|
||||||
|
|
||||||
|
IP 组约束:
|
||||||
|
|
||||||
|
* 手动 IP 组由管理端直接维护 IP/IP 段列表。
|
||||||
|
* 自动 IP 组当前只保存结构化配置,不执行请求日志挖掘。
|
||||||
|
* 订阅 IP 组由 Server 定时从 HTTP/HTTPS URL 同步,支持文本列表和 JSON 映射。
|
||||||
|
* WAF 运行时不访问数据库;发布时将规则组引用的启用 IP 组展开进完整配置版本。
|
||||||
|
|
||||||
判定顺序:
|
判定顺序:
|
||||||
|
|
||||||
* 白名单是放行例外,任意启用规则组命中白名单即放行。
|
* 白名单是放行例外,任意启用规则组命中白名单即放行。
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ Server 发布时必须:
|
|||||||
1. 读取全部启用的 `proxy_routes`。
|
1. 读取全部启用的 `proxy_routes`。
|
||||||
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
|
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
|
||||||
3. 读取域名与证书绑定关系。
|
3. 读取域名与证书绑定关系。
|
||||||
4. 读取 WAF 全局规则组、自定义规则组与网站绑定关系。
|
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
|
||||||
5. 渲染完整 OpenResty 配置与 WAF 运行时配置。
|
5. 展开 WAF 规则组引用的启用 IP 组,渲染完整 OpenResty 配置与 WAF 运行时配置。
|
||||||
6. 计算 `checksum`。
|
6. 计算 `checksum`。
|
||||||
7. 写入 `config_versions`。
|
7. 写入 `config_versions`。
|
||||||
8. 切换激活版本。
|
8. 切换激活版本。
|
||||||
@@ -70,4 +70,4 @@ Agent 发现新版本后会:
|
|||||||
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
|
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
|
||||||
* Server 不提供远程 shell 或任意命令执行入口。
|
* Server 不提供远程 shell 或任意命令执行入口。
|
||||||
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
|
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
|
||||||
* WAF 规则组和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
|
* WAF 规则组、IP 组快照和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
|
||||||
|
|||||||
+3
-1
@@ -81,10 +81,12 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
|||||||
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
|
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
|
||||||
|
|
||||||
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
|
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
|
||||||
|
* 点击 WAF 页面中的 **管理 IP 组** 可以进入独立 IP 组页面。手动 IP 组直接维护 IP/IP 段;自动 IP 组当前保存配置但暂不执行日志挖掘;订阅 IP 组可从远程文本或 JSON 源定时同步。
|
||||||
|
* 在 WAF 规则组的黑白名单中,IP 维度既可以直接添加 IP/IP 段,也可以引用已有 IP 组。发布时 Server 会把启用 IP 组展开到 WAF 运行时配置。
|
||||||
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
|
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
|
||||||
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
|
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
|
||||||
|
|
||||||
WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
|
WAF、IP 组或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
|
||||||
|
|
||||||
## 发布、激活与回滚
|
## 发布、激活与回滚
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Frontend:
|
|||||||
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||||
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
|
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
|
||||||
* **系统配置与第三方登录**:`options` (全局参数), `auth_sources` (第三方认证源), `external_accounts` (外部绑定账号).
|
* **系统配置与第三方登录**:`options` (全局参数), `auth_sources` (第三方认证源), `external_accounts` (外部绑定账号).
|
||||||
* **安全与 WAF**:`waf_rule_groups` (WAF规则组), `waf_rule_group_bindings` (网站WAF绑定).
|
* **安全与 WAF**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
|
||||||
|
|
||||||
### 2. 底层数据库技术约束
|
### 2. 底层数据库技术约束
|
||||||
在编写或修改模型时,必须严格遵守以下持久化与数据库设计准则:
|
在编写或修改模型时,必须严格遵守以下持久化与数据库设计准则:
|
||||||
@@ -159,6 +159,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
|
|||||||
|
|
||||||
* 发布时读取全部启用的 `proxy_routes`。
|
* 发布时读取全部启用的 `proxy_routes`。
|
||||||
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
|
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
|
||||||
|
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
|
||||||
* 生成完整 OpenResty 配置。
|
* 生成完整 OpenResty 配置。
|
||||||
* 计算 `checksum`。
|
* 计算 `checksum`。
|
||||||
* 写入 `config_versions`。
|
* 写入 `config_versions`。
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
|
|||||||
| 只读接口 | 使用 `GET` |
|
| 只读接口 | 使用 `GET` |
|
||||||
| 变更类接口 | 使用 `POST` |
|
| 变更类接口 | 使用 `POST` |
|
||||||
|
|
||||||
|
## WAF IP 组接口
|
||||||
|
|
||||||
|
管理端 WAF IP 组接口统一要求管理端 Session 鉴权:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/waf/ip-groups` | 查询 IP 组列表 |
|
||||||
|
| `GET` | `/api/waf/ip-groups/:id` | 查询单个 IP 组 |
|
||||||
|
| `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 组 |
|
||||||
|
|
||||||
|
IP 组 `type` 支持 `manual`、`automatic`、`subscription`。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。
|
||||||
|
|
||||||
## 鉴权
|
## 鉴权
|
||||||
|
|
||||||
管理端继续复用现有登录、角色与 Session。
|
管理端继续复用现有登录、角色与 Session。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func ListWAFRuleGroups(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetWAFRuleGroup(c *gin.Context) {
|
func GetWAFRuleGroup(c *gin.Context) {
|
||||||
id, ok := parseUintPathParam(c, "id")
|
id, ok := parseIDParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ func CreateWAFRuleGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateWAFRuleGroup(c *gin.Context) {
|
func UpdateWAFRuleGroup(c *gin.Context) {
|
||||||
id, ok := parseUintPathParam(c, "id")
|
id, ok := parseIDParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func UpdateWAFRuleGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DeleteWAFRuleGroup(c *gin.Context) {
|
func DeleteWAFRuleGroup(c *gin.Context) {
|
||||||
id, ok := parseUintPathParam(c, "id")
|
id, ok := parseIDParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ func DeleteWAFRuleGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReplaceWAFRuleGroupSites(c *gin.Context) {
|
func ReplaceWAFRuleGroupSites(c *gin.Context) {
|
||||||
id, ok := parseUintPathParam(c, "id")
|
id, ok := parseIDParam(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,6 +122,83 @@ func ReplaceWAFSiteRuleGroups(c *gin.Context) {
|
|||||||
respondSuccess(c, view)
|
respondSuccess(c, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListWAFIPGroups(c *gin.Context) {
|
||||||
|
groups, err := service.ListWAFIPGroups()
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWAFIPGroup(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group, err := service.GetWAFIPGroup(id)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateWAFIPGroup(c *gin.Context) {
|
||||||
|
var input service.WAFIPGroupInput
|
||||||
|
if !bindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group, err := service.CreateWAFIPGroup(input)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateWAFIPGroup(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.WAFIPGroupInput
|
||||||
|
if !bindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group, err := service.UpdateWAFIPGroup(id, input)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteWAFIPGroup(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.DeleteWAFIPGroup(id); err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccessMessage(c, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncWAFIPGroup(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.SyncWAFIPGroup(id)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
func parseUintPathParam(c *gin.Context, name string) (uint, bool) {
|
func parseUintPathParam(c *gin.Context, name string) (uint, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ func InitCronJobs() {
|
|||||||
slog.Info("registered SSL renew cron job")
|
slog.Info("registered SSL renew cron job")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = cronRunner.AddJob("@every 5m", &WAFIPGroupSyncJob{})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to register WAF IP group sync cron job", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("registered WAF IP group sync cron job")
|
||||||
|
}
|
||||||
|
|
||||||
cronRunner.Start()
|
cronRunner.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"openflare/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WAFIPGroupSyncJob struct{}
|
||||||
|
|
||||||
|
func (j *WAFIPGroupSyncJob) Run() {
|
||||||
|
if err := service.SyncDueWAFIPGroups(); err != nil {
|
||||||
|
slog.Error("failed to sync due waf ip groups", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ func registeredModels() []any {
|
|||||||
&AcmeAccount{},
|
&AcmeAccount{},
|
||||||
&DnsAccount{},
|
&DnsAccount{},
|
||||||
&WAFRuleGroup{},
|
&WAFRuleGroup{},
|
||||||
|
&WAFIPGroup{},
|
||||||
&WAFRuleGroupBinding{},
|
&WAFRuleGroupBinding{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,41 @@ func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpgradeDatabaseSchemaV15ToV16AddsWAFIPGroups(t *testing.T) {
|
||||||
|
db := openBareTestSQLiteDB(t, "v16.db")
|
||||||
|
if err := registerSharding(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("register sharding: %v", err)
|
||||||
|
}
|
||||||
|
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||||
|
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||||
|
}
|
||||||
|
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("apply current schema: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensureDefaultWAFRuleGroup(db); err != nil {
|
||||||
|
t.Fatalf("ensure default waf rule group: %v", err)
|
||||||
|
}
|
||||||
|
if err := saveDatabaseSchemaVersion(db, 15); err != nil {
|
||||||
|
t.Fatalf("save schema version: %v", err)
|
||||||
|
}
|
||||||
|
if err := upgradeDatabaseSchema(db, "sqlite", 15); err != nil {
|
||||||
|
t.Fatalf("upgrade schema: %v", err)
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(&WAFIPGroup{}) {
|
||||||
|
t.Fatal("expected waf_ip_groups table")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_whitelist_groups") {
|
||||||
|
t.Fatal("expected waf_rule_groups.ip_whitelist_groups column")
|
||||||
|
}
|
||||||
|
version, ok, err := loadDatabaseSchemaVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load schema version: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || version != currentDatabaseSchemaVersion {
|
||||||
|
t.Fatalf("unexpected schema version: got %d ok=%v want %d", version, ok, currentDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
|
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
|
||||||
db := openTestSQLiteDB(t, "legacy-health-events.db")
|
db := openTestSQLiteDB(t, "legacy-health-events.db")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// v16 升级内容:新增 Tunnel 隧道表,为 nodes 增加 TunnelRelay 相关字段,为 proxy_routes 增加隧道上游支持字段。
|
|
||||||
// 背景说明:引入 Tunnel 组件后,边缘节点可通过隧道中继回源,需要区分节点类型(edge_node/relay_node)、记录中继绑定端口,
|
|
||||||
// 并在代理路由中支持 tunnel 上游类型以替代直连回源,同时需要独立的 tunnels 表管理隧道实例。
|
|
||||||
package migrate
|
package migrate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wafIPGroupV17 struct{}
|
||||||
|
|
||||||
|
type wafRuleGroupV17 struct {
|
||||||
|
IPWhitelistGroups string `gorm:"column:ip_whitelist_groups;type:text;not null;default:'[]'"`
|
||||||
|
IPBlacklistGroups string `gorm:"column:ip_blacklist_groups;type:text;not null;default:'[]'"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(V17())
|
||||||
|
}
|
||||||
|
|
||||||
|
func V17() Migration {
|
||||||
|
return Migration{
|
||||||
|
FromVersion: 16,
|
||||||
|
ToVersion: 17,
|
||||||
|
Migrate: migrateV17,
|
||||||
|
Validate: validateV17,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wafIPGroupV17) TableName() string {
|
||||||
|
return "waf_ip_groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wafRuleGroupV17) TableName() string {
|
||||||
|
return "waf_rule_groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateV17(ctx Context, db *gorm.DB, backend string) error {
|
||||||
|
return ctx.ApplyCurrentSchema(db, backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateV17(ctx Context, db *gorm.DB, backend string) error {
|
||||||
|
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 16); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if db == nil || !db.Migrator().HasTable(&wafIPGroupV17{}) {
|
||||||
|
return fmt.Errorf("table waf_ip_groups is missing")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&wafRuleGroupV17{}, "ip_whitelist_groups") {
|
||||||
|
return fmt.Errorf("column waf_rule_groups.ip_whitelist_groups is missing")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&wafRuleGroupV17{}, "ip_blacklist_groups") {
|
||||||
|
return fmt.Errorf("column waf_rule_groups.ip_blacklist_groups is missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
|
|||||||
return validateDatabaseSchemaV15(db, backend)
|
return validateDatabaseSchemaV15(db, backend)
|
||||||
case 16:
|
case 16:
|
||||||
return validateDatabaseSchemaV16(db, backend)
|
return validateDatabaseSchemaV16(db, backend)
|
||||||
|
case 17:
|
||||||
|
return validateDatabaseSchemaV17(db, backend)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1099,8 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error {
|
|||||||
BlockStatusCode: 418,
|
BlockStatusCode: 418,
|
||||||
IPWhitelist: "[]",
|
IPWhitelist: "[]",
|
||||||
IPBlacklist: "[]",
|
IPBlacklist: "[]",
|
||||||
|
IPWhitelistGroups: "[]",
|
||||||
|
IPBlacklistGroups: "[]",
|
||||||
CountryWhitelist: "[]",
|
CountryWhitelist: "[]",
|
||||||
CountryBlacklist: "[]",
|
CountryBlacklist: "[]",
|
||||||
RegionWhitelist: "[]",
|
RegionWhitelist: "[]",
|
||||||
@@ -1170,6 +1174,22 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDatabaseSchemaV17(db *gorm.DB, backend string) error {
|
||||||
|
if err := validateDatabaseSchemaV16(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(&WAFIPGroup{}) {
|
||||||
|
return fmt.Errorf("table waf_ip_groups is missing")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_whitelist_groups") {
|
||||||
|
return fmt.Errorf("column waf_rule_groups.ip_whitelist_groups is missing")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_blacklist_groups") {
|
||||||
|
return fmt.Errorf("column waf_rule_groups.ip_blacklist_groups is missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||||
ctx := databaseSchemaMigrationContext{}
|
ctx := databaseSchemaMigrationContext{}
|
||||||
migrations := []databaseSchemaMigration{}
|
migrations := []databaseSchemaMigration{}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type WAFRuleGroup struct {
|
|||||||
BlockResponseBody string `json:"block_response_body" gorm:"type:text;not null;default:''"`
|
BlockResponseBody string `json:"block_response_body" gorm:"type:text;not null;default:''"`
|
||||||
IPWhitelist string `json:"ip_whitelist" gorm:"type:text;not null;default:'[]'"`
|
IPWhitelist string `json:"ip_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||||
IPBlacklist string `json:"ip_blacklist" gorm:"type:text;not null;default:'[]'"`
|
IPBlacklist string `json:"ip_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||||
|
IPWhitelistGroups string `json:"ip_whitelist_group_ids" gorm:"type:text;not null;default:'[]'"`
|
||||||
|
IPBlacklistGroups string `json:"ip_blacklist_group_ids" gorm:"type:text;not null;default:'[]'"`
|
||||||
CountryWhitelist string `json:"country_whitelist" gorm:"type:text;not null;default:'[]'"`
|
CountryWhitelist string `json:"country_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||||
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||||
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||||
@@ -22,6 +24,26 @@ type WAFRuleGroup struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WAFIPGroup struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:255;not null"`
|
||||||
|
Type string `json:"type" gorm:"size:32;not null;index"`
|
||||||
|
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||||
|
IPList string `json:"ip_list" gorm:"type:text;not null;default:'[]'"`
|
||||||
|
AutoConfig string `json:"auto_config" gorm:"type:text;not null;default:'{}'"`
|
||||||
|
SubscriptionURL string `json:"subscription_url" gorm:"size:2048;not null;default:''"`
|
||||||
|
SubscriptionFormat string `json:"subscription_format" gorm:"size:32;not null;default:'text'"`
|
||||||
|
SubscriptionMappingRule string `json:"subscription_mapping_rule" gorm:"size:255;not null;default:''"`
|
||||||
|
SyncIntervalMinutes int `json:"sync_interval_minutes" gorm:"not null;default:1440"`
|
||||||
|
LastSyncedAt *time.Time `json:"last_synced_at"`
|
||||||
|
NextSyncAt *time.Time `json:"next_sync_at" gorm:"index"`
|
||||||
|
LastSyncStatus string `json:"last_sync_status" gorm:"size:32;not null;default:''"`
|
||||||
|
LastSyncMessage string `json:"last_sync_message" gorm:"type:text;not null;default:''"`
|
||||||
|
Remark string `json:"remark" gorm:"size:255"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WAFRuleGroupBinding struct {
|
type WAFRuleGroupBinding struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
RuleGroupID uint `json:"rule_group_id" gorm:"not null;uniqueIndex:idx_waf_group_route"`
|
RuleGroupID uint `json:"rule_group_id" gorm:"not null;uniqueIndex:idx_waf_group_route"`
|
||||||
@@ -60,6 +82,8 @@ func (group *WAFRuleGroup) Update() error {
|
|||||||
"block_response_body": group.BlockResponseBody,
|
"block_response_body": group.BlockResponseBody,
|
||||||
"ip_whitelist": group.IPWhitelist,
|
"ip_whitelist": group.IPWhitelist,
|
||||||
"ip_blacklist": group.IPBlacklist,
|
"ip_blacklist": group.IPBlacklist,
|
||||||
|
"ip_whitelist_groups": group.IPWhitelistGroups,
|
||||||
|
"ip_blacklist_groups": group.IPBlacklistGroups,
|
||||||
"country_whitelist": group.CountryWhitelist,
|
"country_whitelist": group.CountryWhitelist,
|
||||||
"country_blacklist": group.CountryBlacklist,
|
"country_blacklist": group.CountryBlacklist,
|
||||||
"region_whitelist": group.RegionWhitelist,
|
"region_whitelist": group.RegionWhitelist,
|
||||||
@@ -73,3 +97,69 @@ func (group *WAFRuleGroup) Update() error {
|
|||||||
func (group *WAFRuleGroup) Delete() error {
|
func (group *WAFRuleGroup) Delete() error {
|
||||||
return DB.Delete(group).Error
|
return DB.Delete(group).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListWAFIPGroups() ([]*WAFIPGroup, error) {
|
||||||
|
var groups []*WAFIPGroup
|
||||||
|
err := DB.Order("type asc").Order("id asc").Find(&groups).Error
|
||||||
|
return groups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWAFIPGroupByID(id uint) (*WAFIPGroup, error) {
|
||||||
|
group := &WAFIPGroup{}
|
||||||
|
err := DB.First(group, id).Error
|
||||||
|
return group, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListWAFIPGroupsByIDs(ids []uint) ([]*WAFIPGroup, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []*WAFIPGroup{}, nil
|
||||||
|
}
|
||||||
|
var groups []*WAFIPGroup
|
||||||
|
err := DB.Where("id IN ?", ids).Order("id asc").Find(&groups).Error
|
||||||
|
return groups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListDueSubscriptionWAFIPGroups(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).
|
||||||
|
Order("id asc").
|
||||||
|
Find(&groups).Error
|
||||||
|
return groups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *WAFIPGroup) Insert() error {
|
||||||
|
return DB.Create(group).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *WAFIPGroup) Update() error {
|
||||||
|
return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
|
||||||
|
"name": group.Name,
|
||||||
|
"type": group.Type,
|
||||||
|
"enabled": group.Enabled,
|
||||||
|
"ip_list": group.IPList,
|
||||||
|
"auto_config": group.AutoConfig,
|
||||||
|
"subscription_url": group.SubscriptionURL,
|
||||||
|
"subscription_format": group.SubscriptionFormat,
|
||||||
|
"subscription_mapping_rule": group.SubscriptionMappingRule,
|
||||||
|
"sync_interval_minutes": group.SyncIntervalMinutes,
|
||||||
|
"next_sync_at": group.NextSyncAt,
|
||||||
|
"last_sync_status": group.LastSyncStatus,
|
||||||
|
"last_sync_message": group.LastSyncMessage,
|
||||||
|
"remark": group.Remark,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *WAFIPGroup) UpdateSyncResult() error {
|
||||||
|
return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
|
||||||
|
"ip_list": group.IPList,
|
||||||
|
"last_synced_at": group.LastSyncedAt,
|
||||||
|
"next_sync_at": group.NextSyncAt,
|
||||||
|
"last_sync_status": group.LastSyncStatus,
|
||||||
|
"last_sync_message": group.LastSyncMessage,
|
||||||
|
"subscription_format": group.SubscriptionFormat,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *WAFIPGroup) Delete() error {
|
||||||
|
return DB.Delete(group).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
wafRoute := apiRouter.Group("/waf")
|
wafRoute := apiRouter.Group("/waf")
|
||||||
wafRoute.Use(middleware.AdminAuth())
|
wafRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
wafRoute.GET("/ip-groups", controller.ListWAFIPGroups)
|
||||||
|
wafRoute.GET("/ip-groups/:id", controller.GetWAFIPGroup)
|
||||||
|
wafRoute.POST("/ip-groups", controller.CreateWAFIPGroup)
|
||||||
|
wafRoute.POST("/ip-groups/:id/update", controller.UpdateWAFIPGroup)
|
||||||
|
wafRoute.POST("/ip-groups/:id/delete", controller.DeleteWAFIPGroup)
|
||||||
|
wafRoute.POST("/ip-groups/:id/sync", controller.SyncWAFIPGroup)
|
||||||
wafRoute.GET("/rule-groups", controller.ListWAFRuleGroups)
|
wafRoute.GET("/rule-groups", controller.ListWAFRuleGroups)
|
||||||
wafRoute.GET("/rule-groups/:id", controller.GetWAFRuleGroup)
|
wafRoute.GET("/rule-groups/:id", controller.GetWAFRuleGroup)
|
||||||
wafRoute.POST("/rule-groups", controller.CreateWAFRuleGroup)
|
wafRoute.POST("/rule-groups", controller.CreateWAFRuleGroup)
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ type snapshotWAFRuleGroup struct {
|
|||||||
BlockResponseBody string `json:"block_response_body,omitempty"`
|
BlockResponseBody string `json:"block_response_body,omitempty"`
|
||||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||||
|
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
|
||||||
|
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
|
||||||
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
||||||
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
||||||
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
||||||
@@ -108,6 +110,14 @@ type snapshotWAFRuleGroup struct {
|
|||||||
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
|
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type snapshotWAFIPGroup struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IPList []string `json:"ip_list,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type snapshotWAFBinding struct {
|
type snapshotWAFBinding struct {
|
||||||
RouteID uint `json:"route_id"`
|
RouteID uint `json:"route_id"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
@@ -116,6 +126,7 @@ type snapshotWAFBinding struct {
|
|||||||
|
|
||||||
type snapshotWAFDocument struct {
|
type snapshotWAFDocument struct {
|
||||||
RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"`
|
RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"`
|
||||||
|
IPGroups []snapshotWAFIPGroup `json:"ip_groups,omitempty"`
|
||||||
Bindings []snapshotWAFBinding `json:"bindings"`
|
Bindings []snapshotWAFBinding `json:"bindings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,6 +567,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
|||||||
BlockResponseBody: view.BlockResponseBody,
|
BlockResponseBody: view.BlockResponseBody,
|
||||||
IPWhitelist: view.IPWhitelist,
|
IPWhitelist: view.IPWhitelist,
|
||||||
IPBlacklist: view.IPBlacklist,
|
IPBlacklist: view.IPBlacklist,
|
||||||
|
IPWhitelistGroups: view.IPWhitelistGroups,
|
||||||
|
IPBlacklistGroups: view.IPBlacklistGroups,
|
||||||
CountryWhitelist: view.CountryWhitelist,
|
CountryWhitelist: view.CountryWhitelist,
|
||||||
CountryBlacklist: view.CountryBlacklist,
|
CountryBlacklist: view.CountryBlacklist,
|
||||||
RegionWhitelist: view.RegionWhitelist,
|
RegionWhitelist: view.RegionWhitelist,
|
||||||
@@ -564,6 +577,10 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
|||||||
PoWConfig: view.PoWConfig,
|
PoWConfig: view.PoWConfig,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ipGroups, err := buildSnapshotWAFIPGroups(ruleGroups)
|
||||||
|
if err != nil {
|
||||||
|
return snapshotWAFDocument{}, err
|
||||||
|
}
|
||||||
enabledRouteIDs := make(map[uint]string, len(routes))
|
enabledRouteIDs := make(map[uint]string, len(routes))
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
if route == nil {
|
if route == nil {
|
||||||
@@ -601,7 +618,54 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
|||||||
}
|
}
|
||||||
return bindings[i].SiteName < bindings[j].SiteName
|
return bindings[i].SiteName < bindings[j].SiteName
|
||||||
})
|
})
|
||||||
return snapshotWAFDocument{RuleGroups: ruleGroups, Bindings: bindings}, nil
|
return snapshotWAFDocument{RuleGroups: ruleGroups, IPGroups: ipGroups, Bindings: bindings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSnapshotWAFIPGroups(ruleGroups []snapshotWAFRuleGroup) ([]snapshotWAFIPGroup, error) {
|
||||||
|
idSet := make(map[uint]struct{})
|
||||||
|
for _, group := range ruleGroups {
|
||||||
|
for _, id := range group.IPWhitelistGroups {
|
||||||
|
idSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range group.IPBlacklistGroups {
|
||||||
|
idSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(idSet) == 0 {
|
||||||
|
return []snapshotWAFIPGroup{}, nil
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(idSet))
|
||||||
|
for id := range idSet {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
groups, err := model.ListWAFIPGroupsByIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groupByID := make(map[uint]*model.WAFIPGroup, len(groups))
|
||||||
|
for _, group := range groups {
|
||||||
|
groupByID[group.ID] = group
|
||||||
|
}
|
||||||
|
snapshots := make([]snapshotWAFIPGroup, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
group := groupByID[id]
|
||||||
|
if group == nil {
|
||||||
|
return nil, fmt.Errorf("IP 组 %d 不存在", id)
|
||||||
|
}
|
||||||
|
ips, err := decodeStringList(group.IPList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IP 组 %s 列表无效: %w", group.Name, err)
|
||||||
|
}
|
||||||
|
snapshots = append(snapshots, snapshotWAFIPGroup{
|
||||||
|
ID: group.ID,
|
||||||
|
Name: group.Name,
|
||||||
|
Type: group.Type,
|
||||||
|
Enabled: group.Enabled,
|
||||||
|
IPList: ips,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return snapshots, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustDecodeSnapshotCertIDs(route *model.ProxyRoute) []uint {
|
func mustDecodeSnapshotCertIDs(route *model.ProxyRoute) []uint {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type WAFRuleGroupInput struct {
|
|||||||
BlockResponseBody string `json:"block_response_body"`
|
BlockResponseBody string `json:"block_response_body"`
|
||||||
IPWhitelist []string `json:"ip_whitelist"`
|
IPWhitelist []string `json:"ip_whitelist"`
|
||||||
IPBlacklist []string `json:"ip_blacklist"`
|
IPBlacklist []string `json:"ip_blacklist"`
|
||||||
|
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
|
||||||
|
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
|
||||||
CountryWhitelist []string `json:"country_whitelist"`
|
CountryWhitelist []string `json:"country_whitelist"`
|
||||||
CountryBlacklist []string `json:"country_blacklist"`
|
CountryBlacklist []string `json:"country_blacklist"`
|
||||||
RegionWhitelist []string `json:"region_whitelist"`
|
RegionWhitelist []string `json:"region_whitelist"`
|
||||||
@@ -45,6 +47,8 @@ type WAFRuleGroupView struct {
|
|||||||
BlockResponseBody string `json:"block_response_body"`
|
BlockResponseBody string `json:"block_response_body"`
|
||||||
IPWhitelist []string `json:"ip_whitelist"`
|
IPWhitelist []string `json:"ip_whitelist"`
|
||||||
IPBlacklist []string `json:"ip_blacklist"`
|
IPBlacklist []string `json:"ip_blacklist"`
|
||||||
|
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
|
||||||
|
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
|
||||||
CountryWhitelist []string `json:"country_whitelist"`
|
CountryWhitelist []string `json:"country_whitelist"`
|
||||||
CountryBlacklist []string `json:"country_blacklist"`
|
CountryBlacklist []string `json:"country_blacklist"`
|
||||||
RegionWhitelist []string `json:"region_whitelist"`
|
RegionWhitelist []string `json:"region_whitelist"`
|
||||||
@@ -276,6 +280,8 @@ func EnsureDefaultWAFRuleGroup() error {
|
|||||||
BlockStatusCode: defaultWAFBlockStatusCode,
|
BlockStatusCode: defaultWAFBlockStatusCode,
|
||||||
IPWhitelist: "[]",
|
IPWhitelist: "[]",
|
||||||
IPBlacklist: "[]",
|
IPBlacklist: "[]",
|
||||||
|
IPWhitelistGroups: "[]",
|
||||||
|
IPBlacklistGroups: "[]",
|
||||||
CountryWhitelist: "[]",
|
CountryWhitelist: "[]",
|
||||||
CountryBlacklist: "[]",
|
CountryBlacklist: "[]",
|
||||||
RegionWhitelist: "[]",
|
RegionWhitelist: "[]",
|
||||||
@@ -310,6 +316,14 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("IP 黑名单无效: %w", err)
|
return nil, fmt.Errorf("IP 黑名单无效: %w", err)
|
||||||
}
|
}
|
||||||
|
ipWhitelistGroups, err := normalizeWAFIPGroupIDs(input.IPWhitelistGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IP 白名单引用无效: %w", err)
|
||||||
|
}
|
||||||
|
ipBlacklistGroups, err := normalizeWAFIPGroupIDs(input.IPBlacklistGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IP 黑名单引用无效: %w", err)
|
||||||
|
}
|
||||||
countryWhitelist, err := normalizeWAFCountryList(input.CountryWhitelist)
|
countryWhitelist, err := normalizeWAFCountryList(input.CountryWhitelist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("地域白名单无效: %w", err)
|
return nil, fmt.Errorf("地域白名单无效: %w", err)
|
||||||
@@ -332,6 +346,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
|
|||||||
|
|
||||||
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
|
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
|
||||||
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
|
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
|
||||||
|
ipWhitelistGroupsJSON, _ := json.Marshal(ipWhitelistGroups)
|
||||||
|
ipBlacklistGroupsJSON, _ := json.Marshal(ipBlacklistGroups)
|
||||||
countryWhitelistJSON, _ := json.Marshal(countryWhitelist)
|
countryWhitelistJSON, _ := json.Marshal(countryWhitelist)
|
||||||
countryBlacklistJSON, _ := json.Marshal(countryBlacklist)
|
countryBlacklistJSON, _ := json.Marshal(countryBlacklist)
|
||||||
regionWhitelistJSON, _ := json.Marshal(regionWhitelist)
|
regionWhitelistJSON, _ := json.Marshal(regionWhitelist)
|
||||||
@@ -346,6 +362,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
|
|||||||
group.BlockResponseBody = input.BlockResponseBody
|
group.BlockResponseBody = input.BlockResponseBody
|
||||||
group.IPWhitelist = string(ipWhitelistJSON)
|
group.IPWhitelist = string(ipWhitelistJSON)
|
||||||
group.IPBlacklist = string(ipBlacklistJSON)
|
group.IPBlacklist = string(ipBlacklistJSON)
|
||||||
|
group.IPWhitelistGroups = string(ipWhitelistGroupsJSON)
|
||||||
|
group.IPBlacklistGroups = string(ipBlacklistGroupsJSON)
|
||||||
group.CountryWhitelist = string(countryWhitelistJSON)
|
group.CountryWhitelist = string(countryWhitelistJSON)
|
||||||
group.CountryBlacklist = string(countryBlacklistJSON)
|
group.CountryBlacklist = string(countryBlacklistJSON)
|
||||||
group.RegionWhitelist = string(regionWhitelistJSON)
|
group.RegionWhitelist = string(regionWhitelistJSON)
|
||||||
@@ -382,6 +400,8 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA
|
|||||||
if view.IPBlacklist, err = decodeStringList(group.IPBlacklist); err != nil {
|
if view.IPBlacklist, err = decodeStringList(group.IPBlacklist); err != nil {
|
||||||
return view, err
|
return view, err
|
||||||
}
|
}
|
||||||
|
view.IPWhitelistGroups = mustDecodeUintList(group.IPWhitelistGroups)
|
||||||
|
view.IPBlacklistGroups = mustDecodeUintList(group.IPBlacklistGroups)
|
||||||
if view.CountryWhitelist, err = decodeStringList(group.CountryWhitelist); err != nil {
|
if view.CountryWhitelist, err = decodeStringList(group.CountryWhitelist); err != nil {
|
||||||
return view, err
|
return view, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"openflare/model"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WAFIPGroupTypeManual = "manual"
|
||||||
|
WAFIPGroupTypeAutomatic = "automatic"
|
||||||
|
WAFIPGroupTypeSubscription = "subscription"
|
||||||
|
|
||||||
|
WAFIPGroupSubscriptionFormatText = "text"
|
||||||
|
WAFIPGroupSubscriptionFormatJSON = "json"
|
||||||
|
|
||||||
|
defaultWAFIPGroupSyncIntervalMinutes = 1440
|
||||||
|
minWAFIPGroupSyncIntervalMinutes = 5
|
||||||
|
maxWAFIPGroupSyncIntervalMinutes = 43200
|
||||||
|
maxWAFIPGroupSubscriptionBytes = 2 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type WAFIPGroupInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IPList []string `json:"ip_list"`
|
||||||
|
AutoConfig json.RawMessage `json:"auto_config"`
|
||||||
|
SubscriptionURL string `json:"subscription_url"`
|
||||||
|
SubscriptionFormat string `json:"subscription_format"`
|
||||||
|
SubscriptionMappingRule string `json:"subscription_mapping_rule"`
|
||||||
|
SyncIntervalMinutes int `json:"sync_interval_minutes"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WAFIPGroupView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IPList []string `json:"ip_list"`
|
||||||
|
AutoConfig json.RawMessage `json:"auto_config"`
|
||||||
|
SubscriptionURL string `json:"subscription_url"`
|
||||||
|
SubscriptionFormat string `json:"subscription_format"`
|
||||||
|
SubscriptionMappingRule string `json:"subscription_mapping_rule"`
|
||||||
|
SyncIntervalMinutes int `json:"sync_interval_minutes"`
|
||||||
|
LastSyncedAt string `json:"last_synced_at,omitempty"`
|
||||||
|
NextSyncAt string `json:"next_sync_at,omitempty"`
|
||||||
|
LastSyncStatus string `json:"last_sync_status"`
|
||||||
|
LastSyncMessage string `json:"last_sync_message"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
ReferencedByRuleCount int `json:"referenced_by_rule_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WAFIPGroupSyncResult struct {
|
||||||
|
Group WAFIPGroupView `json:"group"`
|
||||||
|
IPCount int `json:"ip_count"`
|
||||||
|
SyncedAt string `json:"synced_at"`
|
||||||
|
NextSyncAt string `json:"next_sync_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListWAFIPGroups() ([]WAFIPGroupView, error) {
|
||||||
|
groups, err := model.ListWAFIPGroups()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
referenceCounts, err := loadWAFIPGroupReferenceCounts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]WAFIPGroupView, 0, len(groups))
|
||||||
|
for _, group := range groups {
|
||||||
|
view, err := buildWAFIPGroupView(group, referenceCounts[group.ID])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views = append(views, view)
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWAFIPGroup(id uint) (*WAFIPGroupView, error) {
|
||||||
|
group, err := model.GetWAFIPGroupByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
referenceCounts, err := loadWAFIPGroupReferenceCounts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
view, err := buildWAFIPGroupView(group, referenceCounts[group.ID])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateWAFIPGroup(input WAFIPGroupInput) (*WAFIPGroupView, error) {
|
||||||
|
group, err := buildWAFIPGroup(nil, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := group.Insert(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetWAFIPGroup(group.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateWAFIPGroup(id uint, input WAFIPGroupInput) (*WAFIPGroupView, error) {
|
||||||
|
group, err := model.GetWAFIPGroupByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group, err = buildWAFIPGroup(group, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := group.Update(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetWAFIPGroup(group.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteWAFIPGroup(id uint) error {
|
||||||
|
group, err := model.GetWAFIPGroupByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
counts, err := loadWAFIPGroupReferenceCounts()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if counts[group.ID] > 0 {
|
||||||
|
return errors.New("IP 组已被 WAF 规则组引用,请先移除引用")
|
||||||
|
}
|
||||||
|
return group.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncWAFIPGroup(id uint) (*WAFIPGroupSyncResult, error) {
|
||||||
|
group, err := model.GetWAFIPGroupByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return syncWAFIPGroup(group, time.Now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func SyncDueWAFIPGroups() error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
groups, err := model.ListDueSubscriptionWAFIPGroups(now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if _, err := syncWAFIPGroup(group, now); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWAFIPGroup(group *model.WAFIPGroup, input WAFIPGroupInput) (*model.WAFIPGroup, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("IP 组名称不能为空")
|
||||||
|
}
|
||||||
|
groupType := normalizeWAFIPGroupType(input.Type)
|
||||||
|
if groupType == "" {
|
||||||
|
return nil, errors.New("IP 组类型无效")
|
||||||
|
}
|
||||||
|
ipList := input.IPList
|
||||||
|
subscriptionURL := ""
|
||||||
|
subscriptionFormat := normalizeWAFIPGroupSubscriptionFormat(input.SubscriptionFormat)
|
||||||
|
mappingRule := strings.TrimSpace(input.SubscriptionMappingRule)
|
||||||
|
syncInterval := normalizeWAFIPGroupSyncInterval(input.SyncIntervalMinutes)
|
||||||
|
autoConfig := "{}"
|
||||||
|
|
||||||
|
switch groupType {
|
||||||
|
case WAFIPGroupTypeManual:
|
||||||
|
subscriptionFormat = WAFIPGroupSubscriptionFormatText
|
||||||
|
mappingRule = ""
|
||||||
|
case WAFIPGroupTypeAutomatic:
|
||||||
|
raw := strings.TrimSpace(string(input.AutoConfig))
|
||||||
|
if raw == "" {
|
||||||
|
raw = "{}"
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(raw)) || strings.HasPrefix(raw, "[") {
|
||||||
|
return nil, errors.New("自动 IP 组配置必须是 JSON 对象")
|
||||||
|
}
|
||||||
|
autoConfig = raw
|
||||||
|
subscriptionFormat = WAFIPGroupSubscriptionFormatText
|
||||||
|
mappingRule = ""
|
||||||
|
case WAFIPGroupTypeSubscription:
|
||||||
|
subscriptionURL = strings.TrimSpace(input.SubscriptionURL)
|
||||||
|
if err := validateSubscriptionURL(subscriptionURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if subscriptionFormat == "" {
|
||||||
|
subscriptionFormat = WAFIPGroupSubscriptionFormatText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedIPs, err := normalizeWAFIPList(ipList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ipListJSON, _ := json.Marshal(normalizedIPs)
|
||||||
|
if group == nil {
|
||||||
|
group = &model.WAFIPGroup{}
|
||||||
|
}
|
||||||
|
group.Name = name
|
||||||
|
group.Type = groupType
|
||||||
|
group.Enabled = input.Enabled
|
||||||
|
group.IPList = string(ipListJSON)
|
||||||
|
group.AutoConfig = autoConfig
|
||||||
|
group.SubscriptionURL = subscriptionURL
|
||||||
|
group.SubscriptionFormat = subscriptionFormat
|
||||||
|
group.SubscriptionMappingRule = mappingRule
|
||||||
|
group.SyncIntervalMinutes = syncInterval
|
||||||
|
group.NextSyncAt = nextWAFIPGroupSyncAt(group.Type, group.Enabled, syncInterval, group.NextSyncAt)
|
||||||
|
group.Remark = strings.TrimSpace(input.Remark)
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWAFIPGroupView(group *model.WAFIPGroup, referenceCount int) (WAFIPGroupView, error) {
|
||||||
|
if group == nil {
|
||||||
|
return WAFIPGroupView{}, errors.New("waf ip group is nil")
|
||||||
|
}
|
||||||
|
ips, err := decodeStringList(group.IPList)
|
||||||
|
if err != nil {
|
||||||
|
return WAFIPGroupView{}, err
|
||||||
|
}
|
||||||
|
autoConfig := json.RawMessage(strings.TrimSpace(group.AutoConfig))
|
||||||
|
if len(autoConfig) == 0 {
|
||||||
|
autoConfig = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
view := WAFIPGroupView{
|
||||||
|
ID: group.ID,
|
||||||
|
Name: group.Name,
|
||||||
|
Type: group.Type,
|
||||||
|
Enabled: group.Enabled,
|
||||||
|
IPList: ips,
|
||||||
|
AutoConfig: autoConfig,
|
||||||
|
SubscriptionURL: group.SubscriptionURL,
|
||||||
|
SubscriptionFormat: group.SubscriptionFormat,
|
||||||
|
SubscriptionMappingRule: group.SubscriptionMappingRule,
|
||||||
|
SyncIntervalMinutes: group.SyncIntervalMinutes,
|
||||||
|
LastSyncStatus: group.LastSyncStatus,
|
||||||
|
LastSyncMessage: group.LastSyncMessage,
|
||||||
|
Remark: group.Remark,
|
||||||
|
ReferencedByRuleCount: referenceCount,
|
||||||
|
CreatedAt: group.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if group.LastSyncedAt != nil {
|
||||||
|
view.LastSyncedAt = group.LastSyncedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if group.NextSyncAt != nil {
|
||||||
|
view.NextSyncAt = group.NextSyncAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncWAFIPGroup(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) {
|
||||||
|
if group == nil {
|
||||||
|
return nil, errors.New("IP 组不存在")
|
||||||
|
}
|
||||||
|
if group.Type != WAFIPGroupTypeSubscription {
|
||||||
|
return nil, errors.New("只有订阅类型 IP 组支持同步")
|
||||||
|
}
|
||||||
|
content, err := downloadWAFIPGroupSubscription(group.SubscriptionURL)
|
||||||
|
if err != nil {
|
||||||
|
recordWAFIPGroupSyncFailure(group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ips, err := parseWAFIPGroupSubscription(content, group.SubscriptionFormat, group.SubscriptionMappingRule)
|
||||||
|
if err != nil {
|
||||||
|
recordWAFIPGroupSyncFailure(group, now, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ipListJSON, _ := json.Marshal(ips)
|
||||||
|
nextSyncAt := now.Add(time.Duration(group.SyncIntervalMinutes) * time.Minute)
|
||||||
|
group.IPList = string(ipListJSON)
|
||||||
|
group.LastSyncedAt = &now
|
||||||
|
group.NextSyncAt = &nextSyncAt
|
||||||
|
group.LastSyncStatus = "success"
|
||||||
|
group.LastSyncMessage = fmt.Sprintf("同步成功,共 %d 条 IP/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
|
||||||
|
group.NextSyncAt = &nextSyncAt
|
||||||
|
group.LastSyncStatus = "failed"
|
||||||
|
group.LastSyncMessage = syncErr.Error()
|
||||||
|
_ = group.UpdateSyncResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadWAFIPGroupSubscription(rawURL string) ([]byte, error) {
|
||||||
|
if err := validateSubscriptionURL(rawURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("下载订阅失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("订阅返回状态码 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
reader := io.LimitReader(resp.Body, maxWAFIPGroupSubscriptionBytes+1)
|
||||||
|
if _, err := buffer.ReadFrom(reader); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取订阅内容失败: %w", err)
|
||||||
|
}
|
||||||
|
if buffer.Len() > maxWAFIPGroupSubscriptionBytes {
|
||||||
|
return nil, fmt.Errorf("订阅内容不能超过 %d 字节", maxWAFIPGroupSubscriptionBytes)
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWAFIPGroupSubscription(content []byte, format string, mappingRule string) ([]string, error) {
|
||||||
|
switch normalizeWAFIPGroupSubscriptionFormat(format) {
|
||||||
|
case WAFIPGroupSubscriptionFormatJSON:
|
||||||
|
items, err := parseWAFIPGroupJSONSubscription(content, mappingRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalizeWAFIPList(items)
|
||||||
|
default:
|
||||||
|
return normalizeWAFIPList(parseWAFIPGroupTextSubscription(string(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWAFIPGroupTextSubscription(text string) []string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
items := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
item := strings.TrimSpace(line)
|
||||||
|
if item == "" || strings.HasPrefix(item, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWAFIPGroupJSONSubscription(content []byte, mappingRule string) ([]string, error) {
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal(content, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON 订阅解析失败: %w", err)
|
||||||
|
}
|
||||||
|
nodes, err := selectJSONMappingNodes(payload, mappingRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
collectJSONStrings(node, &items)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, errors.New("JSON 订阅没有解析到 IP/IP 段")
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectJSONMappingNodes(payload any, mappingRule string) ([]any, error) {
|
||||||
|
rule := strings.TrimSpace(mappingRule)
|
||||||
|
if rule == "" || rule == "$" {
|
||||||
|
return []any{payload}, nil
|
||||||
|
}
|
||||||
|
rule = strings.TrimPrefix(rule, "$.")
|
||||||
|
nodes := []any{payload}
|
||||||
|
for _, rawSegment := range strings.Split(rule, ".") {
|
||||||
|
segment := strings.TrimSpace(rawSegment)
|
||||||
|
if segment == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expandArray := strings.HasSuffix(segment, "[]")
|
||||||
|
segment = strings.TrimSuffix(segment, "[]")
|
||||||
|
next := make([]any, 0)
|
||||||
|
for _, node := range nodes {
|
||||||
|
object, ok := node.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, ok := object[segment]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expandArray {
|
||||||
|
array, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, array...)
|
||||||
|
} else {
|
||||||
|
next = append(next, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = next
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil, fmt.Errorf("JSON 映射规则 %q 未匹配到内容", mappingRule)
|
||||||
|
}
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectJSONStrings(node any, items *[]string) {
|
||||||
|
switch value := node.(type) {
|
||||||
|
case string:
|
||||||
|
*items = append(*items, value)
|
||||||
|
case []any:
|
||||||
|
for _, item := range value {
|
||||||
|
collectJSONStrings(item, items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSubscriptionURL(rawURL string) error {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return errors.New("订阅 URL 无效")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return errors.New("订阅 URL 仅支持 http 或 https")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWAFIPGroupType(value string) string {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case WAFIPGroupTypeManual, "":
|
||||||
|
return WAFIPGroupTypeManual
|
||||||
|
case WAFIPGroupTypeAutomatic:
|
||||||
|
return WAFIPGroupTypeAutomatic
|
||||||
|
case WAFIPGroupTypeSubscription:
|
||||||
|
return WAFIPGroupTypeSubscription
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWAFIPGroupSubscriptionFormat(value string) string {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case WAFIPGroupSubscriptionFormatJSON:
|
||||||
|
return WAFIPGroupSubscriptionFormatJSON
|
||||||
|
default:
|
||||||
|
return WAFIPGroupSubscriptionFormatText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWAFIPGroupSyncInterval(value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return defaultWAFIPGroupSyncIntervalMinutes
|
||||||
|
}
|
||||||
|
if value < minWAFIPGroupSyncIntervalMinutes {
|
||||||
|
return minWAFIPGroupSyncIntervalMinutes
|
||||||
|
}
|
||||||
|
if value > maxWAFIPGroupSyncIntervalMinutes {
|
||||||
|
return maxWAFIPGroupSyncIntervalMinutes
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextWAFIPGroupSyncAt(groupType string, enabled bool, interval int, current *time.Time) *time.Time {
|
||||||
|
if groupType != WAFIPGroupTypeSubscription || !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if current != nil && current.After(time.Now().UTC()) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
next := time.Now().UTC().Add(time.Duration(normalizeWAFIPGroupSyncInterval(interval)) * time.Minute)
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWAFIPGroupReferenceCounts() (map[uint]int, error) {
|
||||||
|
var groups []model.WAFRuleGroup
|
||||||
|
if err := model.DB.Select("ip_whitelist_groups", "ip_blacklist_groups").Find(&groups).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts := make(map[uint]int)
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, id := range mustDecodeUintList(group.IPWhitelistGroups) {
|
||||||
|
counts[id]++
|
||||||
|
}
|
||||||
|
for _, id := range mustDecodeUintList(group.IPBlacklistGroups) {
|
||||||
|
counts[id]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWAFIPGroupIDs(ids []uint) ([]uint, error) {
|
||||||
|
normalized := uniqueUintIDs(ids)
|
||||||
|
for _, id := range normalized {
|
||||||
|
if _, err := model.GetWAFIPGroupByID(id); err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("IP 组 %d 不存在", id)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDecodeUintList(raw string) []uint {
|
||||||
|
var values []uint
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &values); err != nil {
|
||||||
|
return []uint{}
|
||||||
|
}
|
||||||
|
values = uniqueUintIDs(values)
|
||||||
|
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
|
||||||
|
return values
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -128,3 +130,138 @@ func TestPublishConfigVersionIncludesWAFSnapshotAndRuntimeConfig(t *testing.T) {
|
|||||||
t.Fatalf("expected waf_config.json support file, got %#v", files)
|
t.Fatalf("expected waf_config.json support file, got %#v", files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWAFIPGroupCRUDAndRuleGroupReference(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{
|
||||||
|
Name: "bad actors",
|
||||||
|
Type: WAFIPGroupTypeManual,
|
||||||
|
Enabled: true,
|
||||||
|
IPList: []string{"203.0.113.10", "203.0.113.10", "198.51.100.0/24"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWAFIPGroup failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(ipGroup.IPList) != 2 {
|
||||||
|
t.Fatalf("unexpected normalized IP group list: %#v", ipGroup.IPList)
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := CreateWAFRuleGroup(WAFRuleGroupInput{
|
||||||
|
Name: "referenced",
|
||||||
|
Enabled: true,
|
||||||
|
BlockStatusCode: 403,
|
||||||
|
IPBlacklistGroups: []uint{ipGroup.ID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(group.IPBlacklistGroups) != 1 || group.IPBlacklistGroups[0] != ipGroup.ID {
|
||||||
|
t.Fatalf("unexpected blacklist group refs: %#v", group.IPBlacklistGroups)
|
||||||
|
}
|
||||||
|
if err = DeleteWAFIPGroup(ipGroup.ID); err == nil {
|
||||||
|
t.Fatal("expected referenced IP group delete to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWAFIPGroupSubscriptionParsers(t *testing.T) {
|
||||||
|
textItems, err := parseWAFIPGroupSubscription([]byte("# comment\n203.0.113.10\n\n198.51.100.0/24\n"), "text", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse text subscription failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(textItems) != 2 || textItems[0] != "198.51.100.0/24" || textItems[1] != "203.0.113.10" {
|
||||||
|
t.Fatalf("unexpected text subscription items: %#v", textItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonItems, err := parseWAFIPGroupSubscription([]byte(`{"data":{"items":[{"ip":"203.0.113.11"},{"ip":"203.0.113.12"}]}}`), "json", "data.items[].ip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse json subscription failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jsonItems) != 2 || jsonItems[0] != "203.0.113.11" || jsonItems[1] != "203.0.113.12" {
|
||||||
|
t.Fatalf("unexpected json subscription items: %#v", jsonItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncWAFIPGroupDownloadsSubscription(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("203.0.113.20\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
group, err := CreateWAFIPGroup(WAFIPGroupInput{
|
||||||
|
Name: "subscription",
|
||||||
|
Type: WAFIPGroupTypeSubscription,
|
||||||
|
Enabled: true,
|
||||||
|
SubscriptionURL: server.URL,
|
||||||
|
SubscriptionFormat: WAFIPGroupSubscriptionFormatText,
|
||||||
|
SyncIntervalMinutes: 10,
|
||||||
|
})
|
||||||
|
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 != 1 || result.Group.IPList[0] != "203.0.113.20" {
|
||||||
|
t.Fatalf("unexpected sync result: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishConfigVersionExpandsWAFIPGroupReferences(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||||
|
SiteName: "waf-ip-groups",
|
||||||
|
Domains: []string{"waf-ip-groups.example.com"},
|
||||||
|
OriginURL: "https://origin.internal",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||||
|
}
|
||||||
|
ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{
|
||||||
|
Name: "publish refs",
|
||||||
|
Type: WAFIPGroupTypeManual,
|
||||||
|
Enabled: true,
|
||||||
|
IPList: []string{"203.0.113.30"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWAFIPGroup failed: %v", err)
|
||||||
|
}
|
||||||
|
ruleGroup, err := CreateWAFRuleGroup(WAFRuleGroupInput{
|
||||||
|
Name: "publish group refs",
|
||||||
|
Enabled: true,
|
||||||
|
BlockStatusCode: 451,
|
||||||
|
IPBlacklistGroups: []uint{ipGroup.ID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{ruleGroup.ID}); err != nil {
|
||||||
|
t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err)
|
||||||
|
}
|
||||||
|
result, err := PublishConfigVersion("root", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.SnapshotJSON, `"ip_groups"`) {
|
||||||
|
t.Fatal("expected snapshot to include waf ip groups")
|
||||||
|
}
|
||||||
|
var files []SupportFile
|
||||||
|
if err = json.Unmarshal([]byte(result.Version.SupportFilesJSON), &files); err != nil {
|
||||||
|
t.Fatalf("decode support files failed: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Path == "waf_config.json" && strings.Contains(file.Content, "203.0.113.30") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected expanded IP group in waf_config.json, got %#v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
|
|||||||
groups := make([]wafRuntimeRuleGroup, 0, len(snapshot.RuleGroups))
|
groups := make([]wafRuntimeRuleGroup, 0, len(snapshot.RuleGroups))
|
||||||
globalGroupIDs := make([]uint, 0)
|
globalGroupIDs := make([]uint, 0)
|
||||||
enabledGroupIDs := make(map[uint]struct{}, len(snapshot.RuleGroups))
|
enabledGroupIDs := make(map[uint]struct{}, len(snapshot.RuleGroups))
|
||||||
|
ipGroupsByID := make(map[uint]WAFIPGroup, len(snapshot.IPGroups))
|
||||||
|
for _, group := range snapshot.IPGroups {
|
||||||
|
ipGroupsByID[group.ID] = group
|
||||||
|
}
|
||||||
for _, group := range snapshot.RuleGroups {
|
for _, group := range snapshot.RuleGroups {
|
||||||
if !group.Enabled {
|
if !group.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -211,7 +215,19 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
|
|||||||
globalGroupIDs = append(globalGroupIDs, group.ID)
|
globalGroupIDs = append(globalGroupIDs, group.ID)
|
||||||
}
|
}
|
||||||
enabledGroupIDs[group.ID] = struct{}{}
|
enabledGroupIDs[group.ID] = struct{}{}
|
||||||
groups = append(groups, wafRuntimeRuleGroup{ID: group.ID, Name: group.Name, IsGlobal: group.IsGlobal, BlockStatusCode: statusCode, BlockResponseBody: group.BlockResponseBody, IPWhitelist: group.IPWhitelist, IPBlacklist: group.IPBlacklist, CountryWhitelist: group.CountryWhitelist, CountryBlacklist: group.CountryBlacklist, RegionWhitelist: group.RegionWhitelist, RegionBlacklist: group.RegionBlacklist})
|
groups = append(groups, wafRuntimeRuleGroup{
|
||||||
|
ID: group.ID,
|
||||||
|
Name: group.Name,
|
||||||
|
IsGlobal: group.IsGlobal,
|
||||||
|
BlockStatusCode: statusCode,
|
||||||
|
BlockResponseBody: group.BlockResponseBody,
|
||||||
|
IPWhitelist: expandWAFIPGroups(group.IPWhitelist, group.IPWhitelistGroups, ipGroupsByID),
|
||||||
|
IPBlacklist: expandWAFIPGroups(group.IPBlacklist, group.IPBlacklistGroups, ipGroupsByID),
|
||||||
|
CountryWhitelist: group.CountryWhitelist,
|
||||||
|
CountryBlacklist: group.CountryBlacklist,
|
||||||
|
RegionWhitelist: group.RegionWhitelist,
|
||||||
|
RegionBlacklist: group.RegionBlacklist,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(groups, func(i, j int) bool {
|
sort.Slice(groups, func(i, j int) bool {
|
||||||
if groups[i].IsGlobal != groups[j].IsGlobal {
|
if groups[i].IsGlobal != groups[j].IsGlobal {
|
||||||
@@ -234,6 +250,20 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
|
|||||||
return string(data), err
|
return string(data), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expandWAFIPGroups(direct []string, groupIDs []uint, ipGroupsByID map[uint]WAFIPGroup) []string {
|
||||||
|
items := append([]string{}, direct...)
|
||||||
|
for _, id := range groupIDs {
|
||||||
|
group, ok := ipGroupsByID[id]
|
||||||
|
if !ok || !group.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, group.IPList...)
|
||||||
|
}
|
||||||
|
items = uniqueStrings(items)
|
||||||
|
sort.Strings(items)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func ChecksumBundle(mainConfig string, routeConfig string, supportFiles []SupportFile) string {
|
func ChecksumBundle(mainConfig string, routeConfig string, supportFiles []SupportFile) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString(mainConfig)
|
builder.WriteString(mainConfig)
|
||||||
@@ -683,6 +713,23 @@ func uniqueUintIDs(values []uint) []uint {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
item := strings.TrimSpace(value)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func resolveUpstreamServerName(originURL string, originHost string) string {
|
func resolveUpstreamServerName(originURL string, originHost string) string {
|
||||||
parsed, err := url.Parse(originURL)
|
parsed, err := url.Parse(originURL)
|
||||||
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
|
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ type WAFRuleGroup struct {
|
|||||||
BlockResponseBody string `json:"block_response_body,omitempty"`
|
BlockResponseBody string `json:"block_response_body,omitempty"`
|
||||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||||
|
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
|
||||||
|
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
|
||||||
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
||||||
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
||||||
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
||||||
@@ -135,6 +137,14 @@ type WAFRuleGroup struct {
|
|||||||
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WAFIPGroup struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IPList []string `json:"ip_list,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WAFBinding struct {
|
type WAFBinding struct {
|
||||||
RouteID uint `json:"route_id"`
|
RouteID uint `json:"route_id"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
@@ -143,6 +153,7 @@ type WAFBinding struct {
|
|||||||
|
|
||||||
type WAFDocument struct {
|
type WAFDocument struct {
|
||||||
RuleGroups []WAFRuleGroup `json:"rule_groups"`
|
RuleGroups []WAFRuleGroup `json:"rule_groups"`
|
||||||
|
IPGroups []WAFIPGroup `json:"ip_groups,omitempty"`
|
||||||
Bindings []WAFBinding `json:"bindings"`
|
Bindings []WAFBinding `json:"bindings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { WAFIPGroupsPage } from '@/features/waf/components/ip-groups-page';
|
||||||
|
|
||||||
|
export default function WAFIPGroupsRoute() {
|
||||||
|
return <WAFIPGroupsPage />;
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import { apiRequest } from '@/lib/api/client';
|
|||||||
import type {
|
import type {
|
||||||
WAFRuleGroup,
|
WAFRuleGroup,
|
||||||
WAFRuleGroupPayload,
|
WAFRuleGroupPayload,
|
||||||
|
WAFIPGroup,
|
||||||
|
WAFIPGroupPayload,
|
||||||
|
WAFIPGroupSyncResult,
|
||||||
WAFSiteRuleGroups,
|
WAFSiteRuleGroups,
|
||||||
} from '@/features/waf/types';
|
} from '@/features/waf/types';
|
||||||
|
|
||||||
@@ -47,3 +50,37 @@ export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
|
|||||||
body: JSON.stringify({ ids }),
|
body: JSON.stringify({ ids }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWAFIPGroups() {
|
||||||
|
return apiRequest<WAFIPGroup[]>('/waf/ip-groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWAFIPGroup(id: number) {
|
||||||
|
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWAFIPGroup(payload: WAFIPGroupPayload) {
|
||||||
|
return apiRequest<WAFIPGroup>('/waf/ip-groups', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWAFIPGroup(id: number, payload: WAFIPGroupPayload) {
|
||||||
|
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWAFIPGroup(id: number) {
|
||||||
|
return apiRequest<void>(`/waf/ip-groups/${id}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncWAFIPGroup(id: number) {
|
||||||
|
return apiRequest<WAFIPGroupSyncResult>(`/waf/ip-groups/${id}/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const emptyDraft: WAFRuleGroupPayload = {
|
|||||||
block_response_body: '',
|
block_response_body: '',
|
||||||
ip_whitelist: [],
|
ip_whitelist: [],
|
||||||
ip_blacklist: [],
|
ip_blacklist: [],
|
||||||
|
ip_whitelist_group_ids: [],
|
||||||
|
ip_blacklist_group_ids: [],
|
||||||
country_whitelist: [],
|
country_whitelist: [],
|
||||||
country_blacklist: [],
|
country_blacklist: [],
|
||||||
region_whitelist: [],
|
region_whitelist: [],
|
||||||
@@ -52,6 +54,7 @@ export const defaultRuleModalState: RuleModalState = {
|
|||||||
listType: 'whitelist',
|
listType: 'whitelist',
|
||||||
dimension: 'ip',
|
dimension: 'ip',
|
||||||
ipValue: '',
|
ipValue: '',
|
||||||
|
ipGroupIDs: [],
|
||||||
countryValues: [],
|
countryValues: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +107,8 @@ export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
|||||||
block_response_body: group.block_response_body ?? '',
|
block_response_body: group.block_response_body ?? '',
|
||||||
ip_whitelist: group.ip_whitelist ?? [],
|
ip_whitelist: group.ip_whitelist ?? [],
|
||||||
ip_blacklist: group.ip_blacklist ?? [],
|
ip_blacklist: group.ip_blacklist ?? [],
|
||||||
|
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
|
||||||
|
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
|
||||||
country_whitelist: group.country_whitelist ?? [],
|
country_whitelist: group.country_whitelist ?? [],
|
||||||
country_blacklist: group.country_blacklist ?? [],
|
country_blacklist: group.country_blacklist ?? [],
|
||||||
region_whitelist: group.region_whitelist ?? [],
|
region_whitelist: group.region_whitelist ?? [],
|
||||||
@@ -118,6 +123,8 @@ export function countRuleEntries(group: RuleListRenderable) {
|
|||||||
return (
|
return (
|
||||||
group.ip_whitelist.length +
|
group.ip_whitelist.length +
|
||||||
group.ip_blacklist.length +
|
group.ip_blacklist.length +
|
||||||
|
group.ip_whitelist_group_ids.length +
|
||||||
|
group.ip_blacklist_group_ids.length +
|
||||||
group.country_whitelist.length +
|
group.country_whitelist.length +
|
||||||
group.country_blacklist.length +
|
group.country_blacklist.length +
|
||||||
group.region_whitelist.length +
|
group.region_whitelist.length +
|
||||||
@@ -166,6 +173,11 @@ export function getListFieldKey(
|
|||||||
if (dimension === 'ip') {
|
if (dimension === 'ip') {
|
||||||
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
||||||
}
|
}
|
||||||
|
if (dimension === 'ip_group') {
|
||||||
|
return listType === 'whitelist'
|
||||||
|
? 'ip_whitelist_group_ids'
|
||||||
|
: 'ip_blacklist_group_ids';
|
||||||
|
}
|
||||||
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +191,20 @@ export function updateDraftList(
|
|||||||
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
||||||
case 'ip_blacklist':
|
case 'ip_blacklist':
|
||||||
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
||||||
|
case 'ip_whitelist_group_ids':
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
ip_whitelist_group_ids: updater(
|
||||||
|
draft.ip_whitelist_group_ids.map(String),
|
||||||
|
).map(Number),
|
||||||
|
};
|
||||||
|
case 'ip_blacklist_group_ids':
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
ip_blacklist_group_ids: updater(
|
||||||
|
draft.ip_blacklist_group_ids.map(String),
|
||||||
|
).map(Number),
|
||||||
|
};
|
||||||
case 'country_whitelist':
|
case 'country_whitelist':
|
||||||
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
||||||
case 'country_blacklist':
|
case 'country_blacklist':
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Download, Plus, Save, Trash2 } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import {
|
||||||
|
createWAFIPGroup,
|
||||||
|
deleteWAFIPGroup,
|
||||||
|
getWAFIPGroups,
|
||||||
|
syncWAFIPGroup,
|
||||||
|
updateWAFIPGroup,
|
||||||
|
} from '@/features/waf/api/waf';
|
||||||
|
import type {
|
||||||
|
WAFIPGroup,
|
||||||
|
WAFIPGroupPayload,
|
||||||
|
WAFIPGroupSubscriptionFormat,
|
||||||
|
WAFIPGroupType,
|
||||||
|
} from '@/features/waf/types';
|
||||||
|
import {
|
||||||
|
DangerButton,
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
ResourceSelect,
|
||||||
|
ResourceTextarea,
|
||||||
|
SecondaryButton,
|
||||||
|
ToggleField,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
import { getErrorMessage, listToText, parseTextareaList } from './helpers';
|
||||||
|
|
||||||
|
type FeedbackState = {
|
||||||
|
tone: 'success' | 'danger' | 'info';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IPGroupDraft = WAFIPGroupPayload & {
|
||||||
|
ip_list_text: string;
|
||||||
|
auto_config_text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyIPGroupDraft: IPGroupDraft = {
|
||||||
|
name: '',
|
||||||
|
type: 'manual',
|
||||||
|
enabled: true,
|
||||||
|
ip_list: [],
|
||||||
|
ip_list_text: '',
|
||||||
|
auto_config: {},
|
||||||
|
auto_config_text: '{}',
|
||||||
|
subscription_url: '',
|
||||||
|
subscription_format: 'text',
|
||||||
|
subscription_mapping_rule: '',
|
||||||
|
sync_interval_minutes: 1440,
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<WAFIPGroupType, string> = {
|
||||||
|
manual: '手动',
|
||||||
|
automatic: '自动',
|
||||||
|
subscription: '订阅',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDraft(group: WAFIPGroup | null): IPGroupDraft {
|
||||||
|
if (!group) {
|
||||||
|
return { ...emptyIPGroupDraft };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
type: group.type,
|
||||||
|
enabled: group.enabled,
|
||||||
|
ip_list: group.ip_list ?? [],
|
||||||
|
ip_list_text: listToText(group.ip_list),
|
||||||
|
auto_config: group.auto_config ?? {},
|
||||||
|
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
|
||||||
|
subscription_url: group.subscription_url ?? '',
|
||||||
|
subscription_format: group.subscription_format ?? 'text',
|
||||||
|
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||||
|
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||||
|
remark: group.remark ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload {
|
||||||
|
let autoConfig: Record<string, unknown> = {};
|
||||||
|
if (draft.type === 'automatic') {
|
||||||
|
const parsed = JSON.parse(draft.auto_config_text || '{}') as unknown;
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||||
|
throw new Error('自动配置必须是 JSON 对象。');
|
||||||
|
}
|
||||||
|
autoConfig = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: draft.name,
|
||||||
|
type: draft.type,
|
||||||
|
enabled: draft.enabled,
|
||||||
|
ip_list: parseTextareaList(draft.ip_list_text),
|
||||||
|
auto_config: autoConfig,
|
||||||
|
subscription_url: draft.subscription_url,
|
||||||
|
subscription_format: draft.subscription_format,
|
||||||
|
subscription_mapping_rule: draft.subscription_mapping_rule,
|
||||||
|
sync_interval_minutes: draft.sync_interval_minutes,
|
||||||
|
remark: draft.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WAFIPGroupsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||||
|
const [draft, setDraft] = useState<IPGroupDraft>(emptyIPGroupDraft);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
|
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ['waf', 'ip-groups'],
|
||||||
|
queryFn: getWAFIPGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||||
|
const selectedGroup = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedID === 0
|
||||||
|
? null
|
||||||
|
: (groups.find((group) => group.id === selectedID) ??
|
||||||
|
groups[0] ??
|
||||||
|
null),
|
||||||
|
[groups, selectedID],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
setSelectedID(selectedGroup.id);
|
||||||
|
setDraft(buildDraft(selectedGroup));
|
||||||
|
}
|
||||||
|
}, [selectedGroup]);
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (payload: WAFIPGroupPayload) => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
return updateWAFIPGroup(selectedGroup.id, payload);
|
||||||
|
}
|
||||||
|
return createWAFIPGroup(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async (group) => {
|
||||||
|
setSelectedID(group.id);
|
||||||
|
setFeedback({ tone: 'success', message: 'IP 组已保存。' });
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteWAFIPGroup,
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedID(null);
|
||||||
|
setFeedback({ tone: 'success', message: 'IP 组已删除。' });
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: syncWAFIPGroup,
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
setSelectedID(result.group.id);
|
||||||
|
setFeedback({ tone: 'success', message: result.message });
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupsQuery.isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
if (groupsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="IP 组加载失败"
|
||||||
|
description={getErrorMessage(groupsQuery.error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDraft = () => {
|
||||||
|
try {
|
||||||
|
saveMutation.mutate(buildPayload(draft));
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="IP 组"
|
||||||
|
description="维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。"
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<SecondaryButton type="button" onClick={() => router.push('/waf')}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
返回 WAF
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedID(0);
|
||||||
|
setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建 IP 组
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<AppCard title="IP 组列表">
|
||||||
|
{groups.length === 0 && selectedID !== 0 ? (
|
||||||
|
<EmptyState title="暂无 IP 组" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedID(group.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-2xl border px-4 py-3 text-left transition',
|
||||||
|
selectedGroup?.id === group.id
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{typeLabels[group.type]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{group.enabled ? '启用' : '停用'} · {group.ip_list.length}{' '}
|
||||||
|
条 · 被引用 {group.referenced_by_rule_count} 次
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title={selectedGroup ? selectedGroup.name : '新建 IP 组'}
|
||||||
|
description={
|
||||||
|
draft.type === 'automatic'
|
||||||
|
? '自动 IP 组第一版仅保存配置,暂不执行日志挖掘。'
|
||||||
|
: '保存后可在 WAF 规则组黑白名单中引用。'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{selectedGroup?.type === 'subscription' ? (
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={syncMutation.isPending}
|
||||||
|
onClick={() => syncMutation.mutate(selectedGroup.id)}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{syncMutation.isPending ? '同步中...' : '立即同步'}
|
||||||
|
</SecondaryButton>
|
||||||
|
) : null}
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={saveDraft}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{saveMutation.isPending ? '保存中...' : '保存 IP 组'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<ResourceField label="IP 组名称">
|
||||||
|
<ResourceInput
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="类型">
|
||||||
|
<ResourceSelect
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
type: event.target.value as WAFIPGroupType,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="manual">手动</option>
|
||||||
|
<option value="automatic">自动</option>
|
||||||
|
<option value="subscription">订阅</option>
|
||||||
|
</ResourceSelect>
|
||||||
|
</ResourceField>
|
||||||
|
<ToggleField
|
||||||
|
label="启用 IP 组"
|
||||||
|
description="关闭后保留配置,但发布时不会展开到 WAF 运行时名单。"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ResourceField label="备注">
|
||||||
|
<ResourceInput
|
||||||
|
value={draft.remark}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
remark: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.type === 'subscription' ? (
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<ResourceField label="订阅 URL">
|
||||||
|
<ResourceInput
|
||||||
|
value={draft.subscription_url}
|
||||||
|
placeholder="https://example.com/ip-list.txt"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
subscription_url: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="订阅格式">
|
||||||
|
<ResourceSelect
|
||||||
|
value={draft.subscription_format}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
subscription_format: event.target
|
||||||
|
.value as WAFIPGroupSubscriptionFormat,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="text">文本列表</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</ResourceSelect>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField
|
||||||
|
label="同步间隔(分钟)"
|
||||||
|
hint="最小 5 分钟,默认 1440 分钟。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
value={draft.sync_interval_minutes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
sync_interval_minutes: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField
|
||||||
|
label="JSON 映射规则"
|
||||||
|
hint="留空表示根数组;示例:data.items[]。文本格式无需填写。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={draft.subscription_mapping_rule}
|
||||||
|
disabled={draft.subscription_format !== 'json'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
subscription_mapping_rule: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft.type === 'automatic' ? (
|
||||||
|
<ResourceField
|
||||||
|
label="自动配置 JSON"
|
||||||
|
hint="当前版本只保存配置,不会执行请求日志挖掘。"
|
||||||
|
>
|
||||||
|
<ResourceTextarea
|
||||||
|
value={draft.auto_config_text}
|
||||||
|
className="min-h-64 font-mono"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
auto_config_text: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
) : (
|
||||||
|
<ResourceField
|
||||||
|
label="IP / IP 段"
|
||||||
|
hint={
|
||||||
|
draft.type === 'subscription'
|
||||||
|
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
|
||||||
|
: '支持单个 IP 或 CIDR,每行一个。'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceTextarea
|
||||||
|
value={draft.ip_list_text}
|
||||||
|
className="min-h-72 font-mono"
|
||||||
|
placeholder={'203.0.113.10\n198.51.100.0/24'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
ip_list_text: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedGroup ? (
|
||||||
|
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
|
||||||
|
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
{selectedGroup.last_sync_status
|
||||||
|
? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}`
|
||||||
|
: '尚无同步记录'}
|
||||||
|
</div>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`确认删除 IP 组 ${selectedGroup.name} 吗?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteMutation.mutate(selectedGroup.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
删除
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
|
import type { WAFIPGroup } from '@/features/waf/types';
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
@@ -9,11 +10,17 @@ import {
|
|||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { normalizeItems } from './helpers';
|
import { normalizeItems } from './helpers';
|
||||||
import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types';
|
import type {
|
||||||
|
CountryOption,
|
||||||
|
RuleListType,
|
||||||
|
RuleDimension,
|
||||||
|
RuleModalState,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
export function RuleEntryModal({
|
export function RuleEntryModal({
|
||||||
state,
|
state,
|
||||||
countryOptions,
|
countryOptions,
|
||||||
|
ipGroups,
|
||||||
pending,
|
pending,
|
||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -21,6 +28,7 @@ export function RuleEntryModal({
|
|||||||
}: {
|
}: {
|
||||||
state: RuleModalState;
|
state: RuleModalState;
|
||||||
countryOptions: CountryOption[];
|
countryOptions: CountryOption[];
|
||||||
|
ipGroups: WAFIPGroup[];
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange: (patch: Partial<RuleModalState>) => void;
|
onChange: (patch: Partial<RuleModalState>) => void;
|
||||||
@@ -39,6 +47,10 @@ export function RuleEntryModal({
|
|||||||
() => new Set(state.countryValues),
|
() => new Set(state.countryValues),
|
||||||
[state.countryValues],
|
[state.countryValues],
|
||||||
);
|
);
|
||||||
|
const selectedIPGroupSet = useMemo(
|
||||||
|
() => new Set(state.ipGroupIDs),
|
||||||
|
[state.ipGroupIDs],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredCountries = useMemo(() => {
|
const filteredCountries = useMemo(() => {
|
||||||
const normalized = keyword.trim().toLowerCase();
|
const normalized = keyword.trim().toLowerCase();
|
||||||
@@ -71,9 +83,20 @@ export function RuleEntryModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearCountries = () => onChange({ countryValues: [] });
|
const clearCountries = () => onChange({ countryValues: [] });
|
||||||
|
const toggleIPGroup = (id: number) => {
|
||||||
|
const values = selectedIPGroupSet.has(id)
|
||||||
|
? state.ipGroupIDs.filter((item) => item !== id)
|
||||||
|
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
|
||||||
|
onChange({ ipGroupIDs: values });
|
||||||
|
};
|
||||||
|
|
||||||
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
||||||
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
|
const dimensionLabel =
|
||||||
|
state.dimension === 'ip'
|
||||||
|
? 'IP'
|
||||||
|
: state.dimension === 'ip_group'
|
||||||
|
? 'IP 组'
|
||||||
|
: '地域';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppModal
|
<AppModal
|
||||||
@@ -120,9 +143,10 @@ export function RuleEntryModal({
|
|||||||
</div>
|
</div>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField label="维度" container="div">
|
<ResourceField label="维度" container="div">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: 'ip', label: 'IP' },
|
{ value: 'ip', label: 'IP' },
|
||||||
|
{ value: 'ip_group', label: 'IP 组' },
|
||||||
{ value: 'country', label: '地域' },
|
{ value: 'country', label: '地域' },
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<button
|
<button
|
||||||
@@ -156,6 +180,60 @@ export function RuleEntryModal({
|
|||||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
) : state.dimension === 'ip_group' ? (
|
||||||
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
选择 IP 组
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
|
被引用的 IP 组会在发布配置时展开到 WAF 运行时名单。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||||
|
已选 {state.ipGroupIDs.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{ipGroups.length > 0 ? (
|
||||||
|
ipGroups.map((group) => {
|
||||||
|
const selected = selectedIPGroupSet.has(group.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||||
|
selected
|
||||||
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||||
|
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleIPGroup(group.id)}
|
||||||
|
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{group.type} · {group.ip_list.length} 条 ·{' '}
|
||||||
|
{group.enabled ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--foreground-muted)]">
|
||||||
|
暂无 IP 组,请先进入 IP 组管理页面创建。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ export function RuleListSection({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
items,
|
items,
|
||||||
|
groupItems = [],
|
||||||
tone,
|
tone,
|
||||||
emptyText,
|
emptyText,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onRemoveGroup,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
items: string[];
|
items: string[];
|
||||||
|
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
|
||||||
tone: 'whitelist' | 'blacklist';
|
tone: 'whitelist' | 'blacklist';
|
||||||
emptyText: string;
|
emptyText: string;
|
||||||
onRemove: (item: string) => void;
|
onRemove: (item: string) => void;
|
||||||
|
onRemoveGroup?: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const total = items.length + groupItems.length;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -27,12 +32,20 @@ export function RuleListSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
|
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
|
||||||
{items.length}
|
{total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{items.length > 0 ? (
|
{total > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{groupItems.map((group) => (
|
||||||
|
<RuleChip
|
||||||
|
key={`group-${group.id}`}
|
||||||
|
label={`IP组: ${group.name}${group.enabled ? '' : ' (停用)'}`}
|
||||||
|
tone={tone}
|
||||||
|
onRemove={() => onRemoveGroup?.(group.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<RuleChip
|
<RuleChip
|
||||||
key={item}
|
key={item}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export type FeedbackState = {
|
|||||||
|
|
||||||
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||||
export type RuleListType = 'whitelist' | 'blacklist';
|
export type RuleListType = 'whitelist' | 'blacklist';
|
||||||
export type RuleDimension = 'ip' | 'country';
|
export type RuleDimension = 'ip' | 'ip_group' | 'country';
|
||||||
export type ListFieldKey =
|
export type ListFieldKey =
|
||||||
| 'ip_whitelist'
|
| 'ip_whitelist'
|
||||||
| 'ip_blacklist'
|
| 'ip_blacklist'
|
||||||
|
| 'ip_whitelist_group_ids'
|
||||||
|
| 'ip_blacklist_group_ids'
|
||||||
| 'country_whitelist'
|
| 'country_whitelist'
|
||||||
| 'country_blacklist';
|
| 'country_blacklist';
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export type RuleModalState = {
|
|||||||
listType: RuleListType;
|
listType: RuleListType;
|
||||||
dimension: RuleDimension;
|
dimension: RuleDimension;
|
||||||
ipValue: string;
|
ipValue: string;
|
||||||
|
ipGroupIDs: number[];
|
||||||
countryValues: string[];
|
countryValues: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ export type RuleListRenderable = Pick<
|
|||||||
WAFRuleGroupPayload,
|
WAFRuleGroupPayload,
|
||||||
| 'ip_whitelist'
|
| 'ip_whitelist'
|
||||||
| 'ip_blacklist'
|
| 'ip_blacklist'
|
||||||
|
| 'ip_whitelist_group_ids'
|
||||||
|
| 'ip_blacklist_group_ids'
|
||||||
| 'country_whitelist'
|
| 'country_whitelist'
|
||||||
| 'country_blacklist'
|
| 'country_blacklist'
|
||||||
| 'region_whitelist'
|
| 'region_whitelist'
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { useRouter } from 'next/navigation';
|
||||||
Globe2,
|
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
Plus,
|
|
||||||
Save,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
@@ -30,11 +25,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
createWAFRuleGroup,
|
createWAFRuleGroup,
|
||||||
deleteWAFRuleGroup,
|
deleteWAFRuleGroup,
|
||||||
|
getWAFIPGroups,
|
||||||
getWAFRuleGroups,
|
getWAFRuleGroups,
|
||||||
replaceWAFRuleGroupSites,
|
replaceWAFRuleGroupSites,
|
||||||
updateWAFRuleGroup,
|
updateWAFRuleGroup,
|
||||||
} from '@/features/waf/api/waf';
|
} from '@/features/waf/api/waf';
|
||||||
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
import type {
|
||||||
|
WAFIPGroup,
|
||||||
|
WAFRuleGroup,
|
||||||
|
WAFRuleGroupPayload,
|
||||||
|
} from '@/features/waf/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
import { RuleEntryModal } from './rule-entry-modal';
|
import { RuleEntryModal } from './rule-entry-modal';
|
||||||
@@ -56,9 +56,15 @@ import {
|
|||||||
textToList,
|
textToList,
|
||||||
updateDraftList,
|
updateDraftList,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types';
|
import type {
|
||||||
|
FeedbackState,
|
||||||
|
ListFieldKey,
|
||||||
|
RuleModalState,
|
||||||
|
WAFTab,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
export function WAFPage() {
|
export function WAFPage() {
|
||||||
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
|
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
|
||||||
@@ -73,12 +79,20 @@ export function WAFPage() {
|
|||||||
queryKey: ['waf', 'rule-groups'],
|
queryKey: ['waf', 'rule-groups'],
|
||||||
queryFn: getWAFRuleGroups,
|
queryFn: getWAFRuleGroups,
|
||||||
});
|
});
|
||||||
|
const ipGroupsQuery = useQuery({
|
||||||
|
queryKey: ['waf', 'ip-groups'],
|
||||||
|
queryFn: getWAFIPGroups,
|
||||||
|
});
|
||||||
const routesQuery = useQuery({
|
const routesQuery = useQuery({
|
||||||
queryKey: ['proxy-routes'],
|
queryKey: ['proxy-routes'],
|
||||||
queryFn: getProxyRoutes,
|
queryFn: getProxyRoutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||||
|
const ipGroups = useMemo(
|
||||||
|
() => ipGroupsQuery.data ?? [],
|
||||||
|
[ipGroupsQuery.data],
|
||||||
|
);
|
||||||
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||||
const countryLabelMap = useMemo(
|
const countryLabelMap = useMemo(
|
||||||
@@ -106,6 +120,7 @@ export function WAFPage() {
|
|||||||
const invalidate = async () => {
|
const invalidate = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -152,7 +167,11 @@ export function WAFPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
if (
|
||||||
|
groupsQuery.isLoading ||
|
||||||
|
routesQuery.isLoading ||
|
||||||
|
ipGroupsQuery.isLoading
|
||||||
|
) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
if (groupsQuery.isError) {
|
if (groupsQuery.isError) {
|
||||||
@@ -171,6 +190,14 @@ export function WAFPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (ipGroupsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="IP 组加载失败"
|
||||||
|
description={getErrorMessage(ipGroupsQuery.error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!selectedGroup && groups.length === 0) {
|
if (!selectedGroup && groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -189,6 +216,13 @@ export function WAFPage() {
|
|||||||
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
|
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
|
||||||
)
|
)
|
||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
const ipGroupByID = new Map(ipGroups.map((group) => [group.id, group]));
|
||||||
|
const whitelistGroupItems = draft.ip_whitelist_group_ids
|
||||||
|
.map((id) => ipGroupByID.get(id))
|
||||||
|
.filter((group): group is WAFIPGroup => Boolean(group));
|
||||||
|
const blacklistGroupItems = draft.ip_blacklist_group_ids
|
||||||
|
.map((id) => ipGroupByID.get(id))
|
||||||
|
.filter((group): group is WAFIPGroup => Boolean(group));
|
||||||
|
|
||||||
const openRuleModal = () => {
|
const openRuleModal = () => {
|
||||||
setRuleModal({ ...defaultRuleModalState, open: true });
|
setRuleModal({ ...defaultRuleModalState, open: true });
|
||||||
@@ -202,7 +236,9 @@ export function WAFPage() {
|
|||||||
const values =
|
const values =
|
||||||
ruleModal.dimension === 'ip'
|
ruleModal.dimension === 'ip'
|
||||||
? textToList(ruleModal.ipValue)
|
? textToList(ruleModal.ipValue)
|
||||||
: normalizeItems(ruleModal.countryValues);
|
: ruleModal.dimension === 'ip_group'
|
||||||
|
? ruleModal.ipGroupIDs.map(String)
|
||||||
|
: normalizeItems(ruleModal.countryValues);
|
||||||
|
|
||||||
if (values.length === 0) {
|
if (values.length === 0) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
@@ -210,7 +246,9 @@ export function WAFPage() {
|
|||||||
message:
|
message:
|
||||||
ruleModal.dimension === 'ip'
|
ruleModal.dimension === 'ip'
|
||||||
? '请先输入 IP 或 IP 段。'
|
? '请先输入 IP 或 IP 段。'
|
||||||
: '请先选择地域。',
|
: ruleModal.dimension === 'ip_group'
|
||||||
|
? '请先选择 IP 组。'
|
||||||
|
: '请先选择地域。',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,6 +274,13 @@ export function WAFPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const removeRuleGroup = (key: ListFieldKey, id: number) => {
|
||||||
|
setDraft((current) =>
|
||||||
|
updateDraftList(current, key, (items) =>
|
||||||
|
items.filter((item) => item !== String(id)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const overviewItems: Array<{ label: string; value: ReactNode }> = [
|
const overviewItems: Array<{ label: string; value: ReactNode }> = [
|
||||||
{
|
{
|
||||||
@@ -271,17 +316,26 @@ export function WAFPage() {
|
|||||||
title="WAF"
|
title="WAF"
|
||||||
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
|
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
|
||||||
action={
|
action={
|
||||||
<PrimaryButton
|
<div className="flex flex-wrap gap-3">
|
||||||
type="button"
|
<SecondaryButton
|
||||||
onClick={() => {
|
type="button"
|
||||||
setSelectedID(0);
|
onClick={() => router.push('/waf/ip-groups')}
|
||||||
setActiveTab('basic');
|
>
|
||||||
setDraft({ ...emptyDraft, name: '自定义规则组' });
|
<Network className="mr-2 h-4 w-4" />
|
||||||
}}
|
管理 IP 组
|
||||||
>
|
</SecondaryButton>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<PrimaryButton
|
||||||
新建规则组
|
type="button"
|
||||||
</PrimaryButton>
|
onClick={() => {
|
||||||
|
setSelectedID(0);
|
||||||
|
setActiveTab('basic');
|
||||||
|
setDraft({ ...emptyDraft, name: '自定义规则组' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建规则组
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -487,17 +541,25 @@ export function WAFPage() {
|
|||||||
title="IP 白名单"
|
title="IP 白名单"
|
||||||
description="命中后直接放行,不再继续判断黑名单。"
|
description="命中后直接放行,不再继续判断黑名单。"
|
||||||
items={draft.ip_whitelist}
|
items={draft.ip_whitelist}
|
||||||
|
groupItems={whitelistGroupItems}
|
||||||
tone="whitelist"
|
tone="whitelist"
|
||||||
emptyText="暂无 IP 白名单规则。"
|
emptyText="暂无 IP 白名单规则。"
|
||||||
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
|
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
|
||||||
|
onRemoveGroup={(id) =>
|
||||||
|
removeRuleGroup('ip_whitelist_group_ids', id)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<RuleListSection
|
<RuleListSection
|
||||||
title="IP 黑名单"
|
title="IP 黑名单"
|
||||||
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
|
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
|
||||||
items={draft.ip_blacklist}
|
items={draft.ip_blacklist}
|
||||||
|
groupItems={blacklistGroupItems}
|
||||||
tone="blacklist"
|
tone="blacklist"
|
||||||
emptyText="暂无 IP 黑名单规则。"
|
emptyText="暂无 IP 黑名单规则。"
|
||||||
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
|
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
|
||||||
|
onRemoveGroup={(id) =>
|
||||||
|
removeRuleGroup('ip_blacklist_group_ids', id)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<RuleListSection
|
<RuleListSection
|
||||||
title="地域白名单"
|
title="地域白名单"
|
||||||
@@ -646,6 +708,7 @@ export function WAFPage() {
|
|||||||
<RuleEntryModal
|
<RuleEntryModal
|
||||||
state={ruleModal}
|
state={ruleModal}
|
||||||
countryOptions={countryOptions}
|
countryOptions={countryOptions}
|
||||||
|
ipGroups={ipGroups}
|
||||||
pending={saveMutation.isPending}
|
pending={saveMutation.isPending}
|
||||||
onClose={closeRuleModal}
|
onClose={closeRuleModal}
|
||||||
onChange={(patch) =>
|
onChange={(patch) =>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface WAFRuleGroup {
|
|||||||
block_response_body: string;
|
block_response_body: string;
|
||||||
ip_whitelist: string[];
|
ip_whitelist: string[];
|
||||||
ip_blacklist: string[];
|
ip_blacklist: string[];
|
||||||
|
ip_whitelist_group_ids: number[];
|
||||||
|
ip_blacklist_group_ids: number[];
|
||||||
country_whitelist: string[];
|
country_whitelist: string[];
|
||||||
country_blacklist: string[];
|
country_blacklist: string[];
|
||||||
region_whitelist: string[];
|
region_whitelist: string[];
|
||||||
@@ -29,6 +31,8 @@ export interface WAFRuleGroupPayload {
|
|||||||
block_response_body: string;
|
block_response_body: string;
|
||||||
ip_whitelist: string[];
|
ip_whitelist: string[];
|
||||||
ip_blacklist: string[];
|
ip_blacklist: string[];
|
||||||
|
ip_whitelist_group_ids: number[];
|
||||||
|
ip_blacklist_group_ids: number[];
|
||||||
country_whitelist: string[];
|
country_whitelist: string[];
|
||||||
country_blacklist: string[];
|
country_blacklist: string[];
|
||||||
region_whitelist: string[];
|
region_whitelist: string[];
|
||||||
@@ -45,3 +49,49 @@ export interface WAFSiteRuleGroups {
|
|||||||
applied_rule_groups: WAFRuleGroup[];
|
applied_rule_groups: WAFRuleGroup[];
|
||||||
applied_ids: number[];
|
applied_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription';
|
||||||
|
export type WAFIPGroupSubscriptionFormat = 'text' | 'json';
|
||||||
|
|
||||||
|
export interface WAFIPGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: WAFIPGroupType;
|
||||||
|
enabled: boolean;
|
||||||
|
ip_list: string[];
|
||||||
|
auto_config: Record<string, unknown>;
|
||||||
|
subscription_url: string;
|
||||||
|
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||||
|
subscription_mapping_rule: string;
|
||||||
|
sync_interval_minutes: number;
|
||||||
|
last_synced_at?: string;
|
||||||
|
next_sync_at?: string;
|
||||||
|
last_sync_status: string;
|
||||||
|
last_sync_message: string;
|
||||||
|
remark: string;
|
||||||
|
referenced_by_rule_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WAFIPGroupPayload {
|
||||||
|
name: string;
|
||||||
|
type: WAFIPGroupType;
|
||||||
|
enabled: boolean;
|
||||||
|
ip_list: string[];
|
||||||
|
auto_config: Record<string, unknown>;
|
||||||
|
subscription_url: string;
|
||||||
|
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||||
|
subscription_mapping_rule: string;
|
||||||
|
sync_interval_minutes: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WAFIPGroupSyncResult {
|
||||||
|
group: WAFIPGroup;
|
||||||
|
ip_count: number;
|
||||||
|
synced_at: string;
|
||||||
|
next_sync_at: string;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||||
|
import { WAFIPGroupsPage } from '@/features/waf/components/ip-groups-page';
|
||||||
|
import { WAFPage } from '@/features/waf/components/waf-page';
|
||||||
|
|
||||||
|
const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: pushMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderWithProviders(ui: ReactNode) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>{ui}</ThemeProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubMatchMedia() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'matchMedia',
|
||||||
|
vi.fn().mockImplementation(() => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIPGroup(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 3,
|
||||||
|
name: 'edge blacklist',
|
||||||
|
type: 'manual',
|
||||||
|
enabled: true,
|
||||||
|
ip_list: ['203.0.113.10'],
|
||||||
|
auto_config: {},
|
||||||
|
subscription_url: '',
|
||||||
|
subscription_format: 'text',
|
||||||
|
subscription_mapping_rule: '',
|
||||||
|
sync_interval_minutes: 1440,
|
||||||
|
last_sync_status: '',
|
||||||
|
last_sync_message: '',
|
||||||
|
remark: '',
|
||||||
|
referenced_by_rule_count: 0,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuleGroup(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: '全局规则组',
|
||||||
|
enabled: true,
|
||||||
|
is_global: true,
|
||||||
|
block_status_code: 418,
|
||||||
|
block_response_body: '',
|
||||||
|
ip_whitelist: [],
|
||||||
|
ip_blacklist: [],
|
||||||
|
ip_whitelist_group_ids: [],
|
||||||
|
ip_blacklist_group_ids: [],
|
||||||
|
country_whitelist: [],
|
||||||
|
country_blacklist: [],
|
||||||
|
region_whitelist: [],
|
||||||
|
region_blacklist: [],
|
||||||
|
pow_enabled: false,
|
||||||
|
pow_config: {
|
||||||
|
difficulty: 4,
|
||||||
|
algorithm: 'fast',
|
||||||
|
session_ttl: 600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
whitelist: {
|
||||||
|
ips: [],
|
||||||
|
ip_cidrs: [],
|
||||||
|
paths: [],
|
||||||
|
path_regexes: [],
|
||||||
|
user_agents: [],
|
||||||
|
},
|
||||||
|
blacklist: {
|
||||||
|
ips: [],
|
||||||
|
ip_cidrs: [],
|
||||||
|
paths: [],
|
||||||
|
path_regexes: [],
|
||||||
|
user_agents: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remark: '',
|
||||||
|
applied_site_ids: [],
|
||||||
|
applied_site_count: 0,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WAF IP groups', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pushMock.mockReset();
|
||||||
|
stubMatchMedia();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state and saves a manual IP group', async () => {
|
||||||
|
let groups: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||||
|
|
||||||
|
if (url.includes('/waf/ip-groups') && method === 'POST') {
|
||||||
|
const payload = JSON.parse(String(init?.body));
|
||||||
|
const created = buildIPGroup({
|
||||||
|
id: 7,
|
||||||
|
name: payload.name,
|
||||||
|
ip_list: payload.ip_list,
|
||||||
|
});
|
||||||
|
groups = [created];
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ success: true, message: '', data: created }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/waf/ip-groups')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ success: true, message: '', data: groups }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<WAFIPGroupsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('暂无 IP 组')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /新建 IP 组/ }));
|
||||||
|
await userEvent.clear(screen.getByLabelText('IP 组名称'));
|
||||||
|
await userEvent.type(screen.getByLabelText('IP 组名称'), 'blocked edge');
|
||||||
|
await userEvent.type(
|
||||||
|
screen.getByPlaceholderText(/203\.0\.113\.10/),
|
||||||
|
'203.0.113.10',
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /保存 IP 组/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('IP 组已保存。')).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(groups).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens IP group management from WAF page and references an IP group', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||||
|
|
||||||
|
if (url.includes('/waf/rule-groups/1/update') && method === 'POST') {
|
||||||
|
const payload = JSON.parse(String(init?.body));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: buildRuleGroup({
|
||||||
|
ip_blacklist_group_ids: payload.ip_blacklist_group_ids,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/waf/rule-groups')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: [buildRuleGroup()],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/waf/ip-groups')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: [buildIPGroup()],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/proxy-routes/')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ success: true, message: '', data: [] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<WAFPage />);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole('button', { name: /管理 IP 组/ }),
|
||||||
|
);
|
||||||
|
expect(pushMock).toHaveBeenCalledWith('/waf/ip-groups');
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /黑白名单/ }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /添加/ }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'IP 组' }));
|
||||||
|
await userEvent.click(await screen.findByLabelText(/edge blacklist/));
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: '添加' });
|
||||||
|
await userEvent.click(addButtons[addButtons.length - 1]);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/IP组: edge blacklist/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user