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
+14 -2
View File
@@ -2,7 +2,7 @@
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
![Version](https://img.shields.io/badge/version-0.13.3_Beta-blue)
![Version](https://img.shields.io/badge/version-0.14.0_Beta-blue)
![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white)
@@ -115,9 +115,21 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni
### Routers
- Routing table viewer
- Interface overview with IP assignments
- Firewall rule inspection
- Router-specific settings and configuration
### Firewall & Security
A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set:
- **Dedicated Security Center** — a top-level section with fleet-wide hardening scores, a per-device posture list, and a "Common Findings" rollup that aggregates identical issues across every device; "Manage" deep-links into a device's Security tab. Firewall/NAT, bandwidth, and connections are available on every managed RouterOS device (routers, switches, and APs)
- **Friendly rule builder** — Allow/Drop/Reject action chips, Any/Address/Address-List source & destination pickers, well-known port presets (HTTPS/HTTP/SSH/DNS/RDP/SMB), connection-state chips, per-rule logging, and a live plain-English preview of every rule
- **Address lists as reusable objects** — define `LAN`, `Trusted`, `Blocklist` etc. once and reference them from any rule (the Meraki/UniFi "groups" concept)
- **Rule reordering** — move rules up/down (order is decisive in RouterOS) with a single click
- **Hit counters & dead-rule insight** — per-rule packet/byte counters surface which rules are matching and flag zero-hit rules; one-click counter reset
- **NAT wizards** — guided Port Forward, Masquerade (internet sharing), and 1:1 NAT flows instead of raw fields, plus a Custom mode
- **Safe-apply lockout guard** — refuses to apply an unscoped `input`-chain drop/reject that would lock the platform/admin out of the device, requiring explicit confirmation
- **Security posture audit** — per-device hardening checklist (insecure services like telnet/ftp/www/api, missing input-chain firewall, SNMP exposure) with a score and one-click remediation, plus a management-services table
- **Bandwidth control** — simple-queue management with up/down caps per IP, subnet, or interface, and a one-click "Limit this client" action on the client detail page
- **Active connections viewer** — live connection-tracking table (source/destination, protocol, state, rate, bytes) with search
### Switches
- VLAN management (create, edit, delete VLANs)
- Per-port configuration and VLAN membership
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0",
@@ -21,7 +21,7 @@
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
@@ -6680,9 +6680,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.13.3-beta",
"version": "0.14.0-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
@@ -26,7 +26,7 @@
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"otpauth": "^9.5.1",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
+249 -3
View File
@@ -11,6 +11,7 @@ import type { CredentialPresetRow } from './credentialPresets';
import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation';
import { parsePort } from '../utils/parsePort';
import { safeConnectionError } from '../utils/safeClientError';
import { detectLockoutRisk } from '../utils/firewallSafety';
import { redis } from '../config/redis';
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
@@ -649,14 +650,20 @@ function bodyToRosParams(body: Record<string, unknown>): Record<string, string>
// POST /api/devices/:id/firewall
router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) => {
const { chain, action } = req.body;
const { chain, action, force } = req.body;
if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' });
const params = bodyToRosParams(req.body);
// Safe-apply: refuse a self-lockout rule unless the operator confirms (force).
if (!force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.addFirewallRule(bodyToRosParams(req.body));
await collector.addFirewallRule(params);
return res.status(201).json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
@@ -667,12 +674,51 @@ router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) =
// PUT /api/devices/:id/firewall/:ruleId
router.put('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => {
const params = bodyToRosParams(req.body);
if (!req.body.force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.updateFirewallRule(req.params.ruleId, bodyToRosParams(req.body));
await collector.updateFirewallRule(req.params.ruleId, params);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/move { id, destination? }
router.post('/:id/firewall/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.moveFirewallRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/reset-counters
router.post('/:id/firewall/reset-counters', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.resetFirewallCounters();
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
@@ -783,6 +829,206 @@ router.delete('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Respon
}
});
// POST /api/devices/:id/nat/move { id, destination? }
router.post('/:id/nat/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
await collector.moveNatRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getNatRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// ─── Firewall Address Lists ─────────────────────────────────────────────────────
// Shared helper for the security-feature routes below (DRYs the device fetch +
// connect/disconnect lifecycle the rest of this file uses inline).
async function withCollector<T>(
id: string,
res: Response,
fn: (c: DeviceCollector) => Promise<T>
): Promise<void> {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
if (!deviceRow) { res.status(404).json({ error: 'Device not found' }); return; }
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
const result = await fn(collector);
res.json(result);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
}
router.get('/:id/address-lists', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getAddressLists());
});
router.post('/:id/address-lists', requireWrite, async (req, res) => {
const { list, address } = req.body;
if (!list || !address) return res.status(400).json({ error: 'list and address are required' });
const params: Record<string, string> = { list: String(list), address: String(address) };
if (req.body.comment) params.comment = String(req.body.comment);
if (req.body.timeout) params.timeout = String(req.body.timeout);
await withCollector(req.params.id, res, async (c) => { await c.addAddressListEntry(params); return c.getAddressLists(); });
});
router.put('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
const params: Record<string, string> = {};
for (const k of ['list', 'address', 'comment'] as const) if (req.body[k] !== undefined) params[k] = String(req.body[k]);
if (req.body.disabled !== undefined) params.disabled = req.body.disabled ? 'yes' : 'no';
await withCollector(req.params.id, res, async (c) => { await c.updateAddressListEntry(req.params.entryId, params); return c.getAddressLists(); });
});
router.delete('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeAddressListEntry(req.params.entryId); return { message: 'Entry removed' }; });
});
// ─── Active Connections (read-only) ─────────────────────────────────────────────
router.get('/:id/connections', async (req, res) => {
const limit = Math.min(parseInt(String(req.query.limit || '500'), 10) || 500, 2000);
await withCollector(req.params.id, res, async (c) => {
const rows = await c.getConnections();
// Connection tables can be huge; cap and surface the total so the UI can note truncation.
return { total: rows.length, connections: rows.slice(0, limit) };
});
});
// ─── Simple Queues (bandwidth control) ──────────────────────────────────────────
const QUEUE_FIELD_MAP: Record<string, string> = {
name: 'name', target: 'target', max_limit: 'max-limit', burst_limit: 'burst-limit',
burst_threshold: 'burst-threshold', burst_time: 'burst-time', priority: 'priority',
comment: 'comment', parent: 'parent',
};
function queueBodyToRos(body: Record<string, unknown>): Record<string, string> {
const p: Record<string, string> = {};
for (const [js, ros] of Object.entries(QUEUE_FIELD_MAP)) {
const v = body[js];
if (v !== undefined && v !== null && v !== '') p[ros] = String(v);
}
if (body.disabled !== undefined) p.disabled = body.disabled ? 'yes' : 'no';
return p;
}
router.get('/:id/queues', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getSimpleQueues());
});
router.post('/:id/queues', requireWrite, async (req, res) => {
const { name, target, max_limit } = req.body;
if (!name || !target || !max_limit) return res.status(400).json({ error: 'name, target and max_limit are required' });
await withCollector(req.params.id, res, async (c) => { await c.addSimpleQueue(queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.put('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.updateSimpleQueue(req.params.queueId, queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.delete('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeSimpleQueue(req.params.queueId); return { message: 'Queue removed' }; });
});
// ─── IP Services + Security Posture audit ────────────────────────────────────────
router.get('/:id/services', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getServices());
});
router.put('/:id/services/:serviceId', requireWrite, async (req, res) => {
if (typeof req.body.disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
await withCollector(req.params.id, res, async (c) => { await c.setServiceDisabled(req.params.serviceId, req.body.disabled); return c.getServices(); });
});
// GET /api/devices/:id/security-posture — computes a hardening checklist.
// Heuristic, not standards-based: graduated weighting that won't saturate to 0,
// de-duplicated services, and it never proposes disabling the management
// service MikroTik Manager itself connects through (that would self-lockout).
router.get('/:id/security-posture', async (req, res) => {
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!device) return res.status(404).json({ error: 'Device not found' });
// Which RouterOS service the platform uses to reach this device. Default api
// (8728); api-ssl is 8729. We must never offer to disable this one.
const mgmtService = device.api_port === 8729 ? 'api-ssl' : 'api';
const isRouter = device.device_type === 'router';
await withCollector(req.params.id, res, async (c) => {
const [servicesRaw, fwRules, snmp] = await Promise.all([
c.getServices().catch(() => [] as Record<string, string>[]),
c.getFirewallRules().catch(() => [] as Record<string, string>[]),
c.getSnmpConfig().catch(() => ({} as Record<string, string>)),
]);
// De-duplicate /ip/service rows by name (some devices report duplicates,
// e.g. api/route_BFD twice) so a service is only ever counted once.
const services = Array.from(new Map(servicesRaw.map(s => [s['name'] ?? s['.id'], s])).values());
type Sev = 'high' | 'medium' | 'low';
type Check = { id: string; severity: Sev; title: string; detail: string; serviceId?: string };
const checks: Check[] = [];
for (const s of services) {
const name = s['name'] ?? '';
if (s['disabled'] === 'true') continue;
if (name === 'telnet' || name === 'ftp') {
checks.push({ id: `service-${name}`, severity: 'high', serviceId: s['.id'],
title: `Cleartext service "${name}" is enabled`,
detail: `${name} sends credentials and data unencrypted. Disable it; use SSH/SFTP instead.` });
} else if (name === 'www') {
checks.push({ id: 'service-www', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted WebFig (www) is enabled',
detail: 'The HTTP WebFig interface is unencrypted. Disable "www" and use "www-ssl" (HTTPS) instead.' });
} else if (name === 'api' && mgmtService !== 'api') {
// Plaintext API is on but the platform connects via api-ssl — safe to flag/disable.
checks.push({ id: 'service-api', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted API (api) is enabled',
detail: 'The plaintext RouterOS API is exposed. Disable "api" and use "api-ssl" (8729).' });
} else if (name === mgmtService && name === 'api') {
// The platform is connected through cleartext API — advise, but do NOT
// offer to disable it (that would cut MikroTik Manager off the device).
checks.push({ id: 'mgmt-api-cleartext', severity: 'low',
title: 'MikroTik Manager connects over cleartext API',
detail: 'This device is managed via the unencrypted API (8728). Consider migrating the device and its credentials to API-SSL (8729) for encrypted management.' });
}
}
// Input-chain firewall: a real concern on routers; switches/APs commonly
// (and legitimately) rely on an upstream router's firewall, so it's only
// informational there rather than a high-severity hit.
const hasInputDrop = fwRules.some(r => (r['chain'] === 'input') && ['drop', 'reject'].includes(r['action'] ?? '') && r['disabled'] !== 'true');
if (!hasInputDrop) {
checks.push({
id: 'no-input-firewall', severity: isRouter ? 'high' : 'low',
title: 'No input-chain firewall rule',
detail: isRouter
? 'This router has no enabled drop/reject rule on the input chain, leaving its own services exposed. Add a baseline input firewall.'
: 'No input-chain firewall on this device. Switches/APs often rely on an upstream router firewall — confirm that is the case.',
});
}
if (snmp && snmp['enabled'] === 'true') {
checks.push({ id: 'snmp-enabled', severity: 'low', title: 'SNMP is enabled',
detail: 'Ensure SNMP uses a non-default community string and is restricted to trusted addresses.' });
}
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
const penalty = checks.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
const score = checks.length === 0 ? 100 : Math.max(5, 100 - penalty);
return { score, checks };
});
});
// GET /api/devices/:id/resources (live resource usage)
router.get('/:id/resources', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
@@ -1747,8 +1747,10 @@ export class DeviceCollector {
return this.client.execute('/ip/route/print', { detail: '' });
}
// `stats` makes RouterOS include the per-rule bytes/packets hit counters so
// the UI can show which rules are matching (and flag dead, zero-hit rules).
async getFirewallRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/filter/print', { detail: '' });
return this.client.execute('/ip/firewall/filter/print', { detail: '', stats: '' });
}
async addFirewallRule(params: Record<string, string>): Promise<void> {
@@ -1763,8 +1765,21 @@ export class DeviceCollector {
await this.client.execute('/ip/firewall/filter/remove', { '.id': id });
}
// Reorder: RouterOS evaluates rules top-to-bottom, so order is decisive.
// `destination` is the .id the rule should be placed *before* (RouterOS
// semantics), or omitted to move to the end.
async moveFirewallRule(id: string, destination?: string): Promise<void> {
const params: Record<string, string> = { numbers: id };
if (destination) params.destination = destination;
await this.client.execute('/ip/firewall/filter/move', params);
}
async resetFirewallCounters(): Promise<void> {
await this.client.execute('/ip/firewall/filter/reset-counters-all', {});
}
async getNatRules(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/nat/print', { detail: '' });
return this.client.execute('/ip/firewall/nat/print', { detail: '', stats: '' });
}
async addNatRule(params: Record<string, string>): Promise<void> {
@@ -1779,6 +1794,64 @@ export class DeviceCollector {
await this.client.execute('/ip/firewall/nat/remove', { '.id': id });
}
async moveNatRule(id: string, destination?: string): Promise<void> {
const params: Record<string, string> = { numbers: id };
if (destination) params.destination = destination;
await this.client.execute('/ip/firewall/nat/move', params);
}
// ─── Firewall Address Lists (reusable address objects) ──────────────────────
async getAddressLists(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/address-list/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addAddressListEntry(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/address-list/add', params);
}
async updateAddressListEntry(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/firewall/address-list/set', { '.id': id, ...params });
}
async removeAddressListEntry(id: string): Promise<void> {
await this.client.execute('/ip/firewall/address-list/remove', { '.id': id });
}
// ─── Connection tracking (live active connections) ──────────────────────────
async getConnections(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
// ─── Simple Queues (per-client / per-subnet bandwidth control) ──────────────
async getSimpleQueues(): Promise<Record<string, string>[]> {
return this.client.execute('/queue/simple/print', { detail: '', stats: '' }).catch(() => [] as Record<string, string>[]);
}
async addSimpleQueue(params: Record<string, string>): Promise<void> {
await this.client.execute('/queue/simple/add', params);
}
async updateSimpleQueue(id: string, params: Record<string, string>): Promise<void> {
await this.client.execute('/queue/simple/set', { '.id': id, ...params });
}
async removeSimpleQueue(id: string): Promise<void> {
await this.client.execute('/queue/simple/remove', { '.id': id });
}
// ─── IP Services (for security-posture audit + hardening) ───────────────────
async getServices(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/service/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async setServiceDisabled(id: string, disabled: boolean): Promise<void> {
await this.client.execute('/ip/service/set', { '.id': id, disabled: disabled ? 'yes' : 'no' });
}
// ─── Bridge ────────────────────────────────────────────────────────────────
async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise<void> {
@@ -0,0 +1,52 @@
import { detectLockoutRisk } from '../firewallSafety';
describe('detectLockoutRisk', () => {
it('flags a broad input-chain drop with no source scoping', () => {
const r = detectLockoutRisk({ chain: 'input', action: 'drop' });
expect(r.risky).toBe(true);
expect(r.reason).toMatch(/lock MikroTik Manager/);
});
it('flags an unscoped input drop targeting a management port (ssh)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '22' }).risky).toBe(true);
});
it('flags reject and tarpit the same as drop', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'reject' }).risky).toBe(true);
expect(detectLockoutRisk({ chain: 'input', action: 'tarpit' }).risky).toBe(true);
});
it('does NOT flag rules scoped to a source address', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', 'src-address': '203.0.113.0/24' }).risky).toBe(false);
});
it('does NOT flag rules scoped to a source address-list (underscore key form)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', src_address_list: 'Trusted' }).risky).toBe(false);
});
it('does NOT flag input drops on non-management ports only', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '12345' }).risky).toBe(false);
});
it('does NOT flag forward-chain drops (through-traffic, not device access)', () => {
expect(detectLockoutRisk({ chain: 'forward', action: 'drop' }).risky).toBe(false);
});
it('does NOT flag accept rules', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'accept' }).risky).toBe(false);
});
it('does NOT flag disabled rules (both string forms)', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', disabled: 'yes' }).risky).toBe(false);
expect(detectLockoutRisk({ chain: 'input', action: 'drop', disabled: 'true' }).risky).toBe(false);
});
it('does NOT flag icmp/udp-only input drops', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'icmp' }).risky).toBe(false);
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'udp', 'dst-port': '53' }).risky).toBe(false);
});
it('flags an unscoped drop whose port range covers a management port', () => {
expect(detectLockoutRisk({ chain: 'input', action: 'drop', protocol: 'tcp', 'dst-port': '8000-8800' }).risky).toBe(true); // covers 8291/8728/8729
});
});
+94
View File
@@ -0,0 +1,94 @@
// Lockout guard for firewall changes.
//
// MikroTik Manager controls devices over the RouterOS API (and admins reach
// them over SSH/Winbox). A careless `input`-chain drop/reject rule is the
// classic way to lock both the platform and the operator out of a device. This
// detects that rule shape so the UI can warn and require explicit confirmation
// before applying. It is intentionally conservative: it flags broad,
// unscoped input-chain blocks rather than trying to know the manager's exact
// source IP (which is unknowable through NAT).
export interface FirewallRuleLike {
chain?: string;
action?: string;
protocol?: string;
disabled?: string | boolean;
// accept both RouterOS dashed keys and the API's underscore keys
'src-address'?: string;
src_address?: string;
'dst-port'?: string;
dst_port?: string;
'src-address-list'?: string;
src_address_list?: string;
}
// TCP management ports that, if blocked on the input chain, sever control:
// SSH(22), www(80)/ssl(443), Winbox(8291), API(8728)/API-SSL(8729).
export const DEFAULT_MGMT_PORTS = [22, 80, 443, 8291, 8728, 8729];
export interface LockoutResult {
risky: boolean;
reason: string;
}
function field(rule: FirewallRuleLike, dashed: keyof FirewallRuleLike, under: keyof FirewallRuleLike): string {
const v = (rule[dashed] ?? rule[under] ?? '') as string;
return String(v).trim();
}
// Parse a RouterOS port spec ("22", "80,443", "8000-8100") into a matcher.
function portSpecIncludes(spec: string, ports: number[]): boolean {
const parts = spec.split(',').map((p) => p.trim()).filter(Boolean);
for (const part of parts) {
if (part.includes('-')) {
const [lo, hi] = part.split('-').map((n) => parseInt(n, 10));
if (Number.isFinite(lo) && Number.isFinite(hi) && ports.some((p) => p >= lo && p <= hi)) return true;
} else {
const n = parseInt(part, 10);
if (Number.isFinite(n) && ports.includes(n)) return true;
}
}
return false;
}
export function detectLockoutRisk(
rule: FirewallRuleLike,
opts: { mgmtPorts?: number[] } = {}
): LockoutResult {
const ok = { risky: false, reason: '' };
const mgmtPorts = opts.mgmtPorts ?? DEFAULT_MGMT_PORTS;
const disabled = rule.disabled === true || rule.disabled === 'yes' || rule.disabled === 'true';
if (disabled) return ok;
const action = field(rule, 'action', 'action').toLowerCase();
if (!['drop', 'reject', 'tarpit'].includes(action)) return ok;
// Only the input chain governs access *to the device itself*.
const chain = field(rule, 'chain', 'chain').toLowerCase();
if (chain !== 'input') return ok;
// Management access is TCP; an icmp/udp-only block isn't a lockout.
const protocol = field(rule, 'protocol', 'protocol').toLowerCase();
if (protocol && protocol !== 'tcp') return ok;
// If the rule is scoped to a specific source (address or address-list), assume
// the operator narrowed it deliberately and don't cry wolf.
const srcAddr = field(rule, 'src-address', 'src_address');
const srcList = field(rule, 'src-address-list', 'src_address_list');
if (srcAddr || srcList) return ok;
// Unscoped source. If it targets all ports, or explicitly a management port,
// it would cut control.
const dstPort = field(rule, 'dst-port', 'dst_port');
const portsHit = !dstPort || portSpecIncludes(dstPort, mgmtPorts);
if (!portsHit) return ok;
return {
risky: true,
reason:
`This input-chain "${action}" rule has no source restriction and would match management traffic ` +
`(SSH/Winbox/API/HTTPS). Applying it can lock MikroTik Manager and admins out of the device. ` +
`Scope it with a source address/list, or confirm to apply anyway.`,
};
}
+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';