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;