Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
+198
View File
@@ -0,0 +1,198 @@
import { Router } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { alertService } from '../services/AlertService';
const router = Router();
// All alert routes require authentication
router.use(requireAuth);
// ── Default rules seed ─────────────────────────────────────────────────────
const DEFAULT_RULES = [
{ event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 5 },
{ event_type: 'device_online', enabled: true, threshold: null, cooldown_min: 5 },
{ event_type: 'log_error', enabled: false, threshold: null, cooldown_min: 15 },
{ event_type: 'log_warning', enabled: false, threshold: null, cooldown_min: 30 },
{ event_type: 'high_cpu', enabled: false, threshold: 90, cooldown_min: 30 },
{ event_type: 'high_memory', enabled: false, threshold: 90, cooldown_min: 30 },
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
{ event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 },
];
async function ensureDefaultRules(): Promise<void> {
for (const rule of DEFAULT_RULES) {
await query(
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_type) DO NOTHING`,
[rule.event_type, rule.enabled, rule.threshold, rule.cooldown_min]
);
}
}
// ── Alert Rules ────────────────────────────────────────────────────────────
/** GET /api/alerts/rules — list all rules (seeds defaults on first call) */
router.get('/rules', async (_req, res) => {
await ensureDefaultRules();
const rules = await query(
`SELECT event_type, enabled, threshold, cooldown_min, updated_at
FROM alert_rules ORDER BY event_type`
);
res.json(rules);
});
/** PUT /api/alerts/rules/:type — update a single rule */
router.put('/rules/:type', requireWrite, async (req, res) => {
const { type } = req.params;
const { enabled, threshold, cooldown_min } = req.body;
const row = await alertService.upsertRule(type, {
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
threshold: threshold !== undefined ? (Number(threshold) || null) : undefined,
cooldown_min: cooldown_min !== undefined ? Number(cooldown_min) : undefined,
});
res.json(row);
});
// ── Alert Channels ─────────────────────────────────────────────────────────
/** GET /api/alerts/channels */
router.get('/channels', async (_req, res) => {
const channels = await query(
`SELECT id, name, type, enabled, config, created_at, updated_at
FROM alert_channels ORDER BY id`
);
// Mask sensitive fields before returning
const masked = channels.map((ch: Record<string, unknown>) => ({
...ch,
config: maskConfig(ch.type as string, ch.config as Record<string, unknown>),
}));
res.json(masked);
});
/** POST /api/alerts/channels */
router.post('/channels', requireWrite, async (req, res) => {
const { name, type, enabled = true, config = {} } = req.body;
if (!name || !type) {
return res.status(400).json({ error: 'name and type are required' });
}
const validTypes = ['email', 'slack', 'discord', 'telegram'];
if (!validTypes.includes(type)) {
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
}
const rows = await query(
`INSERT INTO alert_channels (name, type, enabled, config)
VALUES ($1, $2, $3, $4) RETURNING *`,
[name, type, Boolean(enabled), JSON.stringify(config)]
);
const ch = rows[0] as Record<string, unknown>;
res.status(201).json({
...ch,
config: maskConfig(type, config),
});
});
/** PUT /api/alerts/channels/:id */
router.put('/channels/:id', requireWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
const { name, enabled, config } = req.body;
// Fetch existing so we can merge config (preserve masked/unchanged fields)
const existing = await query<{ config: Record<string, unknown>; type: string }>(
`SELECT config, type FROM alert_channels WHERE id = $1`,
[id]
);
if (!existing[0]) return res.status(404).json({ error: 'Channel not found' });
const existingConfig = existing[0].config as Record<string, unknown>;
const type = existing[0].type;
// Merge new config over existing, preserving existing passwords when placeholders sent
const mergedConfig = mergeConfig(type, existingConfig, config ?? {});
const rows = await query(
`UPDATE alert_channels
SET name = COALESCE($2, name),
enabled = COALESCE($3, enabled),
config = $4,
updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id, name ?? null, enabled !== undefined ? Boolean(enabled) : null, JSON.stringify(mergedConfig)]
);
if (!rows[0]) return res.status(404).json({ error: 'Channel not found' });
const ch = rows[0] as Record<string, unknown>;
res.json({ ...ch, config: maskConfig(type, mergedConfig) });
});
/** DELETE /api/alerts/channels/:id */
router.delete('/channels/:id', requireWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
await query(`DELETE FROM alert_channels WHERE id = $1`, [id]);
res.status(204).end();
});
/** POST /api/alerts/channels/:id/test */
router.post('/channels/:id/test', requireWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
try {
await alertService.testChannel(id);
res.json({ message: 'Test alert sent successfully' });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
// ── Alert History ──────────────────────────────────────────────────────────
/** GET /api/alerts/history?limit=50 */
router.get('/history', async (req, res) => {
const limit = Math.min(parseInt(String(req.query.limit ?? 50), 10), 200);
const rows = await query(
`SELECT h.id, h.event_type, h.device_id, h.device_name, h.message,
h.channels_notified, h.sent_at
FROM alert_history h
ORDER BY h.sent_at DESC LIMIT $1`,
[limit]
);
res.json(rows);
});
// ── Helpers ────────────────────────────────────────────────────────────────
const SENSITIVE_KEYS: Record<string, string[]> = {
email: ['smtp_pass'],
slack: [],
discord: [],
telegram: ['bot_token'],
};
function maskConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
const masked = { ...config };
for (const key of SENSITIVE_KEYS[type] ?? []) {
if (masked[key]) masked[key] = '••••••••';
}
return masked;
}
/** When updating, if a sensitive field comes back as the mask placeholder, keep the original. */
function mergeConfig(
type: string,
existing: Record<string, unknown>,
incoming: Record<string, unknown>
): Record<string, unknown> {
const merged = { ...existing, ...incoming };
for (const key of SENSITIVE_KEYS[type] ?? []) {
if (incoming[key] === '••••••••') {
merged[key] = existing[key]; // restore original
}
}
return merged;
}
export default router;
+59
View File
@@ -0,0 +1,59 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { query, queryOne } from '../config/database';
import { signToken, requireAuth } from '../middleware/auth';
const router = Router();
router.post('/login', async (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const user = await queryOne<{
id: number;
username: string;
password_hash: string;
role: string;
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = signToken({ userId: user.id, username: user.username, role: user.role });
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});
router.get('/me', requireAuth, (req: Request, res: Response) => {
res.json({ user: req.user });
});
router.post('/logout', requireAuth, (_req: Request, res: Response) => {
// JWT is stateless; client just discards token
res.json({ message: 'Logged out' });
});
router.put('/password', requireAuth, async (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current and new password required' });
}
const user = await queryOne<{ id: number; password_hash: string }>(
`SELECT id, password_hash FROM users WHERE id = $1`,
[req.user!.userId]
);
if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
const hash = await bcrypt.hash(newPassword, 12);
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, req.user!.userId]);
return res.json({ message: 'Password updated' });
});
export default router;
+92
View File
@@ -0,0 +1,92 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { BackupService } from '../services/BackupService';
const router = Router();
router.use(requireAuth);
const backupService = new BackupService();
// GET /api/backups
router.get('/', async (req: Request, res: Response) => {
const { deviceId } = req.query;
let sql = `
SELECT b.*, d.name as device_name
FROM backups b JOIN devices d ON d.id = b.device_id
WHERE 1=1
`;
const params: unknown[] = [];
if (deviceId) {
sql += ` AND b.device_id = $1`;
params.push(deviceId);
}
sql += ' ORDER BY b.created_at DESC';
const backups = await query(sql, params);
res.json(backups);
});
// POST /api/backups
router.post('/', requireWrite, async (req: Request, res: Response) => {
const { deviceId, notes } = req.body;
if (!deviceId) return res.status(400).json({ error: 'deviceId is required' });
const device = await queryOne<any>(
`SELECT id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
api_username, api_password_encrypted
FROM devices WHERE id = $1`,
[deviceId]
);
if (!device) return res.status(404).json({ error: 'Device not found' });
try {
const backupId = await backupService.createBackup(device, notes);
const backup = await queryOne(`SELECT * FROM backups WHERE id = $1`, [backupId]);
return res.status(201).json(backup);
} catch (err) {
return res.status(500).json({ error: `Backup failed: ${(err as Error).message}` });
}
});
// GET /api/backups/:id/download
router.get('/:id/download', async (req: Request, res: Response) => {
const backup = await queryOne<{ file_path: string; filename: string }>(
`SELECT file_path, filename FROM backups WHERE id = $1`,
[req.params.id]
);
if (!backup) return res.status(404).json({ error: 'Backup not found' });
if (!fs.existsSync(backup.file_path)) {
return res.status(404).json({ error: 'Backup file not found on disk' });
}
res.setHeader('Content-Disposition', `attachment; filename="${backup.filename}"`);
res.setHeader('Content-Type', 'text/plain');
return res.sendFile(path.resolve(backup.file_path));
});
// POST /api/backups/:id/restore
router.post('/:id/restore', requireWrite, async (req: Request, res: Response) => {
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
if (!backup) return res.status(404).json({ error: 'Backup not found' });
try {
await backupService.restoreBackup(parseInt(req.params.id));
return res.json({ message: 'Restore initiated successfully' });
} catch (err) {
return res.status(500).json({ error: `Restore failed: ${(err as Error).message}` });
}
});
// DELETE /api/backups/:id
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
if (!backup) return res.status(404).json({ error: 'Backup not found' });
await backupService.deleteBackup(parseInt(req.params.id));
return res.json({ message: 'Backup deleted' });
});
export default router;
+156
View File
@@ -0,0 +1,156 @@
import { Router, Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { readFile, writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import crypto from 'crypto';
import path from 'path';
import { requireAuth, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
const execFileAsync = promisify(execFile);
const CERTS_DIR = '/certs';
const CERT_PATH = path.join(CERTS_DIR, 'server.crt');
const KEY_PATH = path.join(CERTS_DIR, 'server.key');
const RELOAD_SIGNAL = path.join(CERTS_DIR, '.reload');
function parseCertInfo(pem: string) {
const cert = new crypto.X509Certificate(pem);
const now = new Date();
const validTo = new Date(cert.validTo);
const daysLeft = Math.floor((validTo.getTime() - now.getTime()) / 86400000);
const isSelfSigned = cert.subject === cert.issuer;
// Extract CN from subject string like "CN=Mikrotik Manager, O=Self-Signed"
const cnMatch = cert.subject.match(/CN=([^,\n]+)/);
const cn = cnMatch ? cnMatch[1].trim() : cert.subject;
const issuerCnMatch = cert.issuer.match(/CN=([^,\n]+)/);
const issuerCn = issuerCnMatch ? issuerCnMatch[1].trim() : cert.issuer;
return {
subject: cn,
subject_full: cert.subject,
issuer: issuerCn,
issuer_full: cert.issuer,
serial_number: cert.serialNumber,
valid_from: cert.validFrom,
valid_to: cert.validTo,
days_remaining: daysLeft,
is_self_signed: isSelfSigned,
san: cert.subjectAltName || null,
};
}
// GET /api/cert — current certificate info
router.get('/', async (_req: Request, res: Response) => {
if (!existsSync(CERT_PATH)) {
return res.json({ exists: false });
}
try {
const pem = await readFile(CERT_PATH, 'utf8');
return res.json({ exists: true, ...parseCertInfo(pem) });
} catch {
return res.json({ exists: false });
}
});
// POST /api/cert/regenerate — generate a fresh self-signed certificate
router.post('/regenerate', requireAdmin, async (_req: Request, res: Response) => {
try {
await execFileAsync('openssl', [
'req', '-x509', '-nodes', '-days', '3650',
'-newkey', 'rsa:2048',
'-keyout', KEY_PATH,
'-out', CERT_PATH,
'-subj', '/CN=Mikrotik Manager/O=Self-Signed/OU=Local/C=US',
'-addext', 'subjectAltName=IP:127.0.0.1,DNS:localhost',
], { timeout: 30000 });
// Signal nginx to reload
await writeFile(RELOAD_SIGNAL, '');
const pem = await readFile(CERT_PATH, 'utf8');
return res.json({ message: 'Self-signed certificate regenerated', ...parseCertInfo(pem) });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return res.status(500).json({ error: `Failed to generate certificate: ${msg}` });
}
});
// POST /api/cert/upload — install a custom certificate and private key
router.post('/upload', requireAdmin, async (req: Request, res: Response) => {
const { certificate, private_key } = req.body as { certificate?: string; private_key?: string };
if (!certificate || !private_key) {
return res.status(400).json({ error: 'Both certificate and private_key are required' });
}
// Validate PEM format
if (!certificate.includes('-----BEGIN CERTIFICATE-----')) {
return res.status(400).json({ error: 'certificate does not appear to be a valid PEM certificate' });
}
if (!private_key.includes('-----BEGIN')) {
return res.status(400).json({ error: 'private_key does not appear to be a valid PEM key' });
}
// Parse the certificate
let certInfo;
try {
certInfo = parseCertInfo(certificate);
} catch {
return res.status(400).json({ error: 'Failed to parse certificate — ensure it is valid PEM format' });
}
// Verify the private key matches the certificate's public key
try {
const certObj = new crypto.X509Certificate(certificate);
const privKey = crypto.createPrivateKey(private_key);
const certPubKey = certObj.publicKey;
// Sign test data with private key, verify with cert's public key
const sign = crypto.createSign('SHA256');
sign.update('mikrotik-manager-cert-verify');
const signature = sign.sign(privKey);
const verify = crypto.createVerify('SHA256');
verify.update('mikrotik-manager-cert-verify');
const matches = verify.verify(certPubKey, signature);
if (!matches) {
return res.status(400).json({ error: 'Private key does not match the certificate' });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return res.status(400).json({ error: `Key/certificate validation failed: ${msg}` });
}
// Write files atomically (write to temp, then rename)
const tmpCert = CERT_PATH + '.tmp';
const tmpKey = KEY_PATH + '.tmp';
try {
await writeFile(tmpCert, certificate.trim() + '\n', { mode: 0o644 });
await writeFile(tmpKey, private_key.trim() + '\n', { mode: 0o600 });
// Rename into place
const { rename } = await import('fs/promises');
await rename(tmpCert, CERT_PATH);
await rename(tmpKey, KEY_PATH);
// Signal nginx to reload
await writeFile(RELOAD_SIGNAL, '');
return res.json({ message: 'Certificate installed successfully', ...certInfo });
} catch (err) {
// Clean up temp files if they exist
await unlink(tmpCert).catch(() => {});
await unlink(tmpKey).catch(() => {});
const msg = err instanceof Error ? err.message : String(err);
return res.status(500).json({ error: `Failed to write certificate: ${msg}` });
}
});
export default router;
+322
View File
@@ -0,0 +1,322 @@
import { Router, Request, Response } from 'express';
import * as dgram from 'dgram';
import { query } from '../config/database';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { PollerService } from '../services/PollerService';
import { getQueryApi, bucket } from '../config/influxdb';
const router = Router();
router.use(requireAuth);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void { pollerService = p; }
// GET /api/clients
router.get('/', async (req: Request, res: Response) => {
const { deviceId, active, search, client_type, limit = '100', offset = '0' } = req.query;
// Build WHERE clause shared by both queries
const filters: string[] = [];
const filterParams: unknown[] = [];
let idx = 1;
if (deviceId) {
filters.push(`c.device_id = $${idx++}`);
filterParams.push(deviceId);
}
if (active === 'true') {
filters.push(`c.active = TRUE`);
}
if (client_type) {
filters.push(`c.client_type = $${idx++}`);
filterParams.push(client_type);
}
if (search) {
filters.push(`(c.mac_address ILIKE $${idx} OR c.custom_name ILIKE $${idx} OR c.hostname ILIKE $${idx} OR c.ip_address ILIKE $${idx} OR c.vendor ILIKE $${idx})`);
filterParams.push(`%${search}%`);
idx++;
}
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
// Deduplicate by MAC address across devices: prefer active rows, then most recently seen.
// The inner DISTINCT ON picks the best row per MAC; the outer query applies final sort + pagination.
const sql = `
SELECT * FROM (
SELECT DISTINCT ON (c.mac_address)
c.*, d.name as device_name,
wi.ssid as ssid
FROM clients c
JOIN devices d ON d.id = c.device_id
LEFT JOIN wireless_interfaces wi
ON wi.device_id = c.device_id AND wi.name = c.interface_name
${where}
ORDER BY c.mac_address, (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
) deduped
ORDER BY last_seen DESC NULLS LAST
LIMIT $${idx++} OFFSET $${idx++}
`;
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
const countResult = await query<{ total: string }>(
`SELECT COUNT(DISTINCT c.mac_address) as total FROM clients c ${where}`,
filterParams
);
res.json({
clients,
total: parseInt(countResult[0]?.total || '0', 10),
});
});
// GET /api/clients/:mac — detail view with SSID, VLAN name, device type, upstream topology
router.get('/:mac', async (req: Request, res: Response) => {
const mac = req.params.mac.toLowerCase();
// Order: wireless records before wired (AP knows the client best), then active,
// then most recently seen. Use LATERAL for topology so we never multiply rows.
const rows = await query<Record<string, unknown>>(
`SELECT c.*, d.name as device_name, d.device_type,
wi.ssid as ssid,
v.name as vlan_name,
topo.upstream_device_id,
topo.upstream_interface,
topo.upstream_device_name,
topo.upstream_device_type,
topo.upstream_device_ip
FROM clients c
JOIN devices d ON d.id = c.device_id
LEFT JOIN wireless_interfaces wi
ON wi.device_id = c.device_id AND wi.name = c.interface_name
LEFT JOIN vlans v
ON v.device_id = c.device_id AND v.vlan_id = c.vlan_id
LEFT JOIN LATERAL (
SELECT tl.to_device_id AS upstream_device_id,
tl.to_interface AS upstream_interface,
ud.name AS upstream_device_name,
ud.device_type AS upstream_device_type,
ud.ip_address AS upstream_device_ip
FROM topology_links tl
JOIN devices ud ON ud.id = tl.to_device_id
WHERE tl.from_device_id = c.device_id
LIMIT 1
) topo ON TRUE
WHERE LOWER(c.mac_address) = $1
ORDER BY (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
LIMIT 1`,
[mac]
);
if (!rows.length) return res.status(404).json({ error: 'Client not found' });
return res.json(rows[0]);
});
// GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB
router.get('/:mac/presence', async (req: Request, res: Response) => {
const mac = req.params.mac.toLowerCase();
const rangeRaw = String(req.query.range || '24h');
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
const range = allowedRanges[rangeRaw] || '24h';
// Window size: 5m for 2h/24h, 30m for 7d
const window = range === '7d' ? '30m' : '5m';
const queryApi = getQueryApi();
// Group by mac_address only (drop device_id) so that all devices reporting the
// same client are merged into one series. fn:max means online=1 if any device
// saw the client in that window; createEmpty+fill gives offline=0 for gaps.
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_presence")
|> filter(fn: (r) => r._field == "online")
|> filter(fn: (r) => r.mac_address == "${mac}")
|> group(columns: ["_measurement", "_field", "mac_address"])
|> aggregateWindow(every: ${window}, fn: max, createEmpty: true)
|> fill(value: 0)
|> yield(name: "presence")
`;
const points: { time: string; online: number }[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
points.push({ time, online: value != null ? Math.round(value) : 0 });
});
} catch {
// InfluxDB not available or no data yet
}
return res.json(points);
});
// GET /api/clients/:mac/traffic?range=2h|24h|7d — wireless tx/rx rate graph
router.get('/:mac/traffic', async (req: Request, res: Response) => {
const mac = req.params.mac.toLowerCase();
const rangeRaw = String(req.query.range || '24h');
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
const range = allowedRanges[rangeRaw] || '24h';
const window = range === '7d' ? '30m' : '5m';
const queryApi = getQueryApi();
// Simple query: get the last cumulative byte counter value per time window.
// Non-negative differences are computed in TypeScript below — avoids the
// fragile non_negative_difference+pivot combination in Flux.
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_presence")
|> filter(fn: (r) => r._field == "tx_bytes" or r._field == "rx_bytes")
|> filter(fn: (r) => r.mac_address == "${mac}")
|> group(columns: ["_measurement", "_field", "mac_address"])
|> aggregateWindow(every: ${window}, fn: last, createEmpty: false)
|> yield(name: "traffic_raw")
`;
const raw: { time: string; field: string; value: number }[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const field = tableMeta.get(row, '_field') as string;
const value = tableMeta.get(row, '_value') as number;
if (time && field && value != null) raw.push({ time, field, value });
});
} catch (err) {
console.error(`[clients/traffic] Flux error for ${mac}:`, err);
}
// Separate tx and rx series, sort by time, then compute non-negative deltas
const txSeries = raw.filter(p => p.field === 'tx_bytes').sort((a, b) => a.time.localeCompare(b.time));
const rxSeries = raw.filter(p => p.field === 'rx_bytes').sort((a, b) => a.time.localeCompare(b.time));
const txDeltas = new Map<string, number>();
for (let i = 1; i < txSeries.length; i++) {
const diff = txSeries[i].value - txSeries[i - 1].value;
if (diff >= 0) txDeltas.set(txSeries[i].time, diff);
}
const rxDeltas = new Map<string, number>();
for (let i = 1; i < rxSeries.length; i++) {
const diff = rxSeries[i].value - rxSeries[i - 1].value;
if (diff >= 0) rxDeltas.set(rxSeries[i].time, diff);
}
const allTimes = new Set([...txDeltas.keys(), ...rxDeltas.keys()]);
const points = [...allTimes].sort().map(time => ({
time,
tx_bytes: txDeltas.get(time) ?? 0,
rx_bytes: rxDeltas.get(time) ?? 0,
}));
return res.json(points);
});
// GET /api/clients/:mac/signal?range=2h|24h|7d — signal strength over time
router.get('/:mac/signal', async (req: Request, res: Response) => {
const mac = req.params.mac.toLowerCase();
const rangeRaw = String(req.query.range || '24h');
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
const range = allowedRanges[rangeRaw] || '24h';
const window = range === '7d' ? '30m' : '5m';
const queryApi = getQueryApi();
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_presence")
|> filter(fn: (r) => r._field == "signal_strength")
|> filter(fn: (r) => r.mac_address == "${mac}")
|> group(columns: ["_measurement", "_field", "mac_address"])
|> aggregateWindow(every: ${window}, fn: mean, createEmpty: false)
|> yield(name: "signal")
`;
const points: { time: string; signal_strength: number }[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
if (time && value != null) points.push({ time, signal_strength: Math.round(value) });
});
} catch (err) {
console.error(`[clients/signal] Flux error for ${mac}:`, err);
}
return res.json(points);
});
// POST /api/clients/:mac/wol — send Wake-on-LAN magic packet
router.post('/:mac/wol', requireWrite, async (req: Request, res: Response) => {
const mac = req.params.mac.replace(/[^0-9a-fA-F:.-]/g, '');
const hexMac = mac.replace(/[:\-\.]/g, '');
if (hexMac.length !== 12) {
return res.status(400).json({ error: 'Invalid MAC address' });
}
// Build magic packet: 6 bytes of 0xFF + MAC repeated 16 times
const macBytes = Buffer.from(hexMac, 'hex');
const magic = Buffer.alloc(102);
magic.fill(0xff, 0, 6);
for (let i = 0; i < 16; i++) {
macBytes.copy(magic, 6 + i * 6);
}
return new Promise<void>((resolve) => {
const socket = dgram.createSocket('udp4');
socket.once('error', (err) => {
socket.close();
res.status(500).json({ error: `WoL send failed: ${err.message}` });
resolve();
});
socket.bind(() => {
socket.setBroadcast(true);
socket.send(magic, 0, magic.length, 9, '255.255.255.255', (err) => {
socket.close();
if (err) {
res.status(500).json({ error: `WoL send failed: ${err.message}` });
} else {
res.json({ success: true, message: `Magic packet sent to ${mac}` });
}
resolve();
});
});
});
});
// PUT /api/clients/:mac/hostname (writes to custom_name — user-set names persist across collector cycles)
router.put('/:mac/hostname', requireWrite, async (req: Request, res: Response) => {
const { hostname } = req.body as { hostname?: string };
const mac = req.params.mac.toLowerCase();
const result = await query(
`UPDATE clients SET custom_name = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
[hostname || null, mac]
);
if (!result.length) return res.status(404).json({ error: 'Client not found' });
return res.json(result[0]);
});
// PUT /api/clients/:mac/notes — update the comment/notes field
router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => {
const { notes } = req.body as { notes?: string };
const mac = req.params.mac.toLowerCase();
const result = await query(
`UPDATE clients SET comment = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
[notes ?? null, mac]
);
if (!result.length) return res.status(404).json({ error: 'Client not found' });
return res.json(result[0]);
});
// POST /api/clients/purge — immediately delete inactive clients older than retention period
router.post('/purge', requireAdmin, async (_req: Request, res: Response) => {
if (!pollerService) return res.status(503).json({ error: 'Poller not available' });
const count = await pollerService.pruneStaleClientsNow();
return res.json({ message: `Purged ${count} stale client record(s)`, count });
});
export default router;
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
// GET /api/events
router.get('/', async (req: Request, res: Response) => {
const {
deviceId,
severity,
topic,
search,
since,
limit = '200',
offset = '0',
} = req.query;
const filters: string[] = ['1=1'];
const params: unknown[] = [];
let idx = 1;
if (deviceId) {
filters.push(`e.device_id = $${idx++}`);
params.push(deviceId);
}
if (severity) {
// Accept comma-separated list e.g. "error,warning"
// 'error' also includes 'critical' rows
const requested = String(severity).split(',').map((s) => s.trim()).filter(Boolean);
const expanded = new Set<string>();
for (const s of requested) {
expanded.add(s);
if (s === 'error') expanded.add('critical');
}
const placeholders = Array.from(expanded).map(() => `$${idx++}`).join(',');
filters.push(`e.severity IN (${placeholders})`);
params.push(...Array.from(expanded));
}
if (topic) {
filters.push(`e.topic ILIKE $${idx++}`);
params.push(`%${topic}%`);
}
if (since) {
filters.push(`e.event_time >= $${idx++}`);
params.push(since);
}
if (search) {
filters.push(`(e.message ILIKE $${idx} OR e.topic ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = filters.join(' AND ');
const [events, totalResult, criticalCount] = await Promise.all([
query(
`SELECT e.*, d.name as device_name
FROM events e
LEFT JOIN devices d ON d.id = e.device_id
WHERE ${where}
ORDER BY e.event_time DESC
LIMIT $${idx++} OFFSET $${idx++}`,
[...params, parseInt(String(limit), 10), parseInt(String(offset), 10)]
),
query<{ count: string }>(
`SELECT COUNT(*) as count FROM events e WHERE ${where}`,
params
),
query<{ count: string }>(
`SELECT COUNT(*) as count FROM events WHERE severity IN ('error','critical') AND event_time > NOW() - INTERVAL '24 hours'`
),
]);
res.json({
events,
total: parseInt(totalResult[0]?.count || '0', 10),
criticalCount: parseInt(criticalCount[0]?.count || '0', 10),
});
});
// DELETE /api/events (clear events for a device or all)
router.delete('/', requireWrite, async (req: Request, res: Response) => {
const { deviceId } = req.query;
if (deviceId) {
await query(`DELETE FROM events WHERE device_id = $1`, [deviceId]);
} else {
await query(`DELETE FROM events`);
}
res.json({ message: 'Events cleared' });
});
export default router;
+256
View File
@@ -0,0 +1,256 @@
import { Router, Request, Response } from 'express';
import { getQueryApi, bucket } from '../config/influxdb';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
function rangeToFlux(range: string): string {
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
return allowed.includes(range) ? range : '24h';
}
// GET /api/metrics/clients-over-time?range=24h
router.get('/clients-over-time', async (req: Request, res: Response) => {
const range = rangeToFlux(String(req.query.range || '24h'));
const queryApi = getQueryApi();
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
// This avoids double-counting clients seen by multiple devices simultaneously.
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id == "_global")
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|> yield(name: "clients_over_time")
`;
const rawPoints: { time: string; value: number }[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// InfluxDB might not have data yet
}
// If no global metric exists yet (first run or old data), fall back to max across devices.
// max() is better than sum() since the device with the most clients is the gateway that
// sees everyone — summing would double-count clients visible from multiple devices.
if (rawPoints.length === 0) {
const fallbackQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_counts")
|> filter(fn: (r) => r._field == "total_clients")
|> filter(fn: (r) => r.device_id != "_global")
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> group()
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|> yield(name: "clients_over_time_fallback")
`;
try {
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const value = tableMeta.get(row, '_value') as number;
rawPoints.push({ time, value: Math.round(value) });
});
} catch {
// No data yet
}
}
const points = rawPoints.sort((a, b) => a.time.localeCompare(b.time));
res.json(points);
});
// GET /api/metrics/top-clients?limit=10&range=24h
router.get('/top-clients', async (req: Request, res: Response) => {
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10), 50);
// Use postgres data (most recent client data)
const clients = await query(
`SELECT c.mac_address, c.hostname, c.ip_address, c.interface_name,
c.tx_bytes + c.rx_bytes as total_bytes,
c.tx_bytes, c.rx_bytes, c.client_type, d.name as device_name
FROM clients c JOIN devices d ON d.id = c.device_id
WHERE c.active = TRUE AND (c.tx_bytes + c.rx_bytes) > 0
ORDER BY total_bytes DESC LIMIT $1`,
[limit]
);
res.json(clients);
});
// GET /api/metrics/interface/:deviceId/:interface?range=1h
router.get('/interface/:deviceId/:interface', async (req: Request, res: Response) => {
const range = rangeToFlux(String(req.query.range || '1h'));
const queryApi = getQueryApi();
const deviceId = req.params.deviceId;
const iface = req.params.interface;
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "interface_traffic")
|> filter(fn: (r) => r.device_id == "${deviceId}")
|> filter(fn: (r) => r.interface == "${iface}")
|> filter(fn: (r) => r._field == "rx_bytes" or r._field == "tx_bytes")
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|> derivative(unit: 1s, nonNegative: true)
|> yield(name: "traffic")
`;
const points: Record<string, unknown>[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
points.push({
time: tableMeta.get(row, '_time'),
field: tableMeta.get(row, '_field'),
value: tableMeta.get(row, '_value'),
});
});
} catch {
// No data yet
}
// Pivot rx/tx into single objects per timestamp
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
for (const p of points) {
const t = p['time'] as string;
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
if (p['field'] === 'rx_bytes') pivoted[t].rx = Number(p['value']) || 0;
if (p['field'] === 'tx_bytes') pivoted[t].tx = Number(p['value']) || 0;
}
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
});
// GET /api/metrics/interface/:deviceId/:interface/packets?range=1h
router.get('/interface/:deviceId/:interface/packets', async (req: Request, res: Response) => {
const range = rangeToFlux(String(req.query.range || '1h'));
const queryApi = getQueryApi();
const deviceId = req.params.deviceId;
const iface = req.params.interface;
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "interface_traffic")
|> filter(fn: (r) => r.device_id == "${deviceId}")
|> filter(fn: (r) => r.interface == "${iface}")
|> filter(fn: (r) => r._field == "rx_packets" or r._field == "tx_packets")
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|> derivative(unit: 1s, nonNegative: true)
|> yield(name: "packets")
`;
const points: Record<string, unknown>[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
points.push({
time: tableMeta.get(row, '_time'),
field: tableMeta.get(row, '_field'),
value: tableMeta.get(row, '_value'),
});
});
} catch {
// No data yet
}
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
for (const p of points) {
const t = p['time'] as string;
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
if (p['field'] === 'rx_packets') pivoted[t].rx = Number(p['value']) || 0;
if (p['field'] === 'tx_packets') pivoted[t].tx = Number(p['value']) || 0;
}
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
});
// GET /api/metrics/device/:deviceId/resources?range=24h
router.get('/device/:deviceId/resources', async (req: Request, res: Response) => {
const range = rangeToFlux(String(req.query.range || '24h'));
const queryApi = getQueryApi();
const deviceId = req.params.deviceId;
const fluxQuery = `
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "device_resources")
|> filter(fn: (r) => r.device_id == "${deviceId}")
|> filter(fn: (r) => r._field == "cpu_load" or r._field == "memory_used" or r._field == "memory_total")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> yield(name: "resources")
`;
const points: Record<string, unknown>[] = [];
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
points.push({
time: tableMeta.get(row, '_time'),
field: tableMeta.get(row, '_field'),
value: tableMeta.get(row, '_value'),
});
});
} catch {
// No data yet
}
const pivoted: Record<string, Record<string, unknown>> = {};
for (const p of points) {
const t = p['time'] as string;
if (!pivoted[t]) pivoted[t] = { time: t };
pivoted[t][p['field'] as string] = Number(p['value']) || 0;
}
res.json(Object.values(pivoted).sort((a, b) => String(a['time']).localeCompare(String(b['time']))));
});
// GET /api/metrics/summary - dashboard summary stats
router.get('/summary', async (_req: Request, res: Response) => {
const [deviceStats, clientStats, alertStats] = await Promise.all([
query<{ total: string; online: string; offline: string }>(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status='online') as online,
COUNT(*) FILTER (WHERE status='offline') as offline
FROM devices`
),
query<{ total: string; active: string }>(
`SELECT COUNT(DISTINCT mac_address) as total,
COUNT(DISTINCT mac_address) FILTER (WHERE active=TRUE) as active
FROM clients`
),
query<{ critical: string; warning: string }>(
`SELECT
COUNT(*) FILTER (WHERE severity='error') as critical,
COUNT(*) FILTER (WHERE severity='warning') as warning
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`
),
]);
res.json({
devices: {
total: parseInt(deviceStats[0]?.total || '0', 10),
online: parseInt(deviceStats[0]?.online || '0', 10),
offline: parseInt(deviceStats[0]?.offline || '0', 10),
},
clients: {
total: parseInt(clientStats[0]?.total || '0', 10),
active: parseInt(clientStats[0]?.active || '0', 10),
},
alerts: {
critical: parseInt(alertStats[0]?.critical || '0', 10),
warning: parseInt(alertStats[0]?.warning || '0', 10),
},
});
});
export default router;
+399
View File
@@ -0,0 +1,399 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
// ─── Helper ───────────────────────────────────────────────────────────────────
async function withDevice<T>(
deviceId: number,
res: Response,
fn: (collector: DeviceCollector, device: DeviceRow) => Promise<T>
): Promise<T | void> {
const rows = await query<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [deviceId]);
const device = rows[0];
if (!device) {
res.status(404).json({ error: 'Device not found' });
return;
}
const collector = new DeviceCollector(device);
try {
await collector.connect();
return await fn(collector, device);
} finally {
collector.disconnect();
}
}
function deviceIdParam(req: Request, res: Response): number | null {
const id = parseInt(req.query.deviceId as string);
if (!id) { res.status(400).json({ error: 'deviceId query param is required' }); return null; }
return id;
}
// ─── Overview (all online devices, all services) ──────────────────────────────
router.get('/overview', async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
const results = await Promise.allSettled(
devices.map(async (device: DeviceRow) => {
const collector = new DeviceCollector(device);
try {
await collector.connect();
const [dhcpV4, dhcpV6, dns, ntp, wg] = await Promise.allSettled([
collector.getDhcpServers('ipv4'),
collector.getDhcpServers('ipv6'),
collector.getDnsSettings(),
collector.getNtpSettings(),
collector.getWireGuardInterfaces(),
]);
const v4Servers = dhcpV4.status === 'fulfilled' ? dhcpV4.value : [];
const v6Servers = dhcpV6.status === 'fulfilled' ? dhcpV6.value : [];
const dnsRow = dns.status === 'fulfilled' ? dns.value : null;
const ntpRow = ntp.status === 'fulfilled' ? ntp.value : null;
const wgIfaces = wg.status === 'fulfilled' ? wg.value : [];
return {
id: device.id, name: device.name, ip_address: device.ip_address,
dhcp_v4: { total: v4Servers.length, enabled: v4Servers.filter(s => s['disabled'] !== 'true').length },
dhcp_v6: { total: v6Servers.length, enabled: v6Servers.filter(s => s['disabled'] !== 'true').length },
dns: dnsRow ? { allow_remote: dnsRow['allow-remote-requests'] === 'yes', servers: dnsRow['servers'] || '' } : null,
ntp: ntpRow ? { server_enabled: ntpRow.server['enabled'] === 'yes', client_enabled: ntpRow.client['enabled'] === 'yes' } : null,
wireguard: { total: wgIfaces.length, running: wgIfaces.filter(i => i['running'] === 'true').length },
};
} catch (err) {
return { id: device.id, name: device.name, ip_address: device.ip_address,
dhcp_v4: null, dhcp_v6: null, dns: null, ntp: null, wireguard: null,
error: (err as Error).message };
} finally {
collector.disconnect();
}
})
);
res.json(results.map(r => r.status === 'fulfilled' ? r.value : { error: (r.reason as Error).message }));
});
// ─── DHCP ─────────────────────────────────────────────────────────────────────
// GET /api/network-services/dhcp?deviceId=X
router.get('/dhcp', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const [v4, v6, pv4, pv6] = await Promise.allSettled([
collector.getDhcpServers('ipv4'),
collector.getDhcpServers('ipv6'),
collector.getDhcpPools('ipv4'),
collector.getDhcpPools('ipv6'),
]);
res.json({
ipv4: v4.status === 'fulfilled' ? v4.value : [],
ipv6: v6.status === 'fulfilled' ? v6.value : [],
pools_v4: pv4.status === 'fulfilled' ? pv4.value : [],
pools_v6: pv6.status === 'fulfilled' ? pv6.value : [],
});
});
});
// POST /api/network-services/dhcp/server?deviceId=X — create DHCP server
router.post('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { protocol, ...params } = req.body;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be "ipv4" or "ipv6"' });
await withDevice(deviceId, res, async (collector) => {
await collector.addDhcpServer(params, protocol as 'ipv4' | 'ipv6');
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
res.json(updated);
});
});
// PUT /api/network-services/dhcp/server/:id?deviceId=X — update DHCP server
router.put('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { protocol, ...params } = req.body;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
await withDevice(deviceId, res, async (collector) => {
await collector.updateDhcpServer(req.params.id, params, protocol as 'ipv4' | 'ipv6');
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
res.json(updated);
});
});
// DELETE /api/network-services/dhcp/server/:id?deviceId=X&protocol=X — delete DHCP server
router.delete('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const protocol = req.query.protocol as string;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
await withDevice(deviceId, res, async (collector) => {
await collector.removeDhcpServer(req.params.id, protocol as 'ipv4' | 'ipv6');
res.json({ success: true });
});
});
// PUT /api/network-services/dhcp/server — toggle enable/disable (legacy compat)
router.put('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { serverId, disabled, protocol } = req.body;
if (!serverId) return res.status(400).json({ error: 'serverId is required' });
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
await withDevice(deviceId, res, async (collector) => {
await collector.setDhcpServerDisabled(serverId, disabled, protocol as 'ipv4' | 'ipv6');
res.json({ success: true });
});
});
// GET /api/network-services/dhcp/pools?deviceId=X&protocol=X
router.get('/dhcp/pools', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const protocol = req.query.protocol as string;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
await withDevice(deviceId, res, async (collector) => {
const pools = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
res.json(pools);
});
});
// POST /api/network-services/dhcp/pool?deviceId=X — add pool
router.post('/dhcp/pool', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { protocol, ...params } = req.body;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
await withDevice(deviceId, res, async (collector) => {
await collector.addDhcpPool(params, protocol as 'ipv4' | 'ipv6');
const updated = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
res.json(updated);
});
});
// DELETE /api/network-services/dhcp/pool/:id?deviceId=X&protocol=X — remove pool
router.delete('/dhcp/pool/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const protocol = req.query.protocol as string;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
await withDevice(deviceId, res, async (collector) => {
await collector.removeDhcpPool(req.params.id, protocol as 'ipv4' | 'ipv6');
res.json({ success: true });
});
});
// GET /api/network-services/dhcp/leases?deviceId=X&protocol=X
router.get('/dhcp/leases', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const protocol = (req.query.protocol as string) || 'ipv4';
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be ipv4 or ipv6' });
await withDevice(deviceId, res, async (collector) => {
const leases = await collector.getDhcpLeases(protocol as 'ipv4' | 'ipv6');
res.json(leases);
});
});
// POST /api/network-services/dhcp/static-lease?deviceId=X — add static lease
router.post('/dhcp/static-lease', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { protocol, ...params } = req.body;
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
await withDevice(deviceId, res, async (collector) => {
await collector.addStaticDhcpLease(params, protocol as 'ipv4' | 'ipv6');
res.json({ success: true });
});
});
// DELETE /api/network-services/dhcp/static-lease/:id?deviceId=X&protocol=X
router.delete('/dhcp/static-lease/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const protocol = (req.query.protocol as string) || 'ipv4';
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
await withDevice(deviceId, res, async (collector) => {
await collector.removeStaticDhcpLease(req.params.id, protocol as 'ipv4' | 'ipv6');
res.json({ success: true });
});
});
// ─── DNS ──────────────────────────────────────────────────────────────────────
// GET /api/network-services/dns?deviceId=X
router.get('/dns', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const [settings, statics] = await Promise.allSettled([
collector.getDnsSettings(),
collector.getDnsStaticEntries(),
]);
res.json({
settings: settings.status === 'fulfilled' ? settings.value : {},
statics: statics.status === 'fulfilled' ? statics.value : [],
});
});
});
// PUT /api/network-services/dns?deviceId=X
router.put('/dns', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.setDnsSettings(req.body);
const updated = await collector.getDnsSettings();
res.json(updated);
});
});
// POST /api/network-services/dns/flush?deviceId=X
router.post('/dns/flush', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.flushDnsCache();
res.json({ success: true });
});
});
// GET /api/network-services/dns/static?deviceId=X
router.get('/dns/static', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const entries = await collector.getDnsStaticEntries();
res.json(entries);
});
});
// POST /api/network-services/dns/static?deviceId=X
router.post('/dns/static', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.addDnsStaticEntry(req.body);
const updated = await collector.getDnsStaticEntries();
res.json(updated);
});
});
// PUT /api/network-services/dns/static/:id?deviceId=X
router.put('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.updateDnsStaticEntry(req.params.id, req.body);
const updated = await collector.getDnsStaticEntries();
res.json(updated);
});
});
// DELETE /api/network-services/dns/static/:id?deviceId=X
router.delete('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.removeDnsStaticEntry(req.params.id);
res.json({ success: true });
});
});
// ─── NTP ──────────────────────────────────────────────────────────────────────
// GET /api/network-services/ntp?deviceId=X
router.get('/ntp', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const settings = await collector.getNtpSettings();
res.json(settings);
});
});
// PUT /api/network-services/ntp?deviceId=X
router.put('/ntp', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.setNtpSettings(req.body);
const updated = await collector.getNtpSettings();
res.json(updated);
});
});
// ─── WireGuard ────────────────────────────────────────────────────────────────
// GET /api/network-services/wireguard?deviceId=X
router.get('/wireguard', async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const [interfaces, peers] = await Promise.allSettled([
collector.getWireGuardInterfaces(),
collector.getWireGuardPeers(),
]);
res.json({
interfaces: interfaces.status === 'fulfilled' ? interfaces.value : [],
peers: peers.status === 'fulfilled' ? peers.value : [],
});
});
});
// POST /api/network-services/wireguard?deviceId=X — create interface
router.post('/wireguard', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
const interfaces = await collector.addWireGuardInterface(req.body);
res.json(interfaces);
});
});
// PUT /api/network-services/wireguard/toggle — enable/disable (must be before /:id)
router.put('/wireguard/toggle', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
const { interfaceId, disabled } = req.body;
if (!interfaceId) return res.status(400).json({ error: 'interfaceId is required' });
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
await withDevice(deviceId, res, async (collector) => {
await collector.setWireGuardInterfaceDisabled(interfaceId, disabled);
res.json({ success: true });
});
});
// PUT /api/network-services/wireguard/:id?deviceId=X — update interface
router.put('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.updateWireGuardInterface(req.params.id, req.body);
const updated = await collector.getWireGuardInterfaces();
res.json(updated);
});
});
// DELETE /api/network-services/wireguard/:id?deviceId=X — delete interface
router.delete('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.removeWireGuardInterface(req.params.id);
res.json({ success: true });
});
});
// POST /api/network-services/wireguard/peer?deviceId=X — add peer
router.post('/wireguard/peer', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.addWireGuardPeer(req.body);
const peers = await collector.getWireGuardPeers();
res.json(peers);
});
});
// PUT /api/network-services/wireguard/peer/:id?deviceId=X — update peer
router.put('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.updateWireGuardPeer(req.params.id, req.body);
const peers = await collector.getWireGuardPeers();
res.json(peers);
});
});
// DELETE /api/network-services/wireguard/peer/:id?deviceId=X — delete peer
router.delete('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
await withDevice(deviceId, res, async (collector) => {
await collector.removeWireGuardPeer(req.params.id);
res.json({ success: true });
});
});
export default router;
+150
View File
@@ -0,0 +1,150 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
router.get('/lldp', async (_req: Request, res: Response) => {
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
);
const results = await Promise.allSettled(
routers.map(async (r: DeviceRow) => {
const collector = new DeviceCollector(r);
try {
await collector.connect();
const lldp = await collector.getLldpEnabled();
return { id: r.id, name: r.name, ip_address: r.ip_address, ...lldp };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: routers[i].id,
name: routers[i].name,
ip_address: routers[i].ip_address,
enabled: null as boolean | null,
protocol: null as string | null,
error: (r.reason as Error).message,
};
});
res.json(statuses);
});
// PUT /api/routers/lldp — enable or disable LLDP on all online routers
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: '"enabled" (boolean) is required' });
}
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
);
const results = await Promise.allSettled(
routers.map(async (r: DeviceRow) => {
const collector = new DeviceCollector(r);
try {
await collector.connect();
await collector.setLldpEnabled(enabled);
return { id: r.id, name: r.name, success: true };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: routers[i].id,
name: routers[i].name,
success: false,
error: (r.reason as Error).message,
};
});
return res.json({
applied: statuses.filter(s => s.success).length,
total: routers.length,
results: statuses,
});
});
// GET /api/routers/snmp — SNMP config/status per online router
router.get('/snmp', async (_req: Request, res: Response) => {
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
);
const results = await Promise.allSettled(
routers.map(async (r: DeviceRow) => {
const collector = new DeviceCollector(r);
try {
await collector.connect();
const snmp = await collector.getSnmpConfig();
return { id: r.id, name: r.name, ip_address: r.ip_address, ...snmp };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: routers[i].id, name: routers[i].name, ip_address: routers[i].ip_address,
enabled: null as boolean | null, error: (r.reason as Error).message,
};
});
res.json(statuses);
});
// PUT /api/routers/snmp — apply SNMP config to all online routers
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
const config = req.body as {
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
contact?: string; location?: string; trap_target?: string;
auth_protocol?: string; auth_password?: string;
priv_protocol?: string; priv_password?: string;
};
if (!config.community_name || !config.version) {
return res.status(400).json({ error: 'community_name and version are required' });
}
const routers = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
);
const results = await Promise.allSettled(
routers.map(async (r: DeviceRow) => {
const collector = new DeviceCollector(r);
try {
await collector.connect();
await collector.setSnmpConfig(config);
return { id: r.id, name: r.name, success: true };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return { id: routers[i].id, name: routers[i].name, success: false, error: (r.reason as Error).message };
});
return res.json({ applied: statuses.filter(s => s.success).length, total: routers.length, results: statuses });
});
export default router;
+51
View File
@@ -0,0 +1,51 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
// GET /api/search?q=<term>
router.get('/', async (req: Request, res: Response) => {
const q = String(req.query.q || '').trim();
if (q.length < 2) {
return res.json({ devices: [], clients: [], events: [] });
}
const like = `%${q}%`;
const [devices, clients, events] = await Promise.all([
query(
`SELECT id, name, ip_address, model, device_type, status
FROM devices
WHERE name ILIKE $1 OR ip_address ILIKE $1 OR model ILIKE $1 OR serial_number ILIKE $1
ORDER BY (status = 'online') DESC, name ASC
LIMIT 6`,
[like]
),
query(
`SELECT c.mac_address, c.hostname, c.ip_address, c.device_id, c.active,
d.name as device_name
FROM clients c
LEFT JOIN devices d ON d.id = c.device_id
WHERE c.mac_address ILIKE $1 OR c.hostname ILIKE $1 OR c.ip_address ILIKE $1
ORDER BY c.active DESC, c.last_seen DESC
LIMIT 6`,
[like]
),
query(
`SELECT e.id, e.message, e.severity, e.event_time, e.topic, e.device_id,
d.name as device_name
FROM events e
LEFT JOIN devices d ON d.id = e.device_id
WHERE e.message ILIKE $1 OR e.topic ILIKE $1 OR d.name ILIKE $1
ORDER BY e.event_time DESC
LIMIT 6`,
[like]
),
]);
res.json({ devices, clients, events });
});
export default router;
+115
View File
@@ -0,0 +1,115 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
const router = Router();
router.use(requireAuth);
// GET /api/settings
router.get('/', async (_req: Request, res: Response) => {
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
const map: Record<string, unknown> = {};
for (const s of settings as { key: string; value: unknown }[]) {
map[s.key] = s.value;
}
res.json(map);
});
// PUT /api/settings
router.put('/', requireAdmin, async (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
await query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, JSON.stringify(value)]
);
}
res.json({ message: 'Settings updated' });
});
// GET /api/settings/users
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
const users = await query(
`SELECT id, username, role, created_at FROM users ORDER BY username`
);
res.json(users);
});
// POST /api/settings/users
router.post('/users', requireAdmin, async (req: Request, res: Response) => {
const { username, password, role = 'viewer' } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}
const validRoles = ['admin', 'operator', 'viewer'];
if (!validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
}
const existing = await queryOne(`SELECT id FROM users WHERE username = $1`, [username]);
if (existing) return res.status(409).json({ error: 'Username already exists' });
const hash = await bcrypt.hash(password, 12);
const rows = await query<{ id: number }>(
`INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id`,
[username, hash, role]
);
const created = await queryOne(
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
[rows[0].id]
);
return res.status(201).json(created);
});
// PUT /api/settings/users/:id - update role and/or reset password
router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const { role, password } = req.body;
const existing = await queryOne<{ id: number; role: string }>(
`SELECT id, role FROM users WHERE id = $1`,
[userId]
);
if (!existing) return res.status(404).json({ error: 'User not found' });
// Cannot demote yourself
if (userId === req.user!.userId && role && role !== 'admin') {
return res.status(400).json({ error: 'Cannot change your own role away from admin' });
}
const validRoles = ['admin', 'operator', 'viewer'];
if (role && !validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
if (role) {
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
}
if (password) {
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
const hash = await bcrypt.hash(password, 12);
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
}
const updated = await queryOne(
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
[userId]
);
return res.json(updated);
});
// DELETE /api/settings/users/:id
router.delete('/users/:id', requireAdmin, async (req: Request, res: Response) => {
if (parseInt(req.params.id) === req.user!.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const result = await query(`DELETE FROM users WHERE id = $1 RETURNING id`, [req.params.id]);
if (!result.length) return res.status(404).json({ error: 'User not found' });
return res.json({ message: 'User deleted' });
});
export default router;
+170
View File
@@ -0,0 +1,170 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
// GET /api/switches — all switch devices with port statistics
router.get('/', async (_req: Request, res: Response) => {
const switches = await query(`
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
COUNT(i.id) FILTER (WHERE i.running = true AND i.disabled = false) AS ports_up,
COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ports_down,
COUNT(i.id) FILTER (WHERE i.disabled = true) AS ports_disabled,
COUNT(i.id) AS ports_total
FROM devices d
LEFT JOIN interfaces i ON i.device_id = d.id
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
WHERE d.device_type = 'switch'
GROUP BY d.id
ORDER BY d.name ASC
`);
res.json(switches);
});
// GET /api/switches/lldp — LLDP enabled/disabled status per online switch
router.get('/lldp', async (_req: Request, res: Response) => {
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
);
const results = await Promise.allSettled(
switches.map(async (sw: DeviceRow) => {
const collector = new DeviceCollector(sw);
try {
await collector.connect();
const lldp = await collector.getLldpEnabled();
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...lldp };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: switches[i].id,
name: switches[i].name,
ip_address: switches[i].ip_address,
enabled: null as boolean | null,
protocol: null as string | null,
error: (r.reason as Error).message,
};
});
res.json(statuses);
});
// PUT /api/switches/lldp — enable or disable LLDP on all online switches
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: '"enabled" (boolean) is required' });
}
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
);
const results = await Promise.allSettled(
switches.map(async (sw: DeviceRow) => {
const collector = new DeviceCollector(sw);
try {
await collector.connect();
await collector.setLldpEnabled(enabled);
return { id: sw.id, name: sw.name, success: true };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: switches[i].id,
name: switches[i].name,
success: false,
error: (r.reason as Error).message,
};
});
return res.json({
applied: statuses.filter(s => s.success).length,
total: switches.length,
results: statuses,
});
});
// GET /api/switches/snmp — SNMP config/status per online switch
router.get('/snmp', async (_req: Request, res: Response) => {
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
);
const results = await Promise.allSettled(
switches.map(async (sw: DeviceRow) => {
const collector = new DeviceCollector(sw);
try {
await collector.connect();
const snmp = await collector.getSnmpConfig();
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...snmp };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return {
id: switches[i].id, name: switches[i].name, ip_address: switches[i].ip_address,
enabled: null as boolean | null, error: (r.reason as Error).message,
};
});
res.json(statuses);
});
// PUT /api/switches/snmp — apply SNMP config to all online switches
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
const config = req.body as {
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
contact?: string; location?: string; trap_target?: string;
auth_protocol?: string; auth_password?: string;
priv_protocol?: string; priv_password?: string;
};
if (!config.community_name || !config.version) {
return res.status(400).json({ error: 'community_name and version are required' });
}
const switches = await query<DeviceRow>(
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
);
const results = await Promise.allSettled(
switches.map(async (sw: DeviceRow) => {
const collector = new DeviceCollector(sw);
try {
await collector.connect();
await collector.setSnmpConfig(config);
return { id: sw.id, name: sw.name, success: true };
} finally {
collector.disconnect();
}
})
);
const statuses = results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return { id: switches[i].id, name: switches[i].name, success: false, error: (r.reason as Error).message };
});
return res.json({ applied: statuses.filter(s => s.success).length, total: switches.length, results: statuses });
});
export default router;
+116
View File
@@ -0,0 +1,116 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { PollerService } from '../services/PollerService';
const router = Router();
router.use(requireAuth);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void {
pollerService = p;
}
// GET /api/topology
router.get('/', async (_req: Request, res: Response) => {
// Protocol priority: lldp (point-to-point) > cdp > mndp (L2 broadcast, can be indirect)
const PROTO_RANK: Record<string, number> = { lldp: 0, cdp: 1, mndp: 2 };
const protoRank = (p: string | null) => PROTO_RANK[p ?? ''] ?? 3;
interface LinkRow {
id: number;
from_device_id: number | null;
from_interface: string | null;
to_interface: string | null;
to_device_id: number | null;
neighbor_address: string | null;
neighbor_identity: string | null;
neighbor_platform: string | null;
neighbor_mac: string | null;
stp_role: string | null;
stp_state: string | null;
bridge_name: string | null;
neighbor_caps: string | null;
link_type: string | null;
discovered_by: string | null;
from_device_name: string | null;
to_device_name: string | null;
}
const [devices, allLinks] = await Promise.all([
query(
`SELECT id, name, ip_address, model, device_type, status, ros_version
FROM devices ORDER BY name ASC`
),
query<LinkRow>(
`SELECT tl.*,
fd.name as from_device_name, fd.ip_address as from_device_ip,
td.name as to_device_name, td.ip_address as to_device_ip
FROM topology_links tl
LEFT JOIN devices fd ON fd.id = tl.from_device_id
LEFT JOIN devices td ON td.id = tl.to_device_id
ORDER BY tl.discovered_at DESC`
),
]);
// Deduplicate: for each (from_device, neighbor) pair keep only the highest-priority
// protocol link. This removes MNDP duplicates when the same neighbor was also found
// via LLDP or CDP on the same or a better-quality path.
const bestLinks = new Map<string, LinkRow>();
for (const link of allLinks as LinkRow[]) {
if (!link.from_device_id) continue;
const neighborKey = link.to_device_id
? String(link.to_device_id)
: (link.neighbor_mac || link.neighbor_address || link.neighbor_identity || '');
const key = `${link.from_device_id}::${neighborKey}`;
const existing = bestLinks.get(key);
if (!existing || protoRank(link.link_type) < protoRank(existing.link_type)) {
bestLinks.set(key, link);
}
}
const links = Array.from(bestLinks.values());
// Build synthetic external nodes from unresolved neighbor entries
const externalMap = new Map<string, {
id: string;
name: string;
address: string;
platform: string;
mac: string;
caps: string;
}>();
for (const link of links) {
if (link.to_device_id) continue; // resolved — skip
const key = link.neighbor_address || link.neighbor_mac || link.neighbor_identity || '';
if (!key) continue;
if (!externalMap.has(key)) {
const safeId = key.replace(/[.: ]/g, '');
externalMap.set(key, {
id: `ext-${safeId}`,
name: link.neighbor_identity || link.neighbor_address || 'Unknown',
address: link.neighbor_address || '',
platform: link.neighbor_platform || '',
mac: link.neighbor_mac || '',
caps: link.neighbor_caps || '',
});
}
}
res.json({ devices, links, externalNodes: Array.from(externalMap.values()) });
});
// POST /api/topology/discover - trigger neighbor discovery on all devices
router.post('/discover', requireWrite, async (_req: Request, res: Response) => {
const devices = await query<{ id: number }>(`SELECT id FROM devices WHERE status='online'`);
if (pollerService) {
for (const d of devices) {
await pollerService.scheduleDeviceSync(d.id, 'slow');
}
}
res.json({ message: `Discovery triggered for ${devices.length} device(s)` });
});
export default router;
+704
View File
@@ -0,0 +1,704 @@
import { Router, Request, Response } from 'express';
import { query } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { lookupVendor } from '../utils/oui';
const router = Router();
router.use(requireAuth);
// ─── Helper ───────────────────────────────────────────────────────────────────
async function getAP(id: number): Promise<DeviceRow | null> {
const rows = await query<DeviceRow>(
`SELECT * FROM devices WHERE id = $1 AND device_type = 'wireless_ap'`,
[id]
);
return rows[0] ?? null;
}
// ─── Bulk SSID deployment ─────────────────────────────────────────────────────
// POST /api/wireless/ssid/bulk — create the same SSID on multiple APs simultaneously
router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
const { apIds, ...ssidBody } = req.body as { apIds: number[] } & Record<string, unknown>;
if (!Array.isArray(apIds) || apIds.length === 0) {
return res.status(400).json({ error: '"apIds" must be a non-empty array' });
}
if (!ssidBody.ssid) {
return res.status(400).json({ error: '"ssid" is required' });
}
const placeholders = apIds.map((_, i) => `$${i + 1}`).join(',');
const aps = await query<DeviceRow>(
`SELECT * FROM devices WHERE id IN (${placeholders}) AND device_type = 'wireless_ap'`,
apIds
);
const settled = await Promise.allSettled(
aps.map(async (ap) => {
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const pkg = await collector.detectWifiPackage();
const autoName = await collector.getNextInterfaceName();
// Normalize mode to the correct value for this AP's package type
let resolvedMode = String(ssidBody.mode || 'ap-bridge');
if (pkg === 'wifi' && (resolvedMode === 'ap-bridge' || resolvedMode === 'bridge')) {
resolvedMode = 'ap';
} else if (pkg !== 'wifi' && resolvedMode === 'ap') {
resolvedMode = 'ap-bridge';
}
const params: Record<string, string> = {
name: autoName,
ssid: String(ssidBody.ssid),
mode: resolvedMode,
disabled: ssidBody.disabled ? 'yes' : 'no',
};
const fieldMap: Record<string, string> = {
band: 'band', frequency: 'frequency', channel_width: 'channel-width',
tx_power: 'tx-power', tx_power_mode: 'tx-power-mode',
antenna_gain: 'antenna-gain', country: 'country', installation: 'installation',
security_profile: 'security-profile', passphrase: 'passphrase',
};
for (const [key, rosKey] of Object.entries(fieldMap)) {
if (ssidBody[key]) params[rosKey] = String(ssidBody[key]);
}
if (ssidBody.authentication_types) {
params['authentication-types'] = Array.isArray(ssidBody.authentication_types)
? (ssidBody.authentication_types as string[]).join(',')
: String(ssidBody.authentication_types);
}
await collector.addWirelessInterface(params);
await collector.collectWirelessInterfaces();
return { id: ap.id, name: ap.name as string, ok: true };
} catch (err) {
return { id: ap.id, name: ap.name as string, ok: false, error: (err as Error).message };
} finally {
collector.disconnect();
}
})
);
const results = settled.map(r =>
r.status === 'fulfilled' ? r.value : { id: 0, name: 'unknown', ok: false, error: String(r.reason) }
);
return res.json({ results });
});
// ─── Section overview ─────────────────────────────────────────────────────────
// GET /api/wireless — all wireless_ap devices with aggregated stats
router.get('/', async (_req: Request, res: Response) => {
const aps = await query(`
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
COUNT(DISTINCT wi.id) AS radio_count,
COALESCE(SUM(wi.registered_clients), 0) AS client_count,
COUNT(DISTINCT wi.id) FILTER (WHERE wi.ssid IS NOT NULL AND wi.disabled = false) AS ssid_count
FROM devices d
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
WHERE d.device_type = 'wireless_ap'
GROUP BY d.id
ORDER BY d.name ASC
`);
res.json(aps);
});
// ─── Wireless interfaces (SSIDs) ──────────────────────────────────────────────
// GET /api/wireless/:id/bridges — bridge list + current port memberships
router.get('/:id/bridges', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const [bridges, ports] = await Promise.all([
collector.getBridges(),
collector.getBridgePorts(),
]);
return res.json({ bridges, ports });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// GET /api/wireless/:id/interfaces — live from device
router.get('/:id/interfaces', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const ifaces = await collector.getWirelessInterfaces();
return res.json(ifaces);
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/wireless/:id/interfaces — create new SSID/virtual AP
router.post('/:id/interfaces', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const {
ssid, mode = 'ap-bridge', band, frequency, channel_width,
tx_power, tx_power_mode, antenna_gain, country, installation,
security_profile, disabled = false,
passphrase, authentication_types,
bridge, vlan_id,
// advanced
master_interface,
} = req.body as Record<string, string | boolean | number | string[] | undefined>;
if (!ssid) {
return res.status(400).json({ error: '"ssid" is required' });
}
const collector = new DeviceCollector(ap);
try {
await collector.connect();
// Auto-generate the next available interface name (e.g. wifi3, wifi4 …)
const autoName = await collector.getNextInterfaceName();
const params: Record<string, string> = {
name: autoName,
ssid: String(ssid),
mode: String(mode),
disabled: disabled ? 'yes' : 'no',
};
if (band) params['band'] = String(band);
if (frequency) params['frequency'] = String(frequency);
if (channel_width) params['channel-width'] = String(channel_width);
if (tx_power) params['tx-power'] = String(tx_power);
if (tx_power_mode) params['tx-power-mode'] = String(tx_power_mode);
if (antenna_gain !== undefined) params['antenna-gain'] = String(antenna_gain);
if (country) params['country'] = String(country);
if (installation) params['installation'] = String(installation);
if (security_profile) params['security-profile'] = String(security_profile);
if (passphrase) params['passphrase'] = String(passphrase);
if (authentication_types) {
params['authentication-types'] = Array.isArray(authentication_types)
? authentication_types.join(',')
: String(authentication_types);
}
if (master_interface) params['master-interface'] = String(master_interface);
await collector.addWirelessInterface(params);
// Bridge port membership — must be done after interface exists
if (bridge) {
await collector.setInterfaceBridge(
autoName,
String(bridge),
vlan_id ? Number(vlan_id) : undefined,
);
// Ensure the VLAN ID is also in the bridge VLAN table (untagged)
if (vlan_id) {
await collector.ensureVlanMembership(autoName, Number(vlan_id));
}
}
// Refresh cache
await collector.collectWirelessInterfaces();
return res.status(201).json({ ok: true, name: autoName });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// PUT /api/wireless/:id/interfaces/:name — update SSID/radio config
router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const ifaceName = req.params.name;
const body = req.body as Record<string, string | boolean | number | undefined>;
const params: Record<string, string> = {};
const map: Record<string, string> = {
ssid: 'ssid', mode: 'mode', band: 'band', frequency: 'frequency',
channel_width: 'channel-width', tx_power: 'tx-power',
tx_power_mode: 'tx-power-mode', antenna_gain: 'antenna-gain',
country: 'country', installation: 'installation',
security_profile: 'security-profile', master_interface: 'master-interface',
passphrase: 'passphrase',
};
for (const [key, rosKey] of Object.entries(map)) {
if (body[key] !== undefined) params[rosKey] = String(body[key]);
}
if (body.disabled !== undefined) params['disabled'] = body.disabled ? 'yes' : 'no';
if (body.authentication_types !== undefined) {
params['authentication-types'] = Array.isArray(body.authentication_types)
? (body.authentication_types as string[]).join(',')
: String(body.authentication_types);
}
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.setWirelessInterface(ifaceName, params);
// Bridge port membership — only touched when 'bridge' key is present in the request
if (body.bridge !== undefined) {
const bridgeName = body.bridge ? String(body.bridge) : null;
const pvid = body.vlan_id ? Number(body.vlan_id) : undefined;
await collector.setInterfaceBridge(ifaceName, bridgeName, pvid);
// Ensure the VLAN ID is in the bridge VLAN table (untagged)
if (bridgeName && body.vlan_id) {
await collector.ensureVlanMembership(ifaceName, Number(body.vlan_id));
}
}
await collector.collectWirelessInterfaces();
return res.json({ ok: true });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// DELETE /api/wireless/:id/interfaces/:name
router.delete('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.removeWirelessInterface(req.params.name);
await query(
`DELETE FROM wireless_interfaces WHERE device_id=$1 AND name=$2`,
[ap.id, req.params.name]
);
return res.json({ ok: true });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// ─── Security Profiles ────────────────────────────────────────────────────────
// GET /api/wireless/:id/security-profiles — live from device
router.get('/:id/security-profiles', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const profiles = await collector.getSecurityProfilesLive();
return res.json(profiles);
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/wireless/:id/security-profiles
router.post('/:id/security-profiles', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const {
name, mode = 'dynamic-keys',
authentication_types, passphrase,
unicast_ciphers, group_ciphers, management_protection = 'disabled',
management_protection_key,
} = req.body as Record<string, string | string[] | undefined>;
if (!name) return res.status(400).json({ error: '"name" is required' });
const params: Record<string, string> = {
name: String(name),
mode: String(mode),
'management-protection': String(management_protection),
};
if (authentication_types) {
params['authentication-types'] = Array.isArray(authentication_types)
? authentication_types.join(',')
: String(authentication_types);
}
if (unicast_ciphers) {
params['unicast-ciphers'] = Array.isArray(unicast_ciphers)
? unicast_ciphers.join(',')
: String(unicast_ciphers);
}
if (group_ciphers) {
params['group-ciphers'] = Array.isArray(group_ciphers)
? group_ciphers.join(',')
: String(group_ciphers);
}
if (passphrase) params['passphrase'] = String(passphrase);
if (management_protection_key) params['management-protection-key'] = String(management_protection_key);
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.addSecurityProfile(params);
await collector.collectSecurityProfiles();
return res.status(201).json({ ok: true });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// PUT /api/wireless/:id/security-profiles/:name
router.put('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const profileName = req.params.name;
const body = req.body as Record<string, string | string[] | undefined>;
const params: Record<string, string> = {};
if (body.mode) params['mode'] = String(body.mode);
if (body.management_protection) params['management-protection'] = String(body.management_protection);
if (body.management_protection_key) params['management-protection-key'] = String(body.management_protection_key);
if (body.passphrase) params['passphrase'] = String(body.passphrase);
if (body.authentication_types !== undefined) {
params['authentication-types'] = Array.isArray(body.authentication_types)
? body.authentication_types.join(',')
: String(body.authentication_types);
}
if (body.unicast_ciphers !== undefined) {
params['unicast-ciphers'] = Array.isArray(body.unicast_ciphers)
? body.unicast_ciphers.join(',')
: String(body.unicast_ciphers);
}
if (body.group_ciphers !== undefined) {
params['group-ciphers'] = Array.isArray(body.group_ciphers)
? body.group_ciphers.join(',')
: String(body.group_ciphers);
}
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.setSecurityProfile(profileName, params);
await collector.collectSecurityProfiles();
return res.json({ ok: true });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// DELETE /api/wireless/:id/security-profiles/:name
router.delete('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
await collector.removeSecurityProfile(req.params.name);
await query(
`DELETE FROM wireless_security_profiles WHERE device_id=$1 AND name=$2`,
[ap.id, req.params.name]
);
return res.json({ ok: true });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// ─── Radio monitoring / diagnostics ──────────────────────────────────────────
// GET /api/wireless/:id/radios — hardware radio capabilities (wifi package only)
router.get('/:id/radios', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const radios = await collector.getWifiRadioInfo();
return res.json(radios);
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// GET /api/wireless/:id/registration-table — connected wireless clients
router.get('/:id/registration-table', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const table = await collector.getWirelessRegistrationTable();
return res.json(table);
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// GET /api/wireless/:id/monitor/:iface — real-time radio monitor snapshot
router.get('/:id/monitor/:iface', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const data = await collector.getWirelessMonitor(req.params.iface);
return res.json(data[0] ?? {});
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// GET /api/wireless/:id/scan/:iface — scan for nearby APs (5 s)
router.get('/:id/scan/:iface', async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const results = await collector.scanWireless(req.params.iface);
return res.json(results);
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// Aggregate raw RouterOS spectral-scan rows (35 k+ per 10 s scan) into one point
// per unique frequency. Each row has: freq (MHz, string), magn (dBm, string),
// peak (dBm, string). We average magn and keep the highest peak per frequency.
function aggregateSpectralRows(
rows: Record<string, string>[]
): { freq: number; magn: number; peak: number }[] {
const map = new Map<number, { sum: number; count: number; peak: number }>();
for (const row of rows) {
const freq = parseFloat(row['freq'] || '0');
const magn = parseInt(row['magn'] || '-120', 10);
const peak = parseInt(row['peak'] || magn.toString(), 10);
if (freq <= 0) continue;
const existing = map.get(freq);
if (existing) {
existing.sum += magn;
existing.count += 1;
existing.peak = Math.max(existing.peak, peak);
} else {
map.set(freq, { sum: magn, count: 1, peak });
}
}
return Array.from(map.entries())
.sort(([a], [b]) => a - b)
.map(([freq, { sum, count, peak }]) => ({
freq,
magn: Math.round(sum / count),
peak,
}));
}
// POST /api/wireless/:id/spectral-scan/:iface — run an on-demand spectral scan,
// save the aggregated result and return it immediately.
router.post('/:id/spectral-scan/:iface', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const rows = await collector.collectSpectralScan(req.params.iface);
if (rows.length === 0) {
return res.status(422).json({ error: 'No spectral data returned — interface may not support spectral scan' });
}
const aggregated = aggregateSpectralRows(rows);
const saved = await query<{ id: number; scanned_at: string }>(
`INSERT INTO spectral_scan_data (device_id, interface_name, data, scan_type)
VALUES ($1, $2, $3, 'manual')
RETURNING id, scanned_at`,
[ap.id, req.params.iface, JSON.stringify(aggregated)]
);
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// GET /api/wireless/:id/spectral-history/:iface?limit=5 — return last N scans from DB
router.get('/:id/spectral-history/:iface', async (req: Request, res: Response) => {
const deviceId = parseInt(req.params.id);
const iface = req.params.iface;
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
`SELECT id, scanned_at, scan_type, data
FROM spectral_scan_data
WHERE device_id = $1 AND interface_name = $2
ORDER BY scanned_at DESC
LIMIT $3`,
[deviceId, iface, limit]
);
return res.json(scans);
});
// ─── AP scan ──────────────────────────────────────────────────────────────────
interface APBandEntry {
bssid: string; vendor: string; signal: number;
freq: number; band: string; channel_width: string;
}
interface APNetworkEntry {
ssid: string; security: string; hidden: boolean; entries: APBandEntry[];
}
function normBand(band: string, freq: number): string {
if (band.includes('6ghz') || freq >= 5925) return '6 GHz';
if (band.includes('5ghz') || (freq >= 4900 && freq < 5925)) return '5 GHz';
return '2.4 GHz';
}
function aggregateAPScanRows(
allRows: { iface: string; rows: Record<string, string>[] }[]
): APNetworkEntry[] {
const byKey = new Map<string, APNetworkEntry>();
for (const { rows } of allRows) {
for (const row of rows) {
const ssid = row['network-name'] || row['ssid'] || '';
const bssid = (row['address'] || row['bssid'] || '').toLowerCase();
if (!bssid) continue;
const rawSig = row['signal-strength'] || row['signal'] || '-100';
const signal = parseInt(rawSig, 10) || -100;
const freq = parseFloat(row['frequency'] || row['channel'] || '0');
const band = normBand(row['band'] || row['radio-band'] || '', freq);
// security: prefer explicit field; if auth-types present but no explicit security, call it WPA
const security = row['security']
|| (row['authentication-types'] ? 'WPA' : 'open');
const channelWidth = row['channel-width'] || '';
const key = ssid || `hidden:${bssid}`;
if (!byKey.has(key)) {
byKey.set(key, { ssid, security, hidden: !ssid, entries: [] });
}
const net = byKey.get(key)!;
const existing = net.entries.find(e => e.bssid === bssid && e.freq === freq);
if (existing) {
if (signal > existing.signal) existing.signal = signal;
} else {
net.entries.push({
bssid,
vendor: lookupVendor(bssid),
signal,
freq,
band,
channel_width: channelWidth,
});
}
}
}
return Array.from(byKey.values()).sort((a, b) => {
const aBest = Math.max(...a.entries.map(e => e.signal));
const bBest = Math.max(...b.entries.map(e => e.signal));
return bBest - aBest;
});
}
// POST /api/wireless/:id/ap-scan — scan all interfaces, combine results, save + return
router.post('/:id/ap-scan', requireWrite, async (req: Request, res: Response) => {
const ap = await getAP(parseInt(req.params.id));
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
// Only scan physical radios (master=true). Virtual APs (master=false, e.g. extra SSIDs)
// share a radio with their master interface and cannot initiate scans independently.
const ifaces = await query<{ name: string }>(
`SELECT name FROM wireless_interfaces
WHERE device_id = $1
AND disabled = FALSE
AND (config_json->>'master' IS NULL OR config_json->>'master' != 'false')`,
[ap.id]
);
if (ifaces.length === 0) {
return res.status(422).json({ error: 'No active wireless interfaces found' });
}
// Use a separate TCP connection per radio — a 10 s streaming scan can leave
// residual bytes in the socket that corrupt a subsequent scan on the same connection.
const allRows: { iface: string; rows: Record<string, string>[] }[] = [];
for (const iface of ifaces) {
const collector = new DeviceCollector(ap);
try {
await collector.connect();
const rows = await collector.scanWireless(iface.name, 10_000);
if (rows.length > 0) allRows.push({ iface: iface.name, rows });
} catch {
// ignore per-interface errors; continue to next radio
} finally {
collector.disconnect();
}
}
if (allRows.length === 0) {
return res.status(422).json({ error: 'No AP scan data returned' });
}
try {
const aggregated = aggregateAPScanRows(allRows);
const saved = await query<{ id: number; scanned_at: string }>(
`INSERT INTO ap_scan_data (device_id, data, scan_type)
VALUES ($1, $2, 'manual') RETURNING id, scanned_at`,
[ap.id, JSON.stringify(aggregated)]
);
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
} catch (err) {
return res.status(502).json({ error: (err as Error).message });
}
});
// GET /api/wireless/:id/ap-scan-history?limit=5 — last N saved scans
router.get('/:id/ap-scan-history', async (req: Request, res: Response) => {
const deviceId = parseInt(req.params.id);
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
`SELECT id, scanned_at, scan_type, data
FROM ap_scan_data
WHERE device_id = $1
ORDER BY scanned_at DESC
LIMIT $2`,
[deviceId, limit]
);
return res.json(scans);
});
export default router;