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
@@ -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})`;
|
||||
}
|
||||
Reference in New Issue
Block a user