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