[优化] 添加 WAF IP 组功能,包括 CRUD 接口和前端页面支持

This commit is contained in:
ryan
2026-06-01 08:46:24 +08:00
parent d5f46138d5
commit 95d7045b4a
32 changed files with 2214 additions and 44 deletions
+3
View File
@@ -165,6 +165,8 @@ Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活版本携带的 `waf_config.json`,全局规则组默认生效,网站可叠加自定义规则组。
WAF IP 组由 Server 管理并在发布时展开到 `waf_config.json`。手动 IP 组直接保存 IP/IP 段列表;自动 IP 组当前只保存配置;订阅 IP 组由 Server 定时任务同步远程文本或 JSON 源。OpenResty Lua 只读取 Agent 落地的运行时 JSON,不直接访问 Server 数据库或远程订阅源。
## 核心对象
当前有效实体包括:
@@ -186,6 +188,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
* `traffic_analytics_rollups`
* `node_health_events`
* `waf_rule_groups`
* `waf_ip_groups`
* `waf_rule_group_bindings`
* `acme_accounts`
* `dns_accounts`
+9 -1
View File
@@ -26,7 +26,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| Agent 同步 | 支持注册、心跳、同步、应用结果上报与自更新 |
| OpenResty 托管 | 管理主配置模板、性能参数、缓存参数与 Lua 资源 |
| HTTPS/TLS | 托管证书与域名资产,并按域名绑定证书 |
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、国家级地域黑白名单 |
| WAF | 以全局规则组与网站自定义规则组维护 IP/IP 段、IP 组、国家级地域黑白名单 |
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
| 管理端前端 | 基于 Next.js 的正式管理端 |
@@ -132,9 +132,17 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
一期支持:
* IP / IP 段白名单与黑名单。
* IP 组引用,支持手动、自动、订阅三类 IP 组。
* 国家级地域白名单与黑名单。
* 规则组级拦截状态码与响应页面,默认 `418` 与空页面。
IP 组约束:
* 手动 IP 组由管理端直接维护 IP/IP 段列表。
* 自动 IP 组当前只保存结构化配置,不执行请求日志挖掘。
* 订阅 IP 组由 Server 定时从 HTTP/HTTPS URL 同步,支持文本列表和 JSON 映射。
* WAF 运行时不访问数据库;发布时将规则组引用的启用 IP 组展开进完整配置版本。
判定顺序:
* 白名单是放行例外,任意启用规则组命中白名单即放行。
+3 -3
View File
@@ -17,8 +17,8 @@ Server 发布时必须:
1. 读取全部启用的 `proxy_routes`。
2. 读取 Server 侧 OpenResty 主配置、性能参数、缓存参数和必要 Lua 资源。
3. 读取域名与证书绑定关系。
4. 读取 WAF 全局规则组、自定义规则组与网站绑定关系。
5. 渲染完整 OpenResty 配置与 WAF 运行时配置。
4. 读取 WAF 全局规则组、自定义规则组、IP 组引用与网站绑定关系。
5. 展开 WAF 规则组引用的启用 IP 组,渲染完整 OpenResty 配置与 WAF 运行时配置。
6. 计算 `checksum`。
7. 写入 `config_versions`。
8. 切换激活版本。
@@ -70,4 +70,4 @@ Agent 发现新版本后会:
* Agent API 固定使用节点专属 `agent_token`,首次接入可使用 `discovery_token`。
* Server 不提供远程 shell 或任意命令执行入口。
* 配置版本必须保存完整快照、渲染结果和 `checksum`。
* WAF 规则组和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
* WAF 规则组、IP 组快照和网站绑定关系必须随完整配置版本进入快照与 checksum,回滚时不得依赖当前可变 WAF 配置。
+3 -1
View File
@@ -81,10 +81,12 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
安全防护统一从管理端侧边栏的 **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 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
WAF、IP 组或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
## 发布、激活与回滚
+2 -1
View File
@@ -85,7 +85,7 @@ Frontend:
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
* **系统配置与第三方登录**:`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. 底层数据库技术约束
在编写或修改模型时,必须严格遵守以下持久化与数据库设计准则:
@@ -159,6 +159,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
* 发布时读取全部启用的 `proxy_routes`。
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
* 生成完整 OpenResty 配置。
* 计算 `checksum`。
* 写入 `config_versions`。
+15
View File
@@ -25,6 +25,21 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
| 只读接口 | 使用 `GET` |
| 变更类接口 | 使用 `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。
+81 -4
View File
@@ -21,7 +21,7 @@ func ListWAFRuleGroups(c *gin.Context) {
}
func GetWAFRuleGroup(c *gin.Context) {
id, ok := parseUintPathParam(c, "id")
id, ok := parseIDParam(c)
if !ok {
return
}
@@ -47,7 +47,7 @@ func CreateWAFRuleGroup(c *gin.Context) {
}
func UpdateWAFRuleGroup(c *gin.Context) {
id, ok := parseUintPathParam(c, "id")
id, ok := parseIDParam(c)
if !ok {
return
}
@@ -64,7 +64,7 @@ func UpdateWAFRuleGroup(c *gin.Context) {
}
func DeleteWAFRuleGroup(c *gin.Context) {
id, ok := parseUintPathParam(c, "id")
id, ok := parseIDParam(c)
if !ok {
return
}
@@ -76,7 +76,7 @@ func DeleteWAFRuleGroup(c *gin.Context) {
}
func ReplaceWAFRuleGroupSites(c *gin.Context) {
id, ok := parseUintPathParam(c, "id")
id, ok := parseIDParam(c)
if !ok {
return
}
@@ -122,6 +122,83 @@ func ReplaceWAFSiteRuleGroups(c *gin.Context) {
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) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
+7
View File
@@ -19,6 +19,13 @@ func InitCronJobs() {
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()
}
+14
View File
@@ -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)
}
}
+1
View File
@@ -46,6 +46,7 @@ func registeredModels() []any {
&AcmeAccount{},
&DnsAccount{},
&WAFRuleGroup{},
&WAFIPGroup{},
&WAFRuleGroupBinding{},
}
}
+35
View File
@@ -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) {
db := openTestSQLiteDB(t, "legacy-health-events.db")
-3
View File
@@ -1,6 +1,3 @@
// v16 升级内容:新增 Tunnel 隧道表,为 nodes 增加 TunnelRelay 相关字段,为 proxy_routes 增加隧道上游支持字段。
// 背景说明:引入 Tunnel 组件后,边缘节点可通过隧道中继回源,需要区分节点类型(edge_node/relay_node)、记录中继绑定端口,
// 并在代理路由中支持 tunnel 上游类型以替代直连回源,同时需要独立的 tunnels 表管理隧道实例。
package migrate
import (
+55
View File
@@ -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
}
+20
View File
@@ -78,6 +78,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
return validateDatabaseSchemaV15(db, backend)
case 16:
return validateDatabaseSchemaV16(db, backend)
case 17:
return validateDatabaseSchemaV17(db, backend)
default:
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,
IPWhitelist: "[]",
IPBlacklist: "[]",
IPWhitelistGroups: "[]",
IPBlacklistGroups: "[]",
CountryWhitelist: "[]",
CountryBlacklist: "[]",
RegionWhitelist: "[]",
@@ -1170,6 +1174,22 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
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 {
ctx := databaseSchemaMigrationContext{}
migrations := []databaseSchemaMigration{}
+90
View File
@@ -11,6 +11,8 @@ type WAFRuleGroup struct {
BlockResponseBody string `json:"block_response_body" 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:'[]'"`
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:'[]'"`
CountryBlacklist string `json:"country_blacklist" 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"`
}
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 {
ID uint `json:"id" gorm:"primaryKey"`
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,
"ip_whitelist": group.IPWhitelist,
"ip_blacklist": group.IPBlacklist,
"ip_whitelist_groups": group.IPWhitelistGroups,
"ip_blacklist_groups": group.IPBlacklistGroups,
"country_whitelist": group.CountryWhitelist,
"country_blacklist": group.CountryBlacklist,
"region_whitelist": group.RegionWhitelist,
@@ -73,3 +97,69 @@ func (group *WAFRuleGroup) Update() error {
func (group *WAFRuleGroup) Delete() 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
}
+6
View File
@@ -97,6 +97,12 @@ func SetApiRouter(router *gin.Engine) {
wafRoute := apiRouter.Group("/waf")
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/:id", controller.GetWAFRuleGroup)
wafRoute.POST("/rule-groups", controller.CreateWAFRuleGroup)
+65 -1
View File
@@ -100,6 +100,8 @@ type snapshotWAFRuleGroup struct {
BlockResponseBody string `json:"block_response_body,omitempty"`
IPWhitelist []string `json:"ip_whitelist,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"`
CountryBlacklist []string `json:"country_blacklist,omitempty"`
RegionWhitelist []string `json:"region_whitelist,omitempty"`
@@ -108,6 +110,14 @@ type snapshotWAFRuleGroup struct {
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 {
RouteID uint `json:"route_id"`
SiteName string `json:"site_name"`
@@ -116,6 +126,7 @@ type snapshotWAFBinding struct {
type snapshotWAFDocument struct {
RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"`
IPGroups []snapshotWAFIPGroup `json:"ip_groups,omitempty"`
Bindings []snapshotWAFBinding `json:"bindings"`
}
@@ -556,6 +567,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
BlockResponseBody: view.BlockResponseBody,
IPWhitelist: view.IPWhitelist,
IPBlacklist: view.IPBlacklist,
IPWhitelistGroups: view.IPWhitelistGroups,
IPBlacklistGroups: view.IPBlacklistGroups,
CountryWhitelist: view.CountryWhitelist,
CountryBlacklist: view.CountryBlacklist,
RegionWhitelist: view.RegionWhitelist,
@@ -564,6 +577,10 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
PoWConfig: view.PoWConfig,
})
}
ipGroups, err := buildSnapshotWAFIPGroups(ruleGroups)
if err != nil {
return snapshotWAFDocument{}, err
}
enabledRouteIDs := make(map[uint]string, len(routes))
for _, route := range routes {
if route == nil {
@@ -601,7 +618,54 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
}
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 {
+20
View File
@@ -27,6 +27,8 @@ type WAFRuleGroupInput struct {
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
@@ -45,6 +47,8 @@ type WAFRuleGroupView struct {
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
@@ -276,6 +280,8 @@ func EnsureDefaultWAFRuleGroup() error {
BlockStatusCode: defaultWAFBlockStatusCode,
IPWhitelist: "[]",
IPBlacklist: "[]",
IPWhitelistGroups: "[]",
IPBlacklistGroups: "[]",
CountryWhitelist: "[]",
CountryBlacklist: "[]",
RegionWhitelist: "[]",
@@ -310,6 +316,14 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
if err != nil {
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)
if err != nil {
return nil, fmt.Errorf("地域白名单无效: %w", err)
@@ -332,6 +346,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
ipWhitelistGroupsJSON, _ := json.Marshal(ipWhitelistGroups)
ipBlacklistGroupsJSON, _ := json.Marshal(ipBlacklistGroups)
countryWhitelistJSON, _ := json.Marshal(countryWhitelist)
countryBlacklistJSON, _ := json.Marshal(countryBlacklist)
regionWhitelistJSON, _ := json.Marshal(regionWhitelist)
@@ -346,6 +362,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
group.BlockResponseBody = input.BlockResponseBody
group.IPWhitelist = string(ipWhitelistJSON)
group.IPBlacklist = string(ipBlacklistJSON)
group.IPWhitelistGroups = string(ipWhitelistGroupsJSON)
group.IPBlacklistGroups = string(ipBlacklistGroupsJSON)
group.CountryWhitelist = string(countryWhitelistJSON)
group.CountryBlacklist = string(countryBlacklistJSON)
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 {
return view, err
}
view.IPWhitelistGroups = mustDecodeUintList(group.IPWhitelistGroups)
view.IPBlacklistGroups = mustDecodeUintList(group.IPBlacklistGroups)
if view.CountryWhitelist, err = decodeStringList(group.CountryWhitelist); err != nil {
return view, err
}
+544
View File
@@ -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
}
+137
View File
@@ -2,6 +2,8 @@ package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -128,3 +130,138 @@ func TestPublishConfigVersionIncludesWAFSnapshotAndRuntimeConfig(t *testing.T) {
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))
globalGroupIDs := make([]uint, 0)
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 {
if !group.Enabled {
continue
@@ -211,7 +215,19 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
globalGroupIDs = append(globalGroupIDs, group.ID)
}
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 {
if groups[i].IsGlobal != groups[j].IsGlobal {
@@ -234,6 +250,20 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
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 {
var builder strings.Builder
builder.WriteString(mainConfig)
@@ -683,6 +713,23 @@ func uniqueUintIDs(values []uint) []uint {
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 {
parsed, err := url.Parse(originURL)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
@@ -127,6 +127,8 @@ type WAFRuleGroup struct {
BlockResponseBody string `json:"block_response_body,omitempty"`
IPWhitelist []string `json:"ip_whitelist,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"`
CountryBlacklist []string `json:"country_blacklist,omitempty"`
RegionWhitelist []string `json:"region_whitelist,omitempty"`
@@ -135,6 +137,14 @@ type WAFRuleGroup struct {
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 {
RouteID uint `json:"route_id"`
SiteName string `json:"site_name"`
@@ -143,6 +153,7 @@ type WAFBinding struct {
type WAFDocument struct {
RuleGroups []WAFRuleGroup `json:"rule_groups"`
IPGroups []WAFIPGroup `json:"ip_groups,omitempty"`
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 {
WAFRuleGroup,
WAFRuleGroupPayload,
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFSiteRuleGroups,
} from '@/features/waf/types';
@@ -47,3 +50,37 @@ export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
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: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
@@ -52,6 +54,7 @@ export const defaultRuleModalState: RuleModalState = {
listType: 'whitelist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
@@ -104,6 +107,8 @@ export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
block_response_body: group.block_response_body ?? '',
ip_whitelist: group.ip_whitelist ?? [],
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_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
@@ -118,6 +123,8 @@ export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
@@ -166,6 +173,11 @@ export function getListFieldKey(
if (dimension === 'ip') {
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';
}
@@ -179,6 +191,20 @@ export function updateDraftList(
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case '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':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
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 { Search } from 'lucide-react';
import { AppModal } from '@/components/ui/app-modal';
import type { WAFIPGroup } from '@/features/waf/types';
import {
PrimaryButton,
ResourceField,
@@ -9,11 +10,17 @@ import {
} from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
import { normalizeItems } from './helpers';
import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types';
import type {
CountryOption,
RuleListType,
RuleDimension,
RuleModalState,
} from './types';
export function RuleEntryModal({
state,
countryOptions,
ipGroups,
pending,
onClose,
onChange,
@@ -21,6 +28,7 @@ export function RuleEntryModal({
}: {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
pending: boolean;
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => void;
@@ -39,6 +47,10 @@ export function RuleEntryModal({
() => new Set(state.countryValues),
[state.countryValues],
);
const selectedIPGroupSet = useMemo(
() => new Set(state.ipGroupIDs),
[state.ipGroupIDs],
);
const filteredCountries = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
@@ -71,9 +83,20 @@ export function RuleEntryModal({
};
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 dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
const dimensionLabel =
state.dimension === 'ip'
? 'IP'
: state.dimension === 'ip_group'
? 'IP 组'
: '地域';
return (
<AppModal
@@ -120,9 +143,10 @@ export function RuleEntryModal({
</div>
</ResourceField>
<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_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<button
@@ -156,6 +180,60 @@ export function RuleEntryModal({
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
</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="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,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}) {
const total = items.length + groupItems.length;
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
@@ -27,12 +32,20 @@ export function RuleListSection({
</p>
</div>
<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>
</div>
<div className="mt-5">
{items.length > 0 ? (
{total > 0 ? (
<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) => (
<RuleChip
key={item}
@@ -7,10 +7,12 @@ export type FeedbackState = {
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'country';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
@@ -26,6 +28,7 @@ export type RuleModalState = {
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
@@ -33,6 +36,8 @@ export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
@@ -3,13 +3,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Globe2,
Plus,
Save,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -30,11 +25,16 @@ import {
import {
createWAFRuleGroup,
deleteWAFRuleGroup,
getWAFIPGroups,
getWAFRuleGroups,
replaceWAFRuleGroupSites,
updateWAFRuleGroup,
} 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 { RuleEntryModal } from './rule-entry-modal';
@@ -56,9 +56,15 @@ import {
textToList,
updateDraftList,
} from './helpers';
import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types';
import type {
FeedbackState,
ListFieldKey,
RuleModalState,
WAFTab,
} from './types';
export function WAFPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
@@ -73,12 +79,20 @@ export function WAFPage() {
queryKey: ['waf', 'rule-groups'],
queryFn: getWAFRuleGroups,
});
const ipGroupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const ipGroups = useMemo(
() => ipGroupsQuery.data ?? [],
[ipGroupsQuery.data],
);
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
@@ -106,6 +120,7 @@ export function WAFPage() {
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
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 />;
}
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) {
return (
<EmptyState
@@ -189,6 +216,13 @@ export function WAFPage() {
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
)
.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 = () => {
setRuleModal({ ...defaultRuleModalState, open: true });
@@ -202,7 +236,9 @@ export function WAFPage() {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: normalizeItems(ruleModal.countryValues);
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) {
setFeedback({
@@ -210,7 +246,9 @@ export function WAFPage() {
message:
ruleModal.dimension === 'ip'
? '请先输入 IP 或 IP 段。'
: '请先选择地域。',
: ruleModal.dimension === 'ip_group'
? '请先选择 IP 组。'
: '请先选择地域。',
});
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 }> = [
{
@@ -271,17 +316,26 @@ export function WAFPage() {
title="WAF"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setActiveTab('basic');
setDraft({ ...emptyDraft, name: '自定义规则组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建规则组
</PrimaryButton>
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={() => router.push('/waf/ip-groups')}
>
<Network className="mr-2 h-4 w-4" />
管理 IP 组
</SecondaryButton>
<PrimaryButton
type="button"
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 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={draft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_whitelist_group_ids', id)
}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={draft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_blacklist_group_ids', id)
}
/>
<RuleListSection
title="地域白名单"
@@ -646,6 +708,7 @@ export function WAFPage() {
<RuleEntryModal
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
pending={saveMutation.isPending}
onClose={closeRuleModal}
onChange={(patch) =>
@@ -9,6 +9,8 @@ export interface WAFRuleGroup {
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
@@ -29,6 +31,8 @@ export interface WAFRuleGroupPayload {
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
@@ -45,3 +49,49 @@ export interface WAFSiteRuleGroups {
applied_rule_groups: WAFRuleGroup[];
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();
});
});