diff --git a/README.md b/README.md index 4b57437..958064c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.13.3_Beta-blue) +![Version](https://img.shields.io/badge/version-0.14.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -115,9 +115,21 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni ### Routers - Routing table viewer - Interface overview with IP assignments -- Firewall rule inspection - Router-specific settings and configuration +### Firewall & Security +A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set: +- **Dedicated Security Center** — a top-level section with fleet-wide hardening scores, a per-device posture list, and a "Common Findings" rollup that aggregates identical issues across every device; "Manage" deep-links into a device's Security tab. Firewall/NAT, bandwidth, and connections are available on every managed RouterOS device (routers, switches, and APs) +- **Friendly rule builder** — Allow/Drop/Reject action chips, Any/Address/Address-List source & destination pickers, well-known port presets (HTTPS/HTTP/SSH/DNS/RDP/SMB), connection-state chips, per-rule logging, and a live plain-English preview of every rule +- **Address lists as reusable objects** — define `LAN`, `Trusted`, `Blocklist` etc. once and reference them from any rule (the Meraki/UniFi "groups" concept) +- **Rule reordering** — move rules up/down (order is decisive in RouterOS) with a single click +- **Hit counters & dead-rule insight** — per-rule packet/byte counters surface which rules are matching and flag zero-hit rules; one-click counter reset +- **NAT wizards** — guided Port Forward, Masquerade (internet sharing), and 1:1 NAT flows instead of raw fields, plus a Custom mode +- **Safe-apply lockout guard** — refuses to apply an unscoped `input`-chain drop/reject that would lock the platform/admin out of the device, requiring explicit confirmation +- **Security posture audit** — per-device hardening checklist (insecure services like telnet/ftp/www/api, missing input-chain firewall, SNMP exposure) with a score and one-click remediation, plus a management-services table +- **Bandwidth control** — simple-queue management with up/down caps per IP, subnet, or interface, and a one-click "Limit this client" action on the client detail page +- **Active connections viewer** — live connection-tracking table (source/destination, protocol, state, rate, bytes) with search + ### Switches - VLAN management (create, edit, delete VLANs) - Per-port configuration and VLAN membership diff --git a/backend/package-lock.json b/backend/package-lock.json index 32fb26d..eefe9f1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" diff --git a/backend/package.json b/backend/package.json index 268f9cd..3c2c9ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 3e91794..9aa84e2 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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): Record // 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(`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(`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(`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(`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(`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( + id: string, + res: Response, + fn: (c: DeviceCollector) => Promise +): Promise { + const deviceRow = await queryOne(`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 = { 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 = {}; + 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 = { + 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): Record { + const p: Record = {}; + 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(`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[]), + c.getFirewallRules().catch(() => [] as Record[]), + c.getSnmpConfig().catch(() => ({} as Record)), + ]); + + // 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 = { 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( diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index f9516fa..29b8d9c 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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[]> { - return this.client.execute('/ip/firewall/filter/print', { detail: '' }); + return this.client.execute('/ip/firewall/filter/print', { detail: '', stats: '' }); } async addFirewallRule(params: Record): Promise { @@ -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 { + const params: Record = { numbers: id }; + if (destination) params.destination = destination; + await this.client.execute('/ip/firewall/filter/move', params); + } + + async resetFirewallCounters(): Promise { + await this.client.execute('/ip/firewall/filter/reset-counters-all', {}); + } + async getNatRules(): Promise[]> { - return this.client.execute('/ip/firewall/nat/print', { detail: '' }); + return this.client.execute('/ip/firewall/nat/print', { detail: '', stats: '' }); } async addNatRule(params: Record): Promise { @@ -1779,6 +1794,64 @@ export class DeviceCollector { await this.client.execute('/ip/firewall/nat/remove', { '.id': id }); } + async moveNatRule(id: string, destination?: string): Promise { + const params: Record = { 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[]> { + return this.client.execute('/ip/firewall/address-list/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addAddressListEntry(params: Record): Promise { + await this.client.execute('/ip/firewall/address-list/add', params); + } + + async updateAddressListEntry(id: string, params: Record): Promise { + await this.client.execute('/ip/firewall/address-list/set', { '.id': id, ...params }); + } + + async removeAddressListEntry(id: string): Promise { + await this.client.execute('/ip/firewall/address-list/remove', { '.id': id }); + } + + // ─── Connection tracking (live active connections) ────────────────────────── + + async getConnections(): Promise[]> { + return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record[]); + } + + // ─── Simple Queues (per-client / per-subnet bandwidth control) ────────────── + + async getSimpleQueues(): Promise[]> { + return this.client.execute('/queue/simple/print', { detail: '', stats: '' }).catch(() => [] as Record[]); + } + + async addSimpleQueue(params: Record): Promise { + await this.client.execute('/queue/simple/add', params); + } + + async updateSimpleQueue(id: string, params: Record): Promise { + await this.client.execute('/queue/simple/set', { '.id': id, ...params }); + } + + async removeSimpleQueue(id: string): Promise { + await this.client.execute('/queue/simple/remove', { '.id': id }); + } + + // ─── IP Services (for security-posture audit + hardening) ─────────────────── + + async getServices(): Promise[]> { + return this.client.execute('/ip/service/print', { detail: '' }).catch(() => [] as Record[]); + } + + async setServiceDisabled(id: string, disabled: boolean): Promise { + await this.client.execute('/ip/service/set', { '.id': id, disabled: disabled ? 'yes' : 'no' }); + } + // ─── Bridge ──────────────────────────────────────────────────────────────── async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise { diff --git a/backend/src/utils/__tests__/firewallSafety.test.ts b/backend/src/utils/__tests__/firewallSafety.test.ts new file mode 100644 index 0000000..52690eb --- /dev/null +++ b/backend/src/utils/__tests__/firewallSafety.test.ts @@ -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 + }); +}); diff --git a/backend/src/utils/firewallSafety.ts b/backend/src/utils/firewallSafety.ts new file mode 100644 index 0000000..d72ac32 --- /dev/null +++ b/backend/src/utils/firewallSafety.ts @@ -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.`, + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5597c5e..100d08b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2c58eb1..fae039d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.13.3-beta", + "version": "0.14.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6bf6aa2..11c4493 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/device-detail/ConnectionsTab.tsx b/frontend/src/components/device-detail/ConnectionsTab.tsx new file mode 100644 index 0000000..b52d7fc --- /dev/null +++ b/frontend/src/components/device-detail/ConnectionsTab.tsx @@ -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 = { + 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 ( +
+
+
+
+

+ Active Connections ({total}{total > all.length ? `, showing ${all.length}` : ''}) +

+
+
+
+ + setSearch(e.target.value)} /> +
+ +
+
+ + {isLoading ? ( +
Loading connections…
+ ) : rows.length === 0 ? ( +
{all.length === 0 ? 'No active connections (connection tracking may be disabled).' : 'No connections match your filter.'}
+ ) : ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
ProtoSourceDestinationStateRate ↓/↑Bytes
{proto || '—'}{src.ip}{src.port && :{src.port}}{dst.ip}{dst.port && :{dst.port}}{c['tcp-state'] || c.state || '—'}{replRate !== '0' || origRate !== '0' ? `${replRate}/${origRate}` : '—'}{bytes > 0 ? formatBytes(bytes) : '—'}
+
+ )} +

Live from the device's connection tracking table, auto-refreshing every 15s.

+
+ ); +} diff --git a/frontend/src/components/device-detail/FirewallTab.tsx b/frontend/src/components/device-detail/FirewallTab.tsx index 9808c37..a22b437 100644 --- a/frontend/src/components/device-detail/FirewallTab.tsx +++ b/frontend/src/components/device-detail/FirewallTab.tsx @@ -1,181 +1,172 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2, Pencil, AlertCircle, RefreshCw, Check, X, ArrowRightLeft, Shield } from 'lucide-react'; +import { + Plus, Trash2, Pencil, AlertCircle, RefreshCw, Check, X, ArrowRightLeft, Shield, + ArrowUp, ArrowDown, List, Activity, AlertTriangle, +} from 'lucide-react'; import { devicesApi } from '../../services/api'; import { useCanWrite } from '../../hooks/useCanWrite'; +import { formatBytes, formatCount, ruleSummary, natSummary } from '../../utils/firewallSummary'; import clsx from 'clsx'; -// ─── Shared types ───────────────────────────────────────────────────────────── -interface FirewallRule extends Record { - '.id': string; - chain: string; - action: string; -} +type Row = Record & { '.id': string }; -interface NatRule extends Record { - '.id': string; - chain: string; - action: string; -} - -// ─── Firewall constants ─────────────────────────────────────────────────────── -const COMMON_CHAINS = ['forward', 'input', 'output']; -const FW_ACTIONS = ['accept', 'drop', 'reject', 'log', 'passthrough', 'jump', 'return']; -const PROTOCOLS = ['', 'tcp', 'udp', 'icmp', 'ip', 'gre', 'ospf']; -const CONN_STATES = ['new', 'established', 'related', 'invalid']; +const COMMON_CHAINS = ['forward', 'input', 'output']; +const FW_ACTIONS = ['accept', 'drop', 'reject', 'log', 'passthrough', 'jump', 'return']; +const PROTOCOLS = ['tcp', 'udp', 'icmp', 'ip', 'gre', 'ospf']; +const CONN_STATES = ['new', 'established', 'related', 'invalid']; +const PORT_PRESETS = [ + { label: 'HTTPS', port: '443' }, { label: 'HTTP', port: '80' }, { label: 'SSH', port: '22' }, + { label: 'DNS', port: '53' }, { label: 'RDP', port: '3389' }, { label: 'SMB', port: '445' }, +]; const ACTION_COLOR: Record = { - accept: 'text-green-600 dark:text-green-400', - drop: 'text-red-600 dark:text-red-400', - reject: 'text-red-500 dark:text-red-400', - log: 'text-yellow-600 dark:text-yellow-400', - passthrough: 'text-blue-600 dark:text-blue-400', - jump: 'text-purple-600 dark:text-purple-400', - return: 'text-indigo-500 dark:text-indigo-400', - masquerade: 'text-orange-600 dark:text-orange-400', - 'src-nat': 'text-blue-600 dark:text-blue-400', - 'dst-nat': 'text-cyan-600 dark:text-cyan-400', - netmap: 'text-teal-600 dark:text-teal-400', - redirect: 'text-violet-600 dark:text-violet-400', + accept: 'text-green-600 dark:text-green-400', drop: 'text-red-600 dark:text-red-400', + reject: 'text-red-500 dark:text-red-400', log: 'text-yellow-600 dark:text-yellow-400', + passthrough: 'text-blue-600 dark:text-blue-400', jump: 'text-purple-600 dark:text-purple-400', + return: 'text-indigo-500 dark:text-indigo-400', masquerade: 'text-orange-600 dark:text-orange-400', + 'src-nat': 'text-blue-600 dark:text-blue-400', 'dst-nat': 'text-cyan-600 dark:text-cyan-400', + netmap: 'text-teal-600 dark:text-teal-400', redirect: 'text-violet-600 dark:text-violet-400', }; -// ─── NAT constants ──────────────────────────────────────────────────────────── const NAT_CHAINS = ['srcnat', 'dstnat']; const NAT_ACTIONS = ['masquerade', 'src-nat', 'dst-nat', 'netmap', 'redirect', 'accept', 'drop', 'return', 'jump', 'passthrough']; -// ─── Firewall rule form ─────────────────────────────────────────────────────── +function errMsg(err: unknown) { + return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed'; +} +// Extract a 409 lockout-guard payload from a failed mutation, if present. +function lockoutOf(err: unknown): string | null { + const r = (err as { response?: { status?: number; data?: { lockout?: boolean; reason?: string } } })?.response; + if (r?.status === 409 && r.data?.lockout) return r.data.reason || 'This change may lock you out of the device.'; + return null; +} + +function Toggle({ value, onChange, label }: { value: boolean; onChange: (v: boolean) => void; label: string }) { + return ( +
+ + +
+ ); +} + +// ─── Firewall rule form ───────────────────────────────────────────────────────── +type Endpoint = 'any' | 'address' | 'list'; interface RuleForm { chain: string; action: string; - src_address: string; dst_address: string; + src_kind: Endpoint; src_value: string; + dst_kind: Endpoint; dst_value: string; protocol: string; src_port: string; dst_port: string; in_interface: string; out_interface: string; connection_state: string; jump_target: string; + log: boolean; log_prefix: string; comment: string; disabled: boolean; } - -const EMPTY_FW_FORM: RuleForm = { - chain: 'forward', action: 'accept', - src_address: '', dst_address: '', protocol: '', - src_port: '', dst_port: '', - in_interface: '', out_interface: '', - connection_state: '', jump_target: '', - comment: '', disabled: false, +const EMPTY_FW: RuleForm = { + chain: 'forward', action: 'accept', src_kind: 'any', src_value: '', dst_kind: 'any', dst_value: '', + protocol: '', src_port: '', dst_port: '', in_interface: '', out_interface: '', + connection_state: '', jump_target: '', log: false, log_prefix: '', comment: '', disabled: false, }; - -function ruleToForm(r: FirewallRule): RuleForm { - const states = (r['connection-state'] ?? '').split(',').map(s => s.trim()).filter(Boolean); +function ruleToForm(r: Row): RuleForm { + const srcKind: Endpoint = r['src-address-list'] ? 'list' : r['src-address'] ? 'address' : 'any'; + const dstKind: Endpoint = r['dst-address-list'] ? 'list' : r['dst-address'] ? 'address' : 'any'; return { chain: r.chain ?? 'forward', action: r.action ?? 'accept', - src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '', - protocol: r.protocol ?? '', - src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '', + src_kind: srcKind, src_value: r['src-address-list'] || r['src-address'] || '', + dst_kind: dstKind, dst_value: r['dst-address-list'] || r['dst-address'] || '', + protocol: r.protocol ?? '', src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '', in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '', - connection_state: states.join(','), jump_target: r['jump-target'] ?? '', + connection_state: r['connection-state'] ?? '', jump_target: r['jump-target'] ?? '', + log: r.log === 'yes' || r.log === 'true', log_prefix: r['log-prefix'] ?? '', comment: r.comment ?? '', disabled: r.disabled === 'true', }; } - -function formToPayload(f: RuleForm): Record { +function fwPayload(f: RuleForm, force = false): Record { const p: Record = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' }; - if (f.src_address) p.src_address = f.src_address; - if (f.dst_address) p.dst_address = f.dst_address; + if (f.src_kind === 'address') p.src_address = f.src_value; + if (f.src_kind === 'list') p.src_address_list = f.src_value; + if (f.dst_kind === 'address') p.dst_address = f.dst_value; + if (f.dst_kind === 'list') p.dst_address_list = f.dst_value; if (f.protocol) p.protocol = f.protocol; if (f.src_port) p.src_port = f.src_port; if (f.dst_port) p.dst_port = f.dst_port; if (f.in_interface) p.in_interface = f.in_interface; if (f.out_interface) p.out_interface = f.out_interface; if (f.connection_state) p.connection_state = f.connection_state; - if (f.jump_target) p.jump_target = f.jump_target; - if (f.comment) p.comment = f.comment; + if (f.action === 'jump' && f.jump_target) p.jump_target = f.jump_target; + p.log = f.log ? 'yes' : 'no'; + if (f.log && f.log_prefix) p.log_prefix = f.log_prefix; + if (f.comment) p.comment = f.comment; + if (force) p.force = true; return p; } - -// ─── NAT rule form ──────────────────────────────────────────────────────────── -interface NatForm { - chain: string; action: string; - src_address: string; dst_address: string; - protocol: string; src_port: string; dst_port: string; - in_interface: string; out_interface: string; - to_addresses: string; to_ports: string; - comment: string; disabled: boolean; -} - -const EMPTY_NAT_FORM: NatForm = { - chain: 'srcnat', action: 'masquerade', - src_address: '', dst_address: '', protocol: '', - src_port: '', dst_port: '', - in_interface: '', out_interface: '', - to_addresses: '', to_ports: '', - comment: '', disabled: false, -}; - -function natRuleToForm(r: NatRule): NatForm { +// Mirror the form into a RouterOS-shaped object for the live summary preview. +function formToRuleish(f: RuleForm): Record { return { - chain: r.chain ?? 'srcnat', action: r.action ?? 'masquerade', - src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '', - protocol: r.protocol ?? '', - src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '', - in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '', - to_addresses: r['to-addresses'] ?? '', to_ports: r['to-ports'] ?? '', - comment: r.comment ?? '', disabled: r.disabled === 'true', + action: f.action, protocol: f.protocol, + 'src-address': f.src_kind === 'address' ? f.src_value : '', + 'src-address-list': f.src_kind === 'list' ? f.src_value : '', + 'dst-address': f.dst_kind === 'address' ? f.dst_value : '', + 'dst-address-list': f.dst_kind === 'list' ? f.dst_value : '', + 'dst-port': f.dst_port, 'connection-state': f.connection_state, + 'in-interface': f.in_interface, 'out-interface': f.out_interface, 'jump-target': f.jump_target, }; } -function natFormToPayload(f: NatForm): Record { - const p: Record = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' }; - if (f.src_address) p.src_address = f.src_address; - if (f.dst_address) p.dst_address = f.dst_address; - if (f.protocol) p.protocol = f.protocol; - if (f.src_port) p.src_port = f.src_port; - if (f.dst_port) p.dst_port = f.dst_port; - if (f.in_interface) p.in_interface = f.in_interface; - if (f.out_interface)p.out_interface= f.out_interface; - if (f.to_addresses) p.to_addresses = f.to_addresses; - if (f.to_ports) p.to_ports = f.to_ports; - if (f.comment) p.comment = f.comment; - return p; -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── -function errMsg(err: unknown) { - return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed'; -} - -function DisabledToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { +// ─── Endpoint picker (Any / Address / List) ────────────────────────────────────── +function EndpointPicker({ + label, kind, value, onKind, onValue, lists, +}: { + label: string; kind: Endpoint; value: string; + onKind: (k: Endpoint) => void; onValue: (v: string) => void; lists: string[]; +}) { return ( -
- - +
+ +
+ {(['any', 'address', 'list'] as Endpoint[]).map(k => ( + + ))} +
+ {kind === 'address' && ( + onValue(e.target.value)} placeholder="0.0.0.0/0" /> + )} + {kind === 'list' && ( + lists.length > 0 ? ( + + ) : ( + onValue(e.target.value)} placeholder="list name (none defined yet)" /> + ) + )}
); } // ─── Firewall Rule Modal ────────────────────────────────────────────────────── function RuleModal({ - title, form, setForm, onSave, onClose, isPending, error, + title, form, setForm, onSave, onClose, isPending, error, lists, }: { - title: string; form: RuleForm; - setForm: React.Dispatch>; - onSave: () => void; onClose: () => void; - isPending: boolean; error: string; + title: string; form: RuleForm; setForm: React.Dispatch>; + onSave: () => void; onClose: () => void; isPending: boolean; error: string; lists: string[]; }) { - const set = (key: keyof RuleForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val })); - - const toggleConnState = (state: string) => { - const current = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : []; - const next = current.includes(state) ? current.filter(s => s !== state) : [...current, state]; - set('connection_state', next.join(',')); + const set = (k: keyof RuleForm, v: string | boolean) => setForm(f => ({ ...f, [k]: v })); + const toggleState = (s: string) => { + const cur = form.connection_state ? form.connection_state.split(',').filter(Boolean) : []; + set('connection_state', (cur.includes(s) ? cur.filter(x => x !== s) : [...cur, s]).join(',')); }; - - const connStates = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : []; + const states = form.connection_state ? form.connection_state.split(',').filter(Boolean) : []; const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === ''; return ( @@ -186,87 +177,99 @@ function RuleModal({
+ {/* Action chips */}
-

Basic

-
-
-
- - set('chain', e.target.value)} placeholder="forward" /> - {COMMON_CHAINS.map(c => -
-
- - -
-
- {form.action === 'jump' && ( -
- - set('jump_target', e.target.value)} placeholder="custom-chain" /> -
- )} -
- - set('comment', e.target.value)} placeholder="Optional description…" /> -
- set('disabled', v)} /> + +
+ {[['accept', 'Allow'], ['drop', 'Drop'], ['reject', 'Reject']].map(([a, lbl]) => ( + + ))} +
-
-

Address & Port

-
-
- - -
-
-
- set('src_address', e.target.value)} placeholder="0.0.0.0/0" />
-
- set('dst_address', e.target.value)} placeholder="0.0.0.0/0" />
-
- {hasPorts && ( -
-
- set('src_port', e.target.value)} placeholder="e.g. 80 or 1024-2048" />
-
- set('dst_port', e.target.value)} placeholder="e.g. 443" />
-
- )} +
+
+ + set('chain', e.target.value)} placeholder="forward" /> + {COMMON_CHAINS.map(c =>
+
+ + +
+
+ {form.action === 'jump' && ( +
+ set('jump_target', e.target.value)} placeholder="custom-chain" />
+ )} + +
+ set('src_kind', k)} onValue={v => set('src_value', v)} lists={lists} /> + set('dst_kind', k)} onValue={v => set('dst_value', v)} lists={lists} /> +
+ + {hasPorts && ( +
+ +
+ {PORT_PRESETS.map(p => ( + + ))} +
+ set('dst_port', e.target.value)} placeholder="443 or 1000-2000 (blank = any)" /> +
+ )} + +
+
+ set('in_interface', e.target.value)} placeholder="(any)" />
+
+ set('out_interface', e.target.value)} placeholder="(any)" />
-

Interface

-
-
- set('in_interface', e.target.value)} placeholder="ether1" />
-
- set('out_interface', e.target.value)} placeholder="ether2" />
-
-
- -
-

Connection State

+
{CONN_STATES.map(s => ( - + states.includes(s) ? 'bg-blue-600 border-blue-600 text-white' + : 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300')}>{s} ))}
+ set('log', v)} /> +
+ set('comment', e.target.value)} placeholder="Optional description…" />
+ set('disabled', v)} /> + + {/* Live plain-English preview */} +
+ Preview +
{ruleSummary(formToRuleish(form))}
+
+ {error && (
@@ -285,20 +288,254 @@ function RuleModal({ ); } -// ─── NAT Rule Modal ─────────────────────────────────────────────────────────── -function NatModal({ - title, form, setForm, onSave, onClose, isPending, error, -}: { - title: string; form: NatForm; - setForm: React.Dispatch>; - onSave: () => void; onClose: () => void; - isPending: boolean; error: string; -}) { - const set = (key: keyof NatForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val })); +// ─── Lockout confirm dialog (safe-apply) ───────────────────────────────────────── +function LockoutDialog({ reason, onConfirm, onCancel, pending }: { reason: string; onConfirm: () => void; onCancel: () => void; pending: boolean }) { + return ( +
+
+
+ +
+

Possible lockout

+

{reason}

+
+
+
+ + +
+
+
+ ); +} - const needsTarget = ['src-nat', 'dst-nat', 'netmap'].includes(form.action); - const needsPort = ['redirect', 'dst-nat', 'src-nat'].includes(form.action); - const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === ''; +// ─── Address Lists section ─────────────────────────────────────────────────────── +function AddressListsCard({ deviceId }: { deviceId: number }) { + const qc = useQueryClient(); + const canWrite = useCanWrite(); + const [open, setOpen] = useState(false); + const [adding, setAdding] = useState(false); + const [list, setList] = useState(''); const [address, setAddress] = useState(''); const [comment, setComment] = useState(''); + const [err, setErr] = useState(''); + + const { data: entries = [] } = useQuery({ + queryKey: ['address-lists', deviceId], + queryFn: () => devicesApi.getAddressLists(deviceId).then(r => r.data as Row[]), + }); + const invalidate = () => qc.invalidateQueries({ queryKey: ['address-lists', deviceId] }); + + const addMut = useMutation({ + mutationFn: () => devicesApi.addAddressListEntry(deviceId, { list, address, comment: comment || undefined }), + onSuccess: () => { invalidate(); setAdding(false); setList(''); setAddress(''); setComment(''); setErr(''); }, + onError: (e) => setErr(errMsg(e)), + }); + const delMut = useMutation({ + mutationFn: (id: string) => devicesApi.removeAddressListEntry(deviceId, id), + onSuccess: invalidate, + }); + + const grouped = entries.reduce>((acc, e) => { + const k = e.list ?? '(none)'; (acc[k] ??= []).push(e); return acc; + }, {}); + const listNames = Object.keys(grouped).sort(); + + return ( +
+ + {open && ( +
+

+ Reusable address objects. Reference a list from any firewall rule's Source/Destination instead of repeating IPs. +

+ {listNames.length === 0 ? ( +
No address lists yet.
+ ) : listNames.map(name => ( +
+
{name}
+
+ {grouped[name].map(e => ( +
+ {e.address} + {e.comment && {e.comment}} + {e.dynamic === 'true' && dynamic} + {canWrite && e.dynamic !== 'true' && ( + + )} +
+ ))} +
+
+ ))} + {canWrite && (adding ? ( +
+
+ setList(e.target.value)} placeholder="list name (e.g. Trusted)" list="al-names" /> + {listNames.map(n => + setAddress(e.target.value)} placeholder="192.168.1.0/24 or host" /> +
+ setComment(e.target.value)} placeholder="comment (optional)" /> + {err &&

{err}

} +
+ + +
+
+ ) : ( + + ))} +
+ )} +
+ ); +} + +// ─── Counters cell ──────────────────────────────────────────────────────────── +function Counters({ r }: { r: Row }) { + const bytes = Number(r.bytes || 0); const packets = Number(r.packets || 0); + if (packets === 0) return 0; + return ( + + {formatCount(packets)} pkts
{formatBytes(bytes)} +
+ ); +} + +// ─── Reusable rule table with reorder ──────────────────────────────────────────── +function RuleTable({ + rows, fullOrder, canWrite, canReorder, summaryFn, onEdit, onToggle, onDelete, onMove, +}: { + rows: Row[]; fullOrder: Row[]; canWrite: boolean; canReorder: boolean; + summaryFn: (r: Row) => string; + onEdit: (r: Row) => void; onToggle: (r: Row) => void; onDelete: (r: Row) => void; + onMove: (r: Row, dir: 'up' | 'down') => void; +}) { + return ( +
+ + + + + + + + + {canWrite && + + + {rows.map((r, i) => { + const disabled = r.disabled === 'true'; + const fullIdx = fullOrder.findIndex(x => x['.id'] === r['.id']); + return ( + + + + + + + {canWrite && ( + + )} + + ); + })} + +
#ActionRuleHitsComment} +
{fullIdx >= 0 ? fullIdx + 1 : i + 1} + {r.action}{r['jump-target'] ? ` → ${r['jump-target']}` : ''} + + {r.chain} · {summaryFn(r)} + {r.comment || ''} +
+ {canReorder && ( + <> + + + + )} + + + +
+
+
+ ); +} + +// Compute the RouterOS `destination` (.id to place before) for an up/down move. +function moveDestination(fullOrder: Row[], r: Row, dir: 'up' | 'down'): { ok: boolean; destination?: string } { + const idx = fullOrder.findIndex(x => x['.id'] === r['.id']); + if (idx < 0) return { ok: false }; + if (dir === 'up') { + if (idx === 0) return { ok: false }; + return { ok: true, destination: fullOrder[idx - 1]['.id'] }; + } + if (idx >= fullOrder.length - 1) return { ok: false }; + if (idx + 2 <= fullOrder.length - 1) return { ok: true, destination: fullOrder[idx + 2]['.id'] }; + return { ok: true }; // moving to the very end → no destination +} + +// ─── NAT wizard form ───────────────────────────────────────────────────────────── +type NatPattern = 'port-forward' | 'masquerade' | 'one-to-one' | 'custom'; +interface NatForm { + pattern: NatPattern; chain: string; action: string; + src_address: string; dst_address: string; protocol: string; src_port: string; dst_port: string; + in_interface: string; out_interface: string; to_addresses: string; to_ports: string; + comment: string; disabled: boolean; +} +const EMPTY_NAT: NatForm = { + pattern: 'port-forward', chain: 'dstnat', action: 'dst-nat', + src_address: '', dst_address: '', protocol: 'tcp', src_port: '', dst_port: '', + in_interface: '', out_interface: '', to_addresses: '', to_ports: '', comment: '', disabled: false, +}; +function natRuleToForm(r: Row): NatForm { + const action = r.action ?? 'masquerade'; + const pattern: NatPattern = action === 'dst-nat' ? 'port-forward' : action === 'masquerade' ? 'masquerade' : action === 'netmap' ? 'one-to-one' : 'custom'; + return { + pattern, chain: r.chain ?? 'srcnat', action, + src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '', protocol: r.protocol ?? '', + src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '', in_interface: r['in-interface'] ?? '', + out_interface: r['out-interface'] ?? '', to_addresses: r['to-addresses'] ?? '', to_ports: r['to-ports'] ?? '', + comment: r.comment ?? '', disabled: r.disabled === 'true', + }; +} +function natPayload(f: NatForm): Record { + const p: Record = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' }; + for (const [k, ros] of [['src_address', 'src_address'], ['dst_address', 'dst_address'], ['protocol', 'protocol'], + ['src_port', 'src_port'], ['dst_port', 'dst_port'], ['in_interface', 'in_interface'], ['out_interface', 'out_interface'], + ['to_addresses', 'to_addresses'], ['to_ports', 'to_ports'], ['comment', 'comment']] as const) { + const v = (f as unknown as Record)[k]; + if (v) p[ros] = v; + } + return p; +} + +function NatModal({ title, form, setForm, onSave, onClose, isPending, error }: { + title: string; form: NatForm; setForm: React.Dispatch>; + onSave: () => void; onClose: () => void; isPending: boolean; error: string; +}) { + const set = (k: keyof NatForm, v: string | boolean) => setForm(f => ({ ...f, [k]: v })); + const setPattern = (pattern: NatPattern) => setForm(f => { + if (pattern === 'port-forward') return { ...f, pattern, chain: 'dstnat', action: 'dst-nat', protocol: f.protocol || 'tcp' }; + if (pattern === 'masquerade') return { ...f, pattern, chain: 'srcnat', action: 'masquerade' }; + if (pattern === 'one-to-one') return { ...f, pattern, chain: 'dstnat', action: 'netmap' }; + return { ...f, pattern }; + }); return (
@@ -308,91 +545,87 @@ function NatModal({
- {/* Basic */}
-

Basic

-
-
-
- - -
-
- - -
-
- {/* action description */} -

- {form.action === 'masquerade' && 'Replaces source IP with router\'s outgoing interface IP. Best for dynamic WAN IPs.'} - {form.action === 'src-nat' && 'Replaces source IP with a static address (specify in To Addresses).'} - {form.action === 'dst-nat' && 'Replaces destination IP/port, used for port forwarding.'} - {form.action === 'netmap' && 'One-to-one address translation. Requires To Addresses.'} - {form.action === 'redirect' && 'Redirects packets to the router itself (specify To Ports for target port).'} -

-
- - set('comment', e.target.value)} placeholder="Optional description…" /> -
- set('disabled', v)} /> + +
+ {([['port-forward', 'Port Forward'], ['masquerade', 'Masquerade (Internet sharing)'], ['one-to-one', '1:1 NAT'], ['custom', 'Custom']] as [NatPattern, string][]).map(([p, lbl]) => ( + + ))}
- {/* Match */} -
-

Match Criteria

+ {form.pattern === 'port-forward' && (
-
- - +
+
+
+
+ set('dst_port', e.target.value)} placeholder="443" />
-
- set('src_address', e.target.value)} placeholder="0.0.0.0/0" />
-
- set('dst_address', e.target.value)} placeholder="0.0.0.0/0" />
-
- {hasPorts && ( -
-
- set('src_port', e.target.value)} placeholder="e.g. 80" />
-
- set('dst_port', e.target.value)} placeholder="e.g. 443" />
-
- )} -
-
- set('in_interface', e.target.value)} placeholder="ether1" />
-
- set('out_interface', e.target.value)} placeholder="ether2" />
+
+ set('to_addresses', e.target.value)} placeholder="192.168.1.10" />
+
+ set('to_ports', e.target.value)} placeholder="443" />
+
+ set('in_interface', e.target.value)} placeholder="ether1" />
-
- - {/* Translation target */} - {(needsTarget || needsPort) && ( -
-

Translation Target

-
- {needsTarget && ( -
- set('to_addresses', e.target.value)} placeholder="e.g. 192.168.1.10 or 192.168.1.10-192.168.1.20" />
- )} - {needsPort && ( -
- set('to_ports', e.target.value)} placeholder="e.g. 8080 or 8080-8090" />
- )} + )} + {form.pattern === 'masquerade' && ( +
+
+ set('out_interface', e.target.value)} placeholder="ether1" />
+
+ set('src_address', e.target.value)} placeholder="192.168.1.0/24" />
+
+ )} + {form.pattern === 'one-to-one' && ( +
+
+ set('dst_address', e.target.value)} placeholder="203.0.113.10" />
+
+ set('to_addresses', e.target.value)} placeholder="192.168.1.10" />
+
+ )} + {form.pattern === 'custom' && ( +
+
+
+
+
+
+
+
+
set('src_address', e.target.value)} />
+
set('dst_address', e.target.value)} />
+
+
+
set('to_addresses', e.target.value)} />
+
set('to_ports', e.target.value)} />
)} +
+ set('comment', e.target.value)} placeholder="Optional description…" />
+ set('disabled', v)} /> + +
+ Preview +
{natSummary({ + action: form.action, chain: form.chain, protocol: form.protocol, 'dst-port': form.dst_port, + 'dst-address': form.dst_address, 'src-address': form.src_address, 'in-interface': form.in_interface, + 'out-interface': form.out_interface, 'to-addresses': form.to_addresses, 'to-ports': form.to_ports, + })}
+
+ {error && (
@@ -411,416 +644,161 @@ function NatModal({ ); } -// ─── NAT Card (router-only) ─────────────────────────────────────────────────── +// ─── NAT Card ──────────────────────────────────────────────────────────────────── function NatCard({ deviceId }: { deviceId: number }) { - const queryClient = useQueryClient(); + const qc = useQueryClient(); const canWrite = useCanWrite(); - - const [chainFilter, setChainFilter] = useState('all'); - const [editingRule, setEditingRule] = useState(null); + const [editing, setEditing] = useState(null); const [showAdd, setShowAdd] = useState(false); - const [form, setForm] = useState(EMPTY_NAT_FORM); - const [mutError, setMutError] = useState(''); + const [form, setForm] = useState(EMPTY_NAT); + const [err, setErr] = useState(''); const { data: rules = [], isLoading, refetch, isFetching } = useQuery({ - queryKey: ['nat', deviceId], - queryFn: () => devicesApi.getNat(deviceId).then(r => r.data as NatRule[]), + queryKey: ['nat', deviceId], queryFn: () => devicesApi.getNat(deviceId).then(r => r.data as Row[]), }); + const invalidate = () => qc.invalidateQueries({ queryKey: ['nat', deviceId] }); - const invalidate = () => queryClient.invalidateQueries({ queryKey: ['nat', deviceId] }); + const addMut = useMutation({ mutationFn: (d: Record) => devicesApi.addNatRule(deviceId, d), + onSuccess: () => { invalidate(); setShowAdd(false); setErr(''); }, onError: e => setErr(errMsg(e)) }); + const updMut = useMutation({ mutationFn: ({ id, d }: { id: string; d: Record }) => devicesApi.updateNatRule(deviceId, id, d), + onSuccess: () => { invalidate(); setEditing(null); setErr(''); }, onError: e => setErr(errMsg(e)) }); + const delMut = useMutation({ mutationFn: (id: string) => devicesApi.deleteNatRule(deviceId, id), onSuccess: invalidate }); + const toggleMut = useMutation({ mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.updateNatRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }), onSuccess: invalidate }); + const moveMut = useMutation({ mutationFn: ({ id, destination }: { id: string; destination?: string }) => devicesApi.moveNatRule(deviceId, id, destination), onSuccess: invalidate }); - const addMutation = useMutation({ - mutationFn: (data: Record) => devicesApi.addNatRule(deviceId, data), - onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); }, - onError: (err: unknown) => setMutError(errMsg(err)), - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - devicesApi.updateNatRule(deviceId, id, data), - onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); }, - onError: (err: unknown) => setMutError(errMsg(err)), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => devicesApi.deleteNatRule(deviceId, id), - onSuccess: () => { invalidate(); setMutError(''); }, - onError: (err: unknown) => setMutError(errMsg(err)), - }); - - const toggleMutation = useMutation({ - mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => - devicesApi.updateNatRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }), - onSuccess: () => invalidate(), - }); - - const openAdd = () => { - setForm({ ...EMPTY_NAT_FORM, chain: chainFilter !== 'all' ? chainFilter : 'srcnat' }); - setMutError(''); - setShowAdd(true); - }; - - const openEdit = (rule: NatRule) => { - setForm(natRuleToForm(rule)); - setMutError(''); - setEditingRule(rule); - }; - - const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()]; - const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter); + const onMove = (r: Row, dir: 'up' | 'down') => { const d = moveDestination(rules, r, dir); if (d.ok) moveMut.mutate({ id: r['.id'], destination: d.destination }); }; return ( -
- {/* Section header */} -
-
- -
-

Network Address Translation (NAT)

-
- - {/* Toolbar */} -
-
- {allChains.map(c => ( - - ))} +
+
+
+
+

NAT ({rules.length})

- - {canWrite && ( - - )} + + {canWrite && }
- - {mutError && ( -
- -

{mutError}

-
- )} - - {isLoading ? ( -
Loading NAT rules…
- ) : filtered.length === 0 ? ( -
- No NAT rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}. -
- ) : ( -
- - - - - - - - - - - - - {canWrite && - - - {filtered.map((r, i) => { - const disabled = r.disabled === 'true'; - const srcPort = r['src-port']; - const dstPort = r['dst-port']; - const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' '); - const toCell = [ - r['to-addresses'] && r['to-addresses'], - r['to-ports'] && `:${r['to-ports']}`, - ].filter(Boolean).join(''); - return ( - - - - - - - - - - - {canWrite && ( - - )} - - ); - })} - -
#ChainActionProtoSrcDstPortsToComment} -
{i + 1}{r.chain} - {r.action} - {r.protocol || 'any'} - {[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'} - - {[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'} - {portCell || '—'}{toCell || '—'}{r.comment || ''} -
- - - -
-
-
- )} - - {showAdd && ( - addMutation.mutate(natFormToPayload(form))} - onClose={() => setShowAdd(false)} - isPending={addMutation.isPending} - error={mutError} - /> - )} - {editingRule && ( - updateMutation.mutate({ id: editingRule['.id'], data: natFormToPayload(form) })} - onClose={() => setEditingRule(null)} - isPending={updateMutation.isPending} - error={mutError} - /> - )} + {err &&
{err}
} + {isLoading ?
Loading…
+ : rules.length === 0 ?
No NAT rules.
+ : { setForm(natRuleToForm(r)); setErr(''); setEditing(r); }} + onToggle={r => toggleMut.mutate({ id: r['.id'], disabled: !(r.disabled === 'true') })} + onDelete={r => { if (confirm('Delete this NAT rule?')) delMut.mutate(r['.id']); }} onMove={onMove} />} + {showAdd && setShowAdd(false)} onSave={() => addMut.mutate(natPayload(form))} />} + {editing && setEditing(null)} onSave={() => updMut.mutate({ id: editing['.id'], d: natPayload(form) })} />}
); } -// ─── Main FirewallTab ───────────────────────────────────────────────────────── -export default function FirewallTab({ deviceId, deviceType }: { deviceId: number; deviceType?: string }) { - const queryClient = useQueryClient(); +// ─── Main FirewallTab ───────────────────────────────────────────────────────────── +export default function FirewallTab({ deviceId }: { deviceId: number }) { + const qc = useQueryClient(); const canWrite = useCanWrite(); - const [chainFilter, setChainFilter] = useState('all'); - const [editingRule, setEditingRule] = useState(null); + const [editing, setEditing] = useState(null); const [showAdd, setShowAdd] = useState(false); - const [form, setForm] = useState(EMPTY_FW_FORM); - const [mutError, setMutError] = useState(''); + const [form, setForm] = useState(EMPTY_FW); + const [err, setErr] = useState(''); + // Safe-apply: the backend returns 409 {lockout} for self-lockout rules; we + // stash the pending payload + reason and let the operator confirm with force. + const [lockout, setLockout] = useState<{ payload: Record; mode: 'add' | 'edit'; id?: string; reason: string } | null>(null); const { data: rules = [], isLoading, refetch, isFetching } = useQuery({ - queryKey: ['firewall', deviceId], - queryFn: () => devicesApi.getFirewall(deviceId).then(r => r.data as FirewallRule[]), + queryKey: ['firewall', deviceId], queryFn: () => devicesApi.getFirewall(deviceId).then(r => r.data as Row[]), }); - - const invalidate = () => queryClient.invalidateQueries({ queryKey: ['firewall', deviceId] }); - - const addMutation = useMutation({ - mutationFn: (data: Record) => devicesApi.addFirewallRule(deviceId, data), - onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); }, - onError: (err: unknown) => setMutError(errMsg(err)), + const { data: addrLists = [] } = useQuery({ + queryKey: ['address-lists', deviceId], queryFn: () => devicesApi.getAddressLists(deviceId).then(r => r.data as Row[]), }); + const listNames = Array.from(new Set(addrLists.map(e => e.list).filter(Boolean))).sort(); + const invalidate = () => qc.invalidateQueries({ queryKey: ['firewall', deviceId] }); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - devicesApi.updateFirewallRule(deviceId, id, data), - onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); }, - onError: (err: unknown) => setMutError(errMsg(err)), + const addMut = useMutation({ + mutationFn: (d: Record) => devicesApi.addFirewallRule(deviceId, d), + onSuccess: () => { invalidate(); setShowAdd(false); setErr(''); setLockout(null); }, }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => devicesApi.deleteFirewallRule(deviceId, id), - onSuccess: () => invalidate(), - onError: (err: unknown) => setMutError(errMsg(err)), + const updMut = useMutation({ + mutationFn: ({ id, d }: { id: string; d: Record }) => devicesApi.updateFirewallRule(deviceId, id, d), + onSuccess: () => { invalidate(); setEditing(null); setErr(''); setLockout(null); }, }); + const delMut = useMutation({ mutationFn: (id: string) => devicesApi.deleteFirewallRule(deviceId, id), onSuccess: invalidate }); + const toggleMut = useMutation({ mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.updateFirewallRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }), onSuccess: invalidate }); + const moveMut = useMutation({ mutationFn: ({ id, destination }: { id: string; destination?: string }) => devicesApi.moveFirewallRule(deviceId, id, destination), onSuccess: invalidate }); + const resetMut = useMutation({ mutationFn: () => devicesApi.resetFirewallCounters(deviceId), onSuccess: invalidate }); - const toggleMutation = useMutation({ - mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => - devicesApi.updateFirewallRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }), - onSuccess: () => invalidate(), - }); - - const openAdd = () => { - setForm({ ...EMPTY_FW_FORM, chain: chainFilter !== 'all' ? chainFilter : 'forward' }); - setMutError(''); - setShowAdd(true); + const handleErr = (e: unknown, mode: 'add' | 'edit', id?: string) => { + const lo = lockoutOf(e); + if (lo) setLockout({ payload: fwPayload(form, false), mode, id, reason: lo }); + else setErr(errMsg(e)); + }; + const submitAdd = () => addMut.mutate(fwPayload(form), { onError: (e) => handleErr(e, 'add') }); + const submitEdit = () => editing && updMut.mutate({ id: editing['.id'], d: fwPayload(form) }, { onError: (e) => handleErr(e, 'edit', editing['.id']) }); + const confirmLockout = () => { + if (!lockout) return; + const forced = { ...lockout.payload, force: true }; + if (lockout.mode === 'add') addMut.mutate(forced); + else if (lockout.id) updMut.mutate({ id: lockout.id, d: forced }); }; - const openEdit = (rule: FirewallRule) => { - setForm(ruleToForm(rule)); - setMutError(''); - setEditingRule(rule); - }; + const onMove = (r: Row, dir: 'up' | 'down') => { const d = moveDestination(rules, r, dir); if (d.ok) moveMut.mutate({ id: r['.id'], destination: d.destination }); }; const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()]; - const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter); + const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter); - if (isLoading) return
Loading...
; + if (isLoading) return
Loading…
; return (
- {/* Section heading */} + +
-
- -
-

Firewall Rules

+
+

Firewall Filter Rules

- {/* Toolbar */}
{allChains.map(c => ( ))}
- - {canWrite && ( - - )} + {canWrite && } + + {canWrite && }
- {mutError && ( -
- -

{mutError}

-
+ {chainFilter !== 'all' && ( +

Switch to "All" to reorder rules (order is global across chains).

)} + {err &&
{err}
} - {filtered.length === 0 ? ( -
- No firewall rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}. -
- ) : ( -
- - - - - - - - - - - - - {canWrite && - - - {filtered.map((r, i) => { - const disabled = r.disabled === 'true'; - const srcPort = r['src-port']; - const dstPort = r['dst-port']; - const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' '); - return ( - - - - - - - - - - - {canWrite && ( - - )} - - ); - })} - -
#ChainActionProtocolSrcDstPortsStateComment} -
{i + 1}{r.chain} - {r.action}{r['jump-target'] ? ` → ${r['jump-target']}` : ''} - {r.protocol || 'any'} - {[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'} - - {[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'} - {portCell || '—'}{r['connection-state'] || '—'}{r.comment || ''} -
- - - -
-
-
- )} + {filtered.length === 0 ?
No firewall rules{chainFilter !== 'all' ? ` in "${chainFilter}"` : ''}.
+ : { setForm(ruleToForm(r)); setErr(''); setEditing(r); }} + onToggle={r => toggleMut.mutate({ id: r['.id'], disabled: !(r.disabled === 'true') })} + onDelete={r => { if (confirm('Delete this firewall rule?')) delMut.mutate(r['.id']); }} onMove={onMove} />} - {showAdd && ( - addMutation.mutate(formToPayload(form))} - onClose={() => setShowAdd(false)} - isPending={addMutation.isPending} - error={mutError} - /> - )} - {editingRule && ( - updateMutation.mutate({ id: editingRule['.id'], data: formToPayload(form) })} - onClose={() => setEditingRule(null)} - isPending={updateMutation.isPending} - error={mutError} - /> - )} + {showAdd && setShowAdd(false)} onSave={submitAdd} />} + {editing && setEditing(null)} onSave={submitEdit} />} + {lockout && setLockout(null)} onConfirm={confirmLockout} />} - {/* NAT card — routers only */} - {deviceType === 'router' && ( - <> -
- - - )} + {/* NAT is available on all RouterOS devices */} +
+
); } diff --git a/frontend/src/components/device-detail/QueuesTab.tsx b/frontend/src/components/device-detail/QueuesTab.tsx new file mode 100644 index 0000000..c5fad34 --- /dev/null +++ b/frontend/src/components/device-detail/QueuesTab.tsx @@ -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 & { '.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>; + onSave: () => void; onClose: () => void; isPending: boolean; error: string; +}) { + const set = (k: keyof QForm, v: string | boolean) => setForm(f => ({ ...f, [k]: v })); + return ( +
+
+
+

{title}

+ +
+
+
+ set('name', e.target.value)} placeholder="e.g. limit-guest-pc" />
+
+ set('target', e.target.value)} placeholder="192.168.1.50 or 192.168.1.0/24" />
+
+
+ + set('up', e.target.value)} placeholder="10M" /> +
+
+ + set('down', e.target.value)} placeholder="50M" /> +
+
+
+ {RATE_PRESETS.map(r => ( + + ))} +
+

Use suffixes: k, M, G (bits/sec). Leave a field as 0 for unlimited.

+
+ set('comment', e.target.value)} placeholder="Optional" />
+ {error && ( +
+

{error}

+
+ )} +
+ + +
+
+
+
+ ); +} + +export default function QueuesTab({ deviceId }: { deviceId: number }) { + const qc = useQueryClient(); + const canWrite = useCanWrite(); + const [editing, setEditing] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState(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 ( +
+
+
+
+

Bandwidth Queues ({queues.length})

+
+
+ + {canWrite && } +
+
+

Simple queues cap upload/download for a client IP, subnet, or interface. Limit a single client quickly from its detail page.

+ + {isLoading ?
Loading…
+ : queues.length === 0 ?
No bandwidth queues defined.
+ : ( +
+ + + + + + + + {canWrite && + + + {queues.map((q, i) => { + const disabled = q.disabled === 'true'; + const lim = splitLimit(q['max-limit']); + return ( + + + + + + {canWrite && ( + + )} + + ); + })} + +
NameTargetMax ↑/↓Comment} +
{q.name}{q.target || '—'}{lim.up || '0'} / {lim.down || '0'}{q.comment || ''} +
+ + +
+
+
+ )} + + {showAdd && setShowAdd(false)} onSave={() => addMut.mutate()} />} + {editing && setEditing(null)} onSave={() => updMut.mutate(editing['.id'])} />} +
+ ); +} diff --git a/frontend/src/components/device-detail/SecurityTab.tsx b/frontend/src/components/device-detail/SecurityTab.tsx new file mode 100644 index 0000000..547c56b --- /dev/null +++ b/frontend/src/components/device-detail/SecurityTab.tsx @@ -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 & { '.id': string }; + +const SEV_STYLE: Record = { + 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 ( +
+
+
+
+

Security Posture

+
+ +
+ + {isLoading ? ( +
Auditing device…
+ ) : ( + <> + {/* Score + summary */} +
+
+
{score}
+
Hardening
+
+
+ {checks.length === 0 ? ( +
No issues found — this device passes all baseline checks.
+ ) : ( +
{checks.length} issue{checks.length !== 1 ? 's' : ''} found. Review and remediate below.
+ )} +
+
+ + {/* Findings */} + {checks.length > 0 && ( +
+ {checks.map(c => { + const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low; + return ( +
+ +
+
+ {c.title} + {s.label} +
+

{c.detail}

+
+ {canWrite && c.serviceId && ( + + )} +
+ ); + })} +
+ )} + + {/* IP services table */} +
+
Management Services
+ + + + + + + + {canWrite && + + + {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 ( + + + + + + {canWrite && ( + + )} + + ); + })} + +
ServicePortAllowed FromStatus} +
+ {s.name}{isMgmt && (managed via)} + {s.port || '—'}{s.address || 'any'} + + + {enabled ? 'Enabled' : 'Disabled'} + + + {isMgmt ? ( + + in use + + ) : ( + + )} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ffcc060..9cadba0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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' }, diff --git a/frontend/src/pages/ClientDetailPage.tsx b/frontend/src/pages/ClientDetailPage.tsx index 3f65867..fb87d60 100644 --- a/frontend/src/pages/ClientDetailPage.tsx +++ b/frontend/src/pages/ClientDetailPage.tsx @@ -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 ( +
+
+ Bandwidth limit +
+ {!open ? ( + + ) : ( +
+
+ + +
+ {error &&

{error}

} +
+ + +
+

Creates a simple queue on the device for {ip}. Manage it later under the device's Bandwidth tab.

+
+ )} +
+ ); +} + 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
)} + {/* Limit bandwidth — creates a simple queue on the client's device targeting its IP */} + {canWrite && client.ip_address && client.device_id && ( + + )} + {/* Notes */}
diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index cb425b0..86587a1 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -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('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(requestedTab && VALID_TABS.includes(requestedTab) ? requestedTab : 'overview'); const [autoOpenBridge, setAutoOpenBridge] = useState(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' && } - {activeTab === 'firewall' && } + {activeTab === 'firewall' && } + {activeTab === 'security' && } + {activeTab === 'queues' && } + {activeTab === 'connections' && } {activeTab === 'config' && } {activeTab === 'config-history' && } {activeTab === 'hardware' && } diff --git a/frontend/src/pages/SecurityPage.tsx b/frontend/src/pages/SecurityPage.tsx new file mode 100644 index 0000000..3123bae --- /dev/null +++ b/frontend/src/pages/SecurityPage.tsx @@ -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 = { + 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 = { high: 'bg-red-500', medium: 'bg-amber-500', low: 'bg-blue-500' }; +const SEV_ORDER: Record = { 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 ( +
+
+ +
+
+
{label}
+
{value}
+
+
+ ); +} + +export default function SecurityPage() { + const navigate = useNavigate(); + const [expanded, setExpanded] = useState(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 => { + 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(); + 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 ( +
+ {/* Header */} +
+
+

Security

+

Fleet-wide firewall posture and access control

+
+ +
+ + {/* KPI cards */} +
+ + {scored.length}/{online.length}} /> + + 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'} /> +
+ + {/* Main: posture list (wide) + common findings (narrow) */} +
+ {/* Fleet posture */} +
+
+ +

Fleet Security Posture

+
+ {isLoading ? ( +
Auditing devices…
+ ) : online.length === 0 ? ( +
No online devices to scan.
+ ) : ( +
+ {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 ( +
+
+ + +
+ {open && f.checks.length > 0 && ( +
+ {f.checks.map(c => ( +
+ +
{c.title} — {c.detail}
+
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Common findings across the fleet */} +
+
+ +

Common Findings

+
+ {commonFindings.length === 0 ? ( +
+ {isLoading ? 'Scanning…' : No issues across the fleet} +
+ ) : ( +
+ {commonFindings.map(c => ( +
+ + {c.title} + {c.count} {c.count === 1 ? 'device' : 'devices'} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 08a265d..4a871be 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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('/devices'), discovered: () => api.get('/devices/discovered'), @@ -236,6 +244,38 @@ export const devicesApi = { api.put[]>(`/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[]>(`/devices/${id}/firewall/move`, { id: ruleId, destination }), + resetFirewallCounters: (id: number) => + api.post[]>(`/devices/${id}/firewall/reset-counters`), + moveNatRule: (id: number, ruleId: string, destination?: string) => + api.post[]>(`/devices/${id}/nat/move`, { id: ruleId, destination }), + // Firewall address lists (reusable address objects) + getAddressLists: (id: number) => api.get[]>(`/devices/${id}/address-lists`), + addAddressListEntry: (id: number, data: { list: string; address: string; comment?: string; timeout?: string }) => + api.post[]>(`/devices/${id}/address-lists`, data), + updateAddressListEntry: (id: number, entryId: string, data: Record) => + api.put[]>(`/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[] }>(`/devices/${id}/connections`, { params: { limit } }), + // Simple queues (bandwidth control) + getQueues: (id: number) => api.get[]>(`/devices/${id}/queues`), + addQueue: (id: number, data: Record) => + api.post[]>(`/devices/${id}/queues`, data), + updateQueue: (id: number, queueId: string, data: Record) => + api.put[]>(`/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[]>(`/devices/${id}/services`), + setServiceDisabled: (id: number, serviceId: string, disabled: boolean) => + api.put[]>(`/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; diff --git a/frontend/src/utils/firewallSummary.test.ts b/frontend/src/utils/firewallSummary.test.ts new file mode 100644 index 0000000..26e9ac8 --- /dev/null +++ b/frontend/src/utils/firewallSummary.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/firewallSummary.ts b/frontend/src/utils/firewallSummary.ts new file mode 100644 index 0000000..f1d1e99 --- /dev/null +++ b/frontend/src/utils/firewallSummary.ts @@ -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 = { + 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; + +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})`; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index afae2cf..25aa171 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.13.3 Beta'; +export const APP_VERSION = 'v0.14.0 Beta';