Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
887 lines
35 KiB
TypeScript
887 lines
35 KiB
TypeScript
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);
|
|
});
|
|
|
|
// ─── RF Health (channel map, RSSI density, TX retries, connectivity) ──────────
|
|
|
|
// Optional ?deviceId= scopes any of the RF endpoints to a single AP.
|
|
function deviceScope(req: Request): number | null {
|
|
const raw = req.query.deviceId;
|
|
if (raw === undefined || raw === '') return null;
|
|
const id = parseInt(String(raw), 10);
|
|
return Number.isFinite(id) ? id : null;
|
|
}
|
|
|
|
// GET /api/wireless/rf/channels — active physical radios with band/frequency/width
|
|
router.get('/rf/channels', async (req: Request, res: Response) => {
|
|
const deviceId = deviceScope(req);
|
|
const rows = await query(`
|
|
SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid,
|
|
wi.band, wi.frequency, wi.channel_width, wi.registered_clients
|
|
FROM wireless_interfaces wi
|
|
JOIN devices d ON d.id = wi.device_id
|
|
WHERE d.device_type = 'wireless_ap'
|
|
AND wi.disabled = FALSE
|
|
AND wi.frequency IS NOT NULL AND wi.frequency > 0
|
|
AND (wi.config_json->>'master-interface') IS NULL
|
|
${deviceId ? 'AND wi.device_id = $1' : ''}
|
|
ORDER BY wi.frequency ASC, d.name ASC
|
|
`, deviceId ? [deviceId] : []);
|
|
res.json(rows);
|
|
});
|
|
|
|
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)
|
|
router.get('/rf/signals', async (req: Request, res: Response) => {
|
|
const deviceId = deviceScope(req);
|
|
const rows = await query(`
|
|
SELECT c.mac_address, c.signal_strength, c.device_id, d.name AS device_name,
|
|
c.custom_name, c.hostname
|
|
FROM clients c
|
|
JOIN devices d ON d.id = c.device_id
|
|
WHERE c.active = TRUE
|
|
AND c.client_type = 'wireless'
|
|
AND c.signal_strength IS NOT NULL
|
|
AND c.signal_strength < 0
|
|
${deviceId ? 'AND c.device_id = $1' : ''}
|
|
ORDER BY c.signal_strength DESC
|
|
`, deviceId ? [deviceId] : []);
|
|
res.json(rows);
|
|
});
|
|
|
|
// GET /api/wireless/rf/tx-quality?range=6h — latest per-radio TX retry % (from CCQ)
|
|
router.get('/rf/tx-quality', async (req: Request, res: Response) => {
|
|
const deviceId = deviceScope(req);
|
|
const ranges: Record<string, string> = { '1h': '1h', '3h': '3h', '6h': '6h', '12h': '12h', '24h': '24h' };
|
|
const fluxRange = ranges[String(req.query.range || '6h')] || '6h';
|
|
|
|
const { getQueryApi } = await import('../config/influxdb');
|
|
const queryApi = getQueryApi();
|
|
const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik';
|
|
const deviceFilter = deviceId ? `|> filter(fn: (r) => r["device_id"] == "${deviceId}")` : '';
|
|
|
|
const flux = `
|
|
from(bucket: "${bucket}")
|
|
|> range(start: -${fluxRange})
|
|
|> filter(fn: (r) => r["_measurement"] == "wireless_radio_quality")
|
|
|> filter(fn: (r) => r["_field"] == "tx_retry_pct")
|
|
${deviceFilter}
|
|
|> group(columns: ["device_id", "device_name", "interface", "band"])
|
|
|> last()
|
|
`;
|
|
|
|
try {
|
|
const rows: { device_id: string; device_name?: string; interface: string; band: string; tx_retry_pct: number }[] = [];
|
|
await new Promise<void>((resolve, reject) => {
|
|
queryApi.queryRows(flux, {
|
|
next(row, tableMeta) {
|
|
const obj = tableMeta.toObject(row) as Record<string, unknown>;
|
|
rows.push({
|
|
device_id: String(obj['device_id'] || ''),
|
|
device_name: obj['device_name'] ? String(obj['device_name']) : undefined,
|
|
interface: String(obj['interface'] || ''),
|
|
band: String(obj['band'] || 'unknown'),
|
|
tx_retry_pct: obj['_value'] != null ? Number(obj['_value']) : 0,
|
|
});
|
|
},
|
|
error: reject,
|
|
complete: resolve,
|
|
});
|
|
});
|
|
return res.json(rows);
|
|
} catch (err) {
|
|
console.error('TX-quality InfluxDB error:', err);
|
|
return res.json([]);
|
|
}
|
|
});
|
|
|
|
// Classification of wireless/DHCP log lines into the connectivity funnel. These
|
|
// are best-effort regexes over RouterOS log messages; the panel is explicitly
|
|
// labelled as log-derived and approximate.
|
|
const RE_ASSOC_OK = /connected|associated/i;
|
|
const RE_ASSOC_FAIL = /reject|deauth|disassoc|connection lost|connect failed/i;
|
|
const RE_AUTH_FAIL = /key exchange|handshake|authentication failed|auth failed|eap|radius.*(timeout|fail)/i;
|
|
const RE_DHCP_OK = /assigned|bound|leased|offering/i;
|
|
const RE_DHCP_FAIL = /no.*address|declined|nak|offer.*fail|pool.*exhaust/i;
|
|
|
|
// GET /api/wireless/rf/connectivity?range=24h — Association/Auth/DHCP success funnel
|
|
router.get('/rf/connectivity', async (req: Request, res: Response) => {
|
|
const deviceId = deviceScope(req);
|
|
const intervals: Record<string, string> = { '1h': '1 hour', '6h': '6 hours', '24h': '24 hours', '7d': '7 days' };
|
|
const interval = intervals[String(req.query.range || '24h')] || '24 hours';
|
|
|
|
const rows = await query<{ topic: string | null; message: string }>(`
|
|
SELECT topic, message FROM events
|
|
WHERE event_time > NOW() - INTERVAL '${interval}'
|
|
${deviceId ? 'AND device_id = $1' : ''}
|
|
AND (topic ILIKE '%wireless%' OR topic ILIKE '%wifi%' OR topic ILIKE '%dhcp%')
|
|
`, deviceId ? [deviceId] : []);
|
|
|
|
let assocOk = 0, assocFail = 0, authFail = 0, dhcpOk = 0, dhcpFail = 0;
|
|
for (const r of rows) {
|
|
const topic = (r.topic || '').toLowerCase();
|
|
const msg = r.message || '';
|
|
const isDhcp = topic.includes('dhcp');
|
|
if (isDhcp) {
|
|
if (RE_DHCP_FAIL.test(msg)) dhcpFail++;
|
|
else if (RE_DHCP_OK.test(msg)) dhcpOk++;
|
|
} else {
|
|
// wireless / wifi
|
|
if (RE_AUTH_FAIL.test(msg)) authFail++;
|
|
else if (RE_ASSOC_FAIL.test(msg)) assocFail++;
|
|
else if (RE_ASSOC_OK.test(msg)) assocOk++;
|
|
}
|
|
}
|
|
|
|
const rate = (ok: number, fail: number): number | null =>
|
|
ok + fail > 0 ? Math.round((ok / (ok + fail)) * 1000) / 10 : null;
|
|
|
|
res.json({
|
|
range: String(req.query.range || '24h'),
|
|
log_derived: true,
|
|
stages: {
|
|
association: { success: assocOk, failure: assocFail, pct: rate(assocOk, assocFail) },
|
|
// a successful association implies it passed the auth handshake
|
|
authentication: { success: assocOk, failure: authFail, pct: rate(assocOk, authFail) },
|
|
dhcp: { success: dhcpOk, failure: dhcpFail, pct: rate(dhcpOk, dhcpFail) },
|
|
},
|
|
});
|
|
});
|
|
|
|
// ─── Rogue / neighbor AP detection ────────────────────────────────────────────
|
|
|
|
// GET /api/wireless/rf/rogue — classify latest AP scans against our own SSIDs/BSSIDs
|
|
router.get('/rf/rogue', async (_req: Request, res: Response) => {
|
|
const { classifyScans } = await import('../utils/rogueAp');
|
|
|
|
const [scans, radios, ifaceMacs] = await Promise.all([
|
|
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
|
|
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
|
|
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
|
|
ORDER BY a.device_id, a.scanned_at DESC`),
|
|
query<{ ssid: string | null; mac_address: string | null }>(
|
|
`SELECT ssid, mac_address FROM wireless_interfaces`),
|
|
query<{ mac_address: string }>(
|
|
`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`),
|
|
]);
|
|
|
|
const ownSsids = new Set(radios.map(r => r.ssid || '').filter(Boolean));
|
|
const ownBssids = new Set([
|
|
...radios.map(r => (r.mac_address || '').toLowerCase()).filter(Boolean),
|
|
...ifaceMacs.map(r => r.mac_address.toLowerCase()),
|
|
]);
|
|
|
|
const records = scans.map(s => ({
|
|
deviceName: s.device_name,
|
|
scannedAt: String(s.scanned_at),
|
|
networks: Array.isArray(s.data) ? s.data as import('../utils/rogueAp').ScannedNetwork[] : [],
|
|
}));
|
|
|
|
const { rogues, neighbors } = classifyScans(records, ownSsids, ownBssids);
|
|
res.json({
|
|
rogues, neighbors,
|
|
lastScanAt: scans.length ? scans.map(s => String(s.scanned_at)).sort().pop() : null,
|
|
scannedDevices: scans.length,
|
|
});
|
|
});
|
|
|
|
export default router;
|