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
+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;