diff --git a/sealos-complik-admin/web/src/App.tsx b/sealos-complik-admin/web/src/App.tsx index 2409806..61c43f8 100644 --- a/sealos-complik-admin/web/src/App.tsx +++ b/sealos-complik-admin/web/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppLayout } from "./components/AppLayout"; +import { AutobanPolicyPage } from "./pages/AutobanPolicyPage"; import { BansPage } from "./pages/BansPage"; import { CommitmentsPage } from "./pages/CommitmentsPage"; import { ConfigsPage } from "./pages/ConfigsPage"; @@ -19,6 +20,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/sealos-complik-admin/web/src/components/AppLayout.tsx b/sealos-complik-admin/web/src/components/AppLayout.tsx index 358892b..226db56 100644 --- a/sealos-complik-admin/web/src/components/AppLayout.tsx +++ b/sealos-complik-admin/web/src/components/AppLayout.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutGrid, Network, + ShieldAlert, ShieldCheck, } from "lucide-react"; import { NavLink, Outlet } from "react-router-dom"; @@ -16,6 +17,7 @@ const navItems = [ { label: "命名空间详情", path: "/namespaces", icon: ShieldCheck }, { label: "入口路径", path: "/discovered-paths", icon: Network }, { label: "违规中心", path: "/violations", icon: AlertTriangle }, + { label: "自动封禁", path: "/autoban", icon: ShieldAlert }, { label: "项目配置", path: "/configs", icon: FileCog }, { label: "承诺书管理", path: "/commitments", icon: FileText }, { label: "封禁记录", path: "/bans", icon: Ban }, diff --git a/sealos-complik-admin/web/src/contexts/AppDataContext.tsx b/sealos-complik-admin/web/src/contexts/AppDataContext.tsx index 4911915..3d8f225 100644 --- a/sealos-complik-admin/web/src/contexts/AppDataContext.tsx +++ b/sealos-complik-admin/web/src/contexts/AppDataContext.tsx @@ -48,6 +48,11 @@ const quickLinks: QuickLinkItem[] = [ description: "核对当前有效封禁,并补录新的封禁信息。", targetPath: "/bans", }, + { + title: "配置自动封禁", + description: "维护自动封禁开关、触发来源和进程名规则。", + targetPath: "/autoban", + }, { title: "维护项目配置", description: "查看配置类型、描述和 JSON 内容。", diff --git a/sealos-complik-admin/web/src/lib/api.ts b/sealos-complik-admin/web/src/lib/api.ts index 0f4fd4a..65e9019 100644 --- a/sealos-complik-admin/web/src/lib/api.ts +++ b/sealos-complik-admin/web/src/lib/api.ts @@ -1,5 +1,6 @@ import { formatDateTime, toTimestamp } from "./utils"; import type { + AutobanPolicy, BanRecord, CommitmentRecord, ConfigRecord, @@ -20,6 +21,9 @@ import type { ViolationRecord, } from "../types"; +export const AUTOBAN_POLICY_CONFIG_NAME = "autoban_policy"; +export const AUTOBAN_POLICY_CONFIG_TYPE = "autoban_policy"; + type ApiErrorPayload = { message?: string; error?: string; @@ -428,6 +432,11 @@ export async function listConfigRecords() { return data.map(toConfigRecord); } +export async function listConfigRecordsByType(configType: string) { + const data = await request(`/api/configs/type/${encodeURIComponent(configType)}`); + return data.map(toConfigRecord); +} + export async function listConfigRecordsPage(query: RecordListQuery): Promise> { const data = await request>(`/api/configs?${buildRecordListParams(query).toString()}`); return toPaginatedRecords(data, toConfigRecord); @@ -463,6 +472,22 @@ export async function updateConfigRecord(configName: string, input: UpdateConfig }); } +export async function saveAutobanPolicy(policy: AutobanPolicy, existingConfigName?: string) { + const input = { + configName: AUTOBAN_POLICY_CONFIG_NAME, + configType: AUTOBAN_POLICY_CONFIG_TYPE, + description: "Admin automatic namespace ban policy", + value: JSON.stringify(policy), + }; + + if (existingConfigName) { + await updateConfigRecord(existingConfigName, input); + return; + } + + await createConfigRecord(input); +} + export async function listCommitmentRecords() { const data = await request("/api/commitments"); return data.map(toCommitmentRecord); diff --git a/sealos-complik-admin/web/src/pages/AutobanPolicyPage.tsx b/sealos-complik-admin/web/src/pages/AutobanPolicyPage.tsx new file mode 100644 index 0000000..02d0ea6 --- /dev/null +++ b/sealos-complik-admin/web/src/pages/AutobanPolicyPage.tsx @@ -0,0 +1,522 @@ +import { RefreshCw, RotateCcw, Save } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AUTOBAN_POLICY_CONFIG_NAME, + AUTOBAN_POLICY_CONFIG_TYPE, + listConfigRecordsByType, + saveAutobanPolicy, +} from "../lib/api"; +import { Button, Field, Input, PageHeader, StatusPill, SurfaceCard, TextArea } from "../components/ui"; +import { useAppData } from "../contexts/AppDataContext"; +import type { AutobanPolicy, ConfigRecord, RiskTone } from "../types"; + +type AutobanPolicyForm = { + enabled: boolean; + dryRun: boolean; + operatorName: string; + reasonPrefix: string; + complikEnabled: boolean; + procscanEnabled: boolean; + processNameAllowlist: string; + processNameDenylist: string; + namespaceAllowlist: string; + namespaceDenylist: string; +}; + +const systemNamespaceDenylist = ["kube-system", "sealos", "block-system"]; + +const defaultPolicy: AutobanPolicy = { + enabled: false, + dryRun: true, + operatorName: "system/autoban", + reasonPrefix: "Admin auto-ban", + sources: { + complik: { enabled: false }, + procscan: { enabled: true }, + }, + processNameAllowlist: [], + processNameDenylist: [], + namespaceAllowlist: [], + namespaceDenylist: systemNamespaceDenylist, +}; + +function readObject(value: unknown): Record | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +function readBoolean(value: unknown, fallback: boolean) { + return typeof value === "boolean" ? value : fallback; +} + +function readString(value: unknown, fallback: string) { + return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback; +} + +function readStringList(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); +} + +function readSourceEnabled(value: unknown, fallback: boolean) { + if (typeof value === "boolean") { + return value; + } + + const source = readObject(value); + return readBoolean(source?.enabled, fallback); +} + +function parsePolicyValue(value: string): AutobanPolicy { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return defaultPolicy; + } + + const raw = readObject(parsed); + if (!raw) { + return defaultPolicy; + } + + const sources = readObject(raw.sources); + + return { + enabled: readBoolean(raw.enabled, defaultPolicy.enabled), + dryRun: readBoolean(raw.dryRun ?? raw.dry_run, defaultPolicy.dryRun), + operatorName: readString(raw.operatorName ?? raw.operator_name, defaultPolicy.operatorName), + reasonPrefix: readString(raw.reasonPrefix ?? raw.reason_prefix, defaultPolicy.reasonPrefix), + sources: { + complik: { + enabled: readSourceEnabled(sources?.complik, defaultPolicy.sources.complik.enabled), + }, + procscan: { + enabled: readSourceEnabled(sources?.procscan, defaultPolicy.sources.procscan.enabled), + }, + }, + processNameAllowlist: readStringList(raw.processNameAllowlist ?? raw.process_name_allowlist), + processNameDenylist: readStringList(raw.processNameDenylist ?? raw.process_name_denylist), + namespaceAllowlist: readStringList(raw.namespaceAllowlist ?? raw.namespace_allowlist), + namespaceDenylist: readStringList(raw.namespaceDenylist ?? raw.namespace_denylist), + }; +} + +function formatList(values: string[]) { + return values.join("\n"); +} + +function parseList(value: string) { + return value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function toForm(policy: AutobanPolicy): AutobanPolicyForm { + return { + enabled: policy.enabled, + dryRun: policy.dryRun, + operatorName: policy.operatorName, + reasonPrefix: policy.reasonPrefix, + complikEnabled: policy.sources.complik.enabled, + procscanEnabled: policy.sources.procscan.enabled, + processNameAllowlist: formatList(policy.processNameAllowlist), + processNameDenylist: formatList(policy.processNameDenylist), + namespaceAllowlist: formatList(policy.namespaceAllowlist), + namespaceDenylist: formatList(policy.namespaceDenylist), + }; +} + +function toPolicy(form: AutobanPolicyForm): AutobanPolicy { + return { + enabled: form.enabled, + dryRun: form.dryRun, + operatorName: form.operatorName.trim() || defaultPolicy.operatorName, + reasonPrefix: form.reasonPrefix.trim() || defaultPolicy.reasonPrefix, + sources: { + complik: { + enabled: form.complikEnabled, + }, + procscan: { + enabled: form.procscanEnabled, + }, + }, + processNameAllowlist: parseList(form.processNameAllowlist), + processNameDenylist: parseList(form.processNameDenylist), + namespaceAllowlist: parseList(form.namespaceAllowlist), + namespaceDenylist: parseList(form.namespaceDenylist), + }; +} + +function pickPolicyConfig(configs: ConfigRecord[]) { + return ( + configs.find((item) => item.configName.trim().toLowerCase() === AUTOBAN_POLICY_CONFIG_NAME) ?? + configs.find((item) => item.configType === AUTOBAN_POLICY_CONFIG_TYPE) + ); +} + +function getStatusTone(form: AutobanPolicyForm): RiskTone { + if (!form.enabled) { + return "neutral"; + } + + if (form.dryRun) { + return "warn"; + } + + return "danger"; +} + +function getStatusLabel(form: AutobanPolicyForm) { + if (!form.enabled) { + return "关闭"; + } + + if (form.dryRun) { + return "Dry-run"; + } + + return "生效中"; +} + +function getSourceLabel(form: AutobanPolicyForm) { + const enabledSources = [ + form.complikEnabled ? "CompliK" : "", + form.procscanEnabled ? "ProcScan" : "", + ].filter(Boolean); + + return enabledSources.length > 0 ? enabledSources.join(" / ") : "未启用"; +} + +function getRuleSummary(allowlist: string, denylist: string, emptyLabel: string) { + const allowCount = parseList(allowlist).length; + const denyCount = parseList(denylist).length; + + if (allowCount > 0 && denyCount > 0) { + return `${allowCount} 条允许 / ${denyCount} 条排除`; + } + + if (allowCount > 0) { + return `${allowCount} 条允许名单`; + } + + if (denyCount > 0) { + return `${denyCount} 条排除名单`; + } + + return emptyLabel; +} + +function CheckboxRow({ + checked, + label, + description, + onChange, +}: { + checked: boolean; + label: string; + description: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function SummaryItem({ + label, + value, + tone, + description, +}: { + label: string; + value: string; + tone: RiskTone; + description: string; +}) { + return ( +
+ {label} +
+ {value} +
+

{description}

+
+ ); +} + +export function AutobanPolicyPage() { + const { configRecords, refreshAll } = useAppData(); + const [form, setForm] = useState(() => toForm(defaultPolicy)); + const [policyConfig, setPolicyConfig] = useState(null); + const [loadedFallbackConfig, setLoadedFallbackConfig] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [savedMessage, setSavedMessage] = useState(null); + + const previewPolicy = useMemo(() => toPolicy(form), [form]); + const previewJSON = useMemo(() => JSON.stringify(previewPolicy, null, 2), [previewPolicy]); + + const loadPolicy = useCallback(async () => { + setIsLoading(true); + setError(null); + setSavedMessage(null); + + try { + const typedConfigs = await listConfigRecordsByType(AUTOBAN_POLICY_CONFIG_TYPE); + const canonicalConfig = + typedConfigs.find((item) => item.configName.trim().toLowerCase() === AUTOBAN_POLICY_CONFIG_NAME) ?? null; + const nextConfig = canonicalConfig ?? typedConfigs[0] ?? pickPolicyConfig(configRecords) ?? null; + const nextPolicy = nextConfig ? parsePolicyValue(nextConfig.value) : defaultPolicy; + + setPolicyConfig(canonicalConfig); + setLoadedFallbackConfig(canonicalConfig ? null : nextConfig); + setForm(toForm(nextPolicy)); + } catch (err) { + setError(err instanceof Error ? err.message : "自动封禁策略加载失败"); + } finally { + setIsLoading(false); + } + }, [configRecords]); + + useEffect(() => { + void loadPolicy(); + }, [loadPolicy]); + + const updateForm = (key: TKey, value: AutobanPolicyForm[TKey]) => { + setForm((current) => ({ ...current, [key]: value })); + setSavedMessage(null); + }; + + const handleSave = async () => { + setIsSaving(true); + setError(null); + setSavedMessage(null); + + try { + await saveAutobanPolicy(previewPolicy, policyConfig?.configName); + await refreshAll(); + await loadPolicy(); + setSavedMessage("自动封禁策略已保存。"); + } catch (err) { + setError(err instanceof Error ? err.message : "自动封禁策略保存失败"); + } finally { + setIsSaving(false); + } + }; + + const resetToDefault = () => { + setForm(toForm(defaultPolicy)); + setSavedMessage(null); + setError(null); + }; + + return ( +
+ + + + + } + /> + + {error ?
{error}
: null} + {savedMessage ?
{savedMessage}
: null} + {loadedFallbackConfig ? ( +
+ 当前读取的是同类型配置 {loadedFallbackConfig.configName},保存后会写入规范配置 {AUTOBAN_POLICY_CONFIG_NAME}。 +
+ ) : null} + + +
+ + + 0 ? "warn" : "info"} + value={getRuleSummary(form.namespaceAllowlist, form.namespaceDenylist, "全部允许")} + /> + 0 ? "warn" : "info"} + value={getRuleSummary(form.processNameAllowlist, form.processNameDenylist, "全部允许")} + /> +
+
+ +
+ +
+
+
+
+

策略开关

+

控制自动封禁是否参与处理,以及是否只做 dry-run。

+
+ +
+
+ updateForm("enabled", checked)} + /> + updateForm("dryRun", checked)} + /> +
+
+ +
+
+

触发来源

+

CompliK 和 ProcScan 可以独立控制。

+
+
+ updateForm("complikEnabled", checked)} + /> + updateForm("procscanEnabled", checked)} + /> +
+
+ +
+
+

封禁记录

+

自动创建封禁记录时使用的操作人和原因前缀。

+
+
+ + updateForm("operatorName", event.target.value)} + /> + + + updateForm("reasonPrefix", event.target.value)} + /> + +
+
+ +
+
+

进程名规则

+

每行一个进程名,也可以用英文逗号分隔;denylist 优先。

+
+
+ +