diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 087a576b..0b208068 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -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` diff --git a/docs/design/index.md b/docs/design/index.md index 972841c7..22a99144 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -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 组展开进完整配置版本。 + 判定顺序: * 白名单是放行例外,任意启用规则组命中白名单即放行。 diff --git a/docs/design/release-model.md b/docs/design/release-model.md index c35965fe..28b348e0 100644 --- a/docs/design/release-model.md +++ b/docs/design/release-model.md @@ -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 配置。 diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 78126607..9becec8d 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -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。 ## 发布、激活与回滚 diff --git a/docs/guildline/development-constraints.md b/docs/guildline/development-constraints.md index 9a84fbc9..69b3b422 100644 --- a/docs/guildline/development-constraints.md +++ b/docs/guildline/development-constraints.md @@ -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`。 diff --git a/docs/reference/api.md b/docs/reference/api.md index fdde7c51..4afdfe56 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -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。 diff --git a/openflare_server/controller/waf.go b/openflare_server/controller/waf.go index 796230b7..6e9143a7 100644 --- a/openflare_server/controller/waf.go +++ b/openflare_server/controller/waf.go @@ -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 { diff --git a/openflare_server/job/cron.go b/openflare_server/job/cron.go index 3b558945..2694c269 100644 --- a/openflare_server/job/cron.go +++ b/openflare_server/job/cron.go @@ -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() } diff --git a/openflare_server/job/waf_ip_group_sync.go b/openflare_server/job/waf_ip_group_sync.go new file mode 100644 index 00000000..3e2077e2 --- /dev/null +++ b/openflare_server/job/waf_ip_group_sync.go @@ -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) + } +} diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index d0fb5c9a..233de054 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -46,6 +46,7 @@ func registeredModels() []any { &AcmeAccount{}, &DnsAccount{}, &WAFRuleGroup{}, + &WAFIPGroup{}, &WAFRuleGroupBinding{}, } } diff --git a/openflare_server/model/main_test.go b/openflare_server/model/main_test.go index d1739b2b..5021763d 100644 --- a/openflare_server/model/main_test.go +++ b/openflare_server/model/main_test.go @@ -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") diff --git a/openflare_server/model/migrate/v16.go b/openflare_server/model/migrate/v16.go index 7a189f65..1e949619 100644 --- a/openflare_server/model/migrate/v16.go +++ b/openflare_server/model/migrate/v16.go @@ -1,6 +1,3 @@ -// v16 升级内容:新增 Tunnel 隧道表,为 nodes 增加 TunnelRelay 相关字段,为 proxy_routes 增加隧道上游支持字段。 -// 背景说明:引入 Tunnel 组件后,边缘节点可通过隧道中继回源,需要区分节点类型(edge_node/relay_node)、记录中继绑定端口, -// 并在代理路由中支持 tunnel 上游类型以替代直连回源,同时需要独立的 tunnels 表管理隧道实例。 package migrate import ( diff --git a/openflare_server/model/migrate/v17.go b/openflare_server/model/migrate/v17.go new file mode 100644 index 00000000..6efc70be --- /dev/null +++ b/openflare_server/model/migrate/v17.go @@ -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 +} diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index c616543a..8cc3c2da 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -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{} diff --git a/openflare_server/model/waf.go b/openflare_server/model/waf.go index 45874449..b6994882 100644 --- a/openflare_server/model/waf.go +++ b/openflare_server/model/waf.go @@ -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 +} diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index d2d78053..45acc14c 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -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) diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 7858b843..94b2e784 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -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 { diff --git a/openflare_server/service/waf.go b/openflare_server/service/waf.go index 5520623c..95f6cf78 100644 --- a/openflare_server/service/waf.go +++ b/openflare_server/service/waf.go @@ -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 } diff --git a/openflare_server/service/waf_ip_group.go b/openflare_server/service/waf_ip_group.go new file mode 100644 index 00000000..ea7bdcab --- /dev/null +++ b/openflare_server/service/waf_ip_group.go @@ -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 +} diff --git a/openflare_server/service/waf_test.go b/openflare_server/service/waf_test.go index 1b89af36..a264651f 100644 --- a/openflare_server/service/waf_test.go +++ b/openflare_server/service/waf_test.go @@ -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) + } +} diff --git a/openflare_server/utils/render/openresty/render.go b/openflare_server/utils/render/openresty/render.go index 5abc57d5..d235f636 100644 --- a/openflare_server/utils/render/openresty/render.go +++ b/openflare_server/utils/render/openresty/render.go @@ -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") { diff --git a/openflare_server/utils/render/openresty/types.go b/openflare_server/utils/render/openresty/types.go index 0ea8bc09..c8306b8c 100644 --- a/openflare_server/utils/render/openresty/types.go +++ b/openflare_server/utils/render/openresty/types.go @@ -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"` } diff --git a/openflare_server/web/app/(dashboard)/waf/ip-groups/page.tsx b/openflare_server/web/app/(dashboard)/waf/ip-groups/page.tsx new file mode 100644 index 00000000..2cd010cd --- /dev/null +++ b/openflare_server/web/app/(dashboard)/waf/ip-groups/page.tsx @@ -0,0 +1,5 @@ +import { WAFIPGroupsPage } from '@/features/waf/components/ip-groups-page'; + +export default function WAFIPGroupsRoute() { + return ; +} diff --git a/openflare_server/web/features/waf/api/waf.ts b/openflare_server/web/features/waf/api/waf.ts index 155f96cb..fbc0a43b 100644 --- a/openflare_server/web/features/waf/api/waf.ts +++ b/openflare_server/web/features/waf/api/waf.ts @@ -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('/waf/ip-groups'); +} + +export function getWAFIPGroup(id: number) { + return apiRequest(`/waf/ip-groups/${id}`); +} + +export function createWAFIPGroup(payload: WAFIPGroupPayload) { + return apiRequest('/waf/ip-groups', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateWAFIPGroup(id: number, payload: WAFIPGroupPayload) { + return apiRequest(`/waf/ip-groups/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function deleteWAFIPGroup(id: number) { + return apiRequest(`/waf/ip-groups/${id}/delete`, { + method: 'POST', + }); +} + +export function syncWAFIPGroup(id: number) { + return apiRequest(`/waf/ip-groups/${id}/sync`, { + method: 'POST', + }); +} diff --git a/openflare_server/web/features/waf/components/helpers.ts b/openflare_server/web/features/waf/components/helpers.ts index e63c1793..135a9491 100644 --- a/openflare_server/web/features/waf/components/helpers.ts +++ b/openflare_server/web/features/waf/components/helpers.ts @@ -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': diff --git a/openflare_server/web/features/waf/components/ip-groups-page.tsx b/openflare_server/web/features/waf/components/ip-groups-page.tsx new file mode 100644 index 00000000..b1b0b49d --- /dev/null +++ b/openflare_server/web/features/waf/components/ip-groups-page.tsx @@ -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 = { + 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 = {}; + 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; + } + 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(null); + const [draft, setDraft] = useState(emptyIPGroupDraft); + const [feedback, setFeedback] = useState(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 ; + } + if (groupsQuery.isError) { + return ( + + ); + } + + const saveDraft = () => { + try { + saveMutation.mutate(buildPayload(draft)); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + + return ( +
+ + router.push('/waf')}> + + 返回 WAF + + { + setSelectedID(0); + setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' }); + }} + > + + 新建 IP 组 + +
+ } + /> + + {feedback ? ( + + ) : null} + +
+ + {groups.length === 0 && selectedID !== 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ + + {selectedGroup?.type === 'subscription' ? ( + syncMutation.mutate(selectedGroup.id)} + > + + {syncMutation.isPending ? '同步中...' : '立即同步'} + + ) : null} + + + {saveMutation.isPending ? '保存中...' : '保存 IP 组'} + +
+ } + > +
+
+ + + setDraft((current) => ({ + ...current, + name: event.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + type: event.target.value as WAFIPGroupType, + })) + } + > + + + + + + + setDraft((current) => ({ ...current, enabled: checked })) + } + /> + + + setDraft((current) => ({ + ...current, + remark: event.target.value, + })) + } + /> + +
+ + {draft.type === 'subscription' ? ( +
+ + + setDraft((current) => ({ + ...current, + subscription_url: event.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + subscription_format: event.target + .value as WAFIPGroupSubscriptionFormat, + })) + } + > + + + + + + + setDraft((current) => ({ + ...current, + sync_interval_minutes: Number(event.target.value), + })) + } + /> + + + + setDraft((current) => ({ + ...current, + subscription_mapping_rule: event.target.value, + })) + } + /> + +
+ ) : null} + + {draft.type === 'automatic' ? ( + + + setDraft((current) => ({ + ...current, + auto_config_text: event.target.value, + })) + } + /> + + ) : ( + + + setDraft((current) => ({ + ...current, + ip_list_text: event.target.value, + })) + } + /> + + )} + + {selectedGroup ? ( +
+
+ {selectedGroup.last_sync_status + ? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}` + : '尚无同步记录'} +
+ { + if ( + window.confirm( + `确认删除 IP 组 ${selectedGroup.name} 吗?`, + ) + ) { + deleteMutation.mutate(selectedGroup.id); + } + }} + > + + 删除 + +
+ ) : null} +
+ + + + ); +} diff --git a/openflare_server/web/features/waf/components/rule-entry-modal.tsx b/openflare_server/web/features/waf/components/rule-entry-modal.tsx index 89b963c4..b8940436 100644 --- a/openflare_server/web/features/waf/components/rule-entry-modal.tsx +++ b/openflare_server/web/features/waf/components/rule-entry-modal.tsx @@ -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) => 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 ( -
+
{[ { value: 'ip', label: 'IP' }, + { value: 'ip_group', label: 'IP 组' }, { value: 'country', label: '地域' }, ].map((option) => (