diff --git a/README.md b/README.md index f457489..3a1474c 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.16.2_Beta-blue) +![Version](https://img.shields.io/badge/version-0.16.3_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) @@ -106,6 +106,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni - **Capacity & health** — per-device CPU/memory meters sorted by pressure - **Security posture rollup** — fleet average hardening score, high-severity finding count, and the lowest-scoring devices, deep-linking to the Security Center - **Recent activity feed** — merged config changes, user actions, and alerts + - **Anomaly insights** (Mist-style) — compares each device's last 30 minutes of client-count and CPU against its **same-hour-of-day 14-day baseline** and flags ≥2.5σ deviations, plus error-log burst detection — surfaced alongside rogue-AP alerts in Things to handle ### Device Management - Add, edit, and delete MikroTik devices (routers, switches, wireless APs) @@ -113,6 +114,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni - Firmware update availability detection - Per-device notes, rack location, and physical address with map support - Device credential encryption at rest +- **Encrypted API (api-ssl)** — devices whose API runs on the SSL port (8729) are reached over TLS automatically; RouterOS's default self-signed certificates are accepted, so the credentials and session are encrypted in transit without any manual certificate trust - **Bulk device add** — "Try All" discovered devices runs as a server-side background job (survives browser tab close) with live progress and cancel support - CPU load and historical sparkline displayed correctly for all device types, including hardware switches that report 0% CPU via ASIC offloading - **Device tags** — colored labels for organizing and filtering devices; full tag management in Settings @@ -152,11 +154,20 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set: - Scheduled and on-demand **AP scans** (nearby access point discovery) - Real-time radio monitoring - Wireless client tracking with vendor lookup +- **Device fingerprinting** — every client is classified into a device category (server/NAS, computer, phone, TV, camera, printer, game console, voice assistant, smart-home, IoT, network gear…) from its OUI vendor and hostname, shown as an icon on the Clients list. The fingerprint is fully **overridable per client** on the client detail page, and the override persists across re-polls - **RF Health** (fleet-wide on the Wireless page and per-AP on the Radios tab): - **Channel usage map** across 2.4 / 5 / 6 GHz, computed from each radio's operating channel (live-resolved from `monitor` on the wifi package when channels are auto), highlighting in-use channels and **co-channel overlap** - **AP deployment density** — connected clients plotted across the −90…−30 dBm signal scale, with a coverage-gap warning when too many clients connect at weak RSSI - **AP radio TX retries** histogram (0%→35%+) with a band selector, derived from per-client CCQ (legacy `wireless` driver) - **WiFi connectivity success** funnel (Association / Authentication / DHCP) derived from device logs +- **Rogue & neighbor AP detection** — cross-references stored AP-scan results against your own SSIDs and radio MACs. A foreign BSSID broadcasting one of *your* SSIDs is flagged as a **rogue / evil-twin** (and raised in Operations → Things to handle); everything else is a ranked neighbor-network inventory + +### Guest WiFi (Hotspot) +A one-click captive-portal guest network on RouterOS Hotspot, under **Wireless → Guest WiFi**: +- **Guided setup wizard** — creates the whole guest network in one pass: a new **guest SSID** (a virtual AP on every physical radio), **VLAN segregation** (guest SSIDs tagged onto the bridge with an L3 VLAN interface for the portal — or a dedicated guest bridge when no VLAN is given), IP pool, DHCP, hotspot profile, portal server, a bandwidth-limited guest user profile, and an optional masquerade NAT rule. Idempotent, with honest warnings (e.g. bridge VLAN-filtering disabled). Can also target an existing interface. +- **Vouchers** — batch-generate access codes (`XXXX-XXXX`, no ambiguous characters) with validity hours, data caps, and a speed profile; **printable voucher sheets** (3-up cards with code, limits, and connection instructions) and a used/unused status table +- **Guests online** — live session table (code, IP, MAC, session time, down/up bytes) with one-click disconnect +- **Walled garden** — sites reachable before login, managed inline; per-server enable/disable ### Network Services Each service supports multi-device management with conflict detection: @@ -201,11 +212,24 @@ Per-device diagnostic and testing tools accessible from the device detail Tools | **Packet Capture** | Start the RouterOS sniffer, capture for 5–60 seconds, download a `.pcap` file directly to your browser (opens in Wireshark). Requires SSH credentials on the device. | | **Bandwidth Test** | Measure throughput between two devices. Select any managed device as the target — the bandwidth-test server is automatically enabled on the target before the test and disabled afterward. Manual IP mode available for non-managed targets. | +### Firmware Orchestration +Staged fleet-wide RouterOS upgrades, under the top-level **Firmware** section: +- **Fleet versions** — current RouterOS, latest-known version, and pending RouterBOOT upgrades per device; **Check all for updates** queries every online device live +- **Staged rollouts in waves** — select updatable devices and assign each to a wave (wave 1 = **canary**); the orchestrator runs one rollout at a time, devices sequentially, through a verified pipeline: **pre-upgrade backup → install → ride out the reboot → verify it returned healthy on the new version → next device** +- **Halt on failure** stops the entire remaining rollout if any device fails, so a bad build never reaches the fleet; a reboot that comes back on the *old* version counts as a failure +- **Schedule** rollouts for a future time (pair with a maintenance window); live wave-grouped progress with animated per-device status, `from → to` versions, error detail, and **Cancel** (never interrupts an in-flight flash) + ### Backups - Trigger RouterOS backups on demand via SSH - **Scheduled automatic backups** — pick a daily, weekly, or monthly schedule and time in Settings (no cron knowledge required); runs for all online devices - Download and manage backup files from the UI +### Platform & Automation +Make the platform scriptable and integrable, under **Settings → Automation**: +- **Scoped API tokens** — issue `mtm_…` tokens with **read** or **write** scope and optional expiry for scripting and IaC; the token is shown once (only a SHA-256 hash is stored) and maps onto the role model — no token can perform admin actions or manage other tokens. Use with `Authorization: Bearer mtm_…` against the full REST API +- **Outbound webhooks** — subscribe URLs to any of 12 events (device up/down, log errors, high CPU/memory, cert expiry, device discovered, firmware update, config drift, firmware rollout completed/failed); deliveries are JSON POSTs **HMAC-SHA256 signed** (`X-MTM-Signature`) when a secret is set, with last-status tracking and a Send-test button. Fired through the same alert pipeline (respecting alert rules, cooldowns, and maintenance windows) +- **Scheduled email reports** — daily/weekly/monthly HTML fleet summaries (devices online, outages + downtime, error/warning counts, updates pending, backups taken, top clients by traffic) to any recipient list, using your Alerting SMTP settings; Send-now for instant delivery + ### Configuration History - Per-device config snapshots based on the device's full RouterOS `/export` — config-only, so counters and operational state never create noise — captured automatically whenever the configuration changes (deduplicated by content hash, so a snapshot is only stored on a real change) - **Config History** tab on each device with a timeline of snapshots and a one-line summary of what changed (e.g. `+8 / −3 lines`) diff --git a/backend/package-lock.json b/backend/package-lock.json index d3cb683..cd96969 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", diff --git a/backend/package.json b/backend/package.json index 29bb6f2..6f3b437 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 9605f96..3eabc76 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -163,6 +163,68 @@ ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255); -- first_seen: when this client was first discovered ("connected since"); set once, preserved across polls ALTER TABLE clients ADD COLUMN IF NOT EXISTS first_seen TIMESTAMPTZ; UPDATE clients SET first_seen = last_seen WHERE first_seen IS NULL; +-- custom_category: user override of the fingerprinted device category +ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_category VARCHAR(30); + +-- Firmware orchestration: staged fleet upgrades in waves +CREATE TABLE IF NOT EXISTS firmware_rollouts ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled + halt_on_failure BOOLEAN NOT NULL DEFAULT TRUE, + pre_backup BOOLEAN NOT NULL DEFAULT TRUE, + scheduled_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS firmware_rollout_devices ( + id SERIAL PRIMARY KEY, + rollout_id INTEGER NOT NULL REFERENCES firmware_rollouts(id) ON DELETE CASCADE, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + wave INTEGER NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|backing_up|upgrading|rebooting|verifying|success|failed|skipped + from_version VARCHAR(30), + to_version VARCHAR(30), + error TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_fw_rollout_devices ON firmware_rollout_devices(rollout_id, wave, id); + +-- Platform & automation: scoped API tokens, outbound webhooks, scheduled reports +CREATE TABLE IF NOT EXISTS api_tokens ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + prefix VARCHAR(12) NOT NULL, + scope VARCHAR(10) NOT NULL DEFAULT 'read', -- read|write + created_by VARCHAR(50), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS webhooks ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + url TEXT NOT NULL, + secret VARCHAR(128), + events TEXT[] NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_status INTEGER, + last_fired_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS report_schedules ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + frequency VARCHAR(10) NOT NULL DEFAULT 'weekly', -- daily|weekly|monthly + recipients TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_sent_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id); ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17); diff --git a/backend/src/index.ts b/backend/src/index.ts index fe4ffa3..0db9001 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,11 @@ import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/de import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients'; import eventsRoutes from './routes/events'; import backupsRoutes from './routes/backups'; +import guestWifiRoutes from './routes/guestWifi'; +import firmwareRoutes from './routes/firmware'; +import automationRoutes from './routes/automation'; +import { reportService } from './services/ReportService'; +import { firmwareOrchestrator } from './services/FirmwareOrchestrator'; import operationsRoutes from './routes/operations'; import metricsRoutes from './routes/metrics'; import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology'; @@ -208,6 +213,9 @@ app.use('/api/clients', clientsRoutes); app.use('/api/events', eventsRoutes); app.use('/api/backups', backupsRoutes); app.use('/api/operations', operationsRoutes); +app.use('/api/guest-wifi', guestWifiRoutes); +app.use('/api/firmware', firmwareRoutes); +app.use('/api/automation', automationRoutes); app.use('/api/metrics', metricsRoutes); app.use('/api/topology', topologyRoutes); app.use('/api/settings', settingsRoutes); @@ -270,6 +278,12 @@ async function start(): Promise { // NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled) await netflowCollector.start(); + // Firmware rollout scheduler (starts rollouts whose scheduled_at has arrived) + firmwareOrchestrator.startScheduler(); + + // Scheduled report mailer (hourly check) + reportService.startScheduler(); + // Start HTTP server httpServer.listen(PORT, '0.0.0.0', () => { console.log(`✓ Mikrotik Manager backend running on port ${PORT}`); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index e61b5c6..af4083f 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,10 +1,14 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { createHash } from 'crypto'; +import { query } from '../config/database'; export interface AuthPayload { userId: number; username: string; role: string; + /** Set when the request authenticated with an API token instead of a session. */ + tokenAuth?: boolean; } declare global { @@ -26,6 +30,27 @@ export function verifyToken(token: string): AuthPayload { return jwt.verify(token, JWT_SECRET) as AuthPayload; } +// API tokens ("mtm_…") are hashed at rest; scope maps onto the existing role +// model (read → viewer, write → operator) so requireWrite/requireAdmin keep +// working unchanged. Admin actions always need a real session. +async function authenticateApiToken(token: string): Promise { + const hash = createHash('sha256').update(token).digest('hex'); + const rows = await query<{ id: number; name: string; scope: string; expires_at: string | null }>( + `SELECT id, name, scope, expires_at FROM api_tokens WHERE token_hash = $1`, [hash] + ).catch(() => []); + const t = rows[0]; + if (!t) return null; + if (t.expires_at && new Date(t.expires_at).getTime() < Date.now()) return null; + // Fire-and-forget usage tracking + void query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [t.id]).catch(() => {}); + return { + userId: -t.id, + username: `token:${t.name}`, + role: t.scope === 'write' ? 'operator' : 'viewer', + tokenAuth: true, + }; +} + export function requireAuth(req: Request, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { @@ -34,6 +59,18 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo } const token = authHeader.slice(7); + + if (token.startsWith('mtm_')) { + authenticateApiToken(token) + .then((payload) => { + if (!payload) { res.status(401).json({ error: 'Invalid or expired API token' }); return; } + req.user = payload; + next(); + }) + .catch(() => res.status(401).json({ error: 'Invalid or expired API token' })); + return; + } + try { req.user = verifyToken(token); next(); diff --git a/backend/src/routes/automation.ts b/backend/src/routes/automation.ts new file mode 100644 index 0000000..a21d392 --- /dev/null +++ b/backend/src/routes/automation.ts @@ -0,0 +1,160 @@ +import { Router, Request, Response } from 'express'; +import { randomBytes, createHash } from 'crypto'; +import { query, queryOne } from '../config/database'; +import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth'; +import { webhookService, WEBHOOK_EVENTS } from '../services/WebhookService'; +import { reportService, computeNextRun } from '../services/ReportService'; + +const router = Router(); +router.use(requireAuth); + +// ─── API tokens (admin only — a token can never mint more tokens) ───────────── + +router.get('/tokens', requireAdmin, async (_req: Request, res: Response) => { + const rows = await query( + `SELECT id, name, prefix, scope, created_by, last_used_at, expires_at, created_at + FROM api_tokens ORDER BY created_at DESC`); + res.json(rows); +}); + +router.post('/tokens', requireAdmin, async (req: Request, res: Response) => { + const { name, scope, expires_days } = req.body as { name?: string; scope?: string; expires_days?: number }; + if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' }); + if (scope !== 'read' && scope !== 'write') return res.status(400).json({ error: 'scope must be read or write' }); + + const token = `mtm_${randomBytes(24).toString('hex')}`; + const hash = createHash('sha256').update(token).digest('hex'); + const expiresAt = expires_days && expires_days > 0 + ? new Date(Date.now() + expires_days * 86_400_000).toISOString() + : null; + + const row = await queryOne<{ id: number }>( + `INSERT INTO api_tokens (name, token_hash, prefix, scope, created_by, expires_at) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + [name.trim().slice(0, 100), hash, token.slice(0, 12), scope, req.user?.username ?? null, expiresAt]); + + // The full token is returned exactly once — only the hash is stored. + res.status(201).json({ id: row!.id, token }); +}); + +router.delete('/tokens/:id', requireAdmin, async (req: Request, res: Response) => { + await query(`DELETE FROM api_tokens WHERE id = $1`, [parseInt(req.params.id, 10)]); + res.json({ ok: true }); +}); + +// ─── Webhooks ────────────────────────────────────────────────────────────────── + +router.get('/webhooks', async (_req: Request, res: Response) => { + const rows = await query( + `SELECT id, name, url, (secret IS NOT NULL AND secret != '') AS has_secret, + events, enabled, last_status, last_fired_at, created_at + FROM webhooks ORDER BY created_at DESC`); + res.json({ webhooks: rows, availableEvents: WEBHOOK_EVENTS }); +}); + +router.post('/webhooks', requireWrite, async (req: Request, res: Response) => { + const { name, url, secret, events } = req.body as { name?: string; url?: string; secret?: string; events?: string[] }; + if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' }); + if (!url || !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' }); + const evts = (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e)); + if (evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' }); + + const row = await queryOne<{ id: number }>( + `INSERT INTO webhooks (name, url, secret, events) VALUES ($1,$2,$3,$4) RETURNING id`, + [name.trim().slice(0, 100), url.trim(), secret?.trim() || null, evts]); + res.status(201).json({ id: row!.id }); +}); + +router.put('/webhooks/:id', requireWrite, async (req: Request, res: Response) => { + const id = parseInt(req.params.id, 10); + const { name, url, secret, events, enabled } = req.body as { + name?: string; url?: string; secret?: string | null; events?: string[]; enabled?: boolean; + }; + const existing = await queryOne<{ id: number }>(`SELECT id FROM webhooks WHERE id = $1`, [id]); + if (!existing) return res.status(404).json({ error: 'Webhook not found' }); + + if (url !== undefined && !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' }); + const evts = events !== undefined + ? (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e)) + : undefined; + if (evts !== undefined && evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' }); + + await query( + `UPDATE webhooks SET + name = COALESCE($2, name), + url = COALESCE($3, url), + secret = CASE WHEN $4::text IS NOT NULL THEN NULLIF($4, '') ELSE secret END, + events = COALESCE($5, events), + enabled = COALESCE($6, enabled) + WHERE id = $1`, + [id, name?.trim().slice(0, 100) ?? null, url?.trim() ?? null, secret ?? null, evts ?? null, enabled ?? null]); + res.json({ ok: true }); +}); + +router.delete('/webhooks/:id', requireWrite, async (req: Request, res: Response) => { + await query(`DELETE FROM webhooks WHERE id = $1`, [parseInt(req.params.id, 10)]); + res.json({ ok: true }); +}); + +router.post('/webhooks/:id/test', requireWrite, async (req: Request, res: Response) => { + try { + const result = await webhookService.sendTest(parseInt(req.params.id, 10)); + res.json({ ok: result.status >= 200 && result.status < 300, status: result.status }); + } catch (e) { + res.status(400).json({ error: (e as Error).message }); + } +}); + +// ─── Scheduled reports ───────────────────────────────────────────────────────── + +router.get('/reports', async (_req: Request, res: Response) => { + const rows = await query(`SELECT * FROM report_schedules ORDER BY created_at DESC`); + res.json(rows); +}); + +router.post('/reports', requireWrite, async (req: Request, res: Response) => { + const { name, frequency, recipients } = req.body as { name?: string; frequency?: string; recipients?: string }; + if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' }); + if (!['daily', 'weekly', 'monthly'].includes(frequency ?? '')) return res.status(400).json({ error: 'frequency must be daily, weekly or monthly' }); + const rcpts = (recipients ?? '').split(',').map(r => r.trim()).filter(Boolean); + if (rcpts.length === 0 || rcpts.some(r => !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(r))) { + return res.status(400).json({ error: 'recipients must be comma-separated email addresses' }); + } + + const row = await queryOne<{ id: number }>( + `INSERT INTO report_schedules (name, frequency, recipients, next_run_at) + VALUES ($1,$2,$3,$4) RETURNING id`, + [name.trim().slice(0, 100), frequency, rcpts.join(', '), computeNextRun(frequency!, new Date())]); + res.status(201).json({ id: row!.id }); +}); + +router.put('/reports/:id', requireWrite, async (req: Request, res: Response) => { + const id = parseInt(req.params.id, 10); + const { enabled } = req.body as { enabled?: boolean }; + const result = await query( + `UPDATE report_schedules SET enabled = COALESCE($2, enabled) WHERE id = $1 RETURNING id`, + [id, enabled ?? null]); + if (!result.length) return res.status(404).json({ error: 'Report schedule not found' }); + res.json({ ok: true }); +}); + +router.delete('/reports/:id', requireWrite, async (req: Request, res: Response) => { + await query(`DELETE FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]); + res.json({ ok: true }); +}); + +// POST /api/automation/reports/:id/send-now — immediate delivery (also validates SMTP) +router.post('/reports/:id/send-now', requireWrite, async (req: Request, res: Response) => { + const row = await queryOne<{ name: string; frequency: 'daily' | 'weekly' | 'monthly'; recipients: string }>( + `SELECT name, frequency, recipients FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]); + if (!row) return res.status(404).json({ error: 'Report schedule not found' }); + try { + await reportService.send(row); + await query(`UPDATE report_schedules SET last_sent_at = NOW() WHERE id = $1`, [parseInt(req.params.id, 10)]); + res.json({ ok: true }); + } catch (e) { + res.status(502).json({ error: (e as Error).message }); + } +}); + +export default router; diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts index a473d3f..f79d9a9 100644 --- a/backend/src/routes/clients.ts +++ b/backend/src/routes/clients.ts @@ -4,6 +4,18 @@ import { query } from '../config/database'; import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth'; import { PollerService } from '../services/PollerService'; import { getQueryApi, bucket } from '../config/influxdb'; +import { fingerprintClient, DEVICE_CATEGORIES, DeviceCategory } from '../utils/clientFingerprint'; + +// Effective category: the user's override wins; otherwise the fingerprint. +function withCategory>(row: T): T & { device_category: string; auto_category: string } { + const auto = fingerprintClient({ + vendor: row['vendor'] as string | null, + hostname: row['hostname'] as string | null, + custom_name: row['custom_name'] as string | null, + }); + const custom = (row['custom_category'] as string | null) || null; + return { ...row, device_category: custom ?? auto, auto_category: auto }; +} const router = Router(); router.use(requireAuth); @@ -89,7 +101,7 @@ router.get('/', async (req: Request, res: Response) => { ); res.json({ - clients, + clients: (clients as Record[]).map(withCategory), total: parseInt(countResult[0]?.total || '0', 10), }); }); @@ -133,7 +145,7 @@ router.get('/:mac', async (req: Request, res: Response) => { ); if (!rows.length) return res.status(404).json({ error: 'Client not found' }); - return res.json(rows[0]); + return res.json(withCategory(rows[0])); }); // GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB @@ -324,6 +336,24 @@ router.put('/:mac/hostname', requireWrite, async (req: Request, res: Response) = return res.json(result[0]); }); +// PUT /api/clients/:mac/category — override (or clear) the fingerprinted device category +router.put('/:mac/category', requireWrite, async (req: Request, res: Response) => { + const { category } = req.body as { category?: string | null }; + const mac = req.params.mac.toLowerCase(); + + const clear = category === null || category === undefined || category === ''; + if (!clear && !DEVICE_CATEGORIES.includes(category as DeviceCategory)) { + return res.status(400).json({ error: `category must be one of: ${DEVICE_CATEGORIES.join(', ')} (or null to revert to automatic)` }); + } + + const result = await query( + `UPDATE clients SET custom_category = $1 WHERE LOWER(mac_address) = $2 RETURNING *`, + [clear ? null : category, mac] + ); + if (!result.length) return res.status(404).json({ error: 'Client not found' }); + return res.json(withCategory(result[0] as Record)); +}); + // PUT /api/clients/:mac/notes — update the comment/notes field router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => { const { notes } = req.body as { notes?: string }; diff --git a/backend/src/routes/firmware.ts b/backend/src/routes/firmware.ts new file mode 100644 index 0000000..cddb640 --- /dev/null +++ b/backend/src/routes/firmware.ts @@ -0,0 +1,135 @@ +import { Router, Request, Response } from 'express'; +import { query, queryOne } from '../config/database'; +import { requireAuth, requireWrite } from '../middleware/auth'; +import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; +import { firmwareOrchestrator } from '../services/FirmwareOrchestrator'; + +const router = Router(); +router.use(requireAuth); + +// GET /api/firmware/overview — fleet versions + latest rollout +router.get('/overview', async (_req: Request, res: Response) => { + const [devices, latestRollout] = await Promise.all([ + query(`SELECT id, name, device_type, status, model, ros_version, latest_ros_version, + firmware_update_available, firmware_version, upgrade_firmware_version, + routerboard_upgrade_available + FROM devices ORDER BY name ASC`), + queryOne<{ id: number }>(`SELECT id FROM firmware_rollouts ORDER BY created_at DESC LIMIT 1`), + ]); + res.json({ + devices, + latestRolloutId: latestRollout?.id ?? null, + runningRolloutId: firmwareOrchestrator.running, + }); +}); + +// POST /api/firmware/check-all — refresh update availability on all online devices +router.post('/check-all', requireWrite, async (_req: Request, res: Response) => { + const devices = await query(`SELECT * FROM devices WHERE status='online'`); + const settled = await Promise.allSettled(devices.map(async (d) => { + const c = new DeviceCollector(d); + try { + await c.connect(); + const s = await c.checkForUpdates(); + const installed = (s['installed-version'] || '').trim(); + const latest = (s['latest-version'] || '').trim(); + const available = !!latest && latest !== installed; + await query( + `UPDATE devices SET ros_version=COALESCE(NULLIF($2,''), ros_version), + latest_ros_version=NULLIF($3,''), firmware_update_available=$4 WHERE id=$1`, + [d.id, installed, latest, available]); + return { name: d.name, installed, latest, available }; + } finally { c.disconnect(); } + })); + res.json({ + results: settled.map((s, i) => s.status === 'fulfilled' + ? { ...s.value, ok: true } + : { name: devices[i].name, ok: false, error: (s.reason as Error)?.message }), + }); +}); + +// POST /api/firmware/rollouts — create a rollout (optionally scheduled) +router.post('/rollouts', requireWrite, async (req: Request, res: Response) => { + const { name, halt_on_failure, pre_backup, scheduled_at, devices, start } = req.body as { + name?: string; halt_on_failure?: boolean; pre_backup?: boolean; scheduled_at?: string | null; + devices?: { device_id: number; wave: number }[]; start?: boolean; + }; + if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' }); + if (!Array.isArray(devices) || devices.length === 0) return res.status(400).json({ error: 'devices array is required' }); + for (const d of devices) { + if (!Number.isInteger(d.device_id) || !Number.isInteger(d.wave) || d.wave < 1 || d.wave > 9) { + return res.status(400).json({ error: 'each device needs device_id and wave (1-9)' }); + } + } + if (scheduled_at && isNaN(Date.parse(scheduled_at))) return res.status(400).json({ error: 'scheduled_at must be a valid timestamp' }); + + const rollout = await queryOne<{ id: number }>( + `INSERT INTO firmware_rollouts (name, halt_on_failure, pre_backup, scheduled_at) + VALUES ($1,$2,$3,$4) RETURNING id`, + [name.trim().slice(0, 100), halt_on_failure !== false, pre_backup !== false, scheduled_at || null]); + for (const d of devices) { + await query( + `INSERT INTO firmware_rollout_devices (rollout_id, device_id, wave) VALUES ($1,$2,$3)`, + [rollout!.id, d.device_id, d.wave]); + } + + if (start && !scheduled_at) { + try { await firmwareOrchestrator.start(rollout!.id); } + catch (e) { return res.status(409).json({ error: (e as Error).message, id: rollout!.id }); } + } + res.status(201).json({ id: rollout!.id }); +}); + +// GET /api/firmware/rollouts — recent rollouts with progress counts +router.get('/rollouts', async (_req: Request, res: Response) => { + const rows = await query(` + SELECT r.*, + COUNT(d.id)::int AS device_count, + COUNT(d.id) FILTER (WHERE d.status = 'success')::int AS success_count, + COUNT(d.id) FILTER (WHERE d.status = 'failed')::int AS failed_count + FROM firmware_rollouts r + LEFT JOIN firmware_rollout_devices d ON d.rollout_id = r.id + GROUP BY r.id ORDER BY r.created_at DESC LIMIT 20`); + res.json(rows); +}); + +// GET /api/firmware/rollouts/:id — full rollout detail +router.get('/rollouts/:id', async (req: Request, res: Response) => { + const id = parseInt(req.params.id, 10); + const [rollout, devices] = await Promise.all([ + queryOne(`SELECT * FROM firmware_rollouts WHERE id=$1`, [id]), + query(` + SELECT rd.*, dev.name AS device_name, dev.device_type, dev.model + FROM firmware_rollout_devices rd JOIN devices dev ON dev.id = rd.device_id + WHERE rd.rollout_id = $1 ORDER BY rd.wave ASC, rd.id ASC`, [id]), + ]); + if (!rollout) return res.status(404).json({ error: 'Rollout not found' }); + res.json({ ...rollout, devices }); +}); + +// POST /api/firmware/rollouts/:id/start +router.post('/rollouts/:id/start', requireWrite, async (req: Request, res: Response) => { + try { + await firmwareOrchestrator.start(parseInt(req.params.id, 10)); + res.json({ ok: true }); + } catch (e) { + res.status(409).json({ error: (e as Error).message }); + } +}); + +// POST /api/firmware/rollouts/:id/cancel — stops before the next device (an +// in-flight upgrade is never interrupted mid-write) +router.post('/rollouts/:id/cancel', requireWrite, async (req: Request, res: Response) => { + const id = parseInt(req.params.id, 10); + const rollout = await queryOne<{ status: string }>(`SELECT status FROM firmware_rollouts WHERE id=$1`, [id]); + if (!rollout) return res.status(404).json({ error: 'Rollout not found' }); + if (rollout.status === 'pending') { + await query(`UPDATE firmware_rollouts SET status='cancelled', finished_at=NOW() WHERE id=$1`, [id]); + await query(`UPDATE firmware_rollout_devices SET status='skipped', error='Rollout cancelled' WHERE rollout_id=$1 AND status='pending'`, [id]); + return res.json({ ok: true }); + } + firmwareOrchestrator.cancel(id); + res.json({ ok: true, note: 'Cancelling after the in-flight device finishes' }); +}); + +export default router; diff --git a/backend/src/routes/guestWifi.ts b/backend/src/routes/guestWifi.ts new file mode 100644 index 0000000..7715073 --- /dev/null +++ b/backend/src/routes/guestWifi.ts @@ -0,0 +1,200 @@ +import { Router, Request, Response } from 'express'; +import { randomBytes } from 'crypto'; +import { queryOne } from '../config/database'; +import { requireAuth, requireWrite } from '../middleware/auth'; +import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; + +const router = Router(); +router.use(requireAuth); + +async function getDevice(id: number): Promise { + return queryOne(`SELECT * FROM devices WHERE id = $1`, [id]); +} + +// Run fn against a connected collector for ?deviceId= / body.deviceId +async function withDevice( + req: Request, res: Response, + fn: (c: DeviceCollector) => Promise +): Promise { + const deviceId = parseInt(String(req.query.deviceId ?? (req.body as { deviceId?: number })?.deviceId ?? ''), 10); + if (!deviceId) { res.status(400).json({ error: 'deviceId is required' }); return undefined; } + const device = await getDevice(deviceId); + if (!device) { res.status(404).json({ error: 'Device not found' }); return undefined; } + const collector = new DeviceCollector(device); + try { + await collector.connect(); + return await fn(collector); + } catch (err) { + res.status(502).json({ error: (err as Error).message }); + return undefined; + } finally { + collector.disconnect(); + } +} + +// ─── Overview ──────────────────────────────────────────────────────────────── + +// GET /api/guest-wifi/overview?deviceId= +router.get('/overview', async (req: Request, res: Response) => { + const result = await withDevice(req, res, async (c) => { + const [servers, profiles, userProfiles, users, active, interfaces] = await Promise.all([ + c.getHotspotServers(), c.getHotspotProfiles(), c.getHotspotUserProfiles(), + c.getHotspotUsers(), c.getHotspotActive(), + c.getInterfacesLive().catch(() => [] as Record[]), + ]); + return { + servers, profiles, userProfiles, + userCount: users.length, + activeCount: active.length, + interfaces: interfaces + .filter(i => /^(bridge|vlan|wifi|wlan|ether)/i.test(i['name'] || '') && i['disabled'] !== 'true') + .map(i => ({ name: i['name'], type: i['type'] || '' })), + }; + }); + if (result !== undefined) res.json(result); +}); + +// ─── Guided setup ───────────────────────────────────────────────────────────── + +// POST /api/guest-wifi/setup +router.post('/setup', requireWrite, async (req: Request, res: Response) => { + const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = req.body as Record; + const { ssid, vlanId, masquerade } = req.body as { + ssid?: { ssid?: string; passphrase?: string }; vlanId?: number; masquerade?: boolean; + }; + if (!name || !/^[a-z0-9-]{2,24}$/i.test(name)) { res.status(400).json({ error: 'name (2-24 alphanumeric/dash) is required' }); return; } + if (!ssid?.ssid && !interfaceName) { res.status(400).json({ error: 'Provide a guest SSID to create, or an existing interfaceName' }); return; } + if (ssid?.ssid && (ssid.ssid.length < 1 || ssid.ssid.length > 32)) { res.status(400).json({ error: 'SSID must be 1-32 characters' }); return; } + if (ssid?.passphrase && ssid.passphrase.length < 8) { res.status(400).json({ error: 'Passphrase must be at least 8 characters (or empty for an open network)' }); return; } + if (vlanId !== undefined && (!Number.isInteger(vlanId) || vlanId < 1 || vlanId > 4094)) { res.status(400).json({ error: 'vlanId must be 1-4094' }); return; } + if (!/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/.test(gatewayCidr || '')) { res.status(400).json({ error: 'gatewayCidr must look like 10.5.50.1/24' }); return; } + if (!/^\d{1,3}(\.\d{1,3}){3}-\d{1,3}(\.\d{1,3}){3}$/.test(poolRange || '')) { res.status(400).json({ error: 'poolRange must look like 10.5.50.10-10.5.50.254' }); return; } + if (rateLimit && !/^\d+[kMG]?\/\d+[kMG]?$/.test(rateLimit)) { res.status(400).json({ error: 'rateLimit must look like 10M/10M (rx/tx)' }); return; } + + const result = await withDevice(req, res, (c) => + c.setupGuestNetwork({ + name: name.toLowerCase(), gatewayCidr, poolRange, dnsName, rateLimit, + interfaceName: interfaceName || undefined, + ssid: ssid?.ssid ? { ssid: ssid.ssid, passphrase: ssid.passphrase || undefined } : undefined, + vlanId, masquerade: masquerade !== false, + }) + ); + if (result !== undefined) res.status(201).json(result); +}); + +// ─── Vouchers (hotspot users) ───────────────────────────────────────────────── + +// Human-friendly voucher codes: no ambiguous chars (0/O, 1/I/L) +const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +function generateCode(len = 8): string { + const bytes = randomBytes(len); + let out = ''; + for (let i = 0; i < len; i++) out += CODE_CHARS[bytes[i] % CODE_CHARS.length]; + return `${out.slice(0, 4)}-${out.slice(4)}`; +} + +// GET /api/guest-wifi/users?deviceId= +router.get('/users', async (req: Request, res: Response) => { + const result = await withDevice(req, res, (c) => c.getHotspotUsers()); + if (result !== undefined) res.json(result); +}); + +// POST /api/guest-wifi/vouchers — batch-create voucher users +router.post('/vouchers', requireWrite, async (req: Request, res: Response) => { + const { count, durationHours, dataCapMB, userProfile } = req.body as { + count?: number; durationHours?: number; dataCapMB?: number; userProfile?: string; + }; + const n = Math.min(Math.max(parseInt(String(count ?? 1), 10) || 1, 1), 100); + + const result = await withDevice(req, res, async (c) => { + const existing = new Set((await c.getHotspotUsers()).map(u => u['name'])); + const batch = new Date().toISOString().slice(0, 16).replace('T', ' '); + const codes: string[] = []; + for (let i = 0; i < n; i++) { + let code = generateCode(); + let guard = 0; + while (existing.has(code) && guard++ < 20) code = generateCode(); + existing.add(code); + const params: Record = { name: code, comment: `voucher ${batch}` }; + if (userProfile) params['profile'] = userProfile; + if (durationHours && durationHours > 0) params['limit-uptime'] = `${Math.floor(durationHours)}h`; + if (dataCapMB && dataCapMB > 0) params['limit-bytes-total'] = String(Math.floor(dataCapMB) * 1024 * 1024); + await c.addHotspotUser(params); + codes.push(code); + } + return { created: codes.length, codes }; + }); + if (result !== undefined) res.status(201).json(result); +}); + +// DELETE /api/guest-wifi/users/:id?deviceId= +router.delete('/users/:id', requireWrite, async (req: Request, res: Response) => { + const result = await withDevice(req, res, async (c) => { + await c.removeHotspotUser(req.params.id); + return { ok: true }; + }); + if (result !== undefined) res.json(result); +}); + +// ─── Active sessions ────────────────────────────────────────────────────────── + +// GET /api/guest-wifi/active?deviceId= +router.get('/active', async (req: Request, res: Response) => { + const result = await withDevice(req, res, (c) => c.getHotspotActive()); + if (result !== undefined) res.json(result); +}); + +// DELETE /api/guest-wifi/active/:id?deviceId= — kick a guest +router.delete('/active/:id', requireWrite, async (req: Request, res: Response) => { + const result = await withDevice(req, res, async (c) => { + await c.disconnectHotspotActive(req.params.id); + return { ok: true }; + }); + if (result !== undefined) res.json(result); +}); + +// ─── Walled garden ──────────────────────────────────────────────────────────── + +router.get('/walled-garden', async (req: Request, res: Response) => { + const result = await withDevice(req, res, (c) => c.getWalledGarden()); + if (result !== undefined) res.json(result); +}); + +router.post('/walled-garden', requireWrite, async (req: Request, res: Response) => { + const { dstHost, comment } = req.body as { dstHost?: string; comment?: string }; + if (!dstHost || !dstHost.trim()) { res.status(400).json({ error: 'dstHost is required' }); return; } + const result = await withDevice(req, res, async (c) => { + await c.addWalledGardenEntry(dstHost.trim(), comment); + return { ok: true }; + }); + if (result !== undefined) res.status(201).json(result); +}); + +router.delete('/walled-garden/:id', requireWrite, async (req: Request, res: Response) => { + const result = await withDevice(req, res, async (c) => { + await c.removeWalledGardenEntry(req.params.id); + return { ok: true }; + }); + if (result !== undefined) res.json(result); +}); + +// ─── Server enable/disable/remove ───────────────────────────────────────────── + +router.put('/servers/:id', requireWrite, async (req: Request, res: Response) => { + const { disabled } = req.body as { disabled?: boolean }; + const result = await withDevice(req, res, async (c) => { + await c.setHotspotServerDisabled(req.params.id, !!disabled); + return { ok: true }; + }); + if (result !== undefined) res.json(result); +}); + +router.delete('/servers/:id', requireWrite, async (req: Request, res: Response) => { + const result = await withDevice(req, res, async (c) => { + await c.removeHotspotServer(req.params.id); + return { ok: true }; + }); + if (result !== undefined) res.json(result); +}); + +export default router; diff --git a/backend/src/routes/operations.ts b/backend/src/routes/operations.ts index a2d009d..94543d1 100644 --- a/backend/src/routes/operations.ts +++ b/backend/src/routes/operations.ts @@ -248,6 +248,40 @@ router.get('/insights', async (_req: Request, res: Response) => { }); } + // Rogue APs — evil twins broadcasting our SSIDs from foreign hardware + try { + const { classifyScans } = await import('../utils/rogueAp'); + const [scans, radios, ifaceMacs] = await Promise.all([ + query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(` + SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data + FROM ap_scan_data a JOIN devices d ON d.id = a.device_id + ORDER BY a.device_id, a.scanned_at DESC`), + query<{ ssid: string | null; mac_address: string | null }>(`SELECT ssid, mac_address FROM wireless_interfaces`), + query<{ mac_address: string }>(`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`), + ]); + const ownSsids = new Set(radios.map(r => r.ssid || '').filter(Boolean)); + const ownBssids = new Set([ + ...radios.map(r => (r.mac_address || '').toLowerCase()).filter(Boolean), + ...ifaceMacs.map(r => r.mac_address.toLowerCase()), + ]); + const { rogues } = classifyScans( + scans.map(s => ({ deviceName: s.device_name, scannedAt: String(s.scanned_at), networks: Array.isArray(s.data) ? s.data as import('../utils/rogueAp').ScannedNetwork[] : [] })), + ownSsids, ownBssids + ); + for (const r of rogues) { + attention.push({ + sev: 'error', category: 'security', + title: `Rogue AP broadcasting "${r.ssid}"`, + body: `Foreign BSSID ${r.bssid}${r.vendor ? ` (${r.vendor})` : ''} at ${r.signal} dBm, seen by ${r.seenBy} — possible evil twin.`, + action: 'Open Wireless', path: '/wireless', + }); + } + } catch { /* scan data unavailable */ } + + // Baseline anomalies (client counts, CPU, error bursts) + const anomalies = await detectAnomalies(); + for (const a of anomalies) attention.push(a); + // Severity ordering: error → warn → info const sevRank: Record = { error: 0, warn: 1, info: 2 }; attention.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]); @@ -258,6 +292,121 @@ router.get('/insights', async (_req: Request, res: Response) => { res.json({ attention, capacity, activity }); }); +// ─── Baseline anomaly detection ──────────────────────────────────────────────── +// Mist-style "this is unusual for you" insights: compare the last 30 minutes +// against the same-hour-of-day baseline over the past 14 days (per device) and +// flag deviations of ≥ 2.5 standard deviations. Conservative thresholds — an +// insight should mean something. + +async function influxGroupValues(flux: string): Promise> { + const out = new Map(); + try { + const api = getQueryApi(); + await new Promise((resolve, reject) => { + api.queryRows(flux, { + next(row, tableMeta) { + const o = tableMeta.toObject(row) as Record; + const id = String(o['device_id'] || ''); + if (id) out.set(id, Number(o['_value']) || 0); + }, + error: reject, + complete: resolve, + }); + }); + } catch { /* influx unavailable */ } + return out; +} + +async function detectAnomalies(): Promise { + const items: AttentionItem[] = []; + const hour = new Date().getUTCHours(); + const nameById = new Map(); + const devices = await query<{ id: number; name: string }>(`SELECT id, name FROM devices`); + for (const d of devices) nameById.set(String(d.id), d); + + const metric = async (measurement: string, field: string) => { + const base = ` + from(bucket: "${bucket}") + |> range(start: -14d) + |> filter(fn: (r) => r["_measurement"] == "${measurement}") + |> filter(fn: (r) => r["_field"] == "${field}") + |> hourSelection(start: ${hour}, stop: ${hour}) + |> group(columns: ["device_id"])`; + const [cur, mean, std] = await Promise.all([ + influxGroupValues(` + from(bucket: "${bucket}") + |> range(start: -30m) + |> filter(fn: (r) => r["_measurement"] == "${measurement}") + |> filter(fn: (r) => r["_field"] == "${field}") + |> group(columns: ["device_id"]) + |> mean()`), + influxGroupValues(`${base}\n |> mean()`), + influxGroupValues(`${base}\n |> stddev()`), + ]); + return { cur, mean, std }; + }; + + // Client-count deviations (device dropped clients / unusual surge) + const cc = await metric('client_counts', 'total_clients'); + for (const [id, current] of cc.cur) { + const m = cc.mean.get(id), s = cc.std.get(id); + const dev = nameById.get(id); + if (!dev || m === undefined || s === undefined || s < 1) continue; + const z = (current - m) / s; + if (Math.abs(z) >= 2.5 && Math.abs(current - m) >= 5) { + items.push({ + sev: 'warn', category: 'anomaly', + title: `Client count on ${dev.name} is unusually ${z < 0 ? 'low' : 'high'}`, + body: `${Math.round(current)} clients now vs ~${Math.round(m)} typical for this hour (14-day baseline).`, + action: 'View device', path: `/devices/${dev.id}`, + }); + } + } + + // Sustained CPU deviations (only flag high — idle is not a problem) + const cpu = await metric('device_resources', 'cpu_load'); + for (const [id, current] of cpu.cur) { + const m = cpu.mean.get(id), s = cpu.std.get(id); + const dev = nameById.get(id); + if (!dev || m === undefined || s === undefined || s < 1) continue; + const z = (current - m) / s; + if (z >= 2.5 && current >= 50) { + items.push({ + sev: 'warn', category: 'anomaly', + title: `CPU on ${dev.name} is far above its baseline`, + body: `${Math.round(current)}% for the last 30m vs ~${Math.round(m)}% typical for this hour.`, + action: 'View device', path: `/devices/${dev.id}`, + }); + } + } + + // Error-log bursts (Postgres, hourly rate vs 7-day average) + const bursts = await query<{ device_id: number; name: string; cur: string; avg_hourly: string }>(` + WITH cur AS ( + SELECT device_id, COUNT(*) AS n FROM events + WHERE severity = 'error' AND event_time > NOW() - INTERVAL '1 hour' + GROUP BY device_id + ), hist AS ( + SELECT device_id, COUNT(*) / 168.0 AS avg_hourly FROM events + WHERE severity = 'error' AND event_time > NOW() - INTERVAL '7 days' + GROUP BY device_id + ) + SELECT c.device_id, d.name, c.n AS cur, COALESCE(h.avg_hourly, 0) AS avg_hourly + FROM cur c JOIN devices d ON d.id = c.device_id + LEFT JOIN hist h ON h.device_id = c.device_id + WHERE c.n >= 10 AND c.n > 5 * GREATEST(COALESCE(h.avg_hourly, 0), 0.5)`); + for (const b of bursts) { + items.push({ + sev: 'warn', category: 'anomaly', + title: `Error-log burst on ${b.name}`, + body: `${b.cur} error entries in the last hour (typically ~${Number(b.avg_hourly).toFixed(1)}/h).`, + action: 'Open Events', path: '/events', + }); + } + + return items; +} + // Recent activity merged from config snapshots, audit log, and events. async function buildActivity() { const [configs, audits, events] = await Promise.all([ diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index 7105aa6..c632ee8 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -846,4 +846,41 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => { }); }); +// ─── Rogue / neighbor AP detection ──────────────────────────────────────────── + +// GET /api/wireless/rf/rogue — classify latest AP scans against our own SSIDs/BSSIDs +router.get('/rf/rogue', async (_req: Request, res: Response) => { + const { classifyScans } = await import('../utils/rogueAp'); + + const [scans, radios, ifaceMacs] = await Promise.all([ + query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(` + SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data + FROM ap_scan_data a JOIN devices d ON d.id = a.device_id + ORDER BY a.device_id, a.scanned_at DESC`), + query<{ ssid: string | null; mac_address: string | null }>( + `SELECT ssid, mac_address FROM wireless_interfaces`), + query<{ mac_address: string }>( + `SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`), + ]); + + const ownSsids = new Set(radios.map(r => r.ssid || '').filter(Boolean)); + const ownBssids = new Set([ + ...radios.map(r => (r.mac_address || '').toLowerCase()).filter(Boolean), + ...ifaceMacs.map(r => r.mac_address.toLowerCase()), + ]); + + const records = scans.map(s => ({ + deviceName: s.device_name, + scannedAt: String(s.scanned_at), + networks: Array.isArray(s.data) ? s.data as import('../utils/rogueAp').ScannedNetwork[] : [], + })); + + const { rogues, neighbors } = classifyScans(records, ownSsids, ownBssids); + res.json({ + rogues, neighbors, + lastScanAt: scans.length ? scans.map(s => String(s.scanned_at)).sort().pop() : null, + scannedDevices: scans.length, + }); +}); + export default router; diff --git a/backend/src/services/AlertService.ts b/backend/src/services/AlertService.ts index 9136cd4..76ed186 100644 --- a/backend/src/services/AlertService.ts +++ b/backend/src/services/AlertService.ts @@ -97,6 +97,17 @@ export class AlertService { if (now < until) return; cooldownUntil.set(cooldownKey, now + cooldownMs); + // Outbound webhooks fan out on the same (rule-gated, cooldown-respecting) + // event stream, independent of whether any alert channels are configured. + void import('./WebhookService').then(({ webhookService }) => + webhookService.dispatch(eventType, { + message, + device_id: ctx.deviceId ?? null, + device_name: ctx.deviceName ?? null, + details: ctx.details ?? null, + }) + ).catch(() => {}); + // Get enabled channels const channels = await query( `SELECT * FROM alert_channels WHERE enabled = true ORDER BY id` diff --git a/backend/src/services/FirmwareOrchestrator.ts b/backend/src/services/FirmwareOrchestrator.ts new file mode 100644 index 0000000..35a67fd --- /dev/null +++ b/backend/src/services/FirmwareOrchestrator.ts @@ -0,0 +1,211 @@ +// Staged firmware rollout orchestrator. +// +// Executes a rollout wave-by-wave (wave 1 = canary), each device sequentially: +// pre-upgrade backup → install RouterOS update → wait through the reboot → +// verify it came back healthy on the new version → next device. +// A failure marks the device failed and (with halt_on_failure) stops the whole +// rollout so a bad build never reaches the rest of the fleet. One rollout runs +// at a time; a lightweight scheduler starts rollouts whose scheduled_at has +// arrived (pair with a maintenance window by scheduling inside it). + +import { query, queryOne } from '../config/database'; +import { DeviceCollector, DeviceRow } from './mikrotik/DeviceCollector'; +import { BackupService } from './BackupService'; + +const REBOOT_GRACE_MS = 25_000; // let the device actually go down +const REBOOT_POLL_MS = 15_000; // probe cadence while waiting +const REBOOT_TIMEOUT_MS = 12 * 60_000; // give slow flash writes room + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +interface RolloutRow { + id: number; name: string; status: string; + halt_on_failure: boolean; pre_backup: boolean; +} +interface RolloutDeviceRow { + id: number; rollout_id: number; device_id: number; wave: number; status: string; +} + +export class FirmwareOrchestrator { + private activeRolloutId: number | null = null; + private cancelRequested = false; + private schedulerTimer: ReturnType | null = null; + private backupService = new BackupService(); + + get running(): number | null { return this.activeRolloutId; } + + startScheduler(): void { + if (this.schedulerTimer) return; + this.schedulerTimer = setInterval(() => { + this.startDueRollouts().catch(e => console.error('[Firmware] scheduler error:', e)); + }, 60_000); + } + + stopScheduler(): void { + if (this.schedulerTimer) { clearInterval(this.schedulerTimer); this.schedulerTimer = null; } + } + + private async startDueRollouts(): Promise { + if (this.activeRolloutId) return; + const due = await queryOne<{ id: number }>( + `SELECT id FROM firmware_rollouts + WHERE status = 'pending' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW() + ORDER BY scheduled_at ASC LIMIT 1`); + if (due) await this.start(due.id).catch(e => console.error(`[Firmware] scheduled start of #${due.id} failed:`, e)); + } + + async start(rolloutId: number): Promise { + if (this.activeRolloutId) throw new Error(`Rollout #${this.activeRolloutId} is already running`); + const rollout = await queryOne(`SELECT * FROM firmware_rollouts WHERE id = $1`, [rolloutId]); + if (!rollout) throw new Error('Rollout not found'); + if (rollout.status !== 'pending') throw new Error(`Rollout is ${rollout.status} — only pending rollouts can start`); + + this.activeRolloutId = rolloutId; + this.cancelRequested = false; + await query(`UPDATE firmware_rollouts SET status='running', started_at=NOW() WHERE id=$1`, [rolloutId]); + + // Fire-and-forget the run loop; callers poll status via the API. + void this.run(rollout).catch(async (e) => { + console.error(`[Firmware] rollout #${rolloutId} crashed:`, e); + await query(`UPDATE firmware_rollouts SET status='failed', finished_at=NOW() WHERE id=$1`, [rolloutId]); + }).finally(() => { this.activeRolloutId = null; }); + } + + cancel(rolloutId: number): void { + if (this.activeRolloutId === rolloutId) this.cancelRequested = true; + } + + private async run(rollout: RolloutRow): Promise { + const items = await query( + `SELECT * FROM firmware_rollout_devices WHERE rollout_id=$1 ORDER BY wave ASC, id ASC`, + [rollout.id]); + + let halted = false; + for (const item of items) { + if (this.cancelRequested || halted) { + await query(`UPDATE firmware_rollout_devices SET status='skipped', + error=$2 WHERE id=$1 AND status='pending'`, + [item.id, this.cancelRequested ? 'Rollout cancelled' : 'Halted: earlier device failed']); + continue; + } + const ok = await this.upgradeDevice(rollout, item); + if (!ok && rollout.halt_on_failure) halted = true; + } + + const finalStatus = this.cancelRequested ? 'cancelled' : halted ? 'failed' : 'completed'; + await query(`UPDATE firmware_rollouts SET status=$2, finished_at=NOW() WHERE id=$1`, [rollout.id, finalStatus]); + console.log(`[Firmware] rollout #${rollout.id} ${finalStatus}`); + + if (finalStatus === 'completed' || finalStatus === 'failed') { + const counts = await queryOne<{ ok: string; failed: string }>( + `SELECT COUNT(*) FILTER (WHERE status='success')::text AS ok, + COUNT(*) FILTER (WHERE status='failed')::text AS failed + FROM firmware_rollout_devices WHERE rollout_id=$1`, [rollout.id]); + void import('./WebhookService').then(({ webhookService }) => + webhookService.dispatch(finalStatus === 'completed' ? 'rollout_completed' : 'rollout_failed', { + rollout_id: rollout.id, name: rollout.name, + succeeded: parseInt(counts?.ok || '0', 10), failed: parseInt(counts?.failed || '0', 10), + }) + ).catch(() => {}); + } + } + + private async setItem(id: number, fields: Record): Promise { + const keys = Object.keys(fields); + const sets = keys.map((k, i) => `${k}=$${i + 2}`).join(', '); + await query(`UPDATE firmware_rollout_devices SET ${sets} WHERE id=$1`, [id, ...keys.map(k => fields[k])]); + } + + private async upgradeDevice(rollout: RolloutRow, item: RolloutDeviceRow): Promise { + const device = await queryOne(`SELECT * FROM devices WHERE id=$1`, [item.device_id]); + if (!device) { + await this.setItem(item.id, { status: 'failed', error: 'Device no longer exists' }); + return false; + } + const fail = async (error: string) => { + console.error(`[Firmware] ${device.name}: ${error}`); + await this.setItem(item.id, { status: 'failed', error }); + await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]); + return false; + }; + + await query(`UPDATE firmware_rollout_devices SET started_at=NOW() WHERE id=$1`, [item.id]); + const fromVersion = (device.ros_version || '').trim(); + await this.setItem(item.id, { from_version: fromVersion || null }); + + // 1. Pre-upgrade backup + if (rollout.pre_backup) { + await this.setItem(item.id, { status: 'backing_up' }); + try { + await this.backupService.createBackup({ + id: device.id, name: device.name, ip_address: device.ip_address, + ssh_port: device.ssh_port ?? 22, ssh_username: device.ssh_username, + ssh_password_encrypted: device.ssh_password_encrypted, + api_username: device.api_username, api_password_encrypted: device.api_password_encrypted, + }, `Pre-upgrade backup (rollout "${rollout.name}")`, 'pre-upgrade'); + } catch (e) { + return fail(`Pre-upgrade backup failed: ${(e as Error).message}`); + } + } + + // 2. Kick off the install (device downloads, installs, and reboots itself) + await this.setItem(item.id, { status: 'upgrading' }); + const collector = new DeviceCollector(device); + try { + await collector.connect(); + const status = await collector.checkForUpdates(); + const installed = (status['installed-version'] || '').trim(); + const latest = (status['latest-version'] || '').trim(); + if (!latest || latest === installed) { + await this.setItem(item.id, { status: 'skipped', error: 'Already up to date', to_version: installed || null }); + await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]); + collector.disconnect(); + return true; + } + await collector.installUpdate(); + } catch (e) { + collector.disconnect(); + return fail(`Update install failed: ${(e as Error).message}`); + } + collector.disconnect(); + + // 3. Ride out the reboot + await this.setItem(item.id, { status: 'rebooting' }); + await sleep(REBOOT_GRACE_MS); + const deadline = Date.now() + REBOOT_TIMEOUT_MS; + let backOnline = false; + let newVersion = ''; + while (Date.now() < deadline) { + if (this.cancelRequested) break; + const probe = new DeviceCollector(device); + try { + await probe.connect(); + await this.setItem(item.id, { status: 'verifying' }); + const resource = await probe.getSystemResource(); + newVersion = (resource['version'] || '').split(' ')[0]; + probe.disconnect(); + backOnline = true; + break; + } catch { + probe.disconnect(); + await sleep(REBOOT_POLL_MS); + } + } + if (!backOnline) { + return fail(`Device did not come back online within ${Math.round(REBOOT_TIMEOUT_MS / 60000)} minutes after the upgrade — check it manually (a pre-upgrade backup ${rollout.pre_backup ? 'exists' : 'was NOT taken'})`); + } + + // 4. Verify the version actually moved + if (newVersion && fromVersion && newVersion === fromVersion) { + return fail(`Device rebooted but still reports ${newVersion} — the update did not apply`); + } + await this.setItem(item.id, { status: 'success', to_version: newVersion || null }); + await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]); + await query(`UPDATE devices SET ros_version=COALESCE(NULLIF($2,''), ros_version), firmware_update_available=FALSE, status='online', last_seen=NOW() WHERE id=$1`, + [device.id, newVersion]); + console.log(`[Firmware] ${device.name}: upgraded to ${newVersion || 'unknown'}`); + return true; + } +} + +export const firmwareOrchestrator = new FirmwareOrchestrator(); diff --git a/backend/src/services/ReportService.ts b/backend/src/services/ReportService.ts new file mode 100644 index 0000000..315324d --- /dev/null +++ b/backend/src/services/ReportService.ts @@ -0,0 +1,150 @@ +// Scheduled email reports — periodic fleet summaries (device health, alerts, +// top clients, updates, backups) delivered over the SMTP settings of the first +// enabled email alert channel. An hourly scheduler sends whatever is due. + +import nodemailer from 'nodemailer'; +import { query, queryOne } from '../config/database'; + +interface ScheduleRow { + id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly'; + recipients: string; enabled: boolean; next_run_at: string; +} + +const PERIOD_DAYS: Record = { daily: 1, weekly: 7, monthly: 30 }; + +export function computeNextRun(frequency: string, from: Date): Date { + const next = new Date(from); + if (frequency === 'daily') next.setDate(next.getDate() + 1); + else if (frequency === 'monthly') next.setMonth(next.getMonth() + 1); + else next.setDate(next.getDate() + 7); + return next; +} + +const fmtBytes = (b: number) => + b >= 1024 ** 4 ? `${(b / 1024 ** 4).toFixed(2)} TB` + : b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(2)} GB` + : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MB` + : `${(b / 1024).toFixed(1)} KB`; + +export class ReportService { + private timer: ReturnType | null = null; + + startScheduler(): void { + if (this.timer) return; + this.timer = setInterval(() => { + this.sendDue().catch(e => console.error('[Reports] scheduler error:', e)); + }, 60 * 60_000); + } + + stopScheduler(): void { + if (this.timer) { clearInterval(this.timer); this.timer = null; } + } + + private async sendDue(): Promise { + const due = await query( + `SELECT * FROM report_schedules WHERE enabled = TRUE AND next_run_at <= NOW()`); + for (const s of due) { + try { + await this.send(s); + await query( + `UPDATE report_schedules SET last_sent_at = NOW(), next_run_at = $2 WHERE id = $1`, + [s.id, computeNextRun(s.frequency, new Date())]); + } catch (e) { + console.error(`[Reports] "${s.name}" failed:`, (e as Error).message); + // push next_run_at forward anyway so a broken SMTP config doesn't retry hourly forever + await query(`UPDATE report_schedules SET next_run_at = $2 WHERE id = $1`, + [s.id, computeNextRun(s.frequency, new Date())]); + } + } + } + + async send(schedule: Pick): Promise { + const smtp = await this.smtpConfig(); + const html = await this.buildHtml(schedule.frequency); + const recipients = schedule.recipients.split(',').map(r => r.trim()).filter(Boolean); + if (recipients.length === 0) throw new Error('No recipients configured'); + + const transport = nodemailer.createTransport({ + host: smtp.host, port: smtp.port, secure: smtp.secure, + auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined, + }); + await transport.sendMail({ + from: smtp.from, + to: recipients.join(', '), + subject: `MikroTik Manager — ${schedule.frequency} network report`, + html, + }); + console.log(`[Reports] sent "${schedule.name}" to ${recipients.length} recipient(s)`); + } + + // SMTP settings come from the first enabled email alert channel + private async smtpConfig(): Promise<{ host: string; port: number; secure: boolean; user?: string; pass?: string; from: string }> { + const ch = await queryOne<{ config: Record }>( + `SELECT config FROM alert_channels WHERE type = 'email' AND enabled = TRUE ORDER BY id LIMIT 1`); + const cfg = ch?.config; + const host = cfg?.smtp_host as string | undefined; + if (!host) throw new Error('No enabled email alert channel — configure one under Settings → Alerting to send reports'); + return { + host, + port: (cfg?.smtp_port as number) || 587, + secure: !!cfg?.smtp_secure, + user: cfg?.smtp_user as string | undefined, + pass: cfg?.smtp_pass as string | undefined, + from: (cfg?.from_address as string) || (cfg?.smtp_user as string) || 'reports@mikrotik-manager', + }; + } + + private async buildHtml(frequency: string): Promise { + const days = PERIOD_DAYS[frequency] ?? 7; + const [devices, outages, alerts, topClients, backups] = await Promise.all([ + query<{ total: string; online: string; updates: string }>( + `SELECT COUNT(*)::text AS total, + COUNT(*) FILTER (WHERE status='online')::text AS online, + COUNT(*) FILTER (WHERE firmware_update_available)::text AS updates + FROM devices`), + query<{ n: string; secs: string }>( + `SELECT COUNT(*)::text AS n, COALESCE(SUM(COALESCE(duration_seconds,0)),0)::text AS secs + FROM device_availability WHERE went_offline_at > NOW() - ($1 || ' days')::interval`, [days]), + query<{ errors: string; warnings: string }>( + `SELECT COUNT(*) FILTER (WHERE severity='error')::text AS errors, + COUNT(*) FILTER (WHERE severity='warning')::text AS warnings + FROM events WHERE event_time > NOW() - ($1 || ' days')::interval`, [days]), + query<{ name: string; bytes: string }>( + `SELECT COALESCE(NULLIF(c.custom_name,''), NULLIF(c.hostname,''), ctd.mac_address) AS name, + SUM(ctd.upload_bytes + ctd.download_bytes)::text AS bytes + FROM client_traffic_daily ctd + LEFT JOIN clients c ON LOWER(c.mac_address) = ctd.mac_address + WHERE ctd.day > CURRENT_DATE - $1::int + GROUP BY 1 ORDER BY SUM(ctd.upload_bytes + ctd.download_bytes) DESC LIMIT 5`, [days]), + query<{ n: string }>( + `SELECT COUNT(*)::text AS n FROM backups WHERE created_at > NOW() - ($1 || ' days')::interval`, [days]), + ]); + + const d = devices[0]; + const outageMin = Math.round(parseInt(outages[0]?.secs || '0', 10) / 60); + const row = (label: string, value: string) => + `${label}${value}`; + + const clientRows = topClients.map((c, i) => + `${i + 1}. ${c.name}${fmtBytes(parseInt(c.bytes, 10))}`).join(''); + + return ` +
+

MikroTik Manager — ${frequency} report

+

Covering the last ${days} day${days !== 1 ? 's' : ''} · generated ${new Date().toUTCString()}

+ + ${row('Devices online', `${d?.online ?? 0} / ${d?.total ?? 0}`)} + ${row('Outages', `${outages[0]?.n ?? 0} (${outageMin} min total downtime)`)} + ${row('Error events', alerts[0]?.errors ?? '0')} + ${row('Warning events', alerts[0]?.warnings ?? '0')} + ${row('Firmware updates available', d?.updates ?? '0')} + ${row('Backups taken', backups[0]?.n ?? '0')} +
+ ${topClients.length ? `

Top clients by traffic

+ ${clientRows}
` : ''} +

Sent by MikroTik Manager scheduled reports.

+
`; + } +} + +export const reportService = new ReportService(); diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts new file mode 100644 index 0000000..5fa44c7 --- /dev/null +++ b/backend/src/services/WebhookService.ts @@ -0,0 +1,94 @@ +// Outbound webhooks — POSTs event payloads to subscribed URLs with an HMAC +// signature so receivers can verify authenticity. Fired from the alert +// pipeline (all alert event types) and the firmware orchestrator. + +import { createHmac } from 'crypto'; +import * as https from 'https'; +import * as http from 'http'; +import { URL } from 'url'; +import { query } from '../config/database'; + +export const WEBHOOK_EVENTS = [ + 'device_offline', 'device_online', 'log_error', 'log_warning', + 'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered', + 'firmware_update_available', 'config_drift', + 'rollout_completed', 'rollout_failed', +] as const; +export type WebhookEvent = typeof WEBHOOK_EVENTS[number] | 'test'; + +interface WebhookRow { + id: number; name: string; url: string; secret: string | null; + events: string[]; enabled: boolean; +} + +function postJson(urlStr: string, body: string, headers: Record): Promise { + return new Promise((resolve, reject) => { + let u: URL; + try { u = new URL(urlStr); } catch { reject(new Error('Invalid URL')); return; } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { reject(new Error('URL must be http(s)')); return; } + const lib = u.protocol === 'https:' ? https : http; + const req = lib.request(u, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers }, + timeout: 10_000, + }, (res) => { + res.resume(); // drain + resolve(res.statusCode ?? 0); + }); + req.on('timeout', () => { req.destroy(new Error('Timed out after 10s')); }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +export class WebhookService { + /** Fire an event to every enabled webhook subscribed to it (best-effort, non-blocking). */ + async dispatch(event: WebhookEvent, data: Record): Promise { + const hooks = await query( + `SELECT id, name, url, secret, events, enabled FROM webhooks + WHERE enabled = TRUE AND $1 = ANY(events)`, [event] + ).catch(() => [] as WebhookRow[]); + if (hooks.length === 0) return; + + const body = JSON.stringify({ + event, + timestamp: new Date().toISOString(), + data, + }); + + await Promise.allSettled(hooks.map(h => this.deliver(h, body))); + } + + /** Send a sample payload to one webhook regardless of its subscriptions. */ + async sendTest(id: number): Promise<{ status: number }> { + const rows = await query(`SELECT id, name, url, secret, events, enabled FROM webhooks WHERE id = $1`, [id]); + if (!rows[0]) throw new Error('Webhook not found'); + const body = JSON.stringify({ + event: 'test', + timestamp: new Date().toISOString(), + data: { message: 'MikroTik Manager webhook test — configuration looks good.' }, + }); + const status = await this.deliver(rows[0], body); + return { status }; + } + + private async deliver(hook: WebhookRow, body: string): Promise { + const headers: Record = { 'User-Agent': 'MikroTik-Manager-Webhook' }; + if (hook.secret) { + headers['X-MTM-Signature'] = 'sha256=' + createHmac('sha256', hook.secret).update(body).digest('hex'); + } + let status = 0; + try { + status = await postJson(hook.url, body, headers); + } catch (e) { + console.error(`[Webhook] "${hook.name}" delivery failed:`, (e as Error).message); + status = 0; + } + await query(`UPDATE webhooks SET last_status = $2, last_fired_at = NOW() WHERE id = $1`, [hook.id, status]) + .catch(() => {}); + return status; + } +} + +export const webhookService = new WebhookService(); diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 68907eb..70f946b 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -1580,6 +1580,10 @@ export class DeviceCollector { return this.client.execute('/interface/bridge/print').catch(() => []); } + async getInterfacesLive(): Promise[]> { + return this.client.execute('/interface/print').catch(() => []); + } + async getBridgePorts(): Promise[]> { return this.client.execute('/interface/bridge/port/print').catch(() => []); } @@ -1935,6 +1939,318 @@ export class DeviceCollector { await this.client.execute('/queue/simple/remove', { '.id': id }); } + // ─── Hotspot (Guest WiFi captive portal + vouchers) ───────────────────────── + + async getHotspotServers(): Promise[]> { + return this.client.execute('/ip/hotspot/print', { detail: '' }).catch(() => [] as Record[]); + } + + async setHotspotServerDisabled(id: string, disabled: boolean): Promise { + await this.client.execute('/ip/hotspot/set', { '.id': id, disabled: disabled ? 'yes' : 'no' }); + } + + async removeHotspotServer(id: string): Promise { + await this.client.execute('/ip/hotspot/remove', { '.id': id }); + } + + async getHotspotProfiles(): Promise[]> { + return this.client.execute('/ip/hotspot/profile/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addHotspotProfile(params: Record): Promise { + await this.client.execute('/ip/hotspot/profile/add', params); + } + + async getHotspotUserProfiles(): Promise[]> { + return this.client.execute('/ip/hotspot/user/profile/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addHotspotUserProfile(params: Record): Promise { + await this.client.execute('/ip/hotspot/user/profile/add', params); + } + + async getHotspotUsers(): Promise[]> { + return this.client.execute('/ip/hotspot/user/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addHotspotUser(params: Record): Promise { + await this.client.execute('/ip/hotspot/user/add', params); + } + + async removeHotspotUser(id: string): Promise { + await this.client.execute('/ip/hotspot/user/remove', { '.id': id }); + } + + async getHotspotActive(): Promise[]> { + return this.client.execute('/ip/hotspot/active/print', { detail: '' }).catch(() => [] as Record[]); + } + + async disconnectHotspotActive(id: string): Promise { + await this.client.execute('/ip/hotspot/active/remove', { '.id': id }); + } + + async getWalledGarden(): Promise[]> { + return this.client.execute('/ip/hotspot/walled-garden/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addWalledGardenEntry(dstHost: string, comment?: string): Promise { + const params: Record = { 'dst-host': dstHost, action: 'allow' }; + if (comment) params['comment'] = comment; + await this.client.execute('/ip/hotspot/walled-garden/add', params); + } + + async removeWalledGardenEntry(id: string): Promise { + await this.client.execute('/ip/hotspot/walled-garden/remove', { '.id': id }); + } + + /** + * Full guest-network orchestration. Optionally creates the guest SSID itself + * (a virtual AP on every physical radio) and segregates the traffic onto a + * VLAN, then runs the captive-portal chain on the resulting interface: + * + * [virtual APs] → [bridge/VLAN wiring] → pool → gw address → DHCP → + * hotspot profile → portal server → guest user profile → [masquerade NAT] + * + * Topologies: + * - ssid + vlanId: new SSIDs joined to the main bridge with PVID=vlanId, + * portal binds to a new /interface/vlan on that bridge. + * - ssid only: new SSIDs joined to a dedicated guest bridge, portal + * binds to that bridge (guests stay off the main LAN). + * - neither: portal binds to the provided existing interface. + */ + async setupGuestNetwork(opts: { + name: string; + gatewayCidr: string; + poolRange: string; + dnsName?: string; + rateLimit?: string; + interfaceName?: string; // existing-interface path + ssid?: { ssid: string; passphrase?: string }; + vlanId?: number; + masquerade?: boolean; + }): Promise<{ + server: string; profile: string; userProfile: string; pool: string; + targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; + warnings: string[]; + }> { + const { name, ssid, vlanId, masquerade } = opts; + const warnings: string[] = []; + const ssidInterfaces: string[] = []; + let targetInterface = opts.interfaceName || ''; + let vlanInterface: string | undefined; + + if (ssid) { + // 1. Create a virtual AP for the guest SSID on every physical radio + const pkg = await this.detectWifiPackage(); + if (pkg === 'none') throw new Error('No wireless package detected on this device — cannot create a guest SSID'); + const raw = pkg === 'wifi' + ? await this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) + : await this.client.execute('/interface/wireless/print').catch(() => [] as Record[]); + const physicals = raw.filter(r => !r['master-interface'] && r['name']); + if (physicals.length === 0) throw new Error('No physical radios found to attach the guest SSID to'); + + const existingBySsid = raw.filter(r => + (r['ssid'] || r['configuration.ssid'] || '') === ssid.ssid && r['master-interface']); + if (existingBySsid.length > 0) { + // Idempotency: SSID already exists — reuse it + for (const e of existingBySsid) ssidInterfaces.push(e['name']); + } else { + for (const radio of physicals) { + const ifaceName = await this.getNextInterfaceName(); + const params: Record = { + name: ifaceName, + 'master-interface': radio['name'], + ssid: ssid.ssid, + disabled: 'no', + }; + if (pkg !== 'wifi') params['mode'] = 'ap-bridge'; + if (ssid.passphrase) { + params['passphrase'] = ssid.passphrase; + params['authentication-types'] = 'wpa2-psk'; + } + await this.addWirelessInterface(params); + ssidInterfaces.push(ifaceName); + } + if (pkg !== 'wifi' && ssid.passphrase) { + warnings.push('Legacy wireless package: passphrase requires a security profile — the guest SSID was created OPEN. Assign a security profile manually if you need WPA2.'); + } + } + + // 2. Wire the new SSIDs into the L2 topology + const bridges = await this.getBridges(); + if (vlanId) { + const mainBridge = bridges[0]?.['name']; + if (!mainBridge) throw new Error('No bridge found on this device to attach the guest VLAN to'); + for (const iface of ssidInterfaces) { + await this.setInterfaceBridge(iface, mainBridge, vlanId); + await this.ensureVlanMembership(iface, vlanId); + } + // The bridge itself must be a tagged member so the L3 VLAN interface works + await this.ensureBridgeTaggedMember(mainBridge, vlanId); + // L3 VLAN interface the portal binds to + vlanInterface = `${name}-vlan${vlanId}`; + const vlans = await this.client.execute('/interface/vlan/print').catch(() => [] as Record[]); + const existing = vlans.find(v => v['interface'] === mainBridge && v['vlan-id'] === String(vlanId)); + if (existing) { + vlanInterface = existing['name']; + } else { + await this.client.execute('/interface/vlan/add', { + name: vlanInterface, 'vlan-id': String(vlanId), interface: mainBridge, + }); + } + targetInterface = vlanInterface; + if (bridges[0]?.['vlan-filtering'] !== 'true') { + warnings.push(`Bridge "${mainBridge}" has VLAN filtering disabled — guest VLAN ${vlanId} tagging won't isolate traffic until you enable it (Ports → bridge → VLAN filtering). Enabling it can interrupt management access, so review trunk/tagged config first.`); + } + } else { + // Dedicated guest bridge keeps guests off the main LAN + const guestBridge = `${name}-br`; + if (!bridges.some(b => b['name'] === guestBridge)) { + await this.client.execute('/interface/bridge/add', { name: guestBridge }); + } + for (const iface of ssidInterfaces) { + await this.setInterfaceBridge(iface, guestBridge); + } + targetInterface = guestBridge; + } + } + + if (!targetInterface) throw new Error('No target interface — provide interfaceName or ssid'); + + // 3. Captive-portal chain on the target interface + const chain = await this.setupHotspot({ + name, interfaceName: targetInterface, + gatewayCidr: opts.gatewayCidr, poolRange: opts.poolRange, + dnsName: opts.dnsName, rateLimit: opts.rateLimit, + }); + + // 4. Masquerade so guests can reach the internet + if (masquerade) { + const gwIp = opts.gatewayCidr.split('/')[0]; + const prefix = opts.gatewayCidr.split('/')[1] || '24'; + const network = `${this.networkAddressOf(gwIp, parseInt(prefix, 10))}/${prefix}`; + const natComment = `${name}-guest-masquerade`; + const natRules = await this.client.execute('/ip/firewall/nat/print').catch(() => [] as Record[]); + if (!natRules.some(r => (r['comment'] || '') === natComment)) { + await this.client.execute('/ip/firewall/nat/add', { + chain: 'srcnat', action: 'masquerade', 'src-address': network, comment: natComment, + }); + } + } + + return { ...chain, targetInterface, ssidInterfaces, vlanInterface, warnings }; + } + + /** Ensure the bridge itself is a tagged member of a VLAN (needed for L3 VLAN interfaces). */ + private async ensureBridgeTaggedMember(bridge: string, vlanId: number): Promise { + const rows = await this.client + .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) + .catch(() => [] as Record[]); + const entry = rows[0]; + if (entry?.['.id']) { + const tagged = (entry['tagged'] || '').split(',').map(s => s.trim()).filter(Boolean); + if (!tagged.includes(bridge)) { + await this.client.execute('/interface/bridge/vlan/set', { + '.id': entry['.id'], tagged: [...tagged, bridge].join(','), + }); + } + } else { + await this.client.execute('/interface/bridge/vlan/add', { + bridge, 'vlan-ids': String(vlanId), tagged: bridge, + }); + } + } + + /** + * Orchestrated guest-hotspot setup — the API equivalent of RouterOS's + * interactive `/ip hotspot setup` wizard. Creates (idempotently, keyed on the + * guest network name): IP pool → gateway address on the interface → DHCP + * server + network → hotspot profile → hotspot server → guest user profile. + * Returns the names it created so the route can report/rollback. + */ + async setupHotspot(opts: { + name: string; // logical name, e.g. "guest" → pool/profile/server names derive from it + interfaceName: string; // bridge/vlan/wifi interface to run the portal on + gatewayCidr: string; // e.g. "10.5.50.1/24" — address added to the interface + poolRange: string; // e.g. "10.5.50.10-10.5.50.254" + dnsName?: string; // portal hostname, e.g. "wifi.guest" + rateLimit?: string; // default guest speed, e.g. "10M/10M" (rx/tx) + }): Promise<{ server: string; profile: string; userProfile: string; pool: string }> { + const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = opts; + const poolName = `${name}-pool`; + const profileName = `${name}-profile`; + const userProfileName = `${name}-guest`; + const gwIp = gatewayCidr.split('/')[0]; + + // 1. IP pool + const pools = await this.client.execute('/ip/pool/print').catch(() => []); + if (!pools.some(p => p['name'] === poolName)) { + await this.client.execute('/ip/pool/add', { name: poolName, ranges: poolRange }); + } + + // 2. Gateway address on the interface + const addrs = await this.client.execute('/ip/address/print').catch(() => []); + if (!addrs.some(a => (a['address'] || '') === gatewayCidr && (a['interface'] || '') === interfaceName)) { + await this.client.execute('/ip/address/add', { address: gatewayCidr, interface: interfaceName }); + } + + // 3. DHCP server + network + const dhcpServers = await this.client.execute('/ip/dhcp-server/print').catch(() => []); + if (!dhcpServers.some(s => s['name'] === `${name}-dhcp`)) { + await this.client.execute('/ip/dhcp-server/add', { + name: `${name}-dhcp`, interface: interfaceName, 'address-pool': poolName, disabled: 'no', + }); + } + const prefix = gatewayCidr.split('/')[1] || '24'; + const network = this.networkAddressOf(gwIp, parseInt(prefix, 10)); + const dhcpNets = await this.client.execute('/ip/dhcp-server/network/print').catch(() => []); + if (!dhcpNets.some(n => (n['address'] || '') === `${network}/${prefix}`)) { + await this.client.execute('/ip/dhcp-server/network/add', { + address: `${network}/${prefix}`, gateway: gwIp, 'dns-server': gwIp, + }); + } + + // 4. Hotspot profile + const profiles = await this.getHotspotProfiles(); + if (!profiles.some(p => p['name'] === profileName)) { + const params: Record = { + name: profileName, 'hotspot-address': gwIp, 'login-by': 'http-chap,http-pap', + }; + if (dnsName) params['dns-name'] = dnsName; + await this.addHotspotProfile(params); + } + + // 5. Hotspot server + const servers = await this.getHotspotServers(); + if (!servers.some(s => s['name'] === name)) { + await this.client.execute('/ip/hotspot/add', { + name, interface: interfaceName, 'address-pool': poolName, profile: profileName, disabled: 'no', + }); + } + + // 6. Guest user profile (bandwidth cap + no inter-guest traffic) + const userProfiles = await this.getHotspotUserProfiles(); + if (!userProfiles.some(p => p['name'] === userProfileName)) { + const params: Record = { + name: userProfileName, 'shared-users': '1', + }; + if (rateLimit) params['rate-limit'] = rateLimit; + await this.addHotspotUserProfile(params); + } + + return { server: name, profile: profileName, userProfile: userProfileName, pool: poolName }; + } + + /** Compute the network address for an IPv4 + prefix (e.g. 10.5.50.1/24 → 10.5.50.0). */ + private networkAddressOf(ip: string, prefix: number): string { + const parts = ip.split('.').map(n => parseInt(n, 10)); + if (parts.length !== 4 || parts.some(isNaN)) return ip; + const addr = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + const net = (addr & mask) >>> 0; + return [net >>> 24, (net >>> 16) & 255, (net >>> 8) & 255, net & 255].join('.'); + } + // ─── IP Services (for security-posture audit + hardening) ─────────────────── async getServices(): Promise[]> { diff --git a/backend/src/services/mikrotik/RouterOSClient.ts b/backend/src/services/mikrotik/RouterOSClient.ts index 87e1b66..f1757dc 100644 --- a/backend/src/services/mikrotik/RouterOSClient.ts +++ b/backend/src/services/mikrotik/RouterOSClient.ts @@ -1,4 +1,5 @@ import * as net from 'net'; +import * as tls from 'tls'; import * as crypto from 'crypto'; import { EventEmitter } from 'events'; @@ -34,15 +35,21 @@ export class RouterOSClient extends EventEmitter { private tagCounter = 0; + private readonly useTls: boolean; + constructor( private readonly host: string, private readonly port: number = 8728, private readonly username: string, private readonly password: string, private readonly connectTimeoutMs: number = 15000, - private readonly readTimeoutMs: number = 30000 + private readonly readTimeoutMs: number = 30000, + useTls?: boolean ) { super(); + // api-ssl runs on 8729 by RouterOS convention (the same heuristic the + // security-posture code uses); callers can override explicitly. + this.useTls = useTls ?? port === 8729; } async connect(): Promise { @@ -54,9 +61,7 @@ export class RouterOSClient extends EventEmitter { this.socket?.destroy(); }, this.connectTimeoutMs); - this.socket = net.createConnection({ host: this.host, port: this.port }); - - this.socket.on('connect', async () => { + const onReady = async () => { clearTimeout(timer); try { await this.authenticate(); @@ -67,7 +72,21 @@ export class RouterOSClient extends EventEmitter { this.socket?.destroy(); reject(err); } - }); + }; + + if (this.useTls) { + // api-ssl: RouterOS devices ship self-signed certificates by default, + // so certificate verification is intentionally disabled — the same + // trust model as the plaintext API this replaces, but with the + // credentials and session encrypted in transit. + this.socket = tls.connect( + { host: this.host, port: this.port, rejectUnauthorized: false }, + () => { void onReady(); } + ); + } else { + this.socket = net.createConnection({ host: this.host, port: this.port }); + this.socket.on('connect', () => { void onReady(); }); + } this.socket.on('data', (data: Buffer) => { this.buffer = Buffer.concat([this.buffer, data]); diff --git a/backend/src/utils/__tests__/clientFingerprint.test.ts b/backend/src/utils/__tests__/clientFingerprint.test.ts new file mode 100644 index 0000000..1a24878 --- /dev/null +++ b/backend/src/utils/__tests__/clientFingerprint.test.ts @@ -0,0 +1,37 @@ +import { fingerprintClient } from '../clientFingerprint'; + +describe('fingerprintClient', () => { + it('classifies by vendor OUI', () => { + expect(fingerprintClient({ vendor: 'Ubiquiti Inc' })).toBe('network'); + expect(fingerprintClient({ vendor: 'Routerboard.com' })).toBe('network'); + expect(fingerprintClient({ vendor: 'Proxmox Server Solutions GmbH' })).toBe('server'); + expect(fingerprintClient({ vendor: 'Espressif Inc.' })).toBe('iot'); + expect(fingerprintClient({ vendor: 'Walter Kidde Portable Equipment, Inc.' })).toBe('smart-home'); + expect(fingerprintClient({ vendor: 'Wyze Labs Inc' })).toBe('camera'); + expect(fingerprintClient({ vendor: 'Reolink Innovation Limited' })).toBe('camera'); + expect(fingerprintClient({ vendor: 'Amazon Technologies Inc.' })).toBe('voice-assistant'); + expect(fingerprintClient({ vendor: 'Nanoleaf' })).toBe('smart-home'); + expect(fingerprintClient({ vendor: 'Super Micro Computer, Inc.' })).toBe('server'); + expect(fingerprintClient({ vendor: 'Dell Inc.' })).toBe('computer'); + }); + + it('hostname beats vendor (device identity > NIC maker)', () => { + // LG Innotek NIC inside a webOS TV + expect(fingerprintClient({ vendor: 'LG Innotek', hostname: 'LGwebOSTV.tracelength.home' })).toBe('tv'); + // Onkyo receiver with a LinkSprite NIC-alike hostname pattern + expect(fingerprintClient({ vendor: 'Onkyo Technology K.K.', hostname: 'Onkyo-TX-NR575.home' })).toBe('media'); + // Apple defaults to phone, but a MacBook hostname reclassifies + expect(fingerprintClient({ vendor: 'Apple, Inc.', hostname: 'ricks-macbook-pro' })).toBe('computer'); + expect(fingerprintClient({ vendor: 'Apple, Inc.', hostname: 'Ricks-iPhone' })).toBe('phone'); + }); + + it('uses the custom name when hostname is missing', () => { + expect(fingerprintClient({ vendor: 'IEEE Registration Authority', custom_name: 'Garage Camera 2' })).toBe('camera'); + }); + + it('stays unknown rather than guessing', () => { + expect(fingerprintClient({ vendor: 'Some Unheard Of Co' })).toBe('unknown'); + expect(fingerprintClient({})).toBe('unknown'); + expect(fingerprintClient({ vendor: null, hostname: null })).toBe('unknown'); + }); +}); diff --git a/backend/src/utils/__tests__/rogueAp.test.ts b/backend/src/utils/__tests__/rogueAp.test.ts new file mode 100644 index 0000000..49934d6 --- /dev/null +++ b/backend/src/utils/__tests__/rogueAp.test.ts @@ -0,0 +1,54 @@ +import { classifyScans, ScanRecord } from '../rogueAp'; + +const scans: ScanRecord[] = [{ + deviceName: 'wAP ax', + scannedAt: '2026-07-01T12:00:00Z', + networks: [ + { ssid: 'TestNet', entries: [ + { bssid: 'D0:EA:11:0A:DE:78', signal: -30, band: '2.4 GHz' }, // our own radio + { bssid: 'AA:BB:CC:DD:EE:01', signal: -55, band: '2.4 GHz', vendor: 'EvilCo' }, // evil twin! + ]}, + { ssid: 'NeighborNet', security: 'WPA2', entries: [ + { bssid: 'AA:BB:CC:DD:EE:02', signal: -70, band: '5 GHz', vendor: 'Netgear' }, + ]}, + { ssid: '', hidden: true, entries: [ + { bssid: 'AA:BB:CC:DD:EE:03', signal: -80, band: '5 GHz' }, + ]}, + ], +}, { + deviceName: '2GT-NW-100G', + scannedAt: '2026-07-01T12:05:00Z', + networks: [ + // Same evil twin seen stronger from another AP — should dedup, keep strongest + { ssid: 'TestNet', entries: [{ bssid: 'AA:BB:CC:DD:EE:01', signal: -48, band: '2.4 GHz', vendor: 'EvilCo' }] }, + ], +}]; + +const ownSsids = new Set(['TestNet', 'IoT']); +const ownBssids = new Set(['d0:ea:11:0a:de:78', 'd0:ea:11:0a:de:79']); + +describe('classifyScans', () => { + const { rogues, neighbors } = classifyScans(scans, ownSsids, ownBssids); + + it('flags a foreign BSSID broadcasting our SSID as rogue (evil twin)', () => { + expect(rogues).toHaveLength(1); + expect(rogues[0].bssid).toBe('aa:bb:cc:dd:ee:01'); + expect(rogues[0].ssid).toBe('TestNet'); + }); + + it('dedups across scanning devices, keeping the strongest sighting', () => { + expect(rogues[0].signal).toBe(-48); + expect(rogues[0].seenBy).toBe('2GT-NW-100G'); + }); + + it('ignores our own radios entirely', () => { + const all = [...rogues, ...neighbors].map(x => x.bssid); + expect(all).not.toContain('d0:ea:11:0a:de:78'); + }); + + it('classifies foreign SSIDs as neighbors, hidden ones labelled', () => { + expect(neighbors).toHaveLength(2); + expect(neighbors[0].ssid).toBe('NeighborNet'); // stronger first + expect(neighbors[1].ssid).toBe('(hidden)'); + }); +}); diff --git a/backend/src/utils/clientFingerprint.ts b/backend/src/utils/clientFingerprint.ts new file mode 100644 index 0000000..dd1adc6 --- /dev/null +++ b/backend/src/utils/clientFingerprint.ts @@ -0,0 +1,83 @@ +// Client device fingerprinting — classifies a client into a device category +// from its OUI vendor string, hostname, and connection type. Rule-based and +// deliberately conservative: hostname patterns (most specific) win over vendor +// keywords; unknown stays unknown rather than guessing. + +export const DEVICE_CATEGORIES = [ + 'network', // routers, switches, APs + 'server', // hypervisors, NAS, home-automation hubs + 'computer', // laptops/desktops + 'phone', // phones & tablets + 'tv', // TVs, streaming boxes + 'media', // AV receivers, speakers + 'camera', // IP cameras, doorbells + 'printer', + 'game-console', + 'voice-assistant', + 'smart-home', // thermostats, lights, plugs, sleep tech, safety sensors + 'iot', // generic embedded (ESP32 etc.) + 'unknown', +] as const; + +export type DeviceCategory = typeof DEVICE_CATEGORIES[number]; + +interface Rule { pattern: RegExp; category: DeviceCategory } + +// Hostname rules — checked first (a hostname names the actual device, while a +// NIC vendor names whoever made the radio). +const HOSTNAME_RULES: Rule[] = [ + { pattern: /iphone|ipad|android|pixel|galaxy|oneplus|redmi/i, category: 'phone' }, + { pattern: /macbook|imac|mac-?mini|thinkpad|laptop|desktop|-pc\b|win(dows|1[01])|surface/i, category: 'computer' }, + { pattern: /appletv|apple-tv|chromecast|roku|firetv|fire-tv|shield|webos|bravia|-tv\b|tv\./i, category: 'tv' }, + { pattern: /\bcam(era)?s?\b|cam[-._\d]|doorbell|reolink|wyze|ring-|arlo/i, category: 'camera' }, + { pattern: /printer|officejet|laserjet|deskjet|epson|brother[-_]/i, category: 'printer' }, + { pattern: /ps[45]|playstation|xbox|nintendo/i, category: 'game-console' }, + { pattern: /echo[-_]|alexa|homepod|google-?home|nest-?mini|nest-?hub/i, category: 'voice-assistant' }, + { pattern: /sonos|denon|onkyo|yamaha|receiver|soundbar|onelink|speaker/i, category: 'media' }, + { pattern: /thermostat|hue|lifx|nanoleaf|plug|switchbot|tasmota|shelly|sensor/i, category: 'smart-home' }, + { pattern: /esp[-_]?\d|esp32|esp8266/i, category: 'iot' }, + { pattern: /proxmox|truenas|synology|unraid|nas\b|server|homeassistant|home-assistant|hassio/i, category: 'server' }, + { pattern: /router|switch|gateway|firewall|-ap\b|accesspoint|unifi|mikrotik/i, category: 'network' }, +]; + +// Vendor (OUI) rules — fallback when the hostname doesn't identify the device. +const VENDOR_RULES: Rule[] = [ + { pattern: /ubiquiti|routerboard|mikrotik|cisco|netgear|aruba|juniper|zyxel|d-link|fortinet|ruckus|extreme networks/i, category: 'network' }, + { pattern: /proxmox|vmware|super micro|supermicro|qnap|synology|nutanix|nabu casa/i, category: 'server' }, + { pattern: /reolink|wyze|hikvision|dahua|axis communications|ring llc|arlo|eufy|ubnt.*cam/i, category: 'camera' }, + { pattern: /espressif|tuya|sonoff|itead|shelly|allterco|particle|nordic semi/i, category: 'iot' }, + { pattern: /walter kidde|first alert|nest labs|ecobee|honeywell|nanoleaf|signify|philips lighting|lifx|eight sleep|lutron|leviton|wemo|belkin/i, category: 'smart-home' }, + { pattern: /lg innotek|lg electronics|vizio|tcl|hisense|roku|sceptre/i, category: 'tv' }, + { pattern: /onkyo|sonos|denon|yamaha|bose|harman|bang & olufsen|linksprite/i, category: 'media' }, + { pattern: /sony interactive|nintendo|microsoft.*xbox/i, category: 'game-console' }, + { pattern: /hewlett[- ]packard|\bhp inc|canon|epson|brother industries|lexmark|xerox/i, category: 'printer' }, + { pattern: /raspberry pi/i, category: 'server' }, + { pattern: /intel corporate|asustek|micro-star|gigabyte|dell inc|lenovo|framework|hon hai|azurewave|realtek|liteon|clevo/i, category: 'computer' }, + { pattern: /samsung electro|xiaomi|huawei|oneplus|oppo|vivo mobile|motorola mobility/i, category: 'phone' }, + { pattern: /^apple/i, category: 'phone' }, // most Apple clients on a LAN are iPhones/iPads; hostname rules catch Macs + { pattern: /amazon technologies/i, category: 'voice-assistant' }, // echoes & fire devices dominate Amazon OUIs + { pattern: /google/i, category: 'smart-home' }, // nest/chromecast; hostname rules catch specific types + { pattern: /texas instruments/i, category: 'iot' }, +]; + +export function fingerprintClient(input: { + vendor?: string | null; + hostname?: string | null; + custom_name?: string | null; +}): DeviceCategory { + // Hostname first (also consider the user-assigned name — users name things truthfully) + for (const source of [input.hostname, input.custom_name]) { + const h = (source || '').trim(); + if (!h) continue; + for (const rule of HOSTNAME_RULES) { + if (rule.pattern.test(h)) return rule.category; + } + } + const v = (input.vendor || '').trim(); + if (v) { + for (const rule of VENDOR_RULES) { + if (rule.pattern.test(v)) return rule.category; + } + } + return 'unknown'; +} diff --git a/backend/src/utils/rogueAp.ts b/backend/src/utils/rogueAp.ts new file mode 100644 index 0000000..df4355d --- /dev/null +++ b/backend/src/utils/rogueAp.ts @@ -0,0 +1,71 @@ +// Rogue / neighbor AP classification over stored AP-scan results. +// +// A scanned BSSID falls into one of three buckets: +// - ours: the BSSID belongs to a managed radio → ignored +// - ROGUE: broadcasts one of OUR SSIDs from a BSSID we don't own — an evil +// twin / SSID spoof, the classic WiFi attack Mist/UniFi alert on +// - neighbor: any other foreign AP — inventory, ranked by signal + +export interface ScannedEntry { + bssid: string; vendor?: string; signal: number; freq?: number; + band?: string; channel_width?: string; +} +export interface ScannedNetwork { + ssid: string; security?: string; hidden?: boolean; entries: ScannedEntry[]; +} +export interface ScanRecord { + deviceName: string; + scannedAt: string; + networks: ScannedNetwork[]; +} + +export interface RogueAp { + ssid: string; bssid: string; vendor: string; signal: number; band: string; + seenBy: string; scannedAt: string; +} +export interface NeighborAp { + ssid: string; bssid: string; vendor: string; signal: number; band: string; + security: string; seenBy: string; scannedAt: string; +} + +export function classifyScans( + scans: ScanRecord[], + ownSsids: Set, // SSIDs our managed radios broadcast + ownBssids: Set, // every MAC we own (radio + interface MACs, lowercase) +): { rogues: RogueAp[]; neighbors: NeighborAp[] } { + const rogueByBssid = new Map(); + const neighborByBssid = new Map(); + + for (const scan of scans) { + for (const net of scan.networks) { + for (const e of net.entries) { + const bssid = (e.bssid || '').toLowerCase(); + if (!bssid || ownBssids.has(bssid)) continue; // our own radios + + if (net.ssid && ownSsids.has(net.ssid)) { + const prev = rogueByBssid.get(bssid); + if (!prev || e.signal > prev.signal) { + rogueByBssid.set(bssid, { + ssid: net.ssid, bssid, vendor: e.vendor || '', signal: e.signal, + band: e.band || '', seenBy: scan.deviceName, scannedAt: scan.scannedAt, + }); + } + } else { + const prev = neighborByBssid.get(bssid); + if (!prev || e.signal > prev.signal) { + neighborByBssid.set(bssid, { + ssid: net.ssid || '(hidden)', bssid, vendor: e.vendor || '', signal: e.signal, + band: e.band || '', security: net.security || '', + seenBy: scan.deviceName, scannedAt: scan.scannedAt, + }); + } + } + } + } + } + + return { + rogues: [...rogueByBssid.values()].sort((a, b) => b.signal - a.signal), + neighbors: [...neighborByBssid.values()].sort((a, b) => b.signal - a.signal), + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a6842f2..b9dddcd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "dependencies": { "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index 30ac35a..e4097a7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.2-beta", + "version": "0.16.3-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11c4493..ae63220 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ClientsPage from './pages/ClientsPage'; import EventsPage from './pages/EventsPage'; import TopologyPage from './pages/TopologyPage'; import BackupsPage from './pages/BackupsPage'; +import FirmwarePage from './pages/FirmwarePage'; import SettingsPage from './pages/SettingsPage'; import SwitchesOverviewPage from './pages/SwitchesOverviewPage'; import SwitchesSettingsPage from './pages/SwitchesSettingsPage'; @@ -17,6 +18,7 @@ import RouterSettingsPage from './pages/RouterSettingsPage'; import WirelessPage from './pages/WirelessPage'; import WirelessSettingsPage from './pages/WirelessSettingsPage'; import WirelessClientsPage from './pages/WirelessClientsPage'; +import GuestWifiPage from './pages/GuestWifiPage'; import ClientDetailPage from './pages/ClientDetailPage'; import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage'; import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage'; @@ -56,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -64,6 +67,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1e44cf1..008461a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive, Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi, - Server, Globe, Clock, Shield, FileText, Activity, BarChart3, + Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle, } from 'lucide-react'; import clsx from 'clsx'; import { APP_VERSION } from '../../version'; @@ -19,6 +19,7 @@ const navItems = [ { to: '/events', icon: Bell, label: 'Events' }, { to: '/topology', icon: GitBranch, label: 'Topology' }, { to: '/backups', icon: HardDrive, label: 'Backups' }, + { to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' }, ]; const switchSubItems = [ @@ -34,6 +35,7 @@ const routerSubItems = [ const wirelessSubItems = [ { to: '/wireless', icon: Wifi, label: 'Overview' }, { to: '/wireless/clients', icon: Users, label: 'Clients' }, + { to: '/wireless/guest', icon: Ticket, label: 'Guest WiFi' }, { to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' }, ]; diff --git a/frontend/src/components/settings/AutomationSettings.tsx b/frontend/src/components/settings/AutomationSettings.tsx new file mode 100644 index 0000000..2fe394c --- /dev/null +++ b/frontend/src/components/settings/AutomationSettings.tsx @@ -0,0 +1,267 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + KeyRound, Webhook as WebhookIcon, Mail, Plus, Trash2, Copy, Check, Send, + RefreshCw, AlertTriangle, Power, X, +} from 'lucide-react'; +import clsx from 'clsx'; +import { automationApi } from '../../services/api'; +import { formatDistanceToNow } from 'date-fns'; + +const EVENT_LABEL: Record = { + device_offline: 'Device offline', device_online: 'Device online', + log_error: 'Log error', log_warning: 'Log warning', + high_cpu: 'High CPU', high_memory: 'High memory', cert_expiry: 'Cert expiry', + device_discovered: 'Device discovered', firmware_update_available: 'Firmware update', + config_drift: 'Config drift', rollout_completed: 'Rollout completed', rollout_failed: 'Rollout failed', +}; + +// ─── API tokens ──────────────────────────────────────────────────────────────── + +function TokensSection({ isAdmin }: { isAdmin: boolean }) { + const qc = useQueryClient(); + const [name, setName] = useState(''); + const [scope, setScope] = useState<'read' | 'write'>('read'); + const [expires, setExpires] = useState(''); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + + const { data: tokens = [] } = useQuery({ + queryKey: ['api-tokens'], + queryFn: () => automationApi.listTokens().then(r => r.data), + enabled: isAdmin, + }); + const create = useMutation({ + mutationFn: () => automationApi.createToken({ name: name.trim(), scope, expires_days: expires ? parseInt(expires, 10) : undefined }), + onSuccess: (r) => { setNewToken(r.data.token); setName(''); setExpires(''); qc.invalidateQueries({ queryKey: ['api-tokens'] }); }, + }); + const del = useMutation({ mutationFn: (id: number) => automationApi.deleteToken(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['api-tokens'] }) }); + + if (!isAdmin) { + return
API tokens can only be managed by administrators.
; + } + + return ( +
+
+ +

API Tokens

+ for scripting & IaC — send as Authorization: Bearer mtm_… +
+ +
+ {/* Newly created token (shown once) */} + {newToken && ( +
+
+ Token created — copy it now, it won't be shown again +
+
+ {newToken} + + +
+
+ )} + + {/* Create form */} +
+
setName(e.target.value)} placeholder="ci-pipeline" />
+
+ +
+
setExpires(e.target.value)} placeholder="never" />
+ +
+ + {/* List */} + {tokens.length > 0 && ( +
+ {tokens.map(t => ( +
+ {t.prefix}… + {t.name} + {t.scope} + + {t.last_used_at ? `used ${formatDistanceToNow(new Date(t.last_used_at))} ago` : 'never used'} + {t.expires_at ? ` · expires ${formatDistanceToNow(new Date(t.expires_at), { addSuffix: true })}` : ''} + + +
+ ))} +
+ )} +

Write tokens map to Operator privileges; no token can perform admin actions or manage other tokens.

+
+
+ ); +} + +// ─── Webhooks ────────────────────────────────────────────────────────────────── + +function WebhooksSection() { + const qc = useQueryClient(); + const [form, setForm] = useState({ name: '', url: '', secret: '', events: new Set() }); + const [testResult, setTestResult] = useState>({}); + + const { data } = useQuery({ queryKey: ['webhooks'], queryFn: () => automationApi.listWebhooks().then(r => r.data) }); + const webhooks = data?.webhooks ?? []; + const events = data?.availableEvents ?? []; + + const create = useMutation({ + mutationFn: () => automationApi.createWebhook({ name: form.name.trim(), url: form.url.trim(), secret: form.secret.trim() || undefined, events: [...form.events] }), + onSuccess: () => { setForm({ name: '', url: '', secret: '', events: new Set() }); qc.invalidateQueries({ queryKey: ['webhooks'] }); }, + }); + const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateWebhook(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) }); + const del = useMutation({ mutationFn: (id: number) => automationApi.deleteWebhook(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) }); + const test = useMutation({ + mutationFn: (id: number) => automationApi.testWebhook(id), + onSuccess: (r, id) => { setTestResult(p => ({ ...p, [id]: r.data.ok ? `OK (${r.data.status})` : `HTTP ${r.data.status}` })); setTimeout(() => setTestResult(p => { const n = { ...p }; delete n[id]; return n; }), 5000); qc.invalidateQueries({ queryKey: ['webhooks'] }); }, + onError: (e: unknown, id) => setTestResult(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'failed' })), + }); + + const toggleEvent = (e: string) => setForm(f => { + const s = new Set(f.events); + if (s.has(e)) s.delete(e); else s.add(e); + return { ...f, events: s }; + }); + + return ( +
+
+ +

Outbound Webhooks

+ POST JSON on events · HMAC-signed with X-MTM-Signature +
+ +
+ {/* existing */} + {webhooks.map(h => ( +
+
+ + {h.name} + {h.has_secret && signed} + {h.last_status != null && = 200 && h.last_status < 300 ? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400')}>last: {h.last_status || 'err'}} +
+ {testResult[h.id] && {testResult[h.id]}} + + + +
+
+
{h.url}
+
+ {h.events.map(e => {EVENT_LABEL[e] || e})} +
+
+ ))} + + {/* create */} +
+
+ setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. Slack, PagerDuty)" /> + setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://hooks.example.com/…" /> +
+ setForm(f => ({ ...f, secret: e.target.value }))} placeholder="Signing secret (optional, recommended)" /> +
+ {events.map(e => ( + + ))} +
+
+ +
+ {create.isError &&

{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}

} +
+
+
+ ); +} + +// ─── Scheduled reports ─────────────────────────────────────────────────────────── + +function ReportsSection() { + const qc = useQueryClient(); + const [form, setForm] = useState({ name: '', frequency: 'weekly', recipients: '' }); + const [sendMsg, setSendMsg] = useState>({}); + + const { data: reports = [] } = useQuery({ queryKey: ['report-schedules'], queryFn: () => automationApi.listReports().then(r => r.data) }); + const create = useMutation({ + mutationFn: () => automationApi.createReport(form), + onSuccess: () => { setForm({ name: '', frequency: 'weekly', recipients: '' }); qc.invalidateQueries({ queryKey: ['report-schedules'] }); }, + }); + const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateReport(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) }); + const del = useMutation({ mutationFn: (id: number) => automationApi.deleteReport(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) }); + const sendNow = useMutation({ + mutationFn: (id: number) => automationApi.sendReportNow(id), + onSuccess: (_r, id) => { setSendMsg(p => ({ ...p, [id]: 'Sent!' })); setTimeout(() => setSendMsg(p => { const n = { ...p }; delete n[id]; return n; }), 4000); qc.invalidateQueries({ queryKey: ['report-schedules'] }); }, + onError: (e: unknown, id) => setSendMsg(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Send failed' })), + }); + + return ( +
+
+ +

Scheduled Reports

+ emailed fleet summaries — uses your Alerting SMTP settings +
+ +
+ {reports.map(r => ( +
+ +
+
{r.name} · {r.frequency}
+
{r.recipients}
+
+ + {r.last_sent_at ? `last ${formatDistanceToNow(new Date(r.last_sent_at))} ago` : `next ${formatDistanceToNow(new Date(r.next_run_at), { addSuffix: true })}`} + +
+ {sendMsg[r.id] && {sendMsg[r.id]}} + + + +
+
+ ))} + +
+
setForm(f => ({ ...f, name: e.target.value }))} placeholder="Weekly summary" />
+
+ +
+
setForm(f => ({ ...f, recipients: e.target.value }))} placeholder="ops@company.com, noc@company.com" />
+ + {create.isError &&

{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}

} +
+
+
+ ); +} + +export default function AutomationSettings({ isAdmin }: { isAdmin: boolean }) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/components/wireless/RogueApsCard.tsx b/frontend/src/components/wireless/RogueApsCard.tsx new file mode 100644 index 0000000..87d6c1b --- /dev/null +++ b/frontend/src/components/wireless/RogueApsCard.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ShieldAlert, Radio, ChevronDown, ChevronRight } from 'lucide-react'; +import clsx from 'clsx'; +import { wirelessApi } from '../../services/api'; +import { formatDistanceToNow } from 'date-fns'; + +function signalCls(dbm: number): string { + if (dbm >= -55) return 'text-green-600 dark:text-green-400'; + if (dbm >= -70) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-gray-400 dark:text-slate-500'; +} + +export default function RogueApsCard() { + const [showNeighbors, setShowNeighbors] = useState(false); + const { data } = useQuery({ + queryKey: ['rogue-aps'], + queryFn: () => wirelessApi.getRogueAps().then(r => r.data), + refetchInterval: 5 * 60_000, + }); + + const rogues = data?.rogues ?? []; + const neighbors = data?.neighbors ?? []; + const hasScans = (data?.scannedDevices ?? 0) > 0; + + return ( +
+
+ 0 ? 'text-red-500' : 'text-emerald-500')} /> +

Rogue & Neighbor APs

+ + {data?.lastScanAt + ? `last scan ${formatDistanceToNow(new Date(data.lastScanAt))} ago · ${data.scannedDevices} AP${data.scannedDevices !== 1 ? 's' : ''}` + : 'no scans yet'} + +
+ + {!hasScans ? ( +
+ No AP scan data yet. Run a Nearby Access Points scan from a device's Radios tab + (or enable scheduled scans in Wireless Settings) to detect evil twins and map neighboring networks. +
+ ) : ( +
+ {/* Rogues */} + {rogues.length === 0 ? ( +
+ + No rogue APs — nothing is spoofing your SSIDs. +
+ ) : ( +
+ {rogues.map(r => ( +
+ +
+
+ Foreign AP broadcasting "{r.ssid}" — possible evil twin +
+
+ {r.bssid}{r.vendor ? ` (${r.vendor})` : ''} · {r.band} · seen by {r.seenBy} +
+
+ {r.signal} dBm +
+ ))} +
+ )} + + {/* Neighbors (collapsed) */} + + {showNeighbors && neighbors.length > 0 && ( +
+ + + + + + + + + + + + {neighbors.slice(0, 25).map((n, i) => ( + + + + + + + + ))} + +
SSIDBSSID / VendorBandSecuritySignal
{n.ssid}{n.bssid}{n.vendor ? ` (${n.vendor})` : ''}{n.band}{n.security || '—'}{n.signal} dBm
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ClientDetailPage.tsx b/frontend/src/pages/ClientDetailPage.tsx index fb87d60..d547245 100644 --- a/frontend/src/pages/ClientDetailPage.tsx +++ b/frontend/src/pages/ClientDetailPage.tsx @@ -12,6 +12,7 @@ import { } from 'recharts'; import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { clientsApi, trafficApi, devicesApi } from '../services/api'; +import { CATEGORY_META, SELECTABLE_CATEGORIES } from '../utils/clientCategories'; import type { ClientDetail } from '../services/api'; import type { SignalPoint } from '../services/api'; import clsx from 'clsx'; @@ -329,6 +330,15 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit }, }); + const categoryMutation = useMutation({ + mutationFn: (category: string | null) => clientsApi.updateCategory(client.mac_address, category), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] }); + qc.invalidateQueries({ queryKey: ['clients'] }); + qc.invalidateQueries({ queryKey: ['wireless-clients-page'] }); + }, + }); + const handleWol = async () => { setWolStatus('sending'); try { @@ -431,6 +441,36 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit {client.vendor} )} + {/* Device category — auto-fingerprinted, user-overridable */} + +
+ {canWrite ? ( + + ) : ( + {CATEGORY_META[client.device_category ?? 'unknown']?.label ?? 'Unknown'} + )} + {client.custom_category && ( + + manual + + )} + {categoryMutation.isPending && saving…} +
+
+ + + + ); +} + function formatDataBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -365,7 +380,8 @@ export default function ClientsPage() { className="hover:bg-gray-50 dark:hover:bg-slate-700/30" > -
+
+ + )} + +
+ + {waves.map(w => ( +
+
+ Wave {w}{w === 1 ? ' — canary' : ''} +
+ {(rollout.devices ?? []).filter(d => d.wave === w).map((d: FirmwareRolloutDevice) => { + const meta = ITEM_STATUS[d.status] ?? ITEM_STATUS.pending; + return ( +
+ + {meta.spin && } + {d.status === 'success' && } + {d.status === 'failed' && } + {meta.label} + + {d.device_name} + + {d.from_version || '—'} + {(d.to_version || d.status === 'success') && <>{d.to_version || '?'}} + + {d.error && {d.error}} + + {d.finished_at ? formatDistanceToNow(new Date(d.finished_at), { addSuffix: true }) : ''} + +
+ ); + })} +
+ ))} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function FirmwarePage() { + const qc = useQueryClient(); + const canWrite = useCanWrite(); + const [selected, setSelected] = useState>(new Map()); // deviceId → wave + const [rolloutName, setRolloutName] = useState(''); + const [preBackup, setPreBackup] = useState(true); + const [haltOnFailure, setHaltOnFailure] = useState(true); + const [scheduleAt, setScheduleAt] = useState(''); + const [viewRolloutId, setViewRolloutId] = useState(null); + const [checkResult, setCheckResult] = useState(null); + + const { data: overview, isLoading } = useQuery({ + queryKey: ['fw-overview'], + queryFn: () => firmwareApi.overview().then(r => r.data), + refetchInterval: 30_000, + }); + const { data: rollouts = [] } = useQuery({ + queryKey: ['fw-rollouts'], + queryFn: () => firmwareApi.listRollouts().then(r => r.data), + refetchInterval: 15_000, + }); + + const devices = overview?.devices ?? []; + const updatable = devices.filter(d => d.firmware_update_available && d.status === 'online'); + const upToDate = devices.filter(d => !d.firmware_update_available && d.ros_version).length; + const activeRolloutId = viewRolloutId ?? overview?.runningRolloutId ?? overview?.latestRolloutId ?? null; + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['fw-overview'] }); + qc.invalidateQueries({ queryKey: ['fw-rollouts'] }); + }; + + const checkAll = useMutation({ + mutationFn: () => firmwareApi.checkAll(), + onSuccess: (r) => { + const avail = r.data.results.filter(x => x.ok && x.available).length; + const failed = r.data.results.filter(x => !x.ok).length; + setCheckResult(`Checked ${r.data.results.length} device(s): ${avail} update(s) available${failed ? `, ${failed} unreachable` : ''}`); + setTimeout(() => setCheckResult(null), 6000); + invalidate(); + }, + }); + + const createRollout = useMutation({ + mutationFn: () => firmwareApi.createRollout({ + name: rolloutName.trim() || `RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`, + halt_on_failure: haltOnFailure, + pre_backup: preBackup, + scheduled_at: scheduleAt ? new Date(scheduleAt).toISOString() : null, + start: !scheduleAt, + devices: [...selected.entries()].map(([device_id, wave]) => ({ device_id, wave })), + }), + onSuccess: (r) => { + setSelected(new Map()); + setViewRolloutId(r.data.id); + invalidate(); + }, + }); + + const toggleDevice = (id: number) => { + setSelected(prev => { + const next = new Map(prev); + if (next.has(id)) next.delete(id); + else next.set(id, next.size === 0 ? 1 : 2); // first pick becomes the canary + return next; + }); + }; + const setWave = (id: number, wave: number) => setSelected(prev => new Map(prev).set(id, wave)); + + const selectedCount = selected.size; + const waveSummary = useMemo(() => { + const byWave = new Map(); + for (const w of selected.values()) byWave.set(w, (byWave.get(w) ?? 0) + 1); + return [...byWave.entries()].sort((a, b) => a[0] - b[0]).map(([w, n]) => `wave ${w}: ${n}`).join(' · '); + }, [selected]); + + return ( +
+ {/* Header */} +
+
+

Firmware

+

+ Staged RouterOS upgrades — canary first, wave by wave, with pre-backups and health verification +

+
+
+ {checkResult && {checkResult}} + {canWrite && ( + + )} +
+
+ + {/* KPIs */} +
+
+
+
{upToDate}
+
Up to date
+
+
+
+
{devices.filter(d => d.firmware_update_available).length}
+
Updates available
+
+
+
+
{overview?.runningRolloutId ? 'Running' : '—'}
+
Active rollout
+
+
+ + {/* Fleet table */} +
+
+

Fleet versions

+ {canWrite && updatable.length > 0 && ( + + )} +
+ {isLoading ? ( +
Loading…
+ ) : ( +
+ + + + {canWrite && + + + + + + {canWrite && } + + + + {devices.map((d, i) => { + const isSel = selected.has(d.id); + const selectable = canWrite && d.firmware_update_available && d.status === 'online'; + return ( + + {canWrite && ( + + )} + + + + + + + {canWrite && ( + + )} + + ); + })} + +
} + DeviceModelRouterOSLatestRouterBOOTStatusWave
+ toggleDevice(d.id)} /> + +
+ + {d.name} + {d.status !== 'online' && offline} +
+
{d.model || '—'}{d.ros_version || '—'}{d.latest_ros_version || '—'} + {d.routerboard_upgrade_available + ? {d.firmware_version} → {d.upgrade_firmware_version} + : {d.firmware_version || '—'}} + + {d.firmware_update_available + ? Update available + : Up to date} + + {isSel && ( + + )} +
+
+ )} + + {/* Rollout builder bar */} + {canWrite && selectedCount > 0 && ( +
+
+ setRolloutName(e.target.value)} /> + + + +
+
+ + {waveSummary} + {createRollout.isError && ( + + + {(createRollout.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create rollout'} + + )} + {scheduleAt && ( + + Tip: schedule inside a maintenance window to avoid disrupting users. + + )} +
+
+ )} +
+ + {/* Latest / running rollout */} + {activeRolloutId && } + + {/* Past rollouts */} + {rollouts.length > 1 && ( +
+
+

History

+
+
+ {rollouts.map(r => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/GuestWifiPage.tsx b/frontend/src/pages/GuestWifiPage.tsx new file mode 100644 index 0000000..8ec07a9 --- /dev/null +++ b/frontend/src/pages/GuestWifiPage.tsx @@ -0,0 +1,607 @@ +import { useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Ticket, Wifi, Users, Globe, Power, Trash2, RefreshCw, Plus, Printer, + CheckCircle, AlertTriangle, X, Clock, Database, +} from 'lucide-react'; +import clsx from 'clsx'; +import { guestWifiApi, devicesApi } from '../services/api'; +import { useCanWrite } from '../hooks/useCanWrite'; +import type { Device } from '../types'; + +type HS = Record; + +function formatBytes(raw: string | number | undefined): string { + const b = Number(raw ?? 0); + if (!b) return '0 B'; + if (b < 1024) return `${b} B`; + if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} KB`; + if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} MB`; + return `${(b / 1024 ** 3).toFixed(2)} GB`; +} + +// ─── Voucher print sheet ───────────────────────────────────────────────────── + +function printVouchers(codes: string[], opts: { network: string; durationHours?: number; dataCapMB?: number }) { + const cards = codes.map(code => ` +
+
📶 ${opts.network}
+
${code}
+
+ ${opts.durationHours ? `Valid ${opts.durationHours}h` : 'No time limit'} + ${opts.dataCapMB ? ` · ${opts.dataCapMB >= 1024 ? (opts.dataCapMB / 1024).toFixed(0) + ' GB' : opts.dataCapMB + ' MB'}` : ''} +
+
Connect to WiFi, enter this code as username.
Leave password blank.
+
`).join(''); + const html = `Guest WiFi Vouchers
${cards}
`; + const w = window.open('', '_blank', 'width=900,height=700'); + if (w) { w.document.write(html); w.document.close(); } +} + +// ─── Setup wizard ───────────────────────────────────────────────────────────── + +function SetupWizard({ deviceId, interfaces, isAP, onDone }: { + deviceId: number; + interfaces: { name: string; type: string }[]; + isAP: boolean; + onDone: () => void; +}) { + const [mode, setMode] = useState<'ssid' | 'existing'>(isAP ? 'ssid' : 'existing'); + const [form, setForm] = useState({ + name: 'guest', + ssid: 'Guest WiFi', + passphrase: '', + vlanId: '', + interfaceName: interfaces[0]?.name ?? '', + gatewayCidr: '10.5.50.1/24', + poolRange: '10.5.50.10-10.5.50.254', + dnsName: '', + rateLimit: '10M/10M', + masquerade: true, + }); + const set = (k: K) => (v: typeof form[K]) => setForm(f => ({ ...f, [k]: v })); + const [warnings, setWarnings] = useState([]); + + const mutation = useMutation({ + mutationFn: () => guestWifiApi.setup(deviceId, { + name: form.name, + gatewayCidr: form.gatewayCidr, + poolRange: form.poolRange, + dnsName: form.dnsName || undefined, + rateLimit: form.rateLimit || undefined, + masquerade: form.masquerade, + ...(mode === 'ssid' + ? { + ssid: { ssid: form.ssid, passphrase: form.passphrase || undefined }, + vlanId: form.vlanId ? parseInt(form.vlanId, 10) : undefined, + } + : { interfaceName: form.interfaceName }), + }), + onSuccess: (r) => { + setWarnings(r.data.warnings ?? []); + if ((r.data.warnings ?? []).length === 0) onDone(); + }, + }); + + // Setup succeeded but produced warnings — show them with a continue button + if (warnings.length > 0) { + return ( +
+
+ +

Guest network created — review these

+
+
+ {warnings.map((w, i) => ( +
+ {w} +
+ ))} +
+
+ +
+
+ ); + } + + return ( +
+
+ +

Set up Guest WiFi

+
+

+ Builds the whole guest network in one pass: broadcasts a new guest SSID, isolates the traffic + (VLAN or dedicated bridge), and stands up the captive portal — pool, DHCP, portal server, rate-limited guest profile, NAT. +

+ + {/* Mode toggle */} +
+ {isAP && ( + + )} + +
+ +
+ {mode === 'ssid' ? ( + <> +
+ + set('ssid')(e.target.value)} placeholder="Guest WiFi" maxLength={32} /> +

A new virtual AP is created on every radio (2.4 + 5 GHz).

+
+
+ + set('passphrase')(e.target.value)} placeholder="empty = open network" /> +

Open is typical for captive portals — the voucher is the gate.

+
+
+ + set('vlanId')(e.target.value)} placeholder="e.g. 50" /> +

Tags guest traffic for firewall/isolation. Empty = dedicated guest bridge instead.

+
+ + ) : ( +
+ + +

An existing guest bridge/VLAN/SSID interface.

+
+ )} +
+ + set('name')(e.target.value)} placeholder="guest" /> +

Names the hotspot, pool & profiles (a–z, 0–9, dash).

+
+
+ + set('gatewayCidr')(e.target.value)} placeholder="10.5.50.1/24" /> +
+
+ + set('poolRange')(e.target.value)} placeholder="10.5.50.10-10.5.50.254" /> +
+
+ + set('rateLimit')(e.target.value)} placeholder="10M/10M" /> +

Per-guest cap. Leave empty for unlimited.

+
+
+ + set('dnsName')(e.target.value)} placeholder="wifi.guest" /> +
+
+ + + + {mode === 'existing' && ( +
+ + Pick a dedicated guest interface. Enabling a hotspot on your main LAN interface will put a captive portal in front of every client on it. +
+ )} + + {mutation.isError && ( +

+ {(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Setup failed'} +

+ )} + +
+ +
+
+ ); +} + +// ─── Voucher generator ─────────────────────────────────────────────────────── + +function VoucherGenerator({ deviceId, userProfiles, networkName, onCreated }: { + deviceId: number; + userProfiles: HS[]; + networkName: string; + onCreated: () => void; +}) { + const [count, setCount] = useState(10); + const [durationHours, setDurationHours] = useState(24); + const [dataCapMB, setDataCapMB] = useState(0); + const [profile, setProfile] = useState(userProfiles.find(p => (p['name'] || '').includes('guest'))?.['name'] ?? userProfiles[0]?.['name'] ?? ''); + const [result, setResult] = useState(null); + + const mutation = useMutation({ + mutationFn: () => guestWifiApi.createVouchers(deviceId, { + count, + durationHours: durationHours > 0 ? durationHours : undefined, + dataCapMB: dataCapMB > 0 ? dataCapMB : undefined, + userProfile: profile || undefined, + }), + onSuccess: (r) => { setResult(r.data.codes); onCreated(); }, + }); + + return ( +
+
+ +

Generate vouchers

+
+
+
+ + setCount(parseInt(e.target.value) || 1)} /> +
+
+ + setDurationHours(parseInt(e.target.value) || 0)} /> +

0 = unlimited

+
+
+ + setDataCapMB(parseInt(e.target.value) || 0)} /> +

0 = unlimited

+
+
+ + +
+
+ {mutation.isError && ( +

+ {(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create vouchers'} +

+ )} +
+ +
+ + {/* Result modal */} + {result && ( +
setResult(null)}> +
e.stopPropagation()}> +
+ +

{result.length} voucher{result.length !== 1 ? 's' : ''} created

+ +
+
+ {result.map(c => ( +
{c}
+ ))} +
+
+ + +
+
+
+ )} +
+ ); +} + +// ─── Page ──────────────────────────────────────────────────────────────────── + +export default function GuestWifiPage() { + const qc = useQueryClient(); + const canWrite = useCanWrite(); + const [deviceId, setDeviceId] = useState(''); + const [wgHost, setWgHost] = useState(''); + + const { data: devices = [] } = useQuery({ + queryKey: ['devices'], + queryFn: () => devicesApi.list().then(r => r.data), + staleTime: 60_000, + }); + // Hotspots belong on the device that serves the guest network: an AP or the + // gateway router. Switches (and 'other') aren't sensible portal hosts. + const online = (devices as Device[]).filter( + d => d.status === 'online' && (d.device_type === 'wireless_ap' || d.device_type === 'router') + ); + const devId = typeof deviceId === 'number' ? deviceId : (online[0]?.id ?? 0); + + const { data: overview, isLoading, refetch, isFetching } = useQuery({ + queryKey: ['guest-wifi-overview', devId], + queryFn: () => guestWifiApi.overview(devId).then(r => r.data), + enabled: devId > 0, + }); + + const hasServer = (overview?.servers?.length ?? 0) > 0; + + const { data: users = [] } = useQuery({ + queryKey: ['guest-wifi-users', devId], + queryFn: () => guestWifiApi.users(devId).then(r => r.data), + enabled: devId > 0 && hasServer, + }); + const { data: active = [] } = useQuery({ + queryKey: ['guest-wifi-active', devId], + queryFn: () => guestWifiApi.active(devId).then(r => r.data), + enabled: devId > 0 && hasServer, + refetchInterval: 30_000, + }); + const { data: walledGarden = [] } = useQuery({ + queryKey: ['guest-wifi-wg', devId], + queryFn: () => guestWifiApi.walledGarden(devId).then(r => r.data), + enabled: devId > 0 && hasServer, + }); + + const invalidateAll = () => { + qc.invalidateQueries({ queryKey: ['guest-wifi-overview', devId] }); + qc.invalidateQueries({ queryKey: ['guest-wifi-users', devId] }); + qc.invalidateQueries({ queryKey: ['guest-wifi-active', devId] }); + qc.invalidateQueries({ queryKey: ['guest-wifi-wg', devId] }); + }; + + const toggleServer = useMutation({ + mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => guestWifiApi.setServerDisabled(devId, id, disabled), + onSuccess: invalidateAll, + }); + const deleteUser = useMutation({ mutationFn: (id: string) => guestWifiApi.deleteUser(devId, id), onSuccess: invalidateAll }); + const kickGuest = useMutation({ mutationFn: (id: string) => guestWifiApi.disconnect(devId, id), onSuccess: invalidateAll }); + const addWg = useMutation({ mutationFn: (host: string) => guestWifiApi.addWalledGarden(devId, host), onSuccess: () => { setWgHost(''); invalidateAll(); } }); + const removeWg = useMutation({ mutationFn: (id: string) => guestWifiApi.removeWalledGarden(devId, id), onSuccess: invalidateAll }); + + const vouchers = useMemo(() => users.filter(u => (u['comment'] || '').startsWith('voucher')), [users]); + const networkName = overview?.servers?.[0]?.['name'] ?? 'Guest WiFi'; + + return ( +
+ {/* Header */} +
+
+

Guest WiFi

+

+ Captive portal, vouchers, and guest session management (RouterOS Hotspot) +

+
+
+ + +
+
+ + {online.length === 0 ? ( +
No online devices.
+ ) : isLoading ? ( +
+ Loading hotspot state… +
+ ) : !hasServer ? ( + canWrite + ? d.id === devId)?.device_type === 'wireless_ap'} onDone={invalidateAll} /> + :
No guest network configured on this device.
+ ) : ( + <> + {/* Server status + KPIs */} +
+ {overview!.servers.map(s => { + const disabled = s['disabled'] === 'true'; + return ( +
+
+ +
+
+
{s['name']}
+
{s['interface']} · {disabled ? 'disabled' : 'running'}
+
+ {canWrite && ( + + )} +
+ ); + })} +
+
+
+
{active.length}
+
Guests online
+
+
+
+
+
+
{vouchers.length}
+
Vouchers issued
+
+
+
+ + {/* Voucher generator */} + {canWrite && ( + + )} + + {/* Vouchers table */} +
+
+ +

Vouchers ({vouchers.length})

+ {vouchers.length > 0 && ( + + )} +
+ {vouchers.length === 0 ? ( +
No vouchers yet — generate a batch above.
+ ) : ( +
+ + + + + + + + + + + + + {vouchers.map((v, i) => { + const used = (v['uptime'] && v['uptime'] !== '0s'); + return ( + + + + + + + + + + ); + })} + +
CodeProfileTime limitUsedDataBatch +
{v['name']}{v['profile'] || 'default'}{v['limit-uptime'] || '∞'} + + {used ? `used ${v['uptime']}` : 'unused'} + + {v['limit-bytes-total'] ? formatBytes(v['limit-bytes-total']) : '∞'}{(v['comment'] || '').replace('voucher ', '')} + {canWrite && ( + + )} +
+
+ )} +
+ + {/* Active guests */} +
+
+ +

Guests online ({active.length})

+
+ {active.length === 0 ? ( +
No guests connected right now.
+ ) : ( +
+ + + + + + + + + + + + {active.map((a, i) => ( + + + + + + + + + ))} + +
User / CodeIPMACSessionDown / Up +
{a['user'] || '—'}{a['address'] || '—'}{a['mac-address'] || '—'}{a['uptime'] || '—'}{formatBytes(a['bytes-out'])} / {formatBytes(a['bytes-in'])} + {canWrite && ( + + )} +
+
+ )} +
+ + {/* Walled garden */} +
+
+ +

Walled garden

+ sites reachable before login +
+
+ {canWrite && ( +
+ setWgHost(e.target.value)} + placeholder="*.example.com" onKeyDown={e => { if (e.key === 'Enter' && wgHost.trim()) addWg.mutate(wgHost.trim()); }} /> + +
+ )} + {walledGarden.length === 0 ? ( +

No walled-garden entries.

+ ) : ( +
+ {walledGarden.map((w, i) => ( + + {w['dst-host'] || w['dst-address'] || '—'} + {canWrite && ( + + )} + + ))} +
+ )} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index f5ea6b8..7db2f94 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X, - ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, + ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, } from 'lucide-react'; import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api'; import type { MaintenanceWindow, ConfigTemplate } from '../services/api'; @@ -16,6 +16,7 @@ import { } from '../utils/backupSchedule'; import clsx from 'clsx'; import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings'; +import AutomationSettings from '../components/settings/AutomationSettings'; const ROLE_META: Record = { admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' }, @@ -47,7 +48,7 @@ export default function SettingsPage() { const { theme, setTheme } = useThemeStore(); const isAdmin = user?.role === 'admin'; const canWrite = user?.role !== 'viewer'; - const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general'); const [auditSearch, setAuditSearch] = useState(''); const [auditPage, setAuditPage] = useState(1); const [newTagName, setNewTagName] = useState(''); @@ -470,6 +471,7 @@ export default function SettingsPage() { { key: 'security' as const, label: 'My Password', icon: Key }, { key: 'certificate' as const, label: 'Certificate', icon: Lock }, { key: 'alerting' as const, label: 'Alerting', icon: Bell }, + { key: 'automation' as const, label: 'Automation', icon: Zap }, ...(isAdmin ? [ { key: 'templates' as const, label: 'Config Templates', icon: FileText }, { key: 'tags' as const, label: 'Tags', icon: ShieldCheck }, @@ -1774,6 +1776,10 @@ export default function SettingsPage() { )} + {/* ── Automation (API tokens, webhooks, scheduled reports) ── */} + {activeTab === 'automation' && ( + + )} {/* ── Config Templates ── */} {activeTab === 'templates' && (
diff --git a/frontend/src/pages/WirelessPage.tsx b/frontend/src/pages/WirelessPage.tsx index 011288c..b56e40d 100644 --- a/frontend/src/pages/WirelessPage.tsx +++ b/frontend/src/pages/WirelessPage.tsx @@ -6,6 +6,7 @@ import { import { wirelessApi } from '../services/api'; import type { WirelessAP } from '../types'; import RfHealth from '../components/wireless/RfHealth'; +import RogueApsCard from '../components/wireless/RogueApsCard'; import clsx from 'clsx'; function timeAgo(ts: string | undefined): string { @@ -101,6 +102,9 @@ export default function WirelessPage() { {/* RF Health — channel map, density, TX retries, connectivity */} {aps.length > 0 && } + {/* Rogue & neighbor AP detection (from stored AP scans) */} + {aps.length > 0 && } + {/* AP table */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2b66011..9ced09e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -368,6 +368,8 @@ export const clientsApi = { api.put(`/clients/${mac}/notes`, { notes }), updateHostname: (mac: string, hostname: string) => api.put(`/clients/${mac}/hostname`, { hostname }), + updateCategory: (mac: string, category: string | null) => + api.put(`/clients/${encodeURIComponent(mac)}/category`, { category }), purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'), }; @@ -413,6 +415,124 @@ export const eventsApi = { }; // ─── Backups ────────────────────────────────────────────────────────────────── +// ─── Guest WiFi (Hotspot) ─────────────────────────────────────────────────────── +type HS = Record; +export interface GuestWifiOverview { + servers: HS[]; + profiles: HS[]; + userProfiles: HS[]; + userCount: number; + activeCount: number; + interfaces: { name: string; type: string }[]; +} + +export const guestWifiApi = { + overview: (deviceId: number) => + api.get('/guest-wifi/overview', { params: { deviceId }, timeout: 60_000 }), + setup: (deviceId: number, data: { + name: string; gatewayCidr: string; poolRange: string; dnsName?: string; rateLimit?: string; + interfaceName?: string; ssid?: { ssid: string; passphrase?: string }; vlanId?: number; masquerade?: boolean; + }) => + api.post<{ + server: string; profile: string; userProfile: string; pool: string; + targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; warnings: string[]; + }>('/guest-wifi/setup', { deviceId, ...data }, { timeout: 120_000 }), + users: (deviceId: number) => api.get('/guest-wifi/users', { params: { deviceId } }), + createVouchers: (deviceId: number, data: { count: number; durationHours?: number; dataCapMB?: number; userProfile?: string }) => + api.post<{ created: number; codes: string[] }>('/guest-wifi/vouchers', { deviceId, ...data }, { timeout: 120_000 }), + deleteUser: (deviceId: number, id: string) => + api.delete(`/guest-wifi/users/${encodeURIComponent(id)}`, { params: { deviceId } }), + active: (deviceId: number) => api.get('/guest-wifi/active', { params: { deviceId } }), + disconnect: (deviceId: number, id: string) => + api.delete(`/guest-wifi/active/${encodeURIComponent(id)}`, { params: { deviceId } }), + walledGarden: (deviceId: number) => api.get('/guest-wifi/walled-garden', { params: { deviceId } }), + addWalledGarden: (deviceId: number, dstHost: string, comment?: string) => + api.post('/guest-wifi/walled-garden', { deviceId, dstHost, comment }), + removeWalledGarden: (deviceId: number, id: string) => + api.delete(`/guest-wifi/walled-garden/${encodeURIComponent(id)}`, { params: { deviceId } }), + setServerDisabled: (deviceId: number, id: string, disabled: boolean) => + api.put(`/guest-wifi/servers/${encodeURIComponent(id)}`, { deviceId, disabled }), + removeServer: (deviceId: number, id: string) => + api.delete(`/guest-wifi/servers/${encodeURIComponent(id)}`, { params: { deviceId } }), +}; + +// ─── Platform & automation ────────────────────────────────────────────────────── +export interface ApiToken { + id: number; name: string; prefix: string; scope: 'read' | 'write'; + created_by: string | null; last_used_at: string | null; expires_at: string | null; created_at: string; +} +export interface Webhook { + id: number; name: string; url: string; has_secret: boolean; + events: string[]; enabled: boolean; last_status: number | null; last_fired_at: string | null; created_at: string; +} +export interface ReportSchedule { + id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly'; + recipients: string; enabled: boolean; last_sent_at: string | null; next_run_at: string; created_at: string; +} + +export const automationApi = { + // tokens (admin only) + listTokens: () => api.get('/automation/tokens'), + createToken: (data: { name: string; scope: 'read' | 'write'; expires_days?: number }) => + api.post<{ id: number; token: string }>('/automation/tokens', data), + deleteToken: (id: number) => api.delete(`/automation/tokens/${id}`), + // webhooks + listWebhooks: () => api.get<{ webhooks: Webhook[]; availableEvents: string[] }>('/automation/webhooks'), + createWebhook: (data: { name: string; url: string; secret?: string; events: string[] }) => + api.post<{ id: number }>('/automation/webhooks', data), + updateWebhook: (id: number, data: Partial<{ name: string; url: string; secret: string; events: string[]; enabled: boolean }>) => + api.put(`/automation/webhooks/${id}`, data), + deleteWebhook: (id: number) => api.delete(`/automation/webhooks/${id}`), + testWebhook: (id: number) => api.post<{ ok: boolean; status: number }>(`/automation/webhooks/${id}/test`), + // reports + listReports: () => api.get('/automation/reports'), + createReport: (data: { name: string; frequency: string; recipients: string }) => + api.post<{ id: number }>('/automation/reports', data), + updateReport: (id: number, data: { enabled?: boolean }) => api.put(`/automation/reports/${id}`, data), + deleteReport: (id: number) => api.delete(`/automation/reports/${id}`), + sendReportNow: (id: number) => api.post(`/automation/reports/${id}/send-now`, undefined, { timeout: 60_000 }), +}; + +// ─── Firmware orchestration ───────────────────────────────────────────────────── +export interface FirmwareDeviceRow { + id: number; name: string; device_type: string; status: string; model: string | null; + ros_version: string | null; latest_ros_version: string | null; + firmware_update_available: boolean; + firmware_version: string | null; upgrade_firmware_version: string | null; + routerboard_upgrade_available: boolean; +} +export interface FirmwareRollout { + id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + halt_on_failure: boolean; pre_backup: boolean; + scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string; + device_count?: number; success_count?: number; failed_count?: number; +} +export interface FirmwareRolloutDevice { + id: number; rollout_id: number; device_id: number; wave: number; + status: 'pending' | 'backing_up' | 'upgrading' | 'rebooting' | 'verifying' | 'success' | 'failed' | 'skipped'; + from_version: string | null; to_version: string | null; error: string | null; + started_at: string | null; finished_at: string | null; + device_name: string; device_type: string; model: string | null; +} + +export const firmwareApi = { + overview: () => + api.get<{ devices: FirmwareDeviceRow[]; latestRolloutId: number | null; runningRolloutId: number | null }>('/firmware/overview'), + checkAll: () => + api.post<{ results: { name: string; ok: boolean; installed?: string; latest?: string; available?: boolean; error?: string }[] }>( + '/firmware/check-all', undefined, { timeout: 180_000 }), + createRollout: (data: { + name: string; halt_on_failure: boolean; pre_backup: boolean; + scheduled_at?: string | null; start?: boolean; + devices: { device_id: number; wave: number }[]; + }) => api.post<{ id: number }>('/firmware/rollouts', data), + listRollouts: () => api.get('/firmware/rollouts'), + getRollout: (id: number) => + api.get(`/firmware/rollouts/${id}`), + startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`), + cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`), +}; + // ─── Operations ──────────────────────────────────────────────────────────────── export interface OpsAttentionItem { sev: 'error' | 'warn' | 'info'; @@ -754,6 +874,10 @@ export const wirelessApi = { getAPScanHistory: (apId: number, limit = 5) => api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }), + // Rogue / neighbor AP detection (classified from stored AP scans) + getRogueAps: () => + api.get('/wireless/rf/rogue'), + // RF Health (fleet-wide, or scoped to one AP via deviceId) getChannelUsage: (deviceId?: number) => api.get('/wireless/rf/channels', { params: { deviceId } }), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8ec9636..db12144 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -190,6 +190,12 @@ export interface Client { device_name?: string; mac_address: string; custom_name?: string; + /** Effective device category: user override if set, else the fingerprint. */ + device_category?: string; + /** What the fingerprint engine detected (before any user override). */ + auto_category?: string; + /** User's manual category override, if any. */ + custom_category?: string | null; hostname?: string; vendor?: string; ip_address?: string; @@ -364,6 +370,20 @@ export interface WirelessMetricPoint { noise_floor?: number; } +// ─── Rogue AP detection ───────────────────────────────────────────────────── + +export interface RogueAp { + ssid: string; bssid: string; vendor: string; signal: number; band: string; + seenBy: string; scannedAt: string; +} +export interface NeighborAp extends RogueAp { security: string } +export interface RogueApReport { + rogues: RogueAp[]; + neighbors: NeighborAp[]; + lastScanAt: string | null; + scannedDevices: number; +} + // ─── RF Health ────────────────────────────────────────────────────────────── export interface RfChannelRow { diff --git a/frontend/src/utils/clientCategories.ts b/frontend/src/utils/clientCategories.ts new file mode 100644 index 0000000..efe1c25 --- /dev/null +++ b/frontend/src/utils/clientCategories.ts @@ -0,0 +1,24 @@ +import { + Network, Server, Monitor, Smartphone, Tv, Speaker, Camera, Printer, + Gamepad2, Mic, Home, Cpu, HelpCircle, type LucideIcon, +} from 'lucide-react'; + +// Device categories (must match the backend's DEVICE_CATEGORIES) +export const CATEGORY_META: Record = { + network: { icon: Network, label: 'Network gear' }, + server: { icon: Server, label: 'Server / NAS' }, + computer: { icon: Monitor, label: 'Computer' }, + phone: { icon: Smartphone, label: 'Phone / tablet' }, + tv: { icon: Tv, label: 'TV / streaming' }, + media: { icon: Speaker, label: 'AV / speaker' }, + camera: { icon: Camera, label: 'Camera' }, + printer: { icon: Printer, label: 'Printer' }, + 'game-console': { icon: Gamepad2, label: 'Game console' }, + 'voice-assistant': { icon: Mic, label: 'Voice assistant' }, + 'smart-home': { icon: Home, label: 'Smart home' }, + iot: { icon: Cpu, label: 'IoT device' }, + unknown: { icon: HelpCircle, label: 'Unknown' }, +}; + +/** Categories a user can pick when overriding (same order as the meta map). */ +export const SELECTABLE_CATEGORIES = Object.keys(CATEGORY_META); diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 3be8795..574a0c5 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.16.2 Beta'; +export const APP_VERSION = 'v0.16.3 Beta';