mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
[新增] 添加 WAF 规则组及其绑定的 API 支持,更新前端页面以集成 WAF 功能
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { WAFPage } from '@/features/waf/components/waf-page';
|
||||
|
||||
export default function WAFRoute() {
|
||||
return <WAFPage />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { dashboardNavigation } from '@/lib/constants/navigation';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
@@ -79,6 +80,8 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
<path d="m14 15 3 2-3 2" />
|
||||
</svg>
|
||||
);
|
||||
case 'waf':
|
||||
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
|
||||
case 'release':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ConfigDiffResult {
|
||||
removed_domains: string[];
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
waf_config_changed: boolean;
|
||||
changed_option_keys: string[];
|
||||
changed_option_details: ConfigOptionDiffItem[];
|
||||
current_website_count: number;
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
requestNodeForceSync,
|
||||
requestNodeOpenrestyRestart,
|
||||
requestNodeAgentUpdate,
|
||||
rotateNodeBootstrapToken,
|
||||
updateNode,
|
||||
} from '@/features/nodes/api/nodes';
|
||||
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||
|
||||
@@ -48,6 +48,7 @@ function hasConfigChanges(diff: {
|
||||
removed_domains: string[];
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
waf_config_changed?: boolean;
|
||||
changed_option_keys: string[];
|
||||
}) {
|
||||
return (
|
||||
@@ -58,6 +59,7 @@ function hasConfigChanges(diff: {
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
Boolean(diff.waf_config_changed) ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
WAFSiteRuleGroups,
|
||||
} from '@/features/waf/types';
|
||||
|
||||
export function getWAFRuleGroups() {
|
||||
return apiRequest<WAFRuleGroup[]>('/waf/rule-groups');
|
||||
}
|
||||
|
||||
export function createWAFRuleGroup(payload: WAFRuleGroupPayload) {
|
||||
return apiRequest<WAFRuleGroup>('/waf/rule-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWAFRuleGroup(id: number, payload: WAFRuleGroupPayload) {
|
||||
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWAFRuleGroup(id: number) {
|
||||
return apiRequest<void>(`/waf/rule-groups/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceWAFRuleGroupSites(id: number, ids: number[]) {
|
||||
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/sites`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getWAFSiteRuleGroups(routeId: number) {
|
||||
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`);
|
||||
}
|
||||
|
||||
export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
|
||||
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, Globe2, Plus, Save, Search, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
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 { 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,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {
|
||||
createWAFRuleGroup,
|
||||
deleteWAFRuleGroup,
|
||||
getWAFRuleGroups,
|
||||
replaceWAFRuleGroupSites,
|
||||
updateWAFRuleGroup,
|
||||
} from '@/features/waf/api/waf';
|
||||
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
message: string;
|
||||
};
|
||||
|
||||
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: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
|
||||
function listToText(items: string[]) {
|
||||
return items.join('\n');
|
||||
}
|
||||
|
||||
function textToList(text: string) {
|
||||
return text
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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 ruleCount(group: WAFRuleGroup) {
|
||||
return (
|
||||
group.ip_whitelist.length +
|
||||
group.ip_blacklist.length +
|
||||
group.country_whitelist.length +
|
||||
group.country_blacklist.length
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function WAFPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null);
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['waf', 'rule-groups'],
|
||||
queryFn: getWAFRuleGroups,
|
||||
});
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||
const selectedGroup = useMemo(
|
||||
() =>
|
||||
selectedID === 0
|
||||
? null
|
||||
: (groups.find((group) => group.id === selectedID) ?? groups[0] ?? null),
|
||||
[groups, selectedID],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
setSelectedID(selectedGroup.id);
|
||||
setDraft(buildDraft(selectedGroup));
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: WAFRuleGroupPayload) => {
|
||||
if (selectedGroup) {
|
||||
return updateWAFRuleGroup(selectedGroup.id, payload);
|
||||
}
|
||||
return createWAFRuleGroup(payload);
|
||||
},
|
||||
onSuccess: async (group) => {
|
||||
setSelectedID(group.id);
|
||||
setFeedback({ tone: 'success', message: 'WAF 规则组已保存。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteWAFRuleGroup,
|
||||
onSuccess: async () => {
|
||||
setSelectedID(null);
|
||||
setFeedback({ tone: 'success', message: 'WAF 规则组已删除。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
replaceWAFRuleGroupSites(id, ids),
|
||||
onSuccess: async () => {
|
||||
setApplyGroup(null);
|
||||
setFeedback({ tone: 'success', message: '规则组应用范围已更新。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
if (groupsQuery.isLoading || routesQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (groupsQuery.isError) {
|
||||
return <ErrorState title="WAF 加载失败" description={getErrorMessage(groupsQuery.error)} />;
|
||||
}
|
||||
if (routesQuery.isError) {
|
||||
return <ErrorState title="网站列表加载失败" description={getErrorMessage(routesQuery.error)} />;
|
||||
}
|
||||
if (!selectedGroup && groups.length === 0) {
|
||||
return <EmptyState title="WAF 尚未初始化" description="刷新页面后系统会自动创建全局规则组。" />;
|
||||
}
|
||||
|
||||
const enabledCount = groups.filter((group) => group.enabled).length;
|
||||
const protectedSites = new Set(groups.flatMap((group) => group.applied_site_ids));
|
||||
const totalRules = groups.reduce((sum, group) => sum + ruleCount(group), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="WAF"
|
||||
description="按规则组维护 IP 与地域黑白名单,全局规则始终应用到所有网站。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedID(0);
|
||||
setDraft({ ...emptyDraft, name: '自定义规则组' });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建规则组
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">启用规则组</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--foreground-primary)]">{enabledCount}</p>
|
||||
</AppCard>
|
||||
<AppCard>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">自定义覆盖网站</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--foreground-primary)]">{protectedSites.size}</p>
|
||||
</AppCard>
|
||||
<AppCard>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">黑白名单条目</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[var(--foreground-primary)]">{totalRules}</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<AppCard title="规则组">
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedID(group.id)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedGroup?.id === group.id
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{group.is_global ? <Globe2 className="h-4 w-4" /> : <ShieldCheck className="h-4 w-4" />}
|
||||
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{group.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--foreground-secondary)]">
|
||||
{group.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
|
||||
{group.is_global ? '应用全部网站' : `已应用 ${group.applied_site_count} 个网站`} · {ruleCount(group)} 条规则
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title={selectedGroup ? selectedGroup.name : '新建规则组'}
|
||||
description="白名单命中后直接放行;未命中白名单时继续判断黑名单。"
|
||||
action={
|
||||
selectedGroup && !selectedGroup.is_global ? (
|
||||
<SecondaryButton type="button" onClick={() => setApplyGroup(selectedGroup)}>
|
||||
一键应用
|
||||
</SecondaryButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField label="规则组名称">
|
||||
<ResourceInput
|
||||
value={draft.name}
|
||||
disabled={selectedGroup?.is_global}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="拦截状态码">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={400}
|
||||
max={599}
|
||||
value={draft.block_status_code}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, block_status_code: Number(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用规则组"
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))}
|
||||
/>
|
||||
<ResourceField label="备注">
|
||||
<ResourceInput
|
||||
value={draft.remark}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, remark: event.target.value }))}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="IP / IP 段白名单" hint="每行一个 IP 或 CIDR。">
|
||||
<ResourceTextarea
|
||||
value={listToText(draft.ip_whitelist)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, ip_whitelist: textToList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="IP / IP 段黑名单" hint="每行一个 IP 或 CIDR。">
|
||||
<ResourceTextarea
|
||||
value={listToText(draft.ip_blacklist)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, ip_blacklist: textToList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="国家白名单" hint="ISO 两位国家代码,例如 CN、US。">
|
||||
<ResourceTextarea
|
||||
value={listToText(draft.country_whitelist)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, country_whitelist: textToList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="国家黑名单" hint="ISO 两位国家代码,例如 CN、US。">
|
||||
<ResourceTextarea
|
||||
value={listToText(draft.country_blacklist)}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, country_blacklist: textToList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="拦截页面" className="xl:col-span-2" hint="留空时只返回状态码。">
|
||||
<ResourceTextarea
|
||||
value={draft.block_response_body}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, block_response_body: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
{selectedGroup && !selectedGroup.is_global ? (
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`)) {
|
||||
deleteMutation.mutate(selectedGroup.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DangerButton>
|
||||
) : null}
|
||||
</div>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? '保存中...' : '保存规则组'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
<SiteApplyDrawer
|
||||
group={applyGroup}
|
||||
routes={routes}
|
||||
open={Boolean(applyGroup)}
|
||||
pending={applyMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setApplyGroup(null);
|
||||
}
|
||||
}}
|
||||
onSave={(ids) => {
|
||||
if (applyGroup) {
|
||||
applyMutation.mutate({ id: applyGroup.id, ids });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface WAFRuleGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
is_global: boolean;
|
||||
block_status_code: number;
|
||||
block_response_body: string;
|
||||
ip_whitelist: string[];
|
||||
ip_blacklist: string[];
|
||||
country_whitelist: string[];
|
||||
country_blacklist: string[];
|
||||
region_whitelist: string[];
|
||||
region_blacklist: string[];
|
||||
remark: string;
|
||||
applied_site_ids: number[];
|
||||
applied_site_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WAFRuleGroupPayload {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
block_status_code: number;
|
||||
block_response_body: string;
|
||||
ip_whitelist: string[];
|
||||
ip_blacklist: string[];
|
||||
country_whitelist: string[];
|
||||
country_blacklist: string[];
|
||||
region_whitelist: string[];
|
||||
region_blacklist: string[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFSiteRuleGroups {
|
||||
route_id: number;
|
||||
global_rule_group: WAFRuleGroup | null;
|
||||
rule_groups: WAFRuleGroup[];
|
||||
applied_rule_groups: WAFRuleGroup[];
|
||||
applied_ids: number[];
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
deleteTlsCertificate,
|
||||
getTlsCertificates,
|
||||
} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import {
|
||||
getWAFSiteRuleGroups,
|
||||
replaceWAFSiteRuleGroups,
|
||||
} from '@/features/waf/api/waf';
|
||||
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
|
||||
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
|
||||
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
|
||||
@@ -38,6 +42,7 @@ import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
@@ -54,6 +59,7 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
||||
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
|
||||
const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false);
|
||||
const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false);
|
||||
const [wafSelectedIDs, setWafSelectedIDs] = useState<number[]>([]);
|
||||
const [convertCertificate, setConvertCertificate] =
|
||||
useState<TlsCertificateItem | null>(null);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<
|
||||
@@ -130,6 +136,32 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
||||
const enabledRoutesCount = relatedRoutes.filter(
|
||||
(route) => route.enabled,
|
||||
).length;
|
||||
const wafRouteID = relatedRoutes[0]?.id ?? null;
|
||||
const wafQuery = useQuery({
|
||||
queryKey: ['waf', 'site-rule-groups', wafRouteID],
|
||||
queryFn: () => getWAFSiteRuleGroups(wafRouteID ?? 0),
|
||||
enabled: Boolean(wafRouteID),
|
||||
});
|
||||
const wafMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
|
||||
onSuccess: async (view) => {
|
||||
setWafSelectedIDs(view.applied_ids);
|
||||
setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['waf'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (wafQuery.data) {
|
||||
setWafSelectedIDs(wafQuery.data.applied_ids);
|
||||
}
|
||||
}, [wafQuery.data]);
|
||||
|
||||
const handleDeleteWebsite = () => {
|
||||
if (!website) {
|
||||
@@ -371,6 +403,89 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="WAF"
|
||||
description="全局规则组始终生效,可为当前网站叠加多个自定义规则组。"
|
||||
action={
|
||||
wafRouteID ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={wafMutation.isPending}
|
||||
onClick={() => wafMutation.mutate(wafSelectedIDs)}
|
||||
>
|
||||
{wafMutation.isPending ? '保存中...' : '保存 WAF'}
|
||||
</PrimaryButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!wafRouteID ? (
|
||||
<EmptyState
|
||||
title="暂无可绑定规则"
|
||||
description="当前网站还没有关联代理规则,创建规则后即可配置 WAF。"
|
||||
/>
|
||||
) : wafQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : wafQuery.isError ? (
|
||||
<ErrorState
|
||||
title="WAF 规则组加载失败"
|
||||
description={getErrorMessage(wafQuery.error)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{wafQuery.data?.global_rule_group ? (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{wafQuery.data.global_rule_group.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
全局规则组默认应用到所有网站,不能在单站关闭。
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
label={
|
||||
wafQuery.data.global_rule_group.enabled
|
||||
? '全局启用'
|
||||
: '全局停用'
|
||||
}
|
||||
variant={
|
||||
wafQuery.data.global_rule_group.enabled
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(wafQuery.data?.rule_groups ?? []).map((group) => (
|
||||
<ToggleField
|
||||
key={group.id}
|
||||
label={group.name}
|
||||
description={`已应用 ${group.applied_site_count} 个网站,${group.enabled ? '启用中' : '已停用'}`}
|
||||
checked={wafSelectedIDs.includes(group.id)}
|
||||
onChange={(checked) => {
|
||||
setWafSelectedIDs((current) =>
|
||||
checked
|
||||
? [...current, group.id].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
: current.filter((id) => id !== group.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
暂无自定义规则组,可在 WAF 页面创建后再绑定。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="关联规则">
|
||||
{relatedRoutes.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -21,6 +21,11 @@ export const dashboardNavigation: NavigationItem[] = [
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/waf',
|
||||
label: 'WAF',
|
||||
icon: 'waf',
|
||||
},
|
||||
{
|
||||
href: '/origin',
|
||||
label: '源站',
|
||||
|
||||
@@ -6,6 +6,7 @@ export type NavigationIconKey =
|
||||
| 'domain'
|
||||
| 'certificate'
|
||||
| 'proxy'
|
||||
| 'waf'
|
||||
| 'release'
|
||||
| 'log'
|
||||
| 'performance'
|
||||
|
||||
Reference in New Issue
Block a user