[优化] 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
@@ -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">