Compare commits

...

4 Commits

Author SHA1 Message Date
ryan f4a53d6b5f [优化] 增加断开 WebSocket 客户端的功能,优化连接管理 2026-05-30 19:05:20 +08:00
ryan cef3694d11 [优化] 界面优化 2026-05-30 18:07:18 +08:00
ryan 631d32e5d0 [优化] POW 与 WAF 合并 2026-05-30 17:42:23 +08:00
ryan c74b70b62e [优化] 界面优化 2026-05-30 16:31:26 +08:00
24 changed files with 1303 additions and 1092 deletions
+1 -1
View File
@@ -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:
+10
View File
@@ -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:
+10
View File
@@ -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。
## 发布、激活与回滚
标准链路:
+6
View File
@@ -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
)
+24
View File
@@ -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},
}
}
+4
View File
@@ -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
}
+15
View File
@@ -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]
+52 -29
View File
@@ -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 {
+1
View File
@@ -130,6 +130,7 @@ func DeleteNode(id uint) error {
return err
}
invalidateAgentTokenCache(node.AgentToken)
DisconnectAgentWSClient(node.NodeID)
return nil
}
+49 -28
View File
@@ -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
}
@@ -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&#10;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&#10;192.168.0.0/16"
{...form.register('whitelist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/.well-known/*&#10;/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&#10;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&#10;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;
}