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:
2GT-Rich
2026-06-18 09:24:22 -07:00
co-authored by Claude Opus 4.8
parent 623e54120d
commit ee9c9ff9f7
22 changed files with 1988 additions and 656 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-frontend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"dependencies": {
"@tanstack/react-query": "^5.17.1",
"@types/leaflet": "^1.9.21",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -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&apos;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' },
+65 -2
View File
@@ -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&apos;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>
+18 -4
View File
@@ -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} />}
+203
View File
@@ -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>
);
}
+40
View File
@@ -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');
});
});
+74
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.13.3 Beta';
export const APP_VERSION = 'v0.14.0 Beta';