feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efb430d934
commit
9dfb8a5333
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
// 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}`);
|
||||
|
||||
@@ -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<AuthPayload | null> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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<T extends Record<string, unknown>>(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<string, unknown>[]).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<string, unknown>));
|
||||
});
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -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<DeviceRow>(`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;
|
||||
@@ -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<DeviceRow | null> {
|
||||
return queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
// Run fn against a connected collector for ?deviceId= / body.deviceId
|
||||
async function withDevice<T>(
|
||||
req: Request, res: Response,
|
||||
fn: (c: DeviceCollector) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
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<string, string>[]),
|
||||
]);
|
||||
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<string, string>;
|
||||
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<string, string> = { 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;
|
||||
@@ -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<Sev, number> = { 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<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
try {
|
||||
const api = getQueryApi();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
api.queryRows(flux, {
|
||||
next(row, tableMeta) {
|
||||
const o = tableMeta.toObject(row) as Record<string, unknown>;
|
||||
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<AttentionItem[]> {
|
||||
const items: AttentionItem[] = [];
|
||||
const hour = new Date().getUTCHours();
|
||||
const nameById = new Map<string, { id: number; name: string }>();
|
||||
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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AlertChannel>(
|
||||
`SELECT * FROM alert_channels WHERE enabled = true ORDER BY id`
|
||||
|
||||
@@ -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<void>(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<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
if (this.activeRolloutId) throw new Error(`Rollout #${this.activeRolloutId} is already running`);
|
||||
const rollout = await queryOne<RolloutRow>(`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<void> {
|
||||
const items = await query<RolloutDeviceRow>(
|
||||
`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<string, string | null>): Promise<void> {
|
||||
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<boolean> {
|
||||
const device = await queryOne<DeviceRow>(`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();
|
||||
@@ -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<string, number> = { 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<typeof setInterval> | 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<void> {
|
||||
const due = await query<ScheduleRow>(
|
||||
`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<ScheduleRow, 'name' | 'frequency' | 'recipients'>): Promise<void> {
|
||||
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<string, unknown> }>(
|
||||
`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<string> {
|
||||
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) =>
|
||||
`<tr><td style="padding:6px 12px;color:#64748b;">${label}</td><td style="padding:6px 12px;font-weight:600;color:#0f172a;">${value}</td></tr>`;
|
||||
|
||||
const clientRows = topClients.map((c, i) =>
|
||||
`<tr><td style="padding:4px 12px;color:#64748b;">${i + 1}. ${c.name}</td><td style="padding:4px 12px;color:#0f172a;">${fmtBytes(parseInt(c.bytes, 10))}</td></tr>`).join('');
|
||||
|
||||
return `
|
||||
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:560px;margin:0 auto;">
|
||||
<h2 style="color:#0f172a;">MikroTik Manager — ${frequency} report</h2>
|
||||
<p style="color:#64748b;font-size:13px;">Covering the last ${days} day${days !== 1 ? 's' : ''} · generated ${new Date().toUTCString()}</p>
|
||||
<table style="border-collapse:collapse;width:100%;background:#f8fafc;border-radius:8px;">
|
||||
${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')}
|
||||
</table>
|
||||
${topClients.length ? `<h3 style="color:#0f172a;font-size:14px;margin-top:20px;">Top clients by traffic</h3>
|
||||
<table style="border-collapse:collapse;width:100%;background:#f8fafc;border-radius:8px;">${clientRows}</table>` : ''}
|
||||
<p style="color:#94a3b8;font-size:11px;margin-top:20px;">Sent by MikroTik Manager scheduled reports.</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export const reportService = new ReportService();
|
||||
@@ -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<string, string>): Promise<number> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
const hooks = await query<WebhookRow>(
|
||||
`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<WebhookRow>(`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<number> {
|
||||
const headers: Record<string, string> = { '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();
|
||||
@@ -1580,6 +1580,10 @@ export class DeviceCollector {
|
||||
return this.client.execute('/interface/bridge/print').catch(() => []);
|
||||
}
|
||||
|
||||
async getInterfacesLive(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/interface/print').catch(() => []);
|
||||
}
|
||||
|
||||
async getBridgePorts(): Promise<Record<string, string>[]> {
|
||||
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<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async setHotspotServerDisabled(id: string, disabled: boolean): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/set', { '.id': id, disabled: disabled ? 'yes' : 'no' });
|
||||
}
|
||||
|
||||
async removeHotspotServer(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getHotspotProfiles(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotProfile(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/profile/add', params);
|
||||
}
|
||||
|
||||
async getHotspotUserProfiles(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/user/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotUserProfile(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/profile/add', params);
|
||||
}
|
||||
|
||||
async getHotspotUsers(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/user/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotUser(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/add', params);
|
||||
}
|
||||
|
||||
async removeHotspotUser(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getHotspotActive(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/active/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async disconnectHotspotActive(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/active/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getWalledGarden(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/walled-garden/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addWalledGardenEntry(dstHost: string, comment?: string): Promise<void> {
|
||||
const params: Record<string, string> = { '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<void> {
|
||||
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<string, string>[])
|
||||
: await this.client.execute('/interface/wireless/print').catch(() => [] as Record<string, string>[]);
|
||||
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<string, string> = {
|
||||
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<string, string>[]);
|
||||
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<string, string>[]);
|
||||
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<void> {
|
||||
const rows = await this.client
|
||||
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
|
||||
.catch(() => [] as Record<string, string>[]);
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Record<string, string>[]> {
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<string>, // SSIDs our managed radios broadcast
|
||||
ownBssids: Set<string>, // every MAC we own (radio + interface MACs, lowercase)
|
||||
): { rogues: RogueAp[]; neighbors: NeighborAp[] } {
|
||||
const rogueByBssid = new Map<string, RogueAp>();
|
||||
const neighborByBssid = new Map<string, NeighborAp>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user