feat: 新增资产拦截规则(全局黑名单)增删改查
新增独立于命令拦截(intercept_rules)的资产拦截功能,仅提供规则的 存储与管理;具体匹配/拦截逻辑另行实现。 - db: 新增 asset_intercept_rules 表(schema.sql,幂等)与 CRUD 层 (db/asset_intercept.go);kind 支持全等/模糊的 域名·IP·URL 及 CIDR - db: 首启动 seed 内置模糊拦截政府(.gov/.gov.cn)与教育(.edu/.edu.cn) 网站,settings 标记位门控,可禁用/删除且重启不复活 - server: /api/asset-intercept/rules 的 list/create/update/delete/toggle; cidr、exact_ip 做格式校验 - web: 新增「资产拦截」页(/system/intercept/assets)与侧边栏入口、 api 与类型定义
This commit is contained in:
1 parent
a590b415ba
commit
dfc9d2fa2e
9 files changed
+687
No files matched your search
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BanIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AssetInterceptKind, AssetInterceptRule } from "@/lib/types";
|
||||
|
||||
// ---- kind 元信息 ----
|
||||
|
||||
const KIND_OPTIONS: { value: AssetInterceptKind; label: string; group: string; placeholder: string }[] = [
|
||||
{ value: "exact_domain", label: "域名(全等)", group: "全等匹配", placeholder: "example.gov.cn" },
|
||||
{ value: "exact_ip", label: "IP(全等)", group: "全等匹配", placeholder: "203.0.113.10" },
|
||||
{ value: "exact_url", label: "URL(全等)", group: "全等匹配", placeholder: "https://example.gov.cn/login" },
|
||||
{ value: "fuzzy_domain", label: "域名(模糊)", group: "模糊匹配", placeholder: ".gov.cn" },
|
||||
{ value: "fuzzy_ip", label: "IP(模糊)", group: "模糊匹配", placeholder: "203.0.113." },
|
||||
{ value: "fuzzy_url", label: "URL(模糊)", group: "模糊匹配", placeholder: "/admin" },
|
||||
{ value: "cidr", label: "CIDR 网段", group: "网段", placeholder: "192.168.0.0/16" },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<AssetInterceptKind, string> = Object.fromEntries(
|
||||
KIND_OPTIONS.map((o) => [o.value, o.label]),
|
||||
) as Record<AssetInterceptKind, string>;
|
||||
|
||||
const KIND_GROUPS = ["全等匹配", "模糊匹配", "网段"];
|
||||
|
||||
function KindBadge({ kind }: { kind: AssetInterceptKind }) {
|
||||
const fuzzy = kind.startsWith("fuzzy_");
|
||||
const cidr = kind === "cidr";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
cidr
|
||||
? "border-sky-400 text-sky-600"
|
||||
: fuzzy
|
||||
? "border-amber-400 text-amber-600"
|
||||
: "border-emerald-400 text-emerald-600"
|
||||
}
|
||||
>
|
||||
{KIND_LABEL[kind]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- form state ----
|
||||
|
||||
type RuleForm = {
|
||||
enabled: boolean;
|
||||
kind: AssetInterceptKind;
|
||||
pattern: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const defaultForm = (): RuleForm => ({ enabled: true, kind: "fuzzy_domain", pattern: "", note: "" });
|
||||
|
||||
// 前端轻校验(与后端一致:仅 exact_ip / cidr 做格式校验,其余交后端)。
|
||||
function frontValidate(form: RuleForm): string | null {
|
||||
const p = form.pattern.trim();
|
||||
if (!p) return "匹配内容不能为空";
|
||||
if (form.kind === "cidr" && !/^[0-9a-fA-F:.]+\/\d{1,3}$/.test(p)) {
|
||||
return "CIDR 格式无效,形如 192.168.0.0/16";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function AssetInterceptPage() {
|
||||
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<AssetInterceptRule | null>(null);
|
||||
const [form, setForm] = React.useState<RuleForm>(defaultForm());
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRules(await api.assetInterceptRules());
|
||||
} catch {
|
||||
toast.error("加载资产拦截规则失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function set(patch: Partial<RuleForm>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(defaultForm());
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(rule: AssetInterceptRule) {
|
||||
setEditing(rule);
|
||||
setForm({ enabled: rule.enabled, kind: rule.kind, pattern: rule.pattern, note: rule.note });
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const err = frontValidate(form);
|
||||
if (err) {
|
||||
toast.error(err);
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, pattern: form.pattern.trim() };
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateAssetInterceptRule(editing.id, payload);
|
||||
toast.success("规则已更新");
|
||||
} else {
|
||||
await api.createAssetInterceptRule(payload);
|
||||
toast.success("规则已创建");
|
||||
}
|
||||
setOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(rule: AssetInterceptRule) {
|
||||
if (!window.confirm(`确定删除资产拦截规则「${rule.pattern}」?`)) return;
|
||||
try {
|
||||
await api.deleteAssetInterceptRule(rule.id);
|
||||
toast.success("规则已删除");
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(rule: AssetInterceptRule) {
|
||||
try {
|
||||
await api.toggleAssetInterceptRule(rule.id, !rule.enabled);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = KIND_OPTIONS.find((o) => o.value === form.kind)?.placeholder ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BanIcon className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">资产拦截</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
全局资产黑名单:命中的域名 / IP / URL / 网段将被拦截,不对其执行任何操作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持全等与模糊匹配的域名 / IP / URL,以及 CIDR 网段;默认内置模糊拦截政府(.gov / .gov.cn)与教育(.edu /
|
||||
.edu.cn)网站
|
||||
</p>
|
||||
<Button onClick={openNew} size="sm" className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<BanIcon className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">暂无资产拦截规则</p>
|
||||
<Button size="sm" variant="outline" onClick={openNew}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建第一条规则
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[130px]">类型</TableHead>
|
||||
<TableHead>匹配内容</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead className="w-[64px] text-center">启用</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((rule) => (
|
||||
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
|
||||
<TableCell>
|
||||
<KindBadge kind={rule.kind} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
|
||||
{rule.pattern}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{rule.builtin && (
|
||||
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px]">
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{rule.note}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(rule)}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* editor sheet */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{editing ? "编辑资产拦截规则" : "新建资产拦截规则"}</SheetTitle>
|
||||
<SheetDescription className="text-xs">命中此规则的目标资产会被全局拦截</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<Field label="匹配类型">
|
||||
<Select value={form.kind} onValueChange={(v) => set({ kind: v as AssetInterceptKind })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KIND_GROUPS.map((g) => (
|
||||
<React.Fragment key={g}>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{g}
|
||||
</div>
|
||||
{KIND_OPTIONS.filter((o) => o.group === g).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="匹配内容"
|
||||
hint={
|
||||
form.kind === "cidr"
|
||||
? "CIDR 网段,形如 192.168.0.0/16"
|
||||
: form.kind.startsWith("fuzzy_")
|
||||
? "模糊匹配:目标包含此内容即命中"
|
||||
: "全等匹配:目标需与此内容完全一致"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={form.pattern}
|
||||
onChange={(e) => set({ pattern: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="备注(可选)">
|
||||
<Textarea
|
||||
placeholder="说明这条规则的用途"
|
||||
value={form.note}
|
||||
onChange={(e) => set({ note: e.target.value })}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="asset-rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
|
||||
<Label htmlFor="asset-rule-enabled" className="cursor-pointer">
|
||||
启用此规则
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
AgentTrigger,
|
||||
ArchiveBatchItem,
|
||||
Asset,
|
||||
AssetInterceptRule,
|
||||
Audit,
|
||||
BatchCategoryItem,
|
||||
BatchControlItem,
|
||||
@@ -1099,6 +1100,16 @@ export const api = {
|
||||
toggleInterceptRule: (id: number, enabled: boolean) =>
|
||||
post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }),
|
||||
|
||||
// ---- asset intercept rules(资产拦截:全局黑名单) ----
|
||||
assetInterceptRules: () => get<{ rules: AssetInterceptRule[] }>("/asset-intercept/rules").then((r) => arr(r.rules)),
|
||||
createAssetInterceptRule: (rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
|
||||
post<AssetInterceptRule>("/asset-intercept/rules", rule),
|
||||
updateAssetInterceptRule: (id: number, rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
|
||||
put<AssetInterceptRule>(`/asset-intercept/rules/${id}`, rule),
|
||||
deleteAssetInterceptRule: (id: number) => del<{ deleted: number }>(`/asset-intercept/rules/${id}`),
|
||||
toggleAssetInterceptRule: (id: number, enabled: boolean) =>
|
||||
post<{ ok: boolean; enabled: boolean }>(`/asset-intercept/rules/${id}/toggle`, { enabled }),
|
||||
|
||||
// ---- intercept pending (ask) ----
|
||||
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
|
||||
@@ -1191,6 +1191,27 @@ export interface InterceptRule {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---- Asset Intercept Rules(资产拦截:全局黑名单) ----
|
||||
export type AssetInterceptKind =
|
||||
| "exact_domain"
|
||||
| "exact_ip"
|
||||
| "exact_url"
|
||||
| "fuzzy_domain"
|
||||
| "fuzzy_ip"
|
||||
| "fuzzy_url"
|
||||
| "cidr";
|
||||
|
||||
export interface AssetInterceptRule {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
kind: AssetInterceptKind;
|
||||
pattern: string;
|
||||
note: string;
|
||||
builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InterceptPending {
|
||||
decision_source?: "rule" | "model" | "unknown" | "";
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
Ban,
|
||||
Bot,
|
||||
Brain,
|
||||
Bug,
|
||||
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
|
||||
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
|
||||
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
|
||||
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
|
||||
{ id: "asset-intercept", title: "资产拦截", url: "/system/intercept/assets", icon: Ban },
|
||||
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
|
||||
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
|
||||
{ id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 },
|
||||
|
||||
Reference in new issue
Block a user