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 {
|
if err := d.seedDefaultInterceptRulesV2(); err != nil {
|
||||||
return fmt.Errorf("seed intercept rules v2: %w", err)
|
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
|
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
|
// 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
|
// 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
|
// 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.
|
-- 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_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 '{}';
|
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("PUT /api/intercept/rules/{id}", s.interceptUpdateRule)
|
||||||
mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule)
|
mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule)
|
||||||
mux.HandleFunc("POST /api/intercept/rules/{id}/toggle", s.interceptToggleRule)
|
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", s.interceptListPending)
|
||||||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||||||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
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,
|
AgentTrigger,
|
||||||
ArchiveBatchItem,
|
ArchiveBatchItem,
|
||||||
Asset,
|
Asset,
|
||||||
|
AssetInterceptRule,
|
||||||
Audit,
|
Audit,
|
||||||
BatchCategoryItem,
|
BatchCategoryItem,
|
||||||
BatchControlItem,
|
BatchControlItem,
|
||||||
@@ -1099,6 +1100,16 @@ export const api = {
|
|||||||
toggleInterceptRule: (id: number, enabled: boolean) =>
|
toggleInterceptRule: (id: number, enabled: boolean) =>
|
||||||
post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }),
|
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) ----
|
// ---- intercept pending (ask) ----
|
||||||
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
|
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
|
||||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||||
|
|||||||
@@ -1191,6 +1191,27 @@ export interface InterceptRule {
|
|||||||
updated_at: string;
|
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 {
|
export interface InterceptPending {
|
||||||
decision_source?: "rule" | "model" | "unknown" | "";
|
decision_source?: "rule" | "model" | "unknown" | "";
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Ban,
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
Bug,
|
Bug,
|
||||||
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
|
|||||||
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
|
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
|
||||||
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
|
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
|
||||||
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
|
{ 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: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
|
||||||
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
|
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
|
||||||
{ id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 },
|
{ id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 },
|
||||||
|
|||||||
Reference in new issue
Block a user