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,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;
|
||||
Reference in New Issue
Block a user