[优化] 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' ? (