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
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

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