feat: add Fail2ban security detection console
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.admin-security-page{gap:20px}.security-heading{align-items:flex-end}.security-heading h1{margin:10px 0 4px;font-size:26px}.security-heading p{margin:0;color:#64748b}.security-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.security-kpi{display:grid;grid-template-columns:42px 1fr;grid-template-rows:auto auto;gap:3px 12px;padding:18px}.security-kpi>div{grid-row:1/3;width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e8f0ff;color:#2563eb}.security-kpi>div svg{width:20px}.security-kpi span{font-size:13px;color:#64748b}.security-kpi strong{font-size:24px;line-height:1.1}.security-kpi.is-danger>div{background:#fef2f2;color:#dc2626}.security-overview-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.8fr);gap:16px}.security-chart,.security-latest,.security-table-card{padding:18px}.security-chart header,.security-latest header{display:flex;justify-content:space-between}.security-chart header span,.security-latest header span{font-size:12px;color:#94a3b8}.security-latest-row{display:grid;grid-template-columns:10px 1fr auto;gap:10px;align-items:center;padding:13px 0;border-bottom:1px solid #eef2f7}.security-latest-row div{display:grid;gap:3px}.security-latest-row small,.security-latest-row time{color:#64748b;font-size:12px}.severity-dot{width:8px;height:8px;border-radius:99px;background:#3b82f6}.severity-dot.is-high{background:#f59e0b}.severity-dot.is-critical{background:#ef4444}.security-cell{display:grid;gap:3px}.security-cell span{font-size:11px;color:#94a3b8}.security-actions{display:flex;gap:6px}.security-note,.security-section-toolbar{display:flex;align-items:center;gap:9px;margin-bottom:16px;padding:12px 14px;border-radius:10px;background:#f8fafc;color:#475569;font-size:13px}.security-section-toolbar{justify-content:space-between}.security-error{display:flex;gap:8px;align-items:center;padding:12px 14px;border:1px solid #fecaca;border-radius:10px;background:#fef2f2;color:#b91c1c}.security-empty{height:265px;display:grid;place-items:center;color:#94a3b8}.security-dialog{display:grid;gap:16px}.security-target{display:grid;gap:4px;padding:14px;border-radius:10px;background:#f8fafc}.security-target span,.security-target small{color:#64748b;font-size:12px}.security-target strong{font-family:ui-monospace,monospace;font-size:18px}.security-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.security-form-grid .security-error{grid-column:1/-1}.admin-security-page code{font-size:12px;color:#334155}@media(max-width:1100px){.security-kpis{grid-template-columns:repeat(2,1fr)}.security-overview-grid{grid-template-columns:1fr}}@media(max-width:640px){.security-kpis{grid-template-columns:1fr}.security-form-grid{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { AlertTriangle, Ban, BellRing, RefreshCw, Settings2, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { adminApi, type SecurityAlert, type SecurityBlock, type SecurityOverview, type SecurityProtectedNetwork, type SecurityRule } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Chart, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import './AdminSecurityDetectionPage.css';
|
||||
|
||||
const statusLabels: Record<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
|
||||
const severityLabels: Record<string, string> = { low: '低', medium: '中', high: '高', critical: '严重' };
|
||||
const durationOptions = [{ value: '600', label: '10分钟' }, { value: '3600', label: '1小时' }, { value: '86400', label: '24小时' }, { value: '604800', label: '7天' }];
|
||||
const formatTime = (value?: string | null) => value ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) : '—';
|
||||
function Modal(props: Omit<ComponentProps<typeof BaseModal>, 'open'>) { return <BaseModal {...props} open />; }
|
||||
|
||||
export function AdminSecurityDetectionPage() {
|
||||
const [overview, setOverview] = useState<SecurityOverview | null>(null);
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||
const [rules, setRules] = useState<SecurityRule[]>([]);
|
||||
const [blocks, setBlocks] = useState<SecurityBlock[]>([]);
|
||||
const [networks, setNetworks] = useState<SecurityProtectedNetwork[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [blockAlert, setBlockAlert] = useState<SecurityAlert | null>(null);
|
||||
const [editRule, setEditRule] = useState<SecurityRule | null>(null);
|
||||
const [showNetwork, setShowNetwork] = useState(false);
|
||||
const [reasonAction, setReasonAction] = useState<{ title: string; confirmLabel: string; run: (reason: string) => Promise<void> } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const [nextOverview, nextAlerts, nextRules, nextBlocks, nextNetworks] = await Promise.all([
|
||||
adminApi.getSecurityOverview('24h'), adminApi.listSecurityAlerts({ pageSize: 100 }), adminApi.listSecurityRules(), adminApi.listSecurityBlocks(), adminApi.listProtectedNetworks(),
|
||||
]);
|
||||
setOverview(nextOverview); setAlerts(nextAlerts.items); setRules(nextRules); setBlocks(nextBlocks); setNetworks(nextNetworks);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : '安全检测数据加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const alertColumns: Array<TableColumn<SecurityAlert>> = [
|
||||
{ key: 'level', title: '级别', width: '80px', render: (item) => <Tag tone={item.severity === 'critical' ? 'danger' : item.severity === 'high' ? 'warning' : 'info'}>{severityLabels[item.severity] ?? item.severity}</Tag> },
|
||||
{ key: 'rule', title: '检测类型', width: '180px', render: (item) => <div className="security-cell"><strong>{item.rule.name}</strong><span>{item.rule.code}</span></div> },
|
||||
{ key: 'ip', title: '来源 IP', width: '150px', render: (item) => <code>{item.sourceIp}</code> },
|
||||
{ key: 'count', title: '窗口命中', width: '100px', render: (item) => `${item.eventCount} 次` },
|
||||
{ key: 'time', title: '最后发生', width: '140px', render: (item) => formatTime(item.lastOccurredAt) },
|
||||
{ key: 'status', title: '状态', width: '96px', render: (item) => <Tag tone={item.status === 'blocked' ? 'success' : item.status === 'block_failed' ? 'danger' : 'neutral'}>{statusLabels[item.status] ?? item.status}</Tag> },
|
||||
{ key: 'action', title: '人工处置', width: '190px', render: (item) => <div className="security-actions"><Button disabled={!['open', 'acknowledged', 'block_failed'].includes(item.status)} onClick={() => setBlockAlert(item)} size="sm" variant="danger">封禁</Button><Button disabled={!['open', 'acknowledged', 'block_failed'].includes(item.status)} onClick={() => void ignore(item)} size="sm" variant="ghost">忽略</Button></div> },
|
||||
];
|
||||
function ignore(item: SecurityAlert) { setReasonAction({ title: `忽略告警 · ${item.sourceIp}`, confirmLabel: '确认忽略', run: async (reason) => { await adminApi.ignoreSecurityAlert(item.id, reason); await load(); } }); }
|
||||
|
||||
const ruleColumns: Array<TableColumn<SecurityRule>> = [
|
||||
{ key: 'name', title: '规则', width: '230px', render: (item) => <div className="security-cell"><strong>{item.name}</strong><span>{item.code}</span></div> },
|
||||
{ key: 'source', title: '数据源', width: '110px', render: (item) => item.sourceType },
|
||||
{ key: 'threshold', title: '阈值 / 窗口', width: '150px', render: (item) => `${item.threshold} 次 / ${item.windowSeconds} 秒` },
|
||||
{ key: 'cooldown', title: '冷却', width: '100px', render: (item) => `${item.cooldownSeconds} 秒` },
|
||||
{ key: 'version', title: '生效版本', width: '120px', render: (item) => <Tag tone={item.applyStatus === 'effective' ? 'success' : item.applyStatus === 'failed' ? 'danger' : 'warning'}>{item.effectiveVersion}/{item.configVersion}</Tag> },
|
||||
{ key: 'enabled', title: '启用', width: '90px', render: (item) => <Tag tone={item.enabled ? 'success' : 'neutral'}>{item.enabled ? '启用' : '停用'}</Tag> },
|
||||
{ key: 'action', title: '操作', width: '90px', render: (item) => <Button onClick={() => setEditRule(item)} size="sm" variant="ghost">配置</Button> },
|
||||
];
|
||||
const blockColumns: Array<TableColumn<SecurityBlock>> = [
|
||||
{ key: 'ip', title: 'IP', width: '160px', render: (item) => <code>{item.sourceIp}</code> }, { key: 'executor', title: '执行器', width: '140px', render: (item) => item.executor },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (item) => <Tag tone={item.status === 'blocked' ? 'success' : item.status === 'failed' ? 'danger' : 'neutral'}>{statusLabels[item.status] ?? item.status}</Tag> },
|
||||
{ key: 'reason', title: '原因', width: '260px', render: (item) => item.reason }, { key: 'expiry', title: '到期时间', width: '150px', render: (item) => formatTime(item.expiresAt) }, { key: 'error', title: '执行结果', width: '220px', render: (item) => item.lastError ?? '已由执行器回读确认' },
|
||||
{ key: 'action', title: '操作', width: '90px', render: (item) => <Button disabled={item.status !== 'blocked'} onClick={() => void releaseBlock(item)} size="sm" variant="ghost">解封</Button> },
|
||||
];
|
||||
function releaseBlock(item: SecurityBlock) { setReasonAction({ title: `人工解封 · ${item.sourceIp}`, confirmLabel: '确认解封', run: async (reason) => { await adminApi.unblockSecurityBlock(item.id, reason); await load(); } }); }
|
||||
const networkColumns: Array<TableColumn<SecurityProtectedNetwork>> = [
|
||||
{ key: 'network', title: 'IP / 网段', width: '180px', render: (item) => <code>{item.network}</code> }, { key: 'name', title: '名称', width: '180px', render: (item) => item.name }, { key: 'reason', title: '保护原因', width: '320px', render: (item) => item.reason }, { key: 'status', title: '状态', width: '100px', render: (item) => <Tag tone={item.enabled ? 'success' : 'neutral'}>{item.enabled ? '保护中' : '已停用'}</Tag> },
|
||||
];
|
||||
const chartOption = useMemo<EChartsOption>(() => ({ tooltip: { trigger: 'item' }, legend: { bottom: 0, type: 'scroll' }, series: [{ type: 'pie', radius: ['52%', '76%'], center: ['50%', '43%'], label: { show: false }, data: overview?.sourceDistribution ?? [] }] }), [overview]);
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: '总览', content: <><div className="security-overview-grid"><article className="surface security-chart"><header><strong>24小时检测来源</strong><span>真实事件聚合</span></header>{overview?.sourceDistribution.length ? <Chart height={265} option={chartOption} /> : <div className="security-empty">暂无检测事件</div>}</article><article className="surface security-latest"><header><strong>最新告警</strong><span>{overview?.alerts.length ?? 0} 条</span></header>{overview?.alerts.slice(0, 6).map((item) => <div className="security-latest-row" key={item.id}><span className={`severity-dot is-${item.severity}`} /><div><strong>{item.rule.name}</strong><small>{item.sourceIp} · {item.eventCount} 次</small></div><time>{formatTime(item.lastOccurredAt)}</time></div>)}</article></div></> },
|
||||
{ value: 'alerts', label: '告警中心', content: <section className="surface security-table-card"><Table columns={alertColumns} data={alerts} emptyText="暂无安全告警" rowKey="id" /></section> },
|
||||
{ value: 'rules', label: '规则配置', content: <section className="surface security-table-card"><div className="security-note"><Settings2 size={17} /><span>所有阈值来自数据库;修改后必须由受限安全代理验证并应用,版本一致才标记生效。</span></div><Table columns={ruleColumns} data={rules} rowKey="id" /></section> },
|
||||
{ value: 'blocks', label: '封禁记录', content: <section className="surface security-table-card"><Table columns={blockColumns} data={blocks} emptyText="暂无人工封禁记录" rowKey="id" /></section> },
|
||||
{ value: 'protected', label: '保护名单', content: <section className="surface security-table-card"><div className="security-section-toolbar"><span>受保护的运维出口、内网和可信代理永远不能从面板封禁。</span><Button onClick={() => setShowNetwork(true)}>新增保护网段</Button></div><Table columns={networkColumns} data={networks} emptyText="暂无保护网段" rowKey="id" /></section> },
|
||||
];
|
||||
|
||||
return <section className="page-stack admin-security-page"><div className="page-heading security-heading"><div><Breadcrumb items={['安全控制', '安全检测与封禁']} /><h1>安全检测与封禁</h1><p>Fail2ban 与业务事件只负责检测,由运营人员复核后人工处置</p></div><Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void load()} variant="ghost">刷新</Button></div>
|
||||
{error ? <div className="security-error" role="alert"><AlertTriangle size={18} />{error}</div> : null}
|
||||
<div className="security-kpis"><Kpi icon={<BellRing />} label="24小时检测事件" value={overview?.totalEvents ?? 0} /><Kpi icon={<AlertTriangle />} label="待处置告警" value={overview?.activeAlerts ?? 0} danger={Boolean(overview?.criticalAlerts)} /><Kpi icon={<Ban />} label="生效封禁" value={overview?.activeBlocks ?? 0} /><Kpi icon={overview?.health.agent === 'healthy' ? <ShieldCheck /> : <ShieldOff />} label="安全代理" value={overview?.health.agent === 'healthy' ? '在线' : '不可用'} danger={overview?.health.agent !== 'healthy'} /></div>
|
||||
<Tabs items={tabs} />
|
||||
{blockAlert ? <BlockDialog alert={blockAlert} onClose={() => setBlockAlert(null)} onSaved={async () => { setBlockAlert(null); await load(); }} /> : null}
|
||||
{editRule ? <RuleDialog rule={editRule} onClose={() => setEditRule(null)} onSaved={async () => { setEditRule(null); await load(); }} /> : null}
|
||||
{showNetwork ? <NetworkDialog onClose={() => setShowNetwork(false)} onSaved={async () => { setShowNetwork(false); await load(); }} /> : null}
|
||||
{reasonAction ? <ActionReasonDialog action={reasonAction} onClose={() => setReasonAction(null)} /> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Kpi({ icon, label, value, danger = false }: { icon: React.ReactNode; label: string; value: React.ReactNode; danger?: boolean }) { return <article className={`surface security-kpi ${danger ? 'is-danger' : ''}`}><div>{icon}</div><span>{label}</span><strong>{value}</strong></article>; }
|
||||
function BlockDialog({ alert, onClose, onSaved }: { alert: SecurityAlert; onClose: () => void; onSaved: () => void }) { const [duration, setDuration] = useState(String(alert.rule.defaultBlockSeconds)); const [reason, setReason] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const executor = alert.rule.code.startsWith('admin_') || alert.rule.code.startsWith('client_') ? 'Nginx Real-IP deny' : 'nftables'; async function save() { setSaving(true); setError(''); try { await adminApi.blockSecurityAlert(alert.id, { durationSeconds: Number(duration), reason }); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '封禁失败'); } finally { setSaving(false); } } return <Modal title="确认人工封禁" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving || reason.trim().length < 5} onClick={() => void save()} variant="danger">{saving ? '执行并回读中' : '确认封禁'}</Button></>}><div className="security-dialog"><div className="security-target"><span>目标 IP</span><strong>{alert.sourceIp}</strong><small>{alert.rule.name} · 窗口命中 {alert.eventCount} 次</small></div><Input disabled label="服务端固定执行器" value={executor} /><Select label="封禁时长" options={durationOptions.filter((item) => Number(item.value) <= alert.rule.maximumBlockSeconds)} value={duration} onChange={(event) => setDuration(event.target.value)} /><Textarea label="封禁原因" minLength={5} onChange={(event) => setReason(event.target.value)} required value={reason} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
function RuleDialog({ rule, onClose, onSaved }: { rule: SecurityRule; onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState(rule); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const number = (key: keyof SecurityRule) => (event: React.ChangeEvent<HTMLInputElement>) => setValue({ ...value, [key]: Number(event.target.value) }); async function save() { setSaving(true); try { await adminApi.updateSecurityRule(rule.id, value); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '规则保存失败'); } finally { setSaving(false); } } return <Modal title={`配置规则 · ${rule.name}`} onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '验证并应用中' : '保存并应用'}</Button></>}><div className="security-form-grid"><Select label="启用状态" options={[{ value: 'true', label: '启用' }, { value: 'false', label: '停用' }]} value={String(value.enabled)} onChange={(event) => setValue({ ...value, enabled: event.target.value === 'true' })} /><Select label="告警级别" options={['low','medium','high','critical'].map((item) => ({ value: item, label: severityLabels[item] }))} value={value.severity} onChange={(event) => setValue({ ...value, severity: event.target.value })} /><Input label="触发次数" min={1} onChange={number('threshold')} type="number" value={value.threshold} /><Input label="检测窗口(秒)" min={10} onChange={number('windowSeconds')} type="number" value={value.windowSeconds} /><Input label="告警冷却(秒)" min={0} onChange={number('cooldownSeconds')} type="number" value={value.cooldownSeconds} /><Input label="默认封禁(秒)" min={600} onChange={number('defaultBlockSeconds')} type="number" value={value.defaultBlockSeconds} /><Input label="最大封禁(秒)" min={600} onChange={number('maximumBlockSeconds')} type="number" value={value.maximumBlockSeconds} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
function NetworkDialog({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) { const [value, setValue] = useState({ network: '', name: '', reason: '' }); const [error, setError] = useState(''); async function save() { try { await adminApi.addProtectedNetwork(value); await onSaved(); } catch (reason) { setError(reason instanceof Error ? reason.message : '新增失败'); } } return <Modal title="新增保护网段" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!value.network || !value.name || !value.reason} onClick={() => void save()}>确认保护</Button></>}><div className="security-dialog"><Input label="IP 或 CIDR" placeholder="例如 203.0.113.10 或 10.0.0.0/8" value={value.network} onChange={(event) => setValue({ ...value, network: event.target.value })} /><Input label="名称" value={value.name} onChange={(event) => setValue({ ...value, name: event.target.value })} /><Textarea label="保护原因" value={value.reason} onChange={(event) => setValue({ ...value, reason: event.target.value })} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
|
||||
function ActionReasonDialog({ action, onClose }: { action: { title: string; confirmLabel: string; run: (reason: string) => Promise<void> }; onClose: () => void }) { const [reason, setReason] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); async function save() { setSaving(true); setError(''); try { await action.run(reason); onClose(); } catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); } finally { setSaving(false); } } return <Modal title={action.title} onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving || reason.trim().length < 5} onClick={() => void save()}>{saving ? '处理中' : action.confirmLabel}</Button></>}><div className="security-dialog"><Textarea autoFocus label="操作原因" minLength={5} onChange={(event) => setReason(event.target.value)} required value={reason} />{error ? <div className="security-error">{error}</div> : null}</div></Modal>; }
|
||||
Reference in New Issue
Block a user