mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a53d6b5f | |||
| cef3694d11 | |||
| 631d32e5d0 | |||
| c74b70b62e |
+1
-1
@@ -16,7 +16,7 @@ services:
|
||||
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
|
||||
OPENFLARE_AGENT_TOKEN: "373956188ddead1df6dd7c86cd330b73"
|
||||
OPENFLARE_AGENT_TOKEN: "07800f31d3f181e65d18dca1407d821c"
|
||||
LOG_LEVEL: "debug"
|
||||
|
||||
extra_hosts:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 发布、激活与回滚
|
||||
|
||||
标准链路:
|
||||
|
||||
@@ -150,6 +150,12 @@ func AgentWebSocket(c *gin.Context) {
|
||||
}()
|
||||
|
||||
slog.Debug("agent ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
|
||||
go func() {
|
||||
<-client.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
go streamAgentWSMessages(c, conn, client)
|
||||
|
||||
for {
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 13
|
||||
currentDatabaseSchemaVersion = 14
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"column:pow_enabled;not null;default:false"`
|
||||
PoWConfig string `json:"pow_config" gorm:"column:pow_config;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
|
||||
}
|
||||
|
||||
@@ -127,6 +127,21 @@ func UnregisterAgentWSClient(client *AgentWSClient) {
|
||||
slog.Debug("agent ws connection unregistered", "node_id", client.nodeID, "client_count", count)
|
||||
}
|
||||
|
||||
func DisconnectAgentWSClient(nodeID string) {
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
if client != nil {
|
||||
delete(defaultAgentWSHub.clients, nodeID)
|
||||
}
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
|
||||
if client != nil {
|
||||
client.Close()
|
||||
slog.Debug("agent ws connection forcefully disconnected", "node_id", nodeID, "client_count", count)
|
||||
}
|
||||
}
|
||||
|
||||
func IsAgentWSConnected(nodeID string) bool {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
|
||||
@@ -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,7 +1033,28 @@ func openRestyOptionKeys() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) (string, []SupportFile, error) {
|
||||
func getPoWConfigForRoute(routeID uint, snapshot snapshotWAFDocument) (bool, *ProxyRoutePoWConfig) {
|
||||
for _, binding := range snapshot.Bindings {
|
||||
if binding.RouteID == routeID {
|
||||
for _, groupID := range binding.RuleGroupIDs {
|
||||
for _, group := range snapshot.RuleGroups {
|
||||
if group.ID == groupID && group.PoWEnabled {
|
||||
return true, group.PoWConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, group := range snapshot.RuleGroups {
|
||||
if group.IsGlobal && group.PoWEnabled {
|
||||
return true, group.PoWConfig
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1069,8 +1094,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
if upstreamConfig.UsesNamedUpstream {
|
||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||
}
|
||||
powEnabled, _ := getPoWConfigForRoute(route.ID, wafSnapshot)
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
continue
|
||||
}
|
||||
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
||||
@@ -1131,7 +1157,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
|
||||
if route.RedirectHTTP {
|
||||
if len(httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
assignedDomains := domainsByCertID[certID]
|
||||
@@ -1141,14 +1167,14 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains), displayName))
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
assignedDomains := domainsByCertID[certID]
|
||||
if len(assignedDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
|
||||
}
|
||||
}
|
||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||
@@ -1813,16 +1839,17 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
|
||||
return result
|
||||
}
|
||||
|
||||
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
|
||||
func renderPowConfigBundle(routes []*model.ProxyRoute, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) {
|
||||
type domainEntry struct {
|
||||
Domains []string `json:"domains"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
Domains []string `json:"domains"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config *ProxyRoutePoWConfig `json:"config"`
|
||||
}
|
||||
entries := make([]domainEntry, 0)
|
||||
hasPow := false
|
||||
for _, route := range routes {
|
||||
if !route.PoWEnabled {
|
||||
powEnabled, powConfig := getPoWConfigForRoute(route.ID, wafSnapshot)
|
||||
if !powEnabled {
|
||||
continue
|
||||
}
|
||||
hasPow = true
|
||||
@@ -1830,14 +1857,10 @@ func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, e
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
|
||||
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
|
||||
}
|
||||
entries = append(entries, domainEntry{
|
||||
Domains: domains,
|
||||
Enabled: true,
|
||||
Config: cfg,
|
||||
Config: powConfig,
|
||||
})
|
||||
}
|
||||
if !hasPow {
|
||||
|
||||
@@ -130,6 +130,7 @@ func DeleteNode(id uint) error {
|
||||
return err
|
||||
}
|
||||
invalidateAgentTokenCache(node.AgentToken)
|
||||
DisconnectAgentWSClient(node.NodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+119
-305
@@ -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,134 @@ 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<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],
|
||||
});
|
||||
}
|
||||
}
|
||||
function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
const wafQuery = useQuery({
|
||||
queryKey: ['waf', 'site-rule-groups', route.id],
|
||||
queryFn: () => getWAFSiteRuleGroups(route.id),
|
||||
});
|
||||
|
||||
type PowValues = z.infer<typeof powSchema>;
|
||||
|
||||
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<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),
|
||||
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 (
|
||||
<ConfigSectionShell
|
||||
title="PoW 防护"
|
||||
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
|
||||
formId="proxy-route-pow-form"
|
||||
saving={saving}
|
||||
title="WAF"
|
||||
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。"
|
||||
formId="proxy-route-waf-form"
|
||||
saving={wafMutation.isPending}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-pow-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
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 防护设置已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ToggleField
|
||||
label="启用 PoW 防护"
|
||||
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('pow_enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
{wafQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : wafQuery.isError ? (
|
||||
<ErrorState
|
||||
title="WAF 规则加载失败"
|
||||
description={getErrorMessage(wafQuery.error)}
|
||||
/>
|
||||
|
||||
<ResourceField label="验证算法">
|
||||
<ResourceSelect
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('algorithm')}
|
||||
>
|
||||
<option value="fast">Fast(WebCrypto SHA-256)</option>
|
||||
<option value="slow">Slow(兼容模式)</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="难度"
|
||||
hint="数值越高验证越慢,1-16。推荐 3-5。"
|
||||
error={form.formState.errors.difficulty?.message}
|
||||
) : (
|
||||
<form
|
||||
id="proxy-route-waf-form"
|
||||
className="space-y-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
wafMutation.mutate(selectedIDs);
|
||||
}}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('difficulty')}
|
||||
/>
|
||||
</ResourceField>
|
||||
{wafMutation.isError ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={getErrorMessage(wafMutation.error)}
|
||||
/>
|
||||
) : null}
|
||||
{wafMutation.isSuccess ? (
|
||||
<InlineMessage tone="success" message="WAF 规则组已更新。" />
|
||||
) : null}
|
||||
|
||||
<ResourceField
|
||||
label="会话空闲有效期(秒)"
|
||||
hint="通过验证后,若在此时间内没有新请求,Cookie 会失效;每次访问会自动续期。默认 600 秒。"
|
||||
error={form.formState.errors.session_ttl?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={60}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('session_ttl')}
|
||||
/>
|
||||
</ResourceField>
|
||||
{wafQuery.data?.global_rule_group ? (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
Global Rule Group
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{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
|
||||
label="挑战有效期(秒)"
|
||||
hint="挑战令牌的有效期。"
|
||||
error={form.formState.errors.challenge_ttl?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={30}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('challenge_ttl')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(wafQuery.data?.rule_groups ?? []).map((group) => (
|
||||
<label
|
||||
key={group.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-3 rounded-[22px] border p-4 transition',
|
||||
selectedSet.has(group.id)
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||
<EmptyState title="暂无自定义 WAF 规则组" />
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
@@ -1370,14 +1190,8 @@ export function ProxyRouteConfigPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'pow' ? (
|
||||
<PowSection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
{currentSection === 'waf' ? (
|
||||
<WAFBindingSection route={route} />
|
||||
) : null}
|
||||
|
||||
{currentSection === 'auth' ? (
|
||||
|
||||
@@ -26,9 +26,9 @@ export const websiteConfigSections = [
|
||||
description: '配置站点缓存策略。',
|
||||
},
|
||||
{
|
||||
key: 'pow',
|
||||
label: 'PoW 防护',
|
||||
description: '配置 Proof-of-Work 反爬虫策略。',
|
||||
key: 'waf',
|
||||
label: 'WAF',
|
||||
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
||||
import type {
|
||||
CountryOption,
|
||||
ListFieldKey,
|
||||
RuleDimension,
|
||||
RuleListRenderable,
|
||||
RuleListType,
|
||||
RuleModalState,
|
||||
WAFTab,
|
||||
} from './types';
|
||||
|
||||
export 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: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const emptyDraft: WAFRuleGroupPayload = {
|
||||
name: '',
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
country_whitelist: [],
|
||||
country_blacklist: [],
|
||||
region_whitelist: [],
|
||||
region_blacklist: [],
|
||||
pow_enabled: false,
|
||||
pow_config: defaultPowConfig,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultRuleModalState: RuleModalState = {
|
||||
open: false,
|
||||
listType: 'whitelist',
|
||||
dimension: 'ip',
|
||||
ipValue: '',
|
||||
countryValues: [],
|
||||
};
|
||||
|
||||
export const tabItems: Array<{
|
||||
id: WAFTab;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'basic', label: '基本信息' },
|
||||
{ id: 'lists', label: '黑白名单' },
|
||||
{ id: 'pow', label: 'PoW' },
|
||||
{ id: 'block', label: '拦截返回' },
|
||||
];
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
|
||||
export function textToList(text: string) {
|
||||
return text
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function listToText(items: string[] | undefined) {
|
||||
return (items ?? []).join('\n');
|
||||
}
|
||||
|
||||
export function parseTextareaList(text: string) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeItems(items: string[]) {
|
||||
return Array.from(
|
||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
||||
if (!group) {
|
||||
return { ...emptyDraft };
|
||||
}
|
||||
return {
|
||||
name: group.name,
|
||||
enabled: group.enabled,
|
||||
block_status_code: group.block_status_code || 418,
|
||||
block_response_body: group.block_response_body ?? '',
|
||||
ip_whitelist: group.ip_whitelist ?? [],
|
||||
ip_blacklist: group.ip_blacklist ?? [],
|
||||
country_whitelist: group.country_whitelist ?? [],
|
||||
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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function countRuleEntries(group: RuleListRenderable) {
|
||||
return (
|
||||
group.ip_whitelist.length +
|
||||
group.ip_blacklist.length +
|
||||
group.country_whitelist.length +
|
||||
group.country_blacklist.length +
|
||||
group.region_whitelist.length +
|
||||
group.region_blacklist.length
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCountryOptions() {
|
||||
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
||||
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
const options: CountryOption[] = [];
|
||||
|
||||
for (let first = 65; first <= 90; first += 1) {
|
||||
for (let second = 65; second <= 90; second += 1) {
|
||||
const code = String.fromCharCode(first, second);
|
||||
const zhName = zhDisplayNames.of(code);
|
||||
const enName = enDisplayNames.of(code);
|
||||
|
||||
if (
|
||||
!zhName ||
|
||||
zhName === code ||
|
||||
/未知/.test(zhName) ||
|
||||
!enName ||
|
||||
enName === code ||
|
||||
/Unknown/.test(enName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push({
|
||||
code,
|
||||
zhName,
|
||||
label: `${code} ${zhName}`,
|
||||
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((left, right) => left.code.localeCompare(right.code));
|
||||
}
|
||||
|
||||
export function getListFieldKey(
|
||||
listType: RuleListType,
|
||||
dimension: RuleDimension,
|
||||
): ListFieldKey {
|
||||
if (dimension === 'ip') {
|
||||
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
||||
}
|
||||
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
||||
}
|
||||
|
||||
export function updateDraftList(
|
||||
draft: WAFRuleGroupPayload,
|
||||
key: ListFieldKey,
|
||||
updater: (items: string[]) => string[],
|
||||
) {
|
||||
switch (key) {
|
||||
case 'ip_whitelist':
|
||||
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
||||
case 'ip_blacklist':
|
||||
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
||||
case 'country_whitelist':
|
||||
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
||||
case 'country_blacklist':
|
||||
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
||||
return labelMap.get(code) ?? code;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||
import {
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { listToText, parseTextareaList } from './helpers';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function RuleChip({
|
||||
label,
|
||||
tone,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
|
||||
tone === 'whitelist'
|
||||
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
|
||||
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
|
||||
)}
|
||||
>
|
||||
<span className="break-all">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
|
||||
aria-label={`移除 ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { normalizeItems } from './helpers';
|
||||
import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types';
|
||||
|
||||
export function RuleEntryModal({
|
||||
state,
|
||||
countryOptions,
|
||||
pending,
|
||||
onClose,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: RuleModalState;
|
||||
countryOptions: CountryOption[];
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<RuleModalState>) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
return;
|
||||
}
|
||||
setKeyword('');
|
||||
}, [state.dimension, state.open]);
|
||||
|
||||
const selectedCountrySet = useMemo(
|
||||
() => new Set(state.countryValues),
|
||||
[state.countryValues],
|
||||
);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return countryOptions
|
||||
.filter((option) => !normalized || option.searchText.includes(normalized))
|
||||
.sort((left, right) => {
|
||||
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
||||
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
||||
return (
|
||||
rightSelected - leftSelected || left.code.localeCompare(right.code)
|
||||
);
|
||||
});
|
||||
}, [countryOptions, keyword, selectedCountrySet]);
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
const values = selectedCountrySet.has(code)
|
||||
? state.countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...state.countryValues, code]);
|
||||
onChange({ countryValues: values });
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
onChange({
|
||||
countryValues: normalizeItems([
|
||||
...state.countryValues,
|
||||
...filteredCountries.map((option) => option.code),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
const clearCountries = () => onChange({ countryValues: [] });
|
||||
|
||||
const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单';
|
||||
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={state.open}
|
||||
title={`添加${typeLabel}规则`}
|
||||
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
|
||||
{pending ? '处理中...' : '添加'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="类型" container="div">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'blacklist', label: '黑名单' },
|
||||
{ value: 'whitelist', label: '白名单' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ listType: option.value as RuleListType })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.listType === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
<ResourceField label="维度" container="div">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'ip', label: 'IP' },
|
||||
{ value: 'country', label: '地域' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ dimension: option.value as RuleDimension })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.dimension === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
{state.dimension === 'ip' ? (
|
||||
<ResourceField
|
||||
label="IP / IP 段"
|
||||
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={state.ipValue}
|
||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||
/>
|
||||
</ResourceField>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索国家代码或中文名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearCountries}
|
||||
className="text-xs font-medium text-[var(--foreground-secondary)]"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
地域多选
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
选项显示为「国家代码 国家中文名」。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
已选 {state.countryValues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{filteredCountries.map((option) => {
|
||||
const selected = selectedCountrySet.has(option.code);
|
||||
return (
|
||||
<label
|
||||
key={option.code}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||
selected
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleCountry(option.code)}
|
||||
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { RuleChip } from './rule-chip';
|
||||
|
||||
export function RuleListSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
tone,
|
||||
emptyText,
|
||||
onRemove,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
emptyText: string;
|
||||
onRemove: (item: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<RuleChip
|
||||
key={item}
|
||||
label={item}
|
||||
tone={tone}
|
||||
onRemove={() => onRemove(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-muted)]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { Drawer } from '@/components/ui/drawer';
|
||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||
import { PrimaryButton, SecondaryButton } from '@/features/shared/components/resource-primitives';
|
||||
import type { WAFRuleGroup } from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function SiteApplyDrawer({
|
||||
group,
|
||||
routes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
pending,
|
||||
}: {
|
||||
group: WAFRuleGroup | null;
|
||||
routes: ProxyRouteItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (ids: number[]) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIDs(group?.applied_site_ids ?? []);
|
||||
setKeyword('');
|
||||
}, [group, open]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return routes;
|
||||
}
|
||||
return routes.filter((route) =>
|
||||
[route.site_name, route.primary_domain, ...route.domains]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized),
|
||||
);
|
||||
}, [keyword, routes]);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||
const toggleID = (id: number) => {
|
||||
setSelectedIDs((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id].sort((left, right) => left - right),
|
||||
);
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
const next = new Set(selectedIDs);
|
||||
filteredRoutes.forEach((route) => next.add(route.id));
|
||||
setSelectedIDs([...next].sort((left, right) => left - right));
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title={group ? `应用 ${group.name}` : '应用规则组'}
|
||||
description="选择这个自定义规则组要叠加到哪些网站。"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!group || pending}
|
||||
onClick={() => onSave(selectedIDs)}
|
||||
>
|
||||
{pending ? '保存中...' : '保存应用范围'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索网站或域名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredRoutes.map((route) => (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => toggleID(route.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md border',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
|
||||
: 'border-[var(--border-default)]',
|
||||
)}
|
||||
>
|
||||
{selectedSet.has(route.id) ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
|
||||
{route.domains.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function TabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded-[24px] border px-4 py-4 text-left transition',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { WAFRuleGroupPayload } from '@/features/waf/types';
|
||||
|
||||
export type FeedbackState = {
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||
export type RuleListType = 'whitelist' | 'blacklist';
|
||||
export type RuleDimension = 'ip' | 'country';
|
||||
export type ListFieldKey =
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist';
|
||||
|
||||
export type CountryOption = {
|
||||
code: string;
|
||||
zhName: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type RuleModalState = {
|
||||
open: boolean;
|
||||
listType: RuleListType;
|
||||
dimension: RuleDimension;
|
||||
ipValue: string;
|
||||
countryValues: string[];
|
||||
};
|
||||
|
||||
export type RuleListRenderable = Pick<
|
||||
WAFRuleGroupPayload,
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist'
|
||||
| 'region_whitelist'
|
||||
| 'region_blacklist'
|
||||
>;
|
||||
@@ -4,13 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe2,
|
||||
ListFilter,
|
||||
type LucideIcon,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
@@ -21,10 +17,7 @@ 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 { 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 {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
@@ -44,692 +37,26 @@ import {
|
||||
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type WAFTab = 'basic' | 'lists' | 'block';
|
||||
type RuleListType = 'whitelist' | 'blacklist';
|
||||
type RuleDimension = 'ip' | 'country';
|
||||
type ListFieldKey =
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist';
|
||||
|
||||
type CountryOption = {
|
||||
code: string;
|
||||
zhName: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
type RuleModalState = {
|
||||
open: boolean;
|
||||
listType: RuleListType;
|
||||
dimension: RuleDimension;
|
||||
ipValue: string;
|
||||
countryValues: string[];
|
||||
};
|
||||
|
||||
type RuleListRenderable = Pick<
|
||||
WAFRuleGroupPayload,
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist'
|
||||
| 'region_whitelist'
|
||||
| 'region_blacklist'
|
||||
>;
|
||||
|
||||
const emptyDraft: WAFRuleGroupPayload = {
|
||||
name: '',
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
country_whitelist: [],
|
||||
country_blacklist: [],
|
||||
region_whitelist: [],
|
||||
region_blacklist: [],
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const defaultRuleModalState: RuleModalState = {
|
||||
open: false,
|
||||
listType: 'whitelist',
|
||||
dimension: 'ip',
|
||||
ipValue: '',
|
||||
countryValues: [],
|
||||
};
|
||||
|
||||
const tabItems: Array<{
|
||||
id: WAFTab;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
id: 'basic',
|
||||
label: '基本信息',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: '黑白名单',
|
||||
},
|
||||
{
|
||||
id: 'block',
|
||||
label: '拦截返回',
|
||||
},
|
||||
];
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
|
||||
function textToList(text: string) {
|
||||
return text
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeItems(items: string[]) {
|
||||
return Array.from(
|
||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
||||
if (!group) {
|
||||
return { ...emptyDraft };
|
||||
}
|
||||
return {
|
||||
name: group.name,
|
||||
enabled: group.enabled,
|
||||
block_status_code: group.block_status_code || 418,
|
||||
block_response_body: group.block_response_body ?? '',
|
||||
ip_whitelist: group.ip_whitelist ?? [],
|
||||
ip_blacklist: group.ip_blacklist ?? [],
|
||||
country_whitelist: group.country_whitelist ?? [],
|
||||
country_blacklist: group.country_blacklist ?? [],
|
||||
region_whitelist: group.region_whitelist ?? [],
|
||||
region_blacklist: group.region_blacklist ?? [],
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function countRuleEntries(group: RuleListRenderable) {
|
||||
return (
|
||||
group.ip_whitelist.length +
|
||||
group.ip_blacklist.length +
|
||||
group.country_whitelist.length +
|
||||
group.country_blacklist.length +
|
||||
group.region_whitelist.length +
|
||||
group.region_blacklist.length
|
||||
);
|
||||
}
|
||||
|
||||
function buildCountryOptions() {
|
||||
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
||||
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
const options: CountryOption[] = [];
|
||||
|
||||
for (let first = 65; first <= 90; first += 1) {
|
||||
for (let second = 65; second <= 90; second += 1) {
|
||||
const code = String.fromCharCode(first, second);
|
||||
const zhName = zhDisplayNames.of(code);
|
||||
const enName = enDisplayNames.of(code);
|
||||
|
||||
if (
|
||||
!zhName ||
|
||||
zhName === code ||
|
||||
/未知/.test(zhName) ||
|
||||
!enName ||
|
||||
enName === code ||
|
||||
/Unknown/.test(enName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push({
|
||||
code,
|
||||
zhName,
|
||||
label: `${code} ${zhName}`,
|
||||
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((left, right) => left.code.localeCompare(right.code));
|
||||
}
|
||||
|
||||
function getListFieldKey(
|
||||
listType: RuleListType,
|
||||
dimension: RuleDimension,
|
||||
): ListFieldKey {
|
||||
if (dimension === 'ip') {
|
||||
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
||||
}
|
||||
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
||||
}
|
||||
|
||||
function updateDraftList(
|
||||
draft: WAFRuleGroupPayload,
|
||||
key: ListFieldKey,
|
||||
updater: (items: string[]) => string[],
|
||||
) {
|
||||
switch (key) {
|
||||
case 'ip_whitelist':
|
||||
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
||||
case 'ip_blacklist':
|
||||
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
||||
case 'country_whitelist':
|
||||
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
||||
case 'country_blacklist':
|
||||
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
||||
return labelMap.get(code) ?? code;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded-[24px] border px-4 py-4 text-left transition',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleChip({
|
||||
label,
|
||||
tone,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
|
||||
tone === 'whitelist'
|
||||
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
|
||||
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
|
||||
)}
|
||||
>
|
||||
<span className="break-all">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
|
||||
aria-label={`移除 ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleListSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
tone,
|
||||
emptyText,
|
||||
onRemove,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
emptyText: string;
|
||||
onRemove: (item: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<RuleChip
|
||||
key={item}
|
||||
label={item}
|
||||
tone={tone}
|
||||
onRemove={() => onRemove(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-6 text-sm text-[var(--foreground-secondary)]">
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleEntryModal({
|
||||
state,
|
||||
countryOptions,
|
||||
pending,
|
||||
onClose,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: RuleModalState;
|
||||
countryOptions: CountryOption[];
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<RuleModalState>) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
return;
|
||||
}
|
||||
setKeyword('');
|
||||
}, [state.dimension, state.open]);
|
||||
|
||||
const selectedCountrySet = useMemo(
|
||||
() => new Set(state.countryValues),
|
||||
[state.countryValues],
|
||||
);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return countryOptions
|
||||
.filter((option) => !normalized || option.searchText.includes(normalized))
|
||||
.sort((left, right) => {
|
||||
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
||||
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
||||
return (
|
||||
rightSelected - leftSelected || left.code.localeCompare(right.code)
|
||||
);
|
||||
});
|
||||
}, [countryOptions, keyword, selectedCountrySet]);
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
const values = selectedCountrySet.has(code)
|
||||
? state.countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...state.countryValues, code]);
|
||||
onChange({ countryValues: values });
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
onChange({
|
||||
countryValues: normalizeItems([
|
||||
...state.countryValues,
|
||||
...filteredCountries.map((option) => option.code),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
const clearCountries = () => onChange({ countryValues: [] });
|
||||
|
||||
const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单';
|
||||
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={state.open}
|
||||
title={`添加${typeLabel}规则`}
|
||||
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
|
||||
{pending ? '处理中...' : '添加'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="类型" container="div">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'blacklist', label: '黑名单' },
|
||||
{ value: 'whitelist', label: '白名单' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ listType: option.value as RuleListType })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.listType === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
<ResourceField label="维度" container="div">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'ip', label: 'IP' },
|
||||
{ value: 'country', label: '地域' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ dimension: option.value as RuleDimension })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.dimension === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
{state.dimension === 'ip' ? (
|
||||
<ResourceField
|
||||
label="IP / IP 段"
|
||||
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={state.ipValue}
|
||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||
/>
|
||||
</ResourceField>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索国家代码或中文名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearCountries}
|
||||
className="text-xs font-medium text-[var(--foreground-secondary)]"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
地域多选
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
选项显示为「国家代码 国家中文名」。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
已选 {state.countryValues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{filteredCountries.map((option) => {
|
||||
const selected = selectedCountrySet.has(option.code);
|
||||
return (
|
||||
<label
|
||||
key={option.code}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||
selected
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleCountry(option.code)}
|
||||
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteApplyDrawer({
|
||||
group,
|
||||
routes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
pending,
|
||||
}: {
|
||||
group: WAFRuleGroup | null;
|
||||
routes: ProxyRouteItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (ids: number[]) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIDs(group?.applied_site_ids ?? []);
|
||||
setKeyword('');
|
||||
}, [group, open]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return routes;
|
||||
}
|
||||
return routes.filter((route) =>
|
||||
[route.site_name, route.primary_domain, ...route.domains]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized),
|
||||
);
|
||||
}, [keyword, routes]);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||
const toggleID = (id: number) => {
|
||||
setSelectedIDs((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id].sort((left, right) => left - right),
|
||||
);
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
const next = new Set(selectedIDs);
|
||||
filteredRoutes.forEach((route) => next.add(route.id));
|
||||
setSelectedIDs([...next].sort((left, right) => left - right));
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title={group ? `应用 ${group.name}` : '应用规则组'}
|
||||
description="选择这个自定义规则组要叠加到哪些网站。"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!group || pending}
|
||||
onClick={() => onSave(selectedIDs)}
|
||||
>
|
||||
{pending ? '保存中...' : '保存应用范围'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索网站或域名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredRoutes.map((route) => (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => toggleID(route.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md border',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
|
||||
: 'border-[var(--border-default)]',
|
||||
)}
|
||||
>
|
||||
{selectedSet.has(route.id) ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
|
||||
{route.domains.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
import { RuleEntryModal } from './rule-entry-modal';
|
||||
import { SiteApplyDrawer } from './site-apply-drawer';
|
||||
import { PowTabPanel } from './pow-tab-panel';
|
||||
import { RuleListSection } from './rule-list-section';
|
||||
import { TabButton } from './tab-button';
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildDraft,
|
||||
countRuleEntries,
|
||||
defaultRuleModalState,
|
||||
emptyDraft,
|
||||
formatCountryItem,
|
||||
getErrorMessage,
|
||||
getListFieldKey,
|
||||
normalizeItems,
|
||||
tabItems,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types';
|
||||
|
||||
export function WAFPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -853,14 +180,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 +269,7 @@ export function WAFPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="WAF"
|
||||
description="按规则组维护 IP 与地域黑白名单,全局规则始终应用到所有网站。"
|
||||
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
@@ -1039,7 +358,7 @@ export function WAFPage() {
|
||||
}
|
||||
>
|
||||
<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) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
@@ -1052,28 +371,6 @@ export function WAFPage() {
|
||||
|
||||
{activeTab === 'basic' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={ShieldCheck}
|
||||
label="规则组总数"
|
||||
value={`${groups.length}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Check}
|
||||
label="启用中"
|
||||
value={`${enabledGroups.length}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Globe2}
|
||||
label="受保护网站"
|
||||
value={`${protectedSites.size}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={ListFilter}
|
||||
label="规则总量"
|
||||
value={`${totalRules}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField label="规则组名称">
|
||||
<ResourceInput
|
||||
@@ -1232,6 +529,21 @@ export function WAFPage() {
|
||||
</div>
|
||||
) : 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' ? (
|
||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user