mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
[优化] POW 与 WAF 合并
This commit is contained in:
@@ -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.
|
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
|
## Release, Activate, and Roll Back
|
||||||
|
|
||||||
Standard flow:
|
Standard flow:
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
|||||||
|
|
||||||
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
|
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
|
||||||
|
|
||||||
|
## 配置 WAF 与 PoW
|
||||||
|
|
||||||
|
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
|
||||||
|
|
||||||
|
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
|
||||||
|
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
|
||||||
|
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
|
||||||
|
|
||||||
|
WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
|
||||||
|
|
||||||
## 发布、激活与回滚
|
## 发布、激活与回滚
|
||||||
|
|
||||||
标准链路:
|
标准链路:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "time"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
legacyDatabaseSchemaVersion = 1
|
legacyDatabaseSchemaVersion = 1
|
||||||
currentDatabaseSchemaVersion = 13
|
currentDatabaseSchemaVersion = 14
|
||||||
databaseSchemaVersionRowID = 1
|
databaseSchemaVersionRowID = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1413,6 +1413,8 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error {
|
|||||||
CountryBlacklist: "[]",
|
CountryBlacklist: "[]",
|
||||||
RegionWhitelist: "[]",
|
RegionWhitelist: "[]",
|
||||||
RegionBlacklist: "[]",
|
RegionBlacklist: "[]",
|
||||||
|
PoWEnabled: false,
|
||||||
|
PoWConfig: "{}",
|
||||||
BlockResponseBody: "",
|
BlockResponseBody: "",
|
||||||
}
|
}
|
||||||
if err := db.Create(&group).Error; err != nil {
|
if err := db.Create(&group).Error; err != nil {
|
||||||
@@ -1449,6 +1451,27 @@ func validateDatabaseSchemaV13(db *gorm.DB, backend string) error {
|
|||||||
return nil
|
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 {
|
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||||
return []databaseSchemaMigration{
|
return []databaseSchemaMigration{
|
||||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||||
@@ -1463,6 +1486,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
|
|||||||
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
|
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
|
||||||
{fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12},
|
{fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12},
|
||||||
{fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13},
|
{fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13},
|
||||||
|
{fromVersion: 13, toVersion: 14, migrate: migrateV14, validate: validateDatabaseSchemaV14},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type WAFRuleGroup struct {
|
|||||||
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
|
||||||
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
|
||||||
RegionBlacklist string `json:"region_blacklist" 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"`
|
Remark string `json:"remark" gorm:"size:255"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -62,6 +64,8 @@ func (group *WAFRuleGroup) Update() error {
|
|||||||
"country_blacklist": group.CountryBlacklist,
|
"country_blacklist": group.CountryBlacklist,
|
||||||
"region_whitelist": group.RegionWhitelist,
|
"region_whitelist": group.RegionWhitelist,
|
||||||
"region_blacklist": group.RegionBlacklist,
|
"region_blacklist": group.RegionBlacklist,
|
||||||
|
"pow_enabled": group.PoWEnabled,
|
||||||
|
"pow_config": group.PoWConfig,
|
||||||
"remark": group.Remark,
|
"remark": group.Remark,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,18 +95,20 @@ type snapshotRoute struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type snapshotWAFRuleGroup struct {
|
type snapshotWAFRuleGroup struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
IsGlobal bool `json:"is_global"`
|
IsGlobal bool `json:"is_global"`
|
||||||
BlockStatusCode int `json:"block_status_code"`
|
BlockStatusCode int `json:"block_status_code"`
|
||||||
BlockResponseBody string `json:"block_response_body,omitempty"`
|
BlockResponseBody string `json:"block_response_body,omitempty"`
|
||||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||||
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
CountryWhitelist []string `json:"country_whitelist,omitempty"`
|
||||||
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
CountryBlacklist []string `json:"country_blacklist,omitempty"`
|
||||||
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
RegionWhitelist []string `json:"region_whitelist,omitempty"`
|
||||||
RegionBlacklist []string `json:"region_blacklist,omitempty"`
|
RegionBlacklist []string `json:"region_blacklist,omitempty"`
|
||||||
|
PoWEnabled bool `json:"pow_enabled,omitempty"`
|
||||||
|
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type snapshotWAFBinding struct {
|
type snapshotWAFBinding struct {
|
||||||
@@ -504,7 +506,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig)
|
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig, wafSnapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -512,7 +514,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
|
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes, wafSnapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -617,6 +619,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
|
|||||||
CountryBlacklist: view.CountryBlacklist,
|
CountryBlacklist: view.CountryBlacklist,
|
||||||
RegionWhitelist: view.RegionWhitelist,
|
RegionWhitelist: view.RegionWhitelist,
|
||||||
RegionBlacklist: view.RegionBlacklist,
|
RegionBlacklist: view.RegionBlacklist,
|
||||||
|
PoWEnabled: view.PoWEnabled,
|
||||||
|
PoWConfig: view.PoWConfig,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
enabledRouteIDs := make(map[uint]string, len(routes))
|
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
|
var builder strings.Builder
|
||||||
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
|
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
|
||||||
supportFiles := make([]SupportFile, 0)
|
supportFiles := make([]SupportFile, 0)
|
||||||
|
powEnabledByRoute := wafPowEnabledByRoute(wafSnapshot)
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -20,37 +20,41 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type WAFRuleGroupInput struct {
|
type WAFRuleGroupInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
BlockStatusCode int `json:"block_status_code"`
|
BlockStatusCode int `json:"block_status_code"`
|
||||||
BlockResponseBody string `json:"block_response_body"`
|
BlockResponseBody string `json:"block_response_body"`
|
||||||
IPWhitelist []string `json:"ip_whitelist"`
|
IPWhitelist []string `json:"ip_whitelist"`
|
||||||
IPBlacklist []string `json:"ip_blacklist"`
|
IPBlacklist []string `json:"ip_blacklist"`
|
||||||
CountryWhitelist []string `json:"country_whitelist"`
|
CountryWhitelist []string `json:"country_whitelist"`
|
||||||
CountryBlacklist []string `json:"country_blacklist"`
|
CountryBlacklist []string `json:"country_blacklist"`
|
||||||
RegionWhitelist []string `json:"region_whitelist"`
|
RegionWhitelist []string `json:"region_whitelist"`
|
||||||
RegionBlacklist []string `json:"region_blacklist"`
|
RegionBlacklist []string `json:"region_blacklist"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
|
PoWEnabled bool `json:"pow_enabled"`
|
||||||
|
PoWConfig json.RawMessage `json:"pow_config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WAFRuleGroupView struct {
|
type WAFRuleGroupView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
IsGlobal bool `json:"is_global"`
|
IsGlobal bool `json:"is_global"`
|
||||||
BlockStatusCode int `json:"block_status_code"`
|
BlockStatusCode int `json:"block_status_code"`
|
||||||
BlockResponseBody string `json:"block_response_body"`
|
BlockResponseBody string `json:"block_response_body"`
|
||||||
IPWhitelist []string `json:"ip_whitelist"`
|
IPWhitelist []string `json:"ip_whitelist"`
|
||||||
IPBlacklist []string `json:"ip_blacklist"`
|
IPBlacklist []string `json:"ip_blacklist"`
|
||||||
CountryWhitelist []string `json:"country_whitelist"`
|
CountryWhitelist []string `json:"country_whitelist"`
|
||||||
CountryBlacklist []string `json:"country_blacklist"`
|
CountryBlacklist []string `json:"country_blacklist"`
|
||||||
RegionWhitelist []string `json:"region_whitelist"`
|
RegionWhitelist []string `json:"region_whitelist"`
|
||||||
RegionBlacklist []string `json:"region_blacklist"`
|
RegionBlacklist []string `json:"region_blacklist"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
AppliedSiteIDs []uint `json:"applied_site_ids"`
|
PoWEnabled bool `json:"pow_enabled"`
|
||||||
AppliedSiteCount int `json:"applied_site_count"`
|
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
|
||||||
CreatedAt string `json:"created_at"`
|
AppliedSiteIDs []uint `json:"applied_site_ids"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
AppliedSiteCount int `json:"applied_site_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WAFSiteRuleGroupsView struct {
|
type WAFSiteRuleGroupsView struct {
|
||||||
@@ -275,6 +279,8 @@ func EnsureDefaultWAFRuleGroup() error {
|
|||||||
CountryBlacklist: "[]",
|
CountryBlacklist: "[]",
|
||||||
RegionWhitelist: "[]",
|
RegionWhitelist: "[]",
|
||||||
RegionBlacklist: "[]",
|
RegionBlacklist: "[]",
|
||||||
|
PoWEnabled: false,
|
||||||
|
PoWConfig: "{}",
|
||||||
BlockResponseBody: "",
|
BlockResponseBody: "",
|
||||||
}
|
}
|
||||||
return group.Insert()
|
return group.Insert()
|
||||||
@@ -313,6 +319,15 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
|
|||||||
}
|
}
|
||||||
regionWhitelist := normalizeStringList(input.RegionWhitelist)
|
regionWhitelist := normalizeStringList(input.RegionWhitelist)
|
||||||
regionBlacklist := normalizeStringList(input.RegionBlacklist)
|
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)
|
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
|
||||||
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
|
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
|
||||||
@@ -334,6 +349,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
|
|||||||
group.CountryBlacklist = string(countryBlacklistJSON)
|
group.CountryBlacklist = string(countryBlacklistJSON)
|
||||||
group.RegionWhitelist = string(regionWhitelistJSON)
|
group.RegionWhitelist = string(regionWhitelistJSON)
|
||||||
group.RegionBlacklist = string(regionBlacklistJSON)
|
group.RegionBlacklist = string(regionBlacklistJSON)
|
||||||
|
group.PoWEnabled = input.PoWEnabled
|
||||||
|
group.PoWConfig = string(powConfigJSON)
|
||||||
group.Remark = strings.TrimSpace(input.Remark)
|
group.Remark = strings.TrimSpace(input.Remark)
|
||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
@@ -351,6 +368,7 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA
|
|||||||
BlockStatusCode: group.BlockStatusCode,
|
BlockStatusCode: group.BlockStatusCode,
|
||||||
BlockResponseBody: group.BlockResponseBody,
|
BlockResponseBody: group.BlockResponseBody,
|
||||||
Remark: group.Remark,
|
Remark: group.Remark,
|
||||||
|
PoWEnabled: group.PoWEnabled,
|
||||||
AppliedSiteIDs: appliedSiteIDs,
|
AppliedSiteIDs: appliedSiteIDs,
|
||||||
AppliedSiteCount: len(appliedSiteIDs),
|
AppliedSiteCount: len(appliedSiteIDs),
|
||||||
CreatedAt: group.CreatedAt.Format(time.RFC3339),
|
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 {
|
if view.RegionBlacklist, err = decodeStringList(group.RegionBlacklist); err != nil {
|
||||||
return view, err
|
return view, err
|
||||||
}
|
}
|
||||||
|
if view.PoWConfig, err = decodeStoredPoWConfig(group.PoWEnabled, group.PoWConfig); err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+121
-304
@@ -19,6 +19,10 @@ import {
|
|||||||
getProxyRoute,
|
getProxyRoute,
|
||||||
updateProxyRoute,
|
updateProxyRoute,
|
||||||
} from '@/features/proxy-routes/api/proxy-routes';
|
} from '@/features/proxy-routes/api/proxy-routes';
|
||||||
|
import {
|
||||||
|
getWAFSiteRuleGroups,
|
||||||
|
replaceWAFSiteRuleGroups,
|
||||||
|
} from '@/features/waf/api/waf';
|
||||||
import {
|
import {
|
||||||
buildDomainRowsFromRoute,
|
buildDomainRowsFromRoute,
|
||||||
DomainListInput,
|
DomainListInput,
|
||||||
@@ -729,318 +733,137 @@ function CacheSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PowListValues = {
|
function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
||||||
ips: string;
|
const queryClient = useQueryClient();
|
||||||
ip_cidrs: string;
|
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||||
paths: string;
|
const wafQuery = useQuery({
|
||||||
path_regexes: string;
|
queryKey: ['waf', 'site-rule-groups', route.id],
|
||||||
user_agents: string;
|
queryFn: () => getWAFSiteRuleGroups(route.id),
|
||||||
};
|
|
||||||
|
|
||||||
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<string, string>)[dim.key] || '',
|
|
||||||
);
|
|
||||||
const bl = linesFromTextarea(
|
|
||||||
(value.blacklist as Record<string, string>)[dim.key] || '',
|
|
||||||
);
|
|
||||||
if (wl.length > 0 && bl.length > 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `${dim.label} 不能同时配置白名单和黑名单`,
|
|
||||||
path: ['blacklist', dim.key],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const wafMutation = useMutation({
|
||||||
type PowValues = z.infer<typeof powSchema>;
|
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(route.id, ids),
|
||||||
|
onSuccess: async (result) => {
|
||||||
function buildPowListFromConfig(
|
setSelectedIDs(result.applied_ids);
|
||||||
list:
|
await Promise.all([
|
||||||
| {
|
queryClient.invalidateQueries({
|
||||||
ips?: string[];
|
queryKey: ['waf', 'site-rule-groups', route.id],
|
||||||
ip_cidrs?: string[];
|
}),
|
||||||
paths?: string[];
|
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||||
path_regexes?: string[];
|
queryClient.invalidateQueries({
|
||||||
user_agents?: string[];
|
queryKey: ['config-versions', 'diff'],
|
||||||
}
|
}),
|
||||||
| 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<PowValues>({
|
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
if (wafQuery.data) {
|
||||||
pow_enabled: route.pow_enabled,
|
setSelectedIDs(wafQuery.data.applied_ids);
|
||||||
difficulty: powConfig?.difficulty ?? 4,
|
}
|
||||||
algorithm: powConfig?.algorithm ?? 'fast',
|
}, [wafQuery.data]);
|
||||||
session_ttl: powConfig?.session_ttl ?? 600,
|
|
||||||
challenge_ttl: powConfig?.challenge_ttl ?? 300,
|
|
||||||
whitelist: buildPowListFromConfig(powConfig?.whitelist),
|
|
||||||
blacklist: buildPowListFromConfig(powConfig?.blacklist),
|
|
||||||
});
|
|
||||||
}, [form, route, powConfig]);
|
|
||||||
|
|
||||||
const watchedEnabled = form.watch('pow_enabled');
|
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||||
|
|
||||||
const parseList = (text: string): string[] =>
|
|
||||||
linesFromTextarea(text).filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="PoW 防护"
|
title="WAF"
|
||||||
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
|
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。PoW 规则请在 WAF 页面统一配置和批量应用。"
|
||||||
formId="proxy-route-pow-form"
|
formId="proxy-route-waf-form"
|
||||||
saving={saving}
|
saving={wafMutation.isPending}
|
||||||
>
|
>
|
||||||
<form
|
{wafQuery.isLoading ? (
|
||||||
id="proxy-route-pow-form"
|
<LoadingState />
|
||||||
className="space-y-5"
|
) : wafQuery.isError ? (
|
||||||
onSubmit={form.handleSubmit((values) => {
|
<ErrorState
|
||||||
const powConfigPayload = JSON.stringify({
|
title="WAF 规则加载失败"
|
||||||
difficulty: values.difficulty,
|
description={getErrorMessage(wafQuery.error)}
|
||||||
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 防护设置已保存。' },
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ToggleField
|
|
||||||
label="启用 PoW 防护"
|
|
||||||
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
|
|
||||||
checked={watchedEnabled}
|
|
||||||
onChange={(checked) =>
|
|
||||||
form.setValue('pow_enabled', checked, { shouldDirty: true })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{wafMutation.isError ? (
|
||||||
|
<InlineMessage
|
||||||
|
tone="danger"
|
||||||
|
message={getErrorMessage(wafMutation.error)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{wafMutation.isSuccess ? (
|
||||||
|
<InlineMessage tone="success" message="WAF 规则组已更新。" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ResourceField label="验证算法">
|
{wafQuery.data?.global_rule_group ? (
|
||||||
<ResourceSelect
|
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||||
disabled={!watchedEnabled}
|
<div className="flex items-center justify-between gap-3">
|
||||||
{...form.register('algorithm')}
|
<div>
|
||||||
>
|
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
<option value="fast">Fast(WebCrypto SHA-256)</option>
|
Global Rule Group
|
||||||
<option value="slow">Slow(兼容模式)</option>
|
</p>
|
||||||
</ResourceSelect>
|
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
</ResourceField>
|
{wafQuery.data.global_rule_group.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
始终生效
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ResourceField
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
label="难度"
|
{(wafQuery.data?.rule_groups ?? []).map((group) => (
|
||||||
hint="数值越高验证越慢,1-16。推荐 3-5。"
|
<label
|
||||||
error={form.formState.errors.difficulty?.message}
|
key={group.id}
|
||||||
>
|
className={cn(
|
||||||
<ResourceInput
|
'flex cursor-pointer items-start gap-3 rounded-[22px] border p-4 transition',
|
||||||
type="number"
|
selectedSet.has(group.id)
|
||||||
min={1}
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||||
max={16}
|
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||||
disabled={!watchedEnabled}
|
)}
|
||||||
{...form.register('difficulty')}
|
>
|
||||||
/>
|
<input
|
||||||
</ResourceField>
|
type="checkbox"
|
||||||
|
checked={selectedSet.has(group.id)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const checked = event.target.checked;
|
||||||
|
setSelectedIDs((current) =>
|
||||||
|
checked
|
||||||
|
? [...current, group.id].sort((left, right) => left - right)
|
||||||
|
: current.filter((id) => id !== group.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
|
{group.enabled ? '启用中' : '已停用'} ·{' '}
|
||||||
|
{group.ip_whitelist.length +
|
||||||
|
group.ip_blacklist.length +
|
||||||
|
group.country_whitelist.length +
|
||||||
|
group.country_blacklist.length}{' '}
|
||||||
|
条规则
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResourceField
|
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||||
label="会话空闲有效期(秒)"
|
<EmptyState title="暂无自定义 WAF 规则组" />
|
||||||
hint="通过验证后,若在此时间内没有新请求,Cookie 会失效;每次访问会自动续期。默认 600 秒。"
|
) : null}
|
||||||
error={form.formState.errors.session_ttl?.message}
|
|
||||||
>
|
|
||||||
<ResourceInput
|
|
||||||
type="number"
|
|
||||||
min={60}
|
|
||||||
disabled={!watchedEnabled}
|
|
||||||
{...form.register('session_ttl')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
|
|
||||||
<ResourceField
|
<div className="flex justify-end">
|
||||||
label="挑战有效期(秒)"
|
<PrimaryButton
|
||||||
hint="挑战令牌的有效期。"
|
type="button"
|
||||||
error={form.formState.errors.challenge_ttl?.message}
|
disabled={wafMutation.isPending}
|
||||||
>
|
onClick={() => wafMutation.mutate(selectedIDs)}
|
||||||
<ResourceInput
|
>
|
||||||
type="number"
|
{wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'}
|
||||||
min={30}
|
</PrimaryButton>
|
||||||
disabled={!watchedEnabled}
|
</div>
|
||||||
{...form.register('challenge_ttl')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
||||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
|
||||||
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
白名单(匹配的请求跳过 PoW)
|
|
||||||
</legend>
|
|
||||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="1.2.3.4 5.6.7.8"
|
|
||||||
{...form.register('whitelist.ips')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="10.0.0.0/8 192.168.0.0/16"
|
|
||||||
{...form.register('whitelist.ip_cidrs')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径" hint="每行一个路径通配符">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="/.well-known/* /favicon.ico"
|
|
||||||
{...form.register('whitelist.paths')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="^/api/public/"
|
|
||||||
{...form.register('whitelist.path_regexes')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="Googlebot bingbot"
|
|
||||||
{...form.register('whitelist.user_agents')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
|
||||||
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
黑名单(匹配的请求必须 PoW)
|
|
||||||
</legend>
|
|
||||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="1.2.3.4"
|
|
||||||
{...form.register('blacklist.ips')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="10.0.0.0/8"
|
|
||||||
{...form.register('blacklist.ip_cidrs')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径" hint="每行一个路径通配符">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="/admin/*"
|
|
||||||
{...form.register('blacklist.paths')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="^/private/"
|
|
||||||
{...form.register('blacklist.path_regexes')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
|
||||||
<ResourceTextarea
|
|
||||||
className="min-h-20"
|
|
||||||
placeholder="bot crawler"
|
|
||||||
{...form.register('blacklist.user_agents')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
{form.formState.errors.blacklist && (
|
)}
|
||||||
<p className="text-sm text-[var(--color-danger)]">
|
|
||||||
{Object.values(form.formState.errors.blacklist)
|
|
||||||
.flatMap((e) =>
|
|
||||||
e && typeof e === 'object' && 'message' in e
|
|
||||||
? [e.message as string]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
.join('; ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</ConfigSectionShell>
|
</ConfigSectionShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1370,14 +1193,8 @@ export function ProxyRouteConfigPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentSection === 'pow' ? (
|
{currentSection === 'waf' ? (
|
||||||
<PowSection
|
<WAFBindingSection route={route} />
|
||||||
route={route}
|
|
||||||
saving={saveMutation.isPending}
|
|
||||||
onSave={(payload, context) =>
|
|
||||||
saveMutation.mutate({ payload, context })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentSection === 'auth' ? (
|
{currentSection === 'auth' ? (
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const websiteConfigSections = [
|
|||||||
description: '配置站点缓存策略。',
|
description: '配置站点缓存策略。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pow',
|
key: 'waf',
|
||||||
label: 'PoW 防护',
|
label: 'WAF',
|
||||||
description: '配置 Proof-of-Work 反爬虫策略。',
|
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { ReactNode } from 'react';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
Cpu,
|
||||||
Globe2,
|
Globe2,
|
||||||
ListFilter,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
@@ -23,8 +23,15 @@ import { PageHeader } from '@/components/layout/page-header';
|
|||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
import { Drawer } from '@/components/ui/drawer';
|
import { Drawer } from '@/components/ui/drawer';
|
||||||
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
import {
|
||||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
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 {
|
import {
|
||||||
DangerButton,
|
DangerButton,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@@ -49,7 +56,7 @@ type FeedbackState = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WAFTab = 'basic' | 'lists' | 'block';
|
type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||||
type RuleListType = 'whitelist' | 'blacklist';
|
type RuleListType = 'whitelist' | 'blacklist';
|
||||||
type RuleDimension = 'ip' | 'country';
|
type RuleDimension = 'ip' | 'country';
|
||||||
type ListFieldKey =
|
type ListFieldKey =
|
||||||
@@ -83,6 +90,27 @@ type RuleListRenderable = Pick<
|
|||||||
| 'region_blacklist'
|
| '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 = {
|
const emptyDraft: WAFRuleGroupPayload = {
|
||||||
name: '',
|
name: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -94,6 +122,8 @@ const emptyDraft: WAFRuleGroupPayload = {
|
|||||||
country_blacklist: [],
|
country_blacklist: [],
|
||||||
region_whitelist: [],
|
region_whitelist: [],
|
||||||
region_blacklist: [],
|
region_blacklist: [],
|
||||||
|
pow_enabled: false,
|
||||||
|
pow_config: defaultPowConfig,
|
||||||
remark: '',
|
remark: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +147,10 @@ const tabItems: Array<{
|
|||||||
id: 'lists',
|
id: 'lists',
|
||||||
label: '黑白名单',
|
label: '黑白名单',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'pow',
|
||||||
|
label: 'PoW',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'block',
|
id: 'block',
|
||||||
label: '拦截返回',
|
label: '拦截返回',
|
||||||
@@ -134,6 +168,17 @@ function textToList(text: string) {
|
|||||||
.filter(Boolean);
|
.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[]) {
|
function normalizeItems(items: string[]) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
||||||
@@ -155,6 +200,8 @@ function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
|||||||
country_blacklist: group.country_blacklist ?? [],
|
country_blacklist: group.country_blacklist ?? [],
|
||||||
region_whitelist: group.region_whitelist ?? [],
|
region_whitelist: group.region_whitelist ?? [],
|
||||||
region_blacklist: group.region_blacklist ?? [],
|
region_blacklist: group.region_blacklist ?? [],
|
||||||
|
pow_enabled: group.pow_enabled ?? false,
|
||||||
|
pow_config: group.pow_config ?? defaultPowConfig,
|
||||||
remark: group.remark ?? '',
|
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<ProxyRoutePoWConfig>,
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ToggleField
|
||||||
|
label="启用 PoW 防护"
|
||||||
|
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(newEnabled) => updateConfig(newEnabled, {})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ResourceField label="算法">
|
||||||
|
<select
|
||||||
|
value={config.algorithm}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
|
||||||
|
}
|
||||||
|
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
||||||
|
>
|
||||||
|
<option value="fast">Fast</option>
|
||||||
|
<option value="slow">Slow</option>
|
||||||
|
</select>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="难度">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
value={config.difficulty}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { difficulty: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="会话 TTL">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={60}
|
||||||
|
value={config.session_ttl}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { session_ttl: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="挑战 TTL">
|
||||||
|
<ResourceInput
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
value={config.challenge_ttl}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
{(['whitelist', 'blacklist'] as const).map((scope) => (
|
||||||
|
<div
|
||||||
|
key={scope}
|
||||||
|
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{scope === 'whitelist'
|
||||||
|
? '白名单(跳过 PoW)'
|
||||||
|
: '黑名单(必须 PoW)'}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<ResourceField label="IP">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].ips}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'ips', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="IP CIDR">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].ip_cidrs}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'ip_cidrs', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="路径">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].paths}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'paths', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="路径正则">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].path_regexes}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'path_regexes', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="User-Agent">
|
||||||
|
<ResourceTextarea
|
||||||
|
className="min-h-20"
|
||||||
|
value={draft[scope].user_agents}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateList(scope, 'user_agents', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WAFPage() {
|
export function WAFPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||||
@@ -825,6 +1080,8 @@ export function WAFPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
@@ -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 currentRuleCount = countRuleEntries(draft);
|
||||||
const appliedSiteNames = selectedGroup?.is_global
|
const appliedSiteNames = selectedGroup?.is_global
|
||||||
? ['全部网站']
|
? ['全部网站']
|
||||||
@@ -950,7 +1199,7 @@ export function WAFPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="WAF"
|
title="WAF"
|
||||||
description="按规则组维护 IP 与地域黑白名单,全局规则始终应用到所有网站。"
|
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
|
||||||
action={
|
action={
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1039,7 +1288,7 @@ export function WAFPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
{tabItems.map((tab) => (
|
{tabItems.map((tab) => (
|
||||||
<TabButton
|
<TabButton
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -1210,6 +1459,21 @@ export function WAFPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'pow' ? (
|
||||||
|
<PowTabPanel
|
||||||
|
key={selectedID}
|
||||||
|
enabled={draft.pow_enabled}
|
||||||
|
config={draft.pow_config}
|
||||||
|
onChange={(enabled, config) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
pow_enabled: enabled,
|
||||||
|
pow_config: config,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'block' ? (
|
{activeTab === 'block' ? (
|
||||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||||
|
|
||||||
export interface WAFRuleGroup {
|
export interface WAFRuleGroup {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,6 +13,8 @@ export interface WAFRuleGroup {
|
|||||||
country_blacklist: string[];
|
country_blacklist: string[];
|
||||||
region_whitelist: string[];
|
region_whitelist: string[];
|
||||||
region_blacklist: string[];
|
region_blacklist: string[];
|
||||||
|
pow_enabled: boolean;
|
||||||
|
pow_config: ProxyRoutePoWConfig;
|
||||||
remark: string;
|
remark: string;
|
||||||
applied_site_ids: number[];
|
applied_site_ids: number[];
|
||||||
applied_site_count: number;
|
applied_site_count: number;
|
||||||
@@ -29,6 +33,8 @@ export interface WAFRuleGroupPayload {
|
|||||||
country_blacklist: string[];
|
country_blacklist: string[];
|
||||||
region_whitelist: string[];
|
region_whitelist: string[];
|
||||||
region_blacklist: string[];
|
region_blacklist: string[];
|
||||||
|
pow_enabled: boolean;
|
||||||
|
pow_config: ProxyRoutePoWConfig;
|
||||||
remark: string;
|
remark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user