mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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,77 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// AssetInterceptRule is one row of asset_intercept_rules — a global asset
|
||||
// blocklist entry. Unlike intercept_rules (which matches tool name / input),
|
||||
// these match the *target asset*: an exact/fuzzy domain·ip·url, or a CIDR range.
|
||||
// This layer only stores rules; the matching/enforcement logic lives elsewhere.
|
||||
type AssetInterceptRule struct {
|
||||
ID int64 `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
Builtin bool `json:"builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const assetInterceptRuleCols = `id, enabled, kind, pattern, note, builtin, created_at, updated_at`
|
||||
|
||||
func scanAssetInterceptRule(row interface{ Scan(...any) error }) (AssetInterceptRule, error) {
|
||||
var r AssetInterceptRule
|
||||
err := row.Scan(&r.ID, &r.Enabled, &r.Kind, &r.Pattern, &r.Note, &r.Builtin, &r.CreatedAt, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ListAssetInterceptRules returns all rules, built-ins first then newest first.
|
||||
func (d *DB) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
|
||||
rows, err := d.Query(`SELECT ` + assetInterceptRuleCols + ` FROM asset_intercept_rules ORDER BY builtin DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AssetInterceptRule
|
||||
for rows.Next() {
|
||||
r, err := scanAssetInterceptRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAssetInterceptRule inserts a new user rule (builtin is always false here).
|
||||
func (d *DB) CreateAssetInterceptRule(kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
|
||||
row := d.QueryRow(`
|
||||
INSERT INTO asset_intercept_rules(enabled, kind, pattern, note, builtin)
|
||||
VALUES ($1, $2, $3, $4, false)
|
||||
RETURNING `+assetInterceptRuleCols,
|
||||
enabled, kind, pattern, note)
|
||||
return scanAssetInterceptRule(row)
|
||||
}
|
||||
|
||||
// UpdateAssetInterceptRule replaces the editable fields of an existing rule.
|
||||
func (d *DB) UpdateAssetInterceptRule(id int64, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
|
||||
row := d.QueryRow(`
|
||||
UPDATE asset_intercept_rules
|
||||
SET enabled=$2, kind=$3, pattern=$4, note=$5
|
||||
WHERE id=$1
|
||||
RETURNING `+assetInterceptRuleCols,
|
||||
id, enabled, kind, pattern, note)
|
||||
return scanAssetInterceptRule(row)
|
||||
}
|
||||
|
||||
// DeleteAssetInterceptRule removes a rule (built-in rules are deletable too).
|
||||
func (d *DB) DeleteAssetInterceptRule(id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM asset_intercept_rules WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ToggleAssetInterceptRule flips the enabled state of a rule.
|
||||
func (d *DB) ToggleAssetInterceptRule(id int64, enabled bool) error {
|
||||
_, err := d.Exec(`UPDATE asset_intercept_rules SET enabled=$2 WHERE id=$1`, id, enabled)
|
||||
return err
|
||||
}
|
||||
@@ -261,9 +261,41 @@ ON CONFLICT (name) DO NOTHING`,
|
||||
if err := d.seedDefaultInterceptRulesV2(); err != nil {
|
||||
return fmt.Errorf("seed intercept rules v2: %w", err)
|
||||
}
|
||||
if err := d.seedDefaultAssetInterceptRules(); err != nil {
|
||||
return fmt.Errorf("seed asset intercept rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedDefaultAssetInterceptRules inserts the built-in asset blocklist (fuzzy
|
||||
// domain matches for government / education sites) once on first startup. Gated
|
||||
// by a settings flag so a user's later disable/delete is never resurrected on
|
||||
// restart — same policy as the intercept-rule seed.
|
||||
func (d *DB) seedDefaultAssetInterceptRules() error {
|
||||
if v, _, _ := d.GetSetting("asset_intercept_default_rules_v1"); v == "done" {
|
||||
return nil
|
||||
}
|
||||
rules := []struct {
|
||||
kind string
|
||||
pattern string
|
||||
note string
|
||||
}{
|
||||
{"fuzzy_domain", ".gov", "[内置] 政府网站 (.gov)"},
|
||||
{"fuzzy_domain", ".gov.cn", "[内置] 政府网站 (.gov.cn)"},
|
||||
{"fuzzy_domain", ".edu", "[内置] 教育网站 (.edu)"},
|
||||
{"fuzzy_domain", ".edu.cn", "[内置] 教育网站 (.edu.cn)"},
|
||||
}
|
||||
for _, r := range rules {
|
||||
if _, err := d.Exec(`
|
||||
INSERT INTO asset_intercept_rules(enabled, kind, pattern, note, builtin)
|
||||
VALUES (true, $1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING`, r.kind, r.pattern, r.note); err != nil {
|
||||
return fmt.Errorf("asset rule %q: %w", r.pattern, err)
|
||||
}
|
||||
}
|
||||
return d.SetSetting("asset_intercept_default_rules_v1", "done")
|
||||
}
|
||||
|
||||
// builtinSkillVisibility maps a shipped skill's directory name → the built-in
|
||||
// agent keys that should see it by default. The skill FILES themselves live on the
|
||||
// filesystem (SkillDir, loaded by norma at runtime); DB only carries this visibility
|
||||
|
||||
@@ -1220,3 +1220,31 @@ CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(s
|
||||
-- Additive v3 archive fields; old archives restore these as empty objects.
|
||||
ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- =====================================================================
|
||||
-- 资产拦截规则(全局黑名单)
|
||||
-- 独立于 §K 命令拦截(intercept_rules):intercept_rules 匹配工具名/入参文本,
|
||||
-- 这张表匹配「目标资产」——全等/模糊的域名·IP·URL 以及 CIDR 网段。
|
||||
-- 仅存规则;具体的匹配/拦截逻辑在别处实现。
|
||||
-- kind 七种:
|
||||
-- exact_domain / exact_ip / exact_url —— 全等匹配
|
||||
-- fuzzy_domain / fuzzy_ip / fuzzy_url —— 模糊匹配
|
||||
-- cidr —— CIDR 网段
|
||||
-- =====================================================================
|
||||
CREATE TABLE IF NOT EXISTS asset_intercept_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'exact_domain', 'exact_ip', 'exact_url',
|
||||
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
|
||||
'cidr')),
|
||||
pattern TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
builtin BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(enabled);
|
||||
DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules;
|
||||
CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
@@ -0,0 +1,156 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// --- asset intercept rule CRUD ---
|
||||
//
|
||||
// Global asset blocklist: exact/fuzzy domain·ip·url + CIDR. This layer only
|
||||
// persists rules; the actual match/enforcement logic lives elsewhere.
|
||||
|
||||
func (s *Server) assetInterceptListRules(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
rules, err := pg.ListAssetInterceptRules()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if rules == nil {
|
||||
rules = []db.AssetInterceptRule{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"rules": rules})
|
||||
}
|
||||
|
||||
func (s *Server) assetInterceptCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req assetInterceptRuleReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateAssetInterceptRuleReq(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rule, err := pg.CreateAssetInterceptRule(req.Kind, req.Pattern, req.Note, req.Enabled)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rule)
|
||||
}
|
||||
|
||||
func (s *Server) assetInterceptUpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
var req assetInterceptRuleReq
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateAssetInterceptRuleReq(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rule, err := pg.UpdateAssetInterceptRule(id, req.Kind, req.Pattern, req.Note, req.Enabled)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rule)
|
||||
}
|
||||
|
||||
func (s *Server) assetInterceptDeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
if err := pg.DeleteAssetInterceptRule(id); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *Server) assetInterceptToggleRule(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "bad rule id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := pg.ToggleAssetInterceptRule(id, req.Enabled); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type assetInterceptRuleReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Kind string `json:"kind"`
|
||||
Pattern string `json:"pattern"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// validateAssetInterceptRuleReq normalizes and validates a rule. It trims the
|
||||
// pattern and, for the strictly-formatted kinds (exact_ip / cidr), rejects
|
||||
// malformed values; fuzzy kinds and domain/url patterns are left as free text
|
||||
// (the enforcement layer decides how to interpret them).
|
||||
func validateAssetInterceptRuleReq(req *assetInterceptRuleReq) error {
|
||||
req.Pattern = strings.TrimSpace(req.Pattern)
|
||||
if req.Pattern == "" {
|
||||
return fmt.Errorf("pattern 不能为空")
|
||||
}
|
||||
switch req.Kind {
|
||||
case "exact_domain", "exact_url", "fuzzy_domain", "fuzzy_ip", "fuzzy_url":
|
||||
// free-form, no format check
|
||||
case "exact_ip":
|
||||
if net.ParseIP(req.Pattern) == nil {
|
||||
return fmt.Errorf("exact_ip 不是有效 IP 地址:%s", req.Pattern)
|
||||
}
|
||||
case "cidr":
|
||||
if _, _, err := net.ParseCIDR(req.Pattern); err != nil {
|
||||
return fmt.Errorf("cidr 不是有效网段(形如 192.168.0.0/16):%s", req.Pattern)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("kind 无效:%s", req.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -890,6 +890,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("PUT /api/intercept/rules/{id}", s.interceptUpdateRule)
|
||||
mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule)
|
||||
mux.HandleFunc("POST /api/intercept/rules/{id}/toggle", s.interceptToggleRule)
|
||||
|
||||
// 资产拦截规则管理(全局黑名单:域名/IP/URL/CIDR)
|
||||
mux.HandleFunc("GET /api/asset-intercept/rules", s.assetInterceptListRules)
|
||||
mux.HandleFunc("POST /api/asset-intercept/rules", s.assetInterceptCreateRule)
|
||||
mux.HandleFunc("PUT /api/asset-intercept/rules/{id}", s.assetInterceptUpdateRule)
|
||||
mux.HandleFunc("DELETE /api/asset-intercept/rules/{id}", s.assetInterceptDeleteRule)
|
||||
mux.HandleFunc("POST /api/asset-intercept/rules/{id}/toggle", s.assetInterceptToggleRule)
|
||||
|
||||
mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending)
|
||||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
||||
|
||||
@@ -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