feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efb430d934
commit
9dfb8a5333
@@ -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;
|
||||
Reference in New Issue
Block a user