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