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:
2GT-Rich
2026-07-02 21:58:02 -07:00
co-authored by Claude Opus 4.8
parent efb430d934
commit 9dfb8a5333
38 changed files with 3517 additions and 20 deletions
+62
View File
@@ -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);
+14
View File
@@ -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}`);
+37
View File
@@ -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();
+160
View File
@@ -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;
+32 -2
View File
@@ -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 };
+135
View File
@@ -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;
+200
View File
@@ -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;
+149
View File
@@ -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([
+37
View File
@@ -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;
+11
View File
@@ -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();
+150
View File
@@ -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();
+94
View File
@@ -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)');
});
});
+83
View File
@@ -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';
}
+71
View File
@@ -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),
};
}