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