feat(security): Security Center — firewall, NAT, bandwidth, posture, connections (v0.14.0-beta)
A dedicated top-level Security section plus a Meraki/UniFi-grade firewall built
on the full RouterOS feature set.
Security Center (new /security page)
- Fleet hardening scores, per-device posture list, and a "Common Findings"
rollup aggregating identical issues across the fleet
- "Manage" deep-links into the device's Security tab (?tab= support added)
Firewall & NAT (per device + in Security Center)
- Friendly rule builder: Allow/Drop/Reject chips, Any/Address/Address-List
pickers, port presets, connection-state chips, logging, live plain-English
preview
- Address lists as reusable objects, referenced from rules
- Rule reordering (/ip/firewall/{filter,nat}/move); per-rule hit counters
(print stats) with dead-rule highlighting + reset
- NAT wizards: Port Forward / Masquerade / 1:1 NAT + Custom
- Safe-apply lockout guard: backend 409 on an unscoped input drop/reject that
would sever management; UI confirms before forcing (detectLockoutRisk, tested)
Access & security
- Security posture audit: graduated, de-duplicated scoring that never proposes
disabling the management service the platform connects through (no self-
lockout); switch-aware input-firewall severity; service hardening table
- Bandwidth: simple-queue CRUD + one-click "Limit this client" on client detail
- Active connections viewer
These L3 features are available on all managed RouterOS devices (routers,
switches, APs), not gated by device type.
Backend: DeviceCollector address-list/move/stats/connections/queues/service
methods; device routes for all of the above; firewallSafety + firewallSummary
utils with unit tests (19 new).
Security: bump nodemailer 8 -> 9.0.1 to clear GHSA-p6gq-j5cr-w38f (our
createTransport/sendMail usage is unaffected by the advisory's raw-option path).
Verified live against RouterOS devices; lint/typecheck/build/jest green in both
workspaces; npm audit --audit-level=high clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
623e54120d
commit
ee9c9ff9f7
@@ -26,6 +26,7 @@ import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||
import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage';
|
||||
import NetworkServicesNetflowPage from './pages/NetworkServicesNetflowPage';
|
||||
import TrafficAnalyticsPage from './pages/TrafficAnalyticsPage';
|
||||
import SecurityPage from './pages/SecurityPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
<Route path="network-services/syslog" element={<NetworkServicesSyslogPage />} />
|
||||
<Route path="network-services/netflow" element={<NetworkServicesNetflowPage />} />
|
||||
<Route path="traffic" element={<TrafficAnalyticsPage />} />
|
||||
<Route path="security" element={<SecurityPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw, Activity, Search } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { formatBytes } from '../../utils/firewallSummary';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Split a RouterOS connection address ("192.168.1.10:443") into ip + port.
|
||||
function splitAddr(a?: string): { ip: string; port: string } {
|
||||
if (!a) return { ip: '', port: '' };
|
||||
const i = a.lastIndexOf(':');
|
||||
return i > 0 ? { ip: a.slice(0, i), port: a.slice(i + 1) } : { ip: a, port: '' };
|
||||
}
|
||||
|
||||
const PROTO_COLOR: Record<string, string> = {
|
||||
tcp: 'text-blue-600 dark:text-blue-400', udp: 'text-violet-600 dark:text-violet-400',
|
||||
icmp: 'text-amber-600 dark:text-amber-400',
|
||||
};
|
||||
|
||||
export default function ConnectionsTab({ deviceId }: { deviceId: number }) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['connections', deviceId],
|
||||
queryFn: () => devicesApi.getConnections(deviceId, 500).then(r => r.data),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const all = data?.connections ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const q = search.trim().toLowerCase();
|
||||
const rows = q ? all.filter(c => JSON.stringify(c).toLowerCase().includes(q)) : all;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-cyan-50 dark:bg-cyan-900/20 rounded-lg flex items-center justify-center"><Activity className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /></div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Active Connections <span className="text-gray-400 font-normal">({total}{total > all.length ? `, showing ${all.length}` : ''})</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input className="input pl-8 py-1.5 text-xs w-56" placeholder="Filter IP / port / proto…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading connections…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400">{all.length === 0 ? 'No active connections (connection tracking may be disabled).' : 'No connections match your filter.'}</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left">Proto</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Source</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Destination</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">State</th>
|
||||
<th className="table-header px-3 py-2.5 text-right">Rate ↓/↑</th>
|
||||
<th className="table-header px-3 py-2.5 text-right">Bytes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rows.map((c, i) => {
|
||||
const src = splitAddr(c['src-address']);
|
||||
const dst = splitAddr(c['dst-address']);
|
||||
const proto = (c.protocol || '').toLowerCase();
|
||||
const origRate = c['orig-rate'] || '0';
|
||||
const replRate = c['repl-rate'] || '0';
|
||||
const bytes = (c['orig-bytes'] ? Number(c['orig-bytes']) : 0) + (c['repl-bytes'] ? Number(c['repl-bytes']) : 0);
|
||||
return (
|
||||
<tr key={c['.id'] ?? i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className={clsx('px-3 py-2 text-xs font-semibold uppercase', PROTO_COLOR[proto] ?? 'text-gray-500')}>{proto || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{src.ip}{src.port && <span className="text-gray-400">:{src.port}</span>}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{dst.ip}{dst.port && <span className="text-gray-400">:{dst.port}</span>}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{c['tcp-state'] || c.state || '—'}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-gray-500 dark:text-slate-400 font-mono">{replRate !== '0' || origRate !== '0' ? `${replRate}/${origRate}` : '—'}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-gray-500 dark:text-slate-400">{bytes > 0 ? formatBytes(bytes) : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">Live from the device's connection tracking table, auto-refreshing every 15s.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Pencil, RefreshCw, Check, X, Gauge, AlertCircle } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Row = Record<string, string> & { '.id': string };
|
||||
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
// RouterOS simple-queue max-limit is "upload/download" (e.g. "10M/50M").
|
||||
function splitLimit(v?: string): { up: string; down: string } {
|
||||
if (!v) return { up: '', down: '' };
|
||||
const [up, down] = v.split('/');
|
||||
return { up: up || '', down: down || '' };
|
||||
}
|
||||
function joinLimit(up: string, down: string): string {
|
||||
return `${up || '0'}/${down || '0'}`;
|
||||
}
|
||||
|
||||
interface QForm { name: string; target: string; up: string; down: string; comment: string; disabled: boolean }
|
||||
const EMPTY: QForm = { name: '', target: '', up: '10M', down: '50M', comment: '', disabled: false };
|
||||
|
||||
const RATE_PRESETS = ['1M', '5M', '10M', '25M', '50M', '100M'];
|
||||
|
||||
function QueueModal({ title, form, setForm, onSave, onClose, isPending, error }: {
|
||||
title: string; form: QForm; setForm: React.Dispatch<React.SetStateAction<QForm>>;
|
||||
onSave: () => void; onClose: () => void; isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (k: keyof QForm, v: string | boolean) => setForm(f => ({ ...f, [k]: v }));
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div><label className="label">Name</label>
|
||||
<input className="input" value={form.name} onChange={e => set('name', e.target.value)} placeholder="e.g. limit-guest-pc" /></div>
|
||||
<div><label className="label">Target (IP, subnet, or interface)</label>
|
||||
<input className="input font-mono" value={form.target} onChange={e => set('target', e.target.value)} placeholder="192.168.1.50 or 192.168.1.0/24" /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Max Upload</label>
|
||||
<input className="input font-mono" value={form.up} onChange={e => set('up', e.target.value)} placeholder="10M" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Max Download</label>
|
||||
<input className="input font-mono" value={form.down} onChange={e => set('down', e.target.value)} placeholder="50M" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{RATE_PRESETS.map(r => (
|
||||
<button key={r} type="button" onClick={() => set('down', r)}
|
||||
className="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400">↓ {r}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Use suffixes: <span className="font-mono">k</span>, <span className="font-mono">M</span>, <span className="font-mono">G</span> (bits/sec). Leave a field as <span className="font-mono">0</span> for unlimited.</p>
|
||||
<div><label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional" /></div>
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" /><p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending || !form.name || !form.target} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueuesTab({ deviceId }: { deviceId: number }) {
|
||||
const qc = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<QForm>(EMPTY);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const { data: queues = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['queues', deviceId], queryFn: () => devicesApi.getQueues(deviceId).then(r => r.data as Row[]),
|
||||
});
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['queues', deviceId] });
|
||||
|
||||
const payload = (f: QForm) => ({ name: f.name, target: f.target, max_limit: joinLimit(f.up, f.down), comment: f.comment || undefined, disabled: f.disabled });
|
||||
|
||||
const addMut = useMutation({ mutationFn: () => devicesApi.addQueue(deviceId, payload(form)),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setErr(''); }, onError: e => setErr(errMsg(e)) });
|
||||
const updMut = useMutation({ mutationFn: (id: string) => devicesApi.updateQueue(deviceId, id, payload(form)),
|
||||
onSuccess: () => { invalidate(); setEditing(null); setErr(''); }, onError: e => setErr(errMsg(e)) });
|
||||
const delMut = useMutation({ mutationFn: (id: string) => devicesApi.removeQueue(deviceId, id), onSuccess: invalidate });
|
||||
|
||||
const openEdit = (q: Row) => { const lim = splitLimit(q['max-limit']); setForm({ name: q.name ?? '', target: q.target ?? '', up: lim.up, down: lim.down, comment: q.comment ?? '', disabled: q.disabled === 'true' }); setErr(''); setEditing(q); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg flex items-center justify-center"><Gauge className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /></div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Bandwidth Queues <span className="text-gray-400 font-normal">({queues.length})</span></h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5"><RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh</button>
|
||||
{canWrite && <button onClick={() => { setForm(EMPTY); setErr(''); setShowAdd(true); }} className="btn-primary flex items-center gap-1.5 text-xs py-1.5"><Plus className="w-3.5 h-3.5" /> Add Queue</button>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Simple queues cap upload/download for a client IP, subnet, or interface. Limit a single client quickly from its detail page.</p>
|
||||
|
||||
{isLoading ? <div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…</div>
|
||||
: queues.length === 0 ? <div className="card p-8 text-center text-gray-400">No bandwidth queues defined.</div>
|
||||
: (
|
||||
<div className="card overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Target</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Max ↑/↓</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-20" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{queues.map((q, i) => {
|
||||
const disabled = q.disabled === 'true';
|
||||
const lim = splitLimit(q['max-limit']);
|
||||
return (
|
||||
<tr key={q['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-xs font-medium text-gray-900 dark:text-white">{q.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{q.target || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-emerald-600 dark:text-emerald-400">{lim.up || '0'} / {lim.down || '0'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 italic max-w-[160px] truncate">{q.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button onClick={() => openEdit(q)} className="p-1 rounded text-gray-400 hover:text-blue-500"><Pencil className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => { if (confirm(`Delete queue "${q.name}"?`)) delMut.mutate(q['.id']); }} className="p-1 rounded text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <QueueModal title="Add Bandwidth Queue" form={form} setForm={setForm} isPending={addMut.isPending} error={err} onClose={() => setShowAdd(false)} onSave={() => addMut.mutate()} />}
|
||||
{editing && <QueueModal title="Edit Bandwidth Queue" form={form} setForm={setForm} isPending={updMut.isPending} error={err} onClose={() => setEditing(null)} onSave={() => updMut.mutate(editing['.id'])} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { SecurityCheck } from '../../services/api';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Row = Record<string, string> & { '.id': string };
|
||||
|
||||
const SEV_STYLE: Record<string, { ring: string; text: string; label: string }> = {
|
||||
high: { ring: 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20', text: 'text-red-600 dark:text-red-400', label: 'High' },
|
||||
medium: { ring: 'border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', label: 'Medium' },
|
||||
low: { ring: 'border-blue-300 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20', text: 'text-blue-600 dark:text-blue-400', label: 'Low' },
|
||||
ok: { ring: 'border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-900/20', text: 'text-green-600 dark:text-green-400', label: 'OK' },
|
||||
};
|
||||
|
||||
function scoreColor(score: number) {
|
||||
if (score >= 85) return 'text-green-600 dark:text-green-400';
|
||||
if (score >= 60) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
|
||||
export default function SecurityTab({ deviceId }: { deviceId: number }) {
|
||||
const qc = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: posture, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['security-posture', deviceId],
|
||||
queryFn: () => devicesApi.getSecurityPosture(deviceId).then(r => r.data),
|
||||
});
|
||||
const { data: servicesRaw = [] } = useQuery({
|
||||
queryKey: ['services', deviceId],
|
||||
queryFn: () => devicesApi.getServices(deviceId).then(r => r.data as Row[]),
|
||||
});
|
||||
// Some devices report duplicate /ip/service rows (e.g. api twice) — show one per name.
|
||||
const services = Array.from(new Map(servicesRaw.map(s => [s.name ?? s['.id'], s])).values());
|
||||
// The RouterOS service the platform connects through — never let it be
|
||||
// disabled from here, or MikroTik Manager loses control of the device.
|
||||
const { data: device } = useQuery({
|
||||
queryKey: ['device', deviceId],
|
||||
queryFn: () => devicesApi.get(deviceId).then(r => r.data),
|
||||
});
|
||||
const mgmtService = device?.api_port === 8729 ? 'api-ssl' : 'api';
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['security-posture', deviceId] });
|
||||
qc.invalidateQueries({ queryKey: ['services', deviceId] });
|
||||
};
|
||||
const toggleSvc = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.setServiceDisabled(deviceId, id, disabled),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const checks: SecurityCheck[] = posture?.checks ?? [];
|
||||
const score = posture?.score ?? 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-green-50 dark:bg-green-900/20 rounded-lg flex items-center justify-center"><ShieldCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" /></div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Security Posture</h3>
|
||||
</div>
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Re-scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Auditing device…</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Score + summary */}
|
||||
<div className="card p-5 flex items-center gap-5">
|
||||
<div className="text-center">
|
||||
<div className={clsx('text-4xl font-bold', scoreColor(score))}>{score}</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide" title="Heuristic hardening indicator, not an absolute grade">Hardening</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{checks.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 text-sm font-medium"><ShieldCheck className="w-4 h-4" /> No issues found — this device passes all baseline checks.</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-slate-300 text-sm"><ShieldAlert className="w-4 h-4 text-amber-500" /> {checks.length} issue{checks.length !== 1 ? 's' : ''} found. Review and remediate below.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{checks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{checks.map(c => {
|
||||
const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low;
|
||||
return (
|
||||
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3', s.ring)}>
|
||||
<AlertTriangle className={clsx('w-4 h-4 flex-shrink-0 mt-0.5', s.text)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">{c.title}</span>
|
||||
<span className={clsx('text-[10px] font-bold uppercase px-1.5 py-0.5 rounded', s.text)}>{s.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{c.detail}</p>
|
||||
</div>
|
||||
{canWrite && c.serviceId && (
|
||||
<button onClick={() => toggleSvc.mutate({ id: c.serviceId!, disabled: true })} disabled={toggleSvc.isPending}
|
||||
className="btn-secondary text-xs py-1 flex items-center gap-1.5 flex-shrink-0"><Lock className="w-3 h-3" /> Disable</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP services table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 text-sm font-semibold text-gray-700 dark:text-slate-200">Management Services</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2 text-left">Service</th>
|
||||
<th className="table-header px-3 py-2 text-left">Port</th>
|
||||
<th className="table-header px-3 py-2 text-left">Allowed From</th>
|
||||
<th className="table-header px-3 py-2 text-left">Status</th>
|
||||
{canWrite && <th className="table-header px-3 py-2 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{services.map((s, i) => {
|
||||
const enabled = s.disabled !== 'true';
|
||||
const insecure = ['telnet', 'ftp', 'www'].includes(s.name ?? '') || (s.name === 'api' && mgmtService !== 'api');
|
||||
const isMgmt = s.name === mgmtService;
|
||||
return (
|
||||
<tr key={s['.id'] ?? i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className={clsx('px-3 py-2 text-xs font-medium', enabled && insecure ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white')}>
|
||||
{s.name}{isMgmt && <span className="ml-1.5 text-[10px] font-normal text-blue-500" title="MikroTik Manager connects through this service">(managed via)</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{s.port || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{s.address || 'any'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
enabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', enabled ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
{isMgmt ? (
|
||||
<span className="text-[11px] text-gray-400 flex items-center gap-1 justify-end" title="Disabling this would cut MikroTik Manager off from the device">
|
||||
<Lock className="w-3 h-3" /> in use
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => toggleSvc.mutate({ id: s['.id'], disabled: enabled })} disabled={toggleSvc.isPending}
|
||||
className="text-xs text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 flex items-center gap-1 ml-auto">
|
||||
{enabled ? <><Lock className="w-3 h-3" />Disable</> : <><Check className="w-3 h-3" />Enable</>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const navItems = [
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/traffic', icon: Activity, label: 'Traffic' },
|
||||
{ to: '/security', icon: Shield, label: 'Security' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, Wifi, Network, Router, Layers, Activity,
|
||||
Power, Save, Clock, ChevronRight, AlertCircle, Pencil, X,
|
||||
Power, Save, Clock, ChevronRight, AlertCircle, Pencil, X, Gauge, Check,
|
||||
} from 'lucide-react';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BarChart, Bar, Cell, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { clientsApi, trafficApi } from '../services/api';
|
||||
import { clientsApi, trafficApi, devicesApi } from '../services/api';
|
||||
import type { ClientDetail } from '../services/api';
|
||||
import type { SignalPoint } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
@@ -244,6 +244,64 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
// Quick per-client bandwidth cap: creates a RouterOS simple queue on the
|
||||
// client's device targeting its IP. Mirrors the Queues tab but one-click.
|
||||
function LimitBandwidth({ deviceId, ip, name }: { deviceId: number; ip: string; name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [up, setUp] = useState('10M');
|
||||
const [down, setDown] = useState('50M');
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'done' | 'error'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const apply = async () => {
|
||||
setStatus('saving'); setError('');
|
||||
try {
|
||||
await devicesApi.addQueue(deviceId, {
|
||||
name: `mm-${name}-${ip}`.slice(0, 60).replace(/[^a-zA-Z0-9._-]/g, '_'),
|
||||
target: ip,
|
||||
max_limit: `${up || '0'}/${down || '0'}`,
|
||||
comment: `Limit for ${name} (MikroTik Manager)`,
|
||||
});
|
||||
setStatus('done'); setTimeout(() => { setStatus('idle'); setOpen(false); }, 2500);
|
||||
} catch (e) {
|
||||
setStatus('error');
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create queue');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-slate-300">Bandwidth limit</span>
|
||||
</div>
|
||||
{!open ? (
|
||||
<button onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-200 dark:hover:bg-emerald-900/50 transition-colors">
|
||||
<Gauge className="w-3.5 h-3.5" /> Limit this client
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 border border-emerald-200 dark:border-emerald-800 rounded-lg p-2.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="text-[10px] text-gray-500 dark:text-slate-400">Upload
|
||||
<input className="input font-mono mt-0.5 py-1 text-xs" value={up} onChange={e => setUp(e.target.value)} placeholder="10M" /></label>
|
||||
<label className="text-[10px] text-gray-500 dark:text-slate-400">Download
|
||||
<input className="input font-mono mt-0.5 py-1 text-xs" value={down} onChange={e => setDown(e.target.value)} placeholder="50M" /></label>
|
||||
</div>
|
||||
{error && <p className="text-[10px] text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={apply} disabled={status === 'saving'}
|
||||
className="flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50">
|
||||
{status === 'done' ? <><Check className="w-3 h-3" /> Applied</> : status === 'saving' ? 'Applying…' : 'Apply limit'}
|
||||
</button>
|
||||
<button onClick={() => { setOpen(false); setStatus('idle'); setError(''); }} className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-slate-300">Cancel</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 dark:text-slate-500">Creates a simple queue on the device for <span className="font-mono">{ip}</span>. Manage it later under the device's Bandwidth tab.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrite: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [notes, setNotes] = useState(client.comment || '');
|
||||
@@ -444,6 +502,11 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Limit bandwidth — creates a simple queue on the client's device targeting its IP */}
|
||||
{canWrite && client.ip_address && client.device_id && (
|
||||
<LimitBandwidth deviceId={client.device_id} ip={client.ip_address} name={client.custom_name || client.hostname || client.mac_address} />
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pt-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-slate-300 block mb-1.5">Notes</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Activity, Cpu, MemoryStick, Clock, ExternalLink, TerminalSquare, ShieldCheck,
|
||||
@@ -17,9 +17,12 @@ import ConfigHistoryTab from '../components/device-detail/ConfigHistoryTab';
|
||||
import HardwareTab from '../components/device-detail/HardwareTab';
|
||||
import ToolsTab from '../components/device-detail/ToolsTab';
|
||||
import RadiosTab from '../components/device-detail/RadiosTab';
|
||||
import ConnectionsTab from '../components/device-detail/ConnectionsTab';
|
||||
import QueuesTab from '../components/device-detail/QueuesTab';
|
||||
import SecurityTab from '../components/device-detail/SecurityTab';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios';
|
||||
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'security' | 'queues' | 'connections' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios';
|
||||
|
||||
function formatUptime(raw: string): string {
|
||||
if (!raw) return '—';
|
||||
@@ -46,7 +49,10 @@ export default function DeviceDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
const [searchParams] = useSearchParams();
|
||||
const VALID_TABS: TabKey[] = ['overview', 'ports', 'vlans', 'routing', 'firewall', 'security', 'queues', 'connections', 'config', 'config-history', 'hardware', 'tools', 'radios'];
|
||||
const requestedTab = searchParams.get('tab') as TabKey | null;
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(requestedTab && VALID_TABS.includes(requestedTab) ? requestedTab : 'overview');
|
||||
const [autoOpenBridge, setAutoOpenBridge] = useState<string | null>(null);
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
|
||||
@@ -106,6 +112,11 @@ export default function DeviceDetailPage() {
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'routing', label: 'Routing' },
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
{ key: 'security', label: 'Security' },
|
||||
// RouterOS L3 features are available on every managed device, so show them
|
||||
// for all device types (the tabs render a clean empty state when unused).
|
||||
{ key: 'queues', label: 'Bandwidth' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'config', label: 'Config' },
|
||||
{ key: 'config-history', label: 'Config History' },
|
||||
{ key: 'hardware', label: 'Hardware' },
|
||||
@@ -370,7 +381,10 @@ export default function DeviceDetailPage() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
|
||||
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
|
||||
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} />}
|
||||
{activeTab === 'security' && <SecurityTab deviceId={deviceId} />}
|
||||
{activeTab === 'queues' && <QueuesTab deviceId={deviceId} />}
|
||||
{activeTab === 'connections' && <ConnectionsTab deviceId={deviceId} />}
|
||||
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
|
||||
{activeTab === 'config-history' && <ConfigHistoryTab deviceId={deviceId} />}
|
||||
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield, ShieldCheck, ShieldAlert, RefreshCw, ChevronDown, ChevronRight,
|
||||
Gauge, Server, AlertTriangle, ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../services/api';
|
||||
import type { SecurityCheck } from '../services/api';
|
||||
import type { Device } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DevicePosture {
|
||||
id: number; name: string; ip_address: string; device_type?: string;
|
||||
score: number | null; checks: SecurityCheck[]; error?: string;
|
||||
}
|
||||
|
||||
function scoreColor(score: number | null) {
|
||||
if (score === null) return 'text-gray-400';
|
||||
if (score >= 85) return 'text-green-600 dark:text-green-400';
|
||||
if (score >= 60) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
|
||||
const SEV_BADGE: Record<string, string> = {
|
||||
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
|
||||
medium: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
|
||||
low: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
};
|
||||
const SEV_DOT: Record<string, string> = { high: 'bg-red-500', medium: 'bg-amber-500', low: 'bg-blue-500' };
|
||||
const SEV_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2, ok: 3 };
|
||||
|
||||
function Kpi({ icon: Icon, label, value, accent, valueClass }: {
|
||||
icon: React.ElementType; label: string; value: React.ReactNode; accent: string; valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', accent)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{label}</div>
|
||||
<div className={clsx('text-2xl font-bold leading-tight', valueClass ?? 'text-gray-900 dark:text-white')}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const online = (devices as Device[]).filter(d => d.status === 'online');
|
||||
const onlineKey = online.map(d => d.id).join(',');
|
||||
|
||||
const { data: fleet = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['security-fleet', onlineKey],
|
||||
queryFn: async (): Promise<DevicePosture[]> => {
|
||||
const results = await Promise.allSettled(
|
||||
online.map(async (d) => {
|
||||
const r = await devicesApi.getSecurityPosture(d.id);
|
||||
return { id: d.id, name: d.name, ip_address: d.ip_address, device_type: d.device_type, score: r.data.score, checks: r.data.checks };
|
||||
})
|
||||
);
|
||||
return results.map((r, i) =>
|
||||
r.status === 'fulfilled' ? r.value
|
||||
: { id: online[i].id, name: online[i].name, ip_address: online[i].ip_address, device_type: online[i].device_type, score: null, checks: [], error: (r.reason as Error)?.message });
|
||||
},
|
||||
enabled: online.length > 0,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
const scored = fleet.filter(f => f.score !== null);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, f) => s + (f.score ?? 0), 0) / scored.length) : null;
|
||||
const totalFindings = fleet.reduce((s, f) => s + f.checks.length, 0);
|
||||
const highCount = fleet.reduce((s, f) => s + f.checks.filter(c => c.severity === 'high').length, 0);
|
||||
|
||||
// Aggregate identical findings across the fleet ("telnet enabled — 3 devices").
|
||||
const commonFindings = (() => {
|
||||
const m = new Map<string, { title: string; severity: string; count: number }>();
|
||||
for (const f of fleet) for (const c of f.checks) {
|
||||
const e = m.get(c.title) ?? { title: c.title, severity: c.severity, count: 0 };
|
||||
e.count++; m.set(c.title, e);
|
||||
}
|
||||
return [...m.values()].sort((a, b) => (SEV_ORDER[a.severity] - SEV_ORDER[b.severity]) || (b.count - a.count));
|
||||
})();
|
||||
|
||||
const goManage = (id: number) => navigate(`/devices/${id}?tab=security`);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2"><Shield className="w-5 h-5 text-blue-500" /> Security</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Fleet-wide firewall posture and access control</p>
|
||||
</div>
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Re-scan fleet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Kpi icon={Gauge} label="Avg hardening score" accent="bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||
value={avgScore ?? '—'} valueClass={scoreColor(avgScore)} />
|
||||
<Kpi icon={Server} label="Devices scanned" accent="bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400"
|
||||
value={<>{scored.length}<span className="text-sm font-normal text-gray-400">/{online.length}</span></>} />
|
||||
<Kpi icon={ShieldAlert} label="Total findings" accent="bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400"
|
||||
value={totalFindings} />
|
||||
<Kpi icon={AlertTriangle} label="High severity" accent={highCount > 0 ? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400' : 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'}
|
||||
value={highCount} valueClass={highCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'} />
|
||||
</div>
|
||||
|
||||
{/* Main: posture list (wide) + common findings (narrow) */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
|
||||
{/* Fleet posture */}
|
||||
<div className="card overflow-hidden xl:col-span-2">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-green-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Fleet Security Posture</h2>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Auditing devices…</div>
|
||||
) : online.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">No online devices to scan.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{fleet.map(f => {
|
||||
const open = expanded === f.id;
|
||||
const highs = f.checks.filter(c => c.severity === 'high').length;
|
||||
const meds = f.checks.filter(c => c.severity === 'medium').length;
|
||||
const lows = f.checks.filter(c => c.severity === 'low').length;
|
||||
return (
|
||||
<div key={f.id}>
|
||||
<div className="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<button onClick={() => setExpanded(open ? null : f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-left">
|
||||
{f.checks.length > 0 ? (open ? <ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />) : <span className="w-4 flex-shrink-0" />}
|
||||
<div className={clsx('text-xl font-bold w-10 text-center flex-shrink-0', scoreColor(f.score))}>{f.score ?? '—'}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{f.name}</div>
|
||||
<div className="text-xs text-gray-400 font-mono">{f.ip_address}</div>
|
||||
</div>
|
||||
{f.error ? <span className="text-xs text-red-500">{f.error}</span> : f.checks.length === 0 ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><ShieldCheck className="w-3.5 h-3.5" /> Clean</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{highs > 0 && <span className={clsx('text-xs px-1.5 py-0.5 rounded font-medium', SEV_BADGE.high)}>{highs} high</span>}
|
||||
{meds > 0 && <span className={clsx('text-xs px-1.5 py-0.5 rounded font-medium', SEV_BADGE.medium)}>{meds} med</span>}
|
||||
{lows > 0 && <span className={clsx('text-xs px-1.5 py-0.5 rounded font-medium', SEV_BADGE.low)}>{lows} low</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => goManage(f.id)} className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline flex-shrink-0 ml-1">Manage →</button>
|
||||
</div>
|
||||
{open && f.checks.length > 0 && (
|
||||
<div className="px-5 pb-3 pl-16 space-y-1.5">
|
||||
{f.checks.map(c => (
|
||||
<div key={c.id} className="flex items-start gap-2 text-xs">
|
||||
<ShieldAlert className={clsx('w-3.5 h-3.5 flex-shrink-0 mt-0.5', c.severity === 'high' ? 'text-red-500' : c.severity === 'medium' ? 'text-amber-500' : 'text-blue-500')} />
|
||||
<div><span className="font-medium text-gray-800 dark:text-slate-200">{c.title}</span> <span className="text-gray-500 dark:text-slate-400">— {c.detail}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common findings across the fleet */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Common Findings</h2>
|
||||
</div>
|
||||
{commonFindings.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">
|
||||
{isLoading ? 'Scanning…' : <span className="inline-flex items-center gap-1.5 text-green-600 dark:text-green-400"><ShieldCheck className="w-4 h-4" /> No issues across the fleet</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{commonFindings.map(c => (
|
||||
<div key={c.title} className="flex items-center gap-3 px-5 py-2.5">
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full flex-shrink-0', SEV_DOT[c.severity])} />
|
||||
<span className="text-sm text-gray-700 dark:text-slate-300 flex-1 min-w-0 truncate" title={c.title}>{c.title}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 flex-shrink-0">{c.count} {c.count === 1 ? 'device' : 'devices'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,14 @@ export interface BulkAddJobStatus {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SecurityCheck {
|
||||
id: string;
|
||||
severity: 'high' | 'medium' | 'low' | 'ok';
|
||||
title: string;
|
||||
detail: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
@@ -236,6 +244,38 @@ export const devicesApi = {
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`, data),
|
||||
deleteNatRule: (id: number, ruleId: string) =>
|
||||
api.delete(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`),
|
||||
// Firewall: reorder + counters (order is decisive in RouterOS)
|
||||
moveFirewallRule: (id: number, ruleId: string, destination?: string) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/firewall/move`, { id: ruleId, destination }),
|
||||
resetFirewallCounters: (id: number) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/firewall/reset-counters`),
|
||||
moveNatRule: (id: number, ruleId: string, destination?: string) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/nat/move`, { id: ruleId, destination }),
|
||||
// Firewall address lists (reusable address objects)
|
||||
getAddressLists: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/address-lists`),
|
||||
addAddressListEntry: (id: number, data: { list: string; address: string; comment?: string; timeout?: string }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/address-lists`, data),
|
||||
updateAddressListEntry: (id: number, entryId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/address-lists/${encodeURIComponent(entryId)}`, data),
|
||||
removeAddressListEntry: (id: number, entryId: string) =>
|
||||
api.delete(`/devices/${id}/address-lists/${encodeURIComponent(entryId)}`),
|
||||
// Active connections (read-only)
|
||||
getConnections: (id: number, limit = 500) =>
|
||||
api.get<{ total: number; connections: Record<string, string>[] }>(`/devices/${id}/connections`, { params: { limit } }),
|
||||
// Simple queues (bandwidth control)
|
||||
getQueues: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/queues`),
|
||||
addQueue: (id: number, data: Record<string, unknown>) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/queues`, data),
|
||||
updateQueue: (id: number, queueId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/queues/${encodeURIComponent(queueId)}`, data),
|
||||
removeQueue: (id: number, queueId: string) =>
|
||||
api.delete(`/devices/${id}/queues/${encodeURIComponent(queueId)}`),
|
||||
// IP services + security posture
|
||||
getServices: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/services`),
|
||||
setServiceDisabled: (id: number, serviceId: string, disabled: boolean) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/services/${encodeURIComponent(serviceId)}`, { disabled }),
|
||||
getSecurityPosture: (id: number) =>
|
||||
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
||||
patchLocation: (id: number, data: {
|
||||
location_address?: string | null;
|
||||
location_lat?: number | null;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ruleSummary, natSummary, formatBytes, formatCount } from './firewallSummary';
|
||||
|
||||
describe('ruleSummary', () => {
|
||||
it('summarizes an allow rule with address lists and a port', () => {
|
||||
expect(ruleSummary({
|
||||
action: 'accept', protocol: 'tcp', 'src-address-list': 'LAN', 'dst-port': '443',
|
||||
})).toBe('Allow TCP from list:LAN to any on 443');
|
||||
});
|
||||
|
||||
it('summarizes a drop rule with connection state', () => {
|
||||
expect(ruleSummary({ action: 'drop', protocol: 'tcp', 'connection-state': 'invalid' }))
|
||||
.toBe('Drop TCP from any to any [invalid]');
|
||||
});
|
||||
|
||||
it('falls back to interface when no address is set', () => {
|
||||
expect(ruleSummary({ action: 'accept', 'in-interface': 'ether1' }))
|
||||
.toBe('Allow any from if:ether1 to any');
|
||||
});
|
||||
|
||||
it('shows jump target', () => {
|
||||
expect(ruleSummary({ action: 'jump', 'jump-target': 'mychain' }))
|
||||
.toMatch(/^Jump to mychain/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('natSummary', () => {
|
||||
it('summarizes masquerade', () => {
|
||||
expect(natSummary({ action: 'masquerade', chain: 'srcnat', 'out-interface': 'ether1' }))
|
||||
.toBe('Masquerade any out ether1');
|
||||
});
|
||||
it('summarizes a port-forward (dst-nat)', () => {
|
||||
expect(natSummary({
|
||||
action: 'dst-nat', chain: 'dstnat', protocol: 'tcp', 'dst-port': '443',
|
||||
'to-addresses': '192.168.1.10', 'to-ports': '443',
|
||||
})).toBe('Forward TCP any:443 → 192.168.1.10:443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes / formatCount', () => {
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(5 * 1024 ** 3)).toBe('5.00 GB');
|
||||
});
|
||||
it('formats counts', () => {
|
||||
expect(formatCount(0)).toBe('0');
|
||||
expect(formatCount(2500)).toBe('2.5K');
|
||||
expect(formatCount(3_400_000)).toBe('3.4M');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Pure helpers for the firewall UI: byte formatting and plain-English rule
|
||||
// summaries ("Allow TCP from LAN to any on 443"). Kept separate so they're
|
||||
// unit-testable without rendering.
|
||||
|
||||
export function formatCount(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
const ACTION_VERB: Record<string, string> = {
|
||||
accept: 'Allow', drop: 'Drop', reject: 'Reject', tarpit: 'Tarpit',
|
||||
log: 'Log', jump: 'Jump to', return: 'Return', passthrough: 'Pass through',
|
||||
masquerade: 'Masquerade', 'src-nat': 'Source-NAT', 'dst-nat': 'Forward',
|
||||
netmap: '1:1 NAT', redirect: 'Redirect',
|
||||
};
|
||||
|
||||
type Ruleish = Record<string, string | undefined>;
|
||||
|
||||
function endpoint(addr?: string, list?: string, iface?: string): string {
|
||||
if (list) return `list:${list}`;
|
||||
if (addr) return addr;
|
||||
if (iface) return `if:${iface}`;
|
||||
return 'any';
|
||||
}
|
||||
|
||||
// Build a readable one-liner for a firewall filter rule. Accepts RouterOS
|
||||
// dashed keys (e.g. 'src-address') as returned by the API.
|
||||
export function ruleSummary(r: Ruleish): string {
|
||||
const action = (r.action ?? '').toLowerCase();
|
||||
const verb = ACTION_VERB[action] ?? action ?? 'rule';
|
||||
const proto = r.protocol ? r.protocol.toUpperCase() : 'any';
|
||||
const src = endpoint(r['src-address'], r['src-address-list'], r['in-interface']);
|
||||
const dst = endpoint(r['dst-address'], r['dst-address-list'], r['out-interface']);
|
||||
const port = r['dst-port'] ? ` on ${r['dst-port']}` : '';
|
||||
const state = r['connection-state'] ? ` [${r['connection-state']}]` : '';
|
||||
const tgt = action === 'jump' && r['jump-target'] ? ` ${r['jump-target']}` : '';
|
||||
return `${verb}${tgt} ${proto} from ${src} to ${dst}${port}${state}`;
|
||||
}
|
||||
|
||||
// Readable one-liner for a NAT rule.
|
||||
export function natSummary(r: Ruleish): string {
|
||||
const action = (r.action ?? '').toLowerCase();
|
||||
if (action === 'masquerade') {
|
||||
return `Masquerade ${endpoint(r['src-address'])} out ${r['out-interface'] || 'any'}`;
|
||||
}
|
||||
if (action === 'dst-nat') {
|
||||
const proto = r.protocol ? r.protocol.toUpperCase() : '';
|
||||
const ext = r['dst-port'] ? `:${r['dst-port']}` : '';
|
||||
const to = [r['to-addresses'], r['to-ports'] && `:${r['to-ports']}`].filter(Boolean).join('');
|
||||
return `Forward ${proto} ${endpoint(r['dst-address'], undefined, r['in-interface'])}${ext} → ${to || 'target'}`;
|
||||
}
|
||||
if (action === 'src-nat') {
|
||||
return `Source-NAT ${endpoint(r['src-address'])} → ${r['to-addresses'] || 'target'}`;
|
||||
}
|
||||
if (action === 'netmap') {
|
||||
return `1:1 NAT ${endpoint(r['dst-address'])} ↔ ${r['to-addresses'] || 'target'}`;
|
||||
}
|
||||
if (action === 'redirect') {
|
||||
return `Redirect ${r.protocol?.toUpperCase() || ''} ${r['dst-port'] || ''} → router:${r['to-ports'] || ''}`;
|
||||
}
|
||||
return `${ACTION_VERB[action] ?? action} (${r.chain})`;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.13.3 Beta';
|
||||
export const APP_VERSION = 'v0.14.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user