[优化] POW 与 WAF 合并

This commit is contained in:
ryan
2026-05-30 17:27:23 +08:00
parent c74b70b62e
commit 631d32e5d0
11 changed files with 526 additions and 365 deletions
@@ -19,6 +19,10 @@ import {
getProxyRoute,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import {
getWAFSiteRuleGroups,
replaceWAFSiteRuleGroups,
} from '@/features/waf/api/waf';
import {
buildDomainRowsFromRoute,
DomainListInput,
@@ -729,318 +733,137 @@ function CacheSection({
);
}
type PowListValues = {
ips: string;
ip_cidrs: string;
paths: string;
path_regexes: string;
user_agents: string;
};
const powSchema = z
.object({
pow_enabled: z.boolean(),
difficulty: z.coerce.number().int().min(1).max(16),
algorithm: z.enum(['fast', 'slow']),
session_ttl: z.coerce.number().int().min(60),
challenge_ttl: z.coerce.number().int().min(30),
whitelist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
blacklist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
})
.superRefine((value, context) => {
if (!value.pow_enabled) return;
const dimensions: { key: string; label: string }[] = [
{ key: 'ips', label: 'IP' },
{ key: 'ip_cidrs', label: 'IP CIDR' },
{ key: 'paths', label: '路径' },
{ key: 'path_regexes', label: '路径正则' },
{ key: 'user_agents', label: 'User-Agent' },
];
for (const dim of dimensions) {
const wl = linesFromTextarea(
(value.whitelist as Record<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="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。PoW 规则请在 WAF 页面统一配置和批量应用。"
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)}
/>
) : (
<div className="space-y-5">
{wafMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(wafMutation.error)}
/>
) : null}
{wafMutation.isSuccess ? (
<InlineMessage tone="success" message="WAF 规则组已更新。" />
) : null}
<ResourceField label="验证算法">
<ResourceSelect
disabled={!watchedEnabled}
{...form.register('algorithm')}
>
<option value="fast">Fast(WebCrypto SHA-256)</option>
<option value="slow">Slow(兼容模式)</option>
</ResourceSelect>
</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="数值越高验证越慢,1-16。推荐 3-5。"
error={form.formState.errors.difficulty?.message}
>
<ResourceInput
type="number"
min={1}
max={16}
disabled={!watchedEnabled}
{...form.register('difficulty')}
/>
</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>
<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?.rule_groups ?? []).length === 0 ? (
<EmptyState title="暂无自定义 WAF 规则组" />
) : 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 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 className="flex justify-end">
<PrimaryButton
type="button"
disabled={wafMutation.isPending}
onClick={() => wafMutation.mutate(selectedIDs)}
>
{wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'}
</PrimaryButton>
</div>
</div>
{form.formState.errors.blacklist && (
<p className="text-sm text-[var(--color-danger)]">
{Object.values(form.formState.errors.blacklist)
.flatMap((e) =>
e && typeof e === 'object' && 'message' in e
? [e.message as string]
: [],
)
.join('; ')}
</p>
)}
</form>
)}
</ConfigSectionShell>
);
}
@@ -1370,14 +1193,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',
@@ -5,8 +5,8 @@ import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Check,
Cpu,
Globe2,
ListFilter,
type LucideIcon,
Plus,
Save,
@@ -23,8 +23,15 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { Drawer } from '@/components/ui/drawer';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import {
getProxyRoutes,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import { buildPayloadFromRoute } from '@/features/proxy-routes/helpers';
import type {
ProxyRouteItem,
ProxyRoutePoWConfig,
} from '@/features/proxy-routes/types';
import {
DangerButton,
PrimaryButton,
@@ -49,7 +56,7 @@ type FeedbackState = {
message: string;
};
type WAFTab = 'basic' | 'lists' | 'block';
type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
type RuleListType = 'whitelist' | 'blacklist';
type RuleDimension = 'ip' | 'country';
type ListFieldKey =
@@ -83,6 +90,27 @@ type RuleListRenderable = Pick<
| 'region_blacklist'
>;
const defaultPowConfig: ProxyRoutePoWConfig = {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
};
const emptyDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
@@ -94,6 +122,8 @@ const emptyDraft: WAFRuleGroupPayload = {
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
@@ -117,6 +147,10 @@ const tabItems: Array<{
id: 'lists',
label: '黑白名单',
},
{
id: 'pow',
label: 'PoW',
},
{
id: 'block',
label: '拦截返回',
@@ -134,6 +168,17 @@ function textToList(text: string) {
.filter(Boolean);
}
function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
@@ -155,6 +200,8 @@ function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
@@ -731,6 +778,214 @@ function SiteApplyDrawer({
);
}
function PowTabPanel({
enabled,
config,
onChange,
}: {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}) {
const [draft, setDraft] = useState(() => ({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
}));
useEffect(() => {
setDraft({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
});
}, [config]);
const updateConfig = (
newEnabled: boolean,
newConfig: Partial<ProxyRoutePoWConfig>,
newDraft?: typeof draft,
) => {
const nextConfig = { ...config, ...newConfig };
if (newDraft) {
setDraft(newDraft);
nextConfig.whitelist = {
ips: parseTextareaList(newDraft.whitelist.ips),
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
paths: parseTextareaList(newDraft.whitelist.paths),
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
};
nextConfig.blacklist = {
ips: parseTextareaList(newDraft.blacklist.ips),
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
paths: parseTextareaList(newDraft.blacklist.paths),
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
};
}
onChange(newEnabled, nextConfig);
};
const updateList = (
scope: 'whitelist' | 'blacklist',
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
updateConfig(enabled, {}, nextDraft);
};
return (
<div className="space-y-6">
<ToggleField
label="启用 PoW 防护"
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
checked={enabled}
onChange={(newEnabled) => updateConfig(newEnabled, {})}
/>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<ResourceField label="算法">
<select
value={config.algorithm}
onChange={(event) =>
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
}
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</ResourceField>
<ResourceField label="难度">
<ResourceInput
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
updateConfig(enabled, { difficulty: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="会话 TTL">
<ResourceInput
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
updateConfig(enabled, { session_ttl: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="挑战 TTL">
<ResourceInput
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
}
/>
</ResourceField>
</div>
<div className="grid gap-5 xl:grid-cols-2">
{(['whitelist', 'blacklist'] as const).map((scope) => (
<div
key={scope}
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{scope === 'whitelist'
? '白名单(跳过 PoW)'
: '黑名单(必须 PoW)'}
</h3>
<div className="mt-4 space-y-4">
<ResourceField label="IP">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ips}
onChange={(event) =>
updateList(scope, 'ips', event.target.value)
}
/>
</ResourceField>
<ResourceField label="IP CIDR">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ip_cidrs}
onChange={(event) =>
updateList(scope, 'ip_cidrs', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径">
<ResourceTextarea
className="min-h-20"
value={draft[scope].paths}
onChange={(event) =>
updateList(scope, 'paths', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径正则">
<ResourceTextarea
className="min-h-20"
value={draft[scope].path_regexes}
onChange={(event) =>
updateList(scope, 'path_regexes', event.target.value)
}
/>
</ResourceField>
<ResourceField label="User-Agent">
<ResourceTextarea
className="min-h-20"
value={draft[scope].user_agents}
onChange={(event) =>
updateList(scope, 'user_agents', event.target.value)
}
/>
</ResourceField>
</div>
</div>
))}
</div>
</div>
);
}
export function WAFPage() {
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
@@ -825,6 +1080,8 @@ export function WAFPage() {
},
});
if (groupsQuery.isLoading || routesQuery.isLoading) {
return <LoadingState />;
}
@@ -853,14 +1110,6 @@ export function WAFPage() {
);
}
const enabledGroups = groups.filter((group) => group.enabled);
const protectedSites = new Set(
groups.flatMap((group) => group.applied_site_ids),
);
const totalRules = groups.reduce(
(sum, group) => sum + countRuleEntries(group),
0,
);
const currentRuleCount = countRuleEntries(draft);
const appliedSiteNames = selectedGroup?.is_global
? ['全部网站']
@@ -950,7 +1199,7 @@ export function WAFPage() {
<div className="space-y-6">
<PageHeader
title="WAF"
description="按规则组维护 IP 与地域黑白名单,全局规则始终应用到所有网站。"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<PrimaryButton
type="button"
@@ -1039,7 +1288,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}
@@ -1210,6 +1459,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;
}