From 631d32e5d0e2e6c0ed26f48ce2e2a6391eff8470 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 30 May 2026 17:27:23 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20POW=20=E4=B8=8E=20WAF=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/guide/usage.md | 10 + docs/guide/usage.md | 10 + .../model/database_schema_version.go | 2 +- openflare_server/model/migrations.go | 24 + openflare_server/model/waf.go | 4 + openflare_server/service/config_version.go | 35 +- openflare_server/service/waf.go | 77 ++-- .../components/proxy-route-config-page.tsx | 425 +++++------------- .../web/features/proxy-routes/helpers.ts | 6 +- .../web/features/waf/components/waf-page.tsx | 292 +++++++++++- openflare_server/web/features/waf/types.ts | 6 + 11 files changed, 526 insertions(+), 365 deletions(-) diff --git a/docs/en/guide/usage.md b/docs/en/guide/usage.md index 65977072..d22970a9 100644 --- a/docs/en/guide/usage.md +++ b/docs/en/guide/usage.md @@ -74,6 +74,16 @@ HTTPS is bound per domain, not forced for the whole site. If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot. +## Configure WAF and PoW + +Security controls are managed from the **WAF** sidebar entry: + +* The WAF page manages the global rule group and custom rule groups. The global rule group always applies to every site. Custom rule groups can be applied to selected sites from the rule group drawer or bound from the site detail `WAF` section. +* `PoW` is a tab inside the selected rule group, between `Allow / Block Lists` and `Block Response`. It reuses the existing per-site PoW execution logic and can apply the current PoW policy to every site or the sites bound to the current rule group. +* Site details no longer edit PoW directly. They show the always-on global WAF group and let you bind custom WAF rule groups. PoW rule content and scope should be maintained from the WAF page. + +After changing WAF or PoW settings, publish and activate a new configuration version so Agents can apply the updated OpenResty runtime. + ## Release, Activate, and Roll Back Standard flow: diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 6b53ae81..2d32fd9c 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -76,6 +76,16 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。 如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。 +## 配置 WAF 与 PoW + +安全防护统一从管理端侧边栏的 **WAF** 入口进入: + +* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。 +* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。 +* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。 + +WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。 + ## 发布、激活与回滚 标准链路: diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index 1f0e7224..28b2ff2e 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 13 + currentDatabaseSchemaVersion = 14 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index c3b13812..352ae43b 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -1413,6 +1413,8 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error { CountryBlacklist: "[]", RegionWhitelist: "[]", RegionBlacklist: "[]", + PoWEnabled: false, + PoWConfig: "{}", BlockResponseBody: "", } if err := db.Create(&group).Error; err != nil { @@ -1449,6 +1451,27 @@ func validateDatabaseSchemaV13(db *gorm.DB, backend string) error { return nil } +// migrateV14 adds PoW policy fields to WAF rule groups. +func migrateV14(db *gorm.DB, backend string) error { + if err := applyCurrentSchema(db, backend); err != nil { + return err + } + return ensureDefaultWAFRuleGroup(db) +} + +func validateDatabaseSchemaV14(db *gorm.DB, backend string) error { + if err := validateDatabaseSchemaV13(db, backend); err != nil { + return err + } + if !db.Migrator().HasColumn(&WAFRuleGroup{}, "pow_enabled") { + return fmt.Errorf("column waf_rule_groups.pow_enabled is missing") + } + if !db.Migrator().HasColumn(&WAFRuleGroup{}, "pow_config") { + return fmt.Errorf("column waf_rule_groups.pow_config is missing") + } + return nil +} + func databaseSchemaMigrations() []databaseSchemaMigration { return []databaseSchemaMigration{ {fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2}, @@ -1463,6 +1486,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration { {fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11}, {fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12}, {fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13}, + {fromVersion: 13, toVersion: 14, migrate: migrateV14, validate: validateDatabaseSchemaV14}, } } diff --git a/openflare_server/model/waf.go b/openflare_server/model/waf.go index 9f56ca93..ab7a4ca3 100644 --- a/openflare_server/model/waf.go +++ b/openflare_server/model/waf.go @@ -15,6 +15,8 @@ type WAFRuleGroup struct { CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"` RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"` RegionBlacklist string `json:"region_blacklist" gorm:"type:text;not null;default:'[]'"` + PoWEnabled bool `json:"pow_enabled" gorm:"not null;default:false"` + PoWConfig string `json:"pow_config" 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"` @@ -62,6 +64,8 @@ func (group *WAFRuleGroup) Update() error { "country_blacklist": group.CountryBlacklist, "region_whitelist": group.RegionWhitelist, "region_blacklist": group.RegionBlacklist, + "pow_enabled": group.PoWEnabled, + "pow_config": group.PoWConfig, "remark": group.Remark, }).Error } diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index fe4eb5d9..f987831b 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -95,18 +95,20 @@ type snapshotRoute struct { } type snapshotWAFRuleGroup struct { - ID uint `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - IsGlobal bool `json:"is_global"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body,omitempty"` - IPWhitelist []string `json:"ip_whitelist,omitempty"` - IPBlacklist []string `json:"ip_blacklist,omitempty"` - CountryWhitelist []string `json:"country_whitelist,omitempty"` - CountryBlacklist []string `json:"country_blacklist,omitempty"` - RegionWhitelist []string `json:"region_whitelist,omitempty"` - RegionBlacklist []string `json:"region_blacklist,omitempty"` + ID uint `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + IsGlobal bool `json:"is_global"` + BlockStatusCode int `json:"block_status_code"` + BlockResponseBody string `json:"block_response_body,omitempty"` + IPWhitelist []string `json:"ip_whitelist,omitempty"` + IPBlacklist []string `json:"ip_blacklist,omitempty"` + CountryWhitelist []string `json:"country_whitelist,omitempty"` + CountryBlacklist []string `json:"country_blacklist,omitempty"` + RegionWhitelist []string `json:"region_whitelist,omitempty"` + RegionBlacklist []string `json:"region_blacklist,omitempty"` + PoWEnabled bool `json:"pow_enabled,omitempty"` + PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"` } type snapshotWAFBinding struct { @@ -504,7 +506,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { if err != nil { return nil, err } - routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig) + routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig, wafSnapshot) if err != nil { return nil, err } @@ -512,7 +514,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { if err != nil { return nil, err } - powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes) + powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes, wafSnapshot) if err != nil { return nil, err } @@ -617,6 +619,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument, CountryBlacklist: view.CountryBlacklist, RegionWhitelist: view.RegionWhitelist, RegionBlacklist: view.RegionBlacklist, + PoWEnabled: view.PoWEnabled, + PoWConfig: view.PoWConfig, }) } enabledRouteIDs := make(map[uint]string, len(routes)) @@ -1029,10 +1033,11 @@ func openRestyOptionKeys() []string { } } -func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) (string, []SupportFile, error) { +func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) { var builder strings.Builder builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n") supportFiles := make([]SupportFile, 0) + powEnabledByRoute := wafPowEnabledByRoute(wafSnapshot) for _, route := range routes { domains, err := decodeStoredDomains(route.Domains, route.Domain) if err != nil { diff --git a/openflare_server/service/waf.go b/openflare_server/service/waf.go index ef5337fb..175b5823 100644 --- a/openflare_server/service/waf.go +++ b/openflare_server/service/waf.go @@ -20,37 +20,41 @@ const ( ) type WAFRuleGroupInput struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body"` - IPWhitelist []string `json:"ip_whitelist"` - IPBlacklist []string `json:"ip_blacklist"` - CountryWhitelist []string `json:"country_whitelist"` - CountryBlacklist []string `json:"country_blacklist"` - RegionWhitelist []string `json:"region_whitelist"` - RegionBlacklist []string `json:"region_blacklist"` - Remark string `json:"remark"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + BlockStatusCode int `json:"block_status_code"` + BlockResponseBody string `json:"block_response_body"` + IPWhitelist []string `json:"ip_whitelist"` + IPBlacklist []string `json:"ip_blacklist"` + CountryWhitelist []string `json:"country_whitelist"` + CountryBlacklist []string `json:"country_blacklist"` + RegionWhitelist []string `json:"region_whitelist"` + RegionBlacklist []string `json:"region_blacklist"` + Remark string `json:"remark"` + PoWEnabled bool `json:"pow_enabled"` + PoWConfig json.RawMessage `json:"pow_config"` } type WAFRuleGroupView struct { - ID uint `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - IsGlobal bool `json:"is_global"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body"` - IPWhitelist []string `json:"ip_whitelist"` - IPBlacklist []string `json:"ip_blacklist"` - CountryWhitelist []string `json:"country_whitelist"` - CountryBlacklist []string `json:"country_blacklist"` - RegionWhitelist []string `json:"region_whitelist"` - RegionBlacklist []string `json:"region_blacklist"` - Remark string `json:"remark"` - AppliedSiteIDs []uint `json:"applied_site_ids"` - AppliedSiteCount int `json:"applied_site_count"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID uint `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + IsGlobal bool `json:"is_global"` + BlockStatusCode int `json:"block_status_code"` + BlockResponseBody string `json:"block_response_body"` + IPWhitelist []string `json:"ip_whitelist"` + IPBlacklist []string `json:"ip_blacklist"` + CountryWhitelist []string `json:"country_whitelist"` + CountryBlacklist []string `json:"country_blacklist"` + RegionWhitelist []string `json:"region_whitelist"` + RegionBlacklist []string `json:"region_blacklist"` + Remark string `json:"remark"` + PoWEnabled bool `json:"pow_enabled"` + PoWConfig *ProxyRoutePoWConfig `json:"pow_config"` + AppliedSiteIDs []uint `json:"applied_site_ids"` + AppliedSiteCount int `json:"applied_site_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type WAFSiteRuleGroupsView struct { @@ -275,6 +279,8 @@ func EnsureDefaultWAFRuleGroup() error { CountryBlacklist: "[]", RegionWhitelist: "[]", RegionBlacklist: "[]", + PoWEnabled: false, + PoWConfig: "{}", BlockResponseBody: "", } return group.Insert() @@ -313,6 +319,15 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod } regionWhitelist := normalizeStringList(input.RegionWhitelist) regionBlacklist := normalizeStringList(input.RegionBlacklist) + powConfigRaw := strings.TrimSpace(string(input.PoWConfig)) + if powConfigRaw == "" { + powConfigRaw = "{}" + } + powConfig, err := normalizePoWConfig(input.PoWEnabled, powConfigRaw) + if err != nil { + return nil, err + } + powConfigJSON, _ := json.Marshal(powConfig) ipWhitelistJSON, _ := json.Marshal(ipWhitelist) ipBlacklistJSON, _ := json.Marshal(ipBlacklist) @@ -334,6 +349,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod group.CountryBlacklist = string(countryBlacklistJSON) group.RegionWhitelist = string(regionWhitelistJSON) group.RegionBlacklist = string(regionBlacklistJSON) + group.PoWEnabled = input.PoWEnabled + group.PoWConfig = string(powConfigJSON) group.Remark = strings.TrimSpace(input.Remark) return group, nil } @@ -351,6 +368,7 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA BlockStatusCode: group.BlockStatusCode, BlockResponseBody: group.BlockResponseBody, Remark: group.Remark, + PoWEnabled: group.PoWEnabled, AppliedSiteIDs: appliedSiteIDs, AppliedSiteCount: len(appliedSiteIDs), CreatedAt: group.CreatedAt.Format(time.RFC3339), @@ -375,6 +393,9 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA if view.RegionBlacklist, err = decodeStringList(group.RegionBlacklist); err != nil { return view, err } + if view.PoWConfig, err = decodeStoredPoWConfig(group.PoWEnabled, group.PoWConfig); err != nil { + return view, err + } return view, nil } diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx index 6041a28c..06800449 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx @@ -19,6 +19,10 @@ import { getProxyRoute, updateProxyRoute, } from '@/features/proxy-routes/api/proxy-routes'; +import { + getWAFSiteRuleGroups, + replaceWAFSiteRuleGroups, +} from '@/features/waf/api/waf'; import { buildDomainRowsFromRoute, DomainListInput, @@ -729,318 +733,137 @@ function CacheSection({ ); } -type PowListValues = { - ips: string; - ip_cidrs: string; - paths: string; - path_regexes: string; - user_agents: string; -}; - -const powSchema = z - .object({ - pow_enabled: z.boolean(), - difficulty: z.coerce.number().int().min(1).max(16), - algorithm: z.enum(['fast', 'slow']), - session_ttl: z.coerce.number().int().min(60), - challenge_ttl: z.coerce.number().int().min(30), - whitelist: z.object({ - ips: z.string(), - ip_cidrs: z.string(), - paths: z.string(), - path_regexes: z.string(), - user_agents: z.string(), - }), - blacklist: z.object({ - ips: z.string(), - ip_cidrs: z.string(), - paths: z.string(), - path_regexes: z.string(), - user_agents: z.string(), - }), - }) - .superRefine((value, context) => { - if (!value.pow_enabled) return; - const dimensions: { key: string; label: string }[] = [ - { key: 'ips', label: 'IP' }, - { key: 'ip_cidrs', label: 'IP CIDR' }, - { key: 'paths', label: '路径' }, - { key: 'path_regexes', label: '路径正则' }, - { key: 'user_agents', label: 'User-Agent' }, - ]; - for (const dim of dimensions) { - const wl = linesFromTextarea( - (value.whitelist as Record)[dim.key] || '', - ); - const bl = linesFromTextarea( - (value.blacklist as Record)[dim.key] || '', - ); - if (wl.length > 0 && bl.length > 0) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: `${dim.label} 不能同时配置白名单和黑名单`, - path: ['blacklist', dim.key], - }); - } - } +function WAFBindingSection({ route }: { route: ProxyRouteItem }) { + const queryClient = useQueryClient(); + const [selectedIDs, setSelectedIDs] = useState([]); + const wafQuery = useQuery({ + queryKey: ['waf', 'site-rule-groups', route.id], + queryFn: () => getWAFSiteRuleGroups(route.id), }); - -type PowValues = z.infer; - -function buildPowListFromConfig( - list: - | { - ips?: string[]; - ip_cidrs?: string[]; - paths?: string[]; - path_regexes?: string[]; - user_agents?: string[]; - } - | undefined, -): PowListValues { - return { - ips: (list?.ips ?? []).join('\n'), - ip_cidrs: (list?.ip_cidrs ?? []).join('\n'), - paths: (list?.paths ?? []).join('\n'), - path_regexes: (list?.path_regexes ?? []).join('\n'), - user_agents: (list?.user_agents ?? []).join('\n'), - }; -} - -function PowSection({ - route, - saving, - onSave, -}: { - route: ProxyRouteItem; - saving: boolean; - onSave: SaveHandler; -}) { - const powConfig = route.pow_config; - const form = useForm({ - resolver: zodResolver(powSchema), - defaultValues: { - pow_enabled: route.pow_enabled, - difficulty: powConfig?.difficulty ?? 4, - algorithm: powConfig?.algorithm ?? 'fast', - session_ttl: powConfig?.session_ttl ?? 600, - challenge_ttl: powConfig?.challenge_ttl ?? 300, - whitelist: buildPowListFromConfig(powConfig?.whitelist), - blacklist: buildPowListFromConfig(powConfig?.blacklist), + const wafMutation = useMutation({ + mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(route.id, ids), + onSuccess: async (result) => { + setSelectedIDs(result.applied_ids); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['waf', 'site-rule-groups', route.id], + }), + queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }), + queryClient.invalidateQueries({ + queryKey: ['config-versions', 'diff'], + }), + ]); }, }); useEffect(() => { - form.reset({ - pow_enabled: route.pow_enabled, - difficulty: powConfig?.difficulty ?? 4, - algorithm: powConfig?.algorithm ?? 'fast', - session_ttl: powConfig?.session_ttl ?? 600, - challenge_ttl: powConfig?.challenge_ttl ?? 300, - whitelist: buildPowListFromConfig(powConfig?.whitelist), - blacklist: buildPowListFromConfig(powConfig?.blacklist), - }); - }, [form, route, powConfig]); + if (wafQuery.data) { + setSelectedIDs(wafQuery.data.applied_ids); + } + }, [wafQuery.data]); - const watchedEnabled = form.watch('pow_enabled'); - - const parseList = (text: string): string[] => - linesFromTextarea(text).filter(Boolean); + const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]); return ( -
{ - const powConfigPayload = JSON.stringify({ - difficulty: values.difficulty, - algorithm: values.algorithm, - session_ttl: values.session_ttl, - challenge_ttl: values.challenge_ttl, - whitelist: { - ips: parseList(values.whitelist.ips), - ip_cidrs: parseList(values.whitelist.ip_cidrs), - paths: parseList(values.whitelist.paths), - path_regexes: parseList(values.whitelist.path_regexes), - user_agents: parseList(values.whitelist.user_agents), - }, - blacklist: { - ips: parseList(values.blacklist.ips), - ip_cidrs: parseList(values.blacklist.ip_cidrs), - paths: parseList(values.blacklist.paths), - path_regexes: parseList(values.blacklist.path_regexes), - user_agents: parseList(values.blacklist.user_agents), - }, - }); - onSave( - buildPayloadFromRoute(route, { - pow_enabled: values.pow_enabled, - pow_config: powConfigPayload, - }), - { message: 'PoW 防护设置已保存。' }, - ); - })} - > - - form.setValue('pow_enabled', checked, { shouldDirty: true }) - } + {wafQuery.isLoading ? ( + + ) : wafQuery.isError ? ( + + ) : ( +
+ {wafMutation.isError ? ( + + ) : null} + {wafMutation.isSuccess ? ( + + ) : null} - - - - - - + {wafQuery.data?.global_rule_group ? ( +
+
+
+

+ Global Rule Group +

+

+ {wafQuery.data.global_rule_group.name} +

+
+ + 始终生效 + +
+
+ ) : null} - - - +
+ {(wafQuery.data?.rule_groups ?? []).map((group) => ( + + ))} +
- - - + {(wafQuery.data?.rule_groups ?? []).length === 0 ? ( + + ) : null} - - - - -
-
- - 白名单(匹配的请求跳过 PoW) - - - - - - - - - - - - - - - - -
- -
- - 黑名单(匹配的请求必须 PoW) - - - - - - - - - - - - - - - - -
+
+ wafMutation.mutate(selectedIDs)} + > + {wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'} + +
- {form.formState.errors.blacklist && ( -

- {Object.values(form.formState.errors.blacklist) - .flatMap((e) => - e && typeof e === 'object' && 'message' in e - ? [e.message as string] - : [], - ) - .join('; ')} -

- )} - + )} ); } @@ -1370,14 +1193,8 @@ export function ProxyRouteConfigPage({ /> ) : null} - {currentSection === 'pow' ? ( - - saveMutation.mutate({ payload, context }) - } - /> + {currentSection === 'waf' ? ( + ) : null} {currentSection === 'auth' ? ( diff --git a/openflare_server/web/features/proxy-routes/helpers.ts b/openflare_server/web/features/proxy-routes/helpers.ts index eb999fd1..2541539c 100644 --- a/openflare_server/web/features/proxy-routes/helpers.ts +++ b/openflare_server/web/features/proxy-routes/helpers.ts @@ -26,9 +26,9 @@ export const websiteConfigSections = [ description: '配置站点缓存策略。', }, { - key: 'pow', - label: 'PoW 防护', - description: '配置 Proof-of-Work 反爬虫策略。', + key: 'waf', + label: 'WAF', + description: '绑定 WAF 规则组,并查看当前站点生效策略。', }, { key: 'auth', diff --git a/openflare_server/web/features/waf/components/waf-page.tsx b/openflare_server/web/features/waf/components/waf-page.tsx index 7d23d607..2330a0c9 100644 --- a/openflare_server/web/features/waf/components/waf-page.tsx +++ b/openflare_server/web/features/waf/components/waf-page.tsx @@ -5,8 +5,8 @@ import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Check, + Cpu, Globe2, - ListFilter, type LucideIcon, Plus, Save, @@ -23,8 +23,15 @@ import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { AppModal } from '@/components/ui/app-modal'; import { Drawer } from '@/components/ui/drawer'; -import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; -import type { ProxyRouteItem } from '@/features/proxy-routes/types'; +import { + getProxyRoutes, + updateProxyRoute, +} from '@/features/proxy-routes/api/proxy-routes'; +import { buildPayloadFromRoute } from '@/features/proxy-routes/helpers'; +import type { + ProxyRouteItem, + ProxyRoutePoWConfig, +} from '@/features/proxy-routes/types'; import { DangerButton, PrimaryButton, @@ -49,7 +56,7 @@ type FeedbackState = { message: string; }; -type WAFTab = 'basic' | 'lists' | 'block'; +type WAFTab = 'basic' | 'lists' | 'pow' | 'block'; type RuleListType = 'whitelist' | 'blacklist'; type RuleDimension = 'ip' | 'country'; type ListFieldKey = @@ -83,6 +90,27 @@ type RuleListRenderable = Pick< | 'region_blacklist' >; +const defaultPowConfig: ProxyRoutePoWConfig = { + 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: [], + }, +}; + const emptyDraft: WAFRuleGroupPayload = { name: '', enabled: true, @@ -94,6 +122,8 @@ const emptyDraft: WAFRuleGroupPayload = { country_blacklist: [], region_whitelist: [], region_blacklist: [], + pow_enabled: false, + pow_config: defaultPowConfig, remark: '', }; @@ -117,6 +147,10 @@ const tabItems: Array<{ id: 'lists', label: '黑白名单', }, + { + id: 'pow', + label: 'PoW', + }, { id: 'block', label: '拦截返回', @@ -134,6 +168,17 @@ function textToList(text: string) { .filter(Boolean); } +function listToText(items: string[] | undefined) { + return (items ?? []).join('\n'); +} + +function parseTextareaList(text: string) { + return text + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean); +} + function normalizeItems(items: string[]) { return Array.from( new Set(items.map((item) => item.trim()).filter(Boolean)), @@ -155,6 +200,8 @@ function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { country_blacklist: group.country_blacklist ?? [], region_whitelist: group.region_whitelist ?? [], region_blacklist: group.region_blacklist ?? [], + pow_enabled: group.pow_enabled ?? false, + pow_config: group.pow_config ?? defaultPowConfig, remark: group.remark ?? '', }; } @@ -731,6 +778,214 @@ function SiteApplyDrawer({ ); } + + +function PowTabPanel({ + enabled, + config, + onChange, +}: { + enabled: boolean; + config: ProxyRoutePoWConfig; + onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void; +}) { + const [draft, setDraft] = useState(() => ({ + whitelist: { + ips: listToText(config.whitelist?.ips), + ip_cidrs: listToText(config.whitelist?.ip_cidrs), + paths: listToText(config.whitelist?.paths), + path_regexes: listToText(config.whitelist?.path_regexes), + user_agents: listToText(config.whitelist?.user_agents), + }, + blacklist: { + ips: listToText(config.blacklist?.ips), + ip_cidrs: listToText(config.blacklist?.ip_cidrs), + paths: listToText(config.blacklist?.paths), + path_regexes: listToText(config.blacklist?.path_regexes), + user_agents: listToText(config.blacklist?.user_agents), + }, + })); + + useEffect(() => { + setDraft({ + whitelist: { + ips: listToText(config.whitelist?.ips), + ip_cidrs: listToText(config.whitelist?.ip_cidrs), + paths: listToText(config.whitelist?.paths), + path_regexes: listToText(config.whitelist?.path_regexes), + user_agents: listToText(config.whitelist?.user_agents), + }, + blacklist: { + ips: listToText(config.blacklist?.ips), + ip_cidrs: listToText(config.blacklist?.ip_cidrs), + paths: listToText(config.blacklist?.paths), + path_regexes: listToText(config.blacklist?.path_regexes), + user_agents: listToText(config.blacklist?.user_agents), + }, + }); + }, [config]); + + const updateConfig = ( + newEnabled: boolean, + newConfig: Partial, + newDraft?: typeof draft, + ) => { + const nextConfig = { ...config, ...newConfig }; + if (newDraft) { + setDraft(newDraft); + nextConfig.whitelist = { + ips: parseTextareaList(newDraft.whitelist.ips), + ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs), + paths: parseTextareaList(newDraft.whitelist.paths), + path_regexes: parseTextareaList(newDraft.whitelist.path_regexes), + user_agents: parseTextareaList(newDraft.whitelist.user_agents), + }; + nextConfig.blacklist = { + ips: parseTextareaList(newDraft.blacklist.ips), + ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs), + paths: parseTextareaList(newDraft.blacklist.paths), + path_regexes: parseTextareaList(newDraft.blacklist.path_regexes), + user_agents: parseTextareaList(newDraft.blacklist.user_agents), + }; + } + onChange(newEnabled, nextConfig); + }; + + const updateList = ( + scope: 'whitelist' | 'blacklist', + key: keyof ProxyRoutePoWConfig['whitelist'], + value: string, + ) => { + const nextDraft = { + ...draft, + [scope]: { + ...draft[scope], + [key]: value, + }, + }; + updateConfig(enabled, {}, nextDraft); + }; + + return ( +
+ updateConfig(newEnabled, {})} + /> + +
+ + + + + + updateConfig(enabled, { difficulty: Number(event.target.value) }) + } + /> + + + + updateConfig(enabled, { session_ttl: Number(event.target.value) }) + } + /> + + + + updateConfig(enabled, { challenge_ttl: Number(event.target.value) }) + } + /> + +
+ +
+ {(['whitelist', 'blacklist'] as const).map((scope) => ( +
+

+ {scope === 'whitelist' + ? '白名单(跳过 PoW)' + : '黑名单(必须 PoW)'} +

+
+ + + updateList(scope, 'ips', event.target.value) + } + /> + + + + updateList(scope, 'ip_cidrs', event.target.value) + } + /> + + + + updateList(scope, 'paths', event.target.value) + } + /> + + + + updateList(scope, 'path_regexes', event.target.value) + } + /> + + + + updateList(scope, 'user_agents', event.target.value) + } + /> + +
+
+ ))} +
+
+ ); +} + export function WAFPage() { const queryClient = useQueryClient(); const [selectedID, setSelectedID] = useState(null); @@ -825,6 +1080,8 @@ export function WAFPage() { }, }); + + if (groupsQuery.isLoading || routesQuery.isLoading) { return ; } @@ -853,14 +1110,6 @@ export function WAFPage() { ); } - const enabledGroups = groups.filter((group) => group.enabled); - const protectedSites = new Set( - groups.flatMap((group) => group.applied_site_ids), - ); - const totalRules = groups.reduce( - (sum, group) => sum + countRuleEntries(group), - 0, - ); const currentRuleCount = countRuleEntries(draft); const appliedSiteNames = selectedGroup?.is_global ? ['全部网站'] @@ -950,7 +1199,7 @@ export function WAFPage() {
-
+
{tabItems.map((tab) => ( ) : null} + {activeTab === 'pow' ? ( + + setDraft((current) => ({ + ...current, + pow_enabled: enabled, + pow_config: config, + })) + } + /> + ) : null} + {activeTab === 'block' ? (
diff --git a/openflare_server/web/features/waf/types.ts b/openflare_server/web/features/waf/types.ts index 833f8672..1eebdbd3 100644 --- a/openflare_server/web/features/waf/types.ts +++ b/openflare_server/web/features/waf/types.ts @@ -1,3 +1,5 @@ +import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types'; + export interface WAFRuleGroup { id: number; name: string; @@ -11,6 +13,8 @@ export interface WAFRuleGroup { country_blacklist: string[]; region_whitelist: string[]; region_blacklist: string[]; + pow_enabled: boolean; + pow_config: ProxyRoutePoWConfig; remark: string; applied_site_ids: number[]; applied_site_count: number; @@ -29,6 +33,8 @@ export interface WAFRuleGroupPayload { country_blacklist: string[]; region_whitelist: string[]; region_blacklist: string[]; + pow_enabled: boolean; + pow_config: ProxyRoutePoWConfig; remark: string; }