import { Router, Request, Response } from 'express'; import { randomUUID } from 'crypto'; import { Client as SshClient } from 'ssh2'; import { query, queryOne } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { encrypt, decrypt } from '../utils/crypto'; import { RouterOSClient } from '../services/mikrotik/RouterOSClient'; import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; import { PollerService } from '../services/PollerService'; import type { CredentialPresetRow } from './credentialPresets'; import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation'; import { parsePort } from '../utils/parsePort'; import { safeConnectionError } from '../utils/safeClientError'; import { redis } from '../config/redis'; import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker'; // Resolve a credential preset id into decrypted credentials. Returns null if // no id was provided; throws a readable error if the id is invalid. async function loadCredentialPreset( id: number | null | undefined, ctx?: CreateDeviceContext ): Promise<{ api_username: string; api_password: string; api_port: number | null; ssh_username: string | null; ssh_password: string | null; ssh_port: number | null; } | null> { if (id === null || id === undefined) return null; const preset = await queryOne( `SELECT * FROM credential_presets WHERE id = $1`, [id] ); if (!preset) throw new Error(`Credential preset ${id} not found`); const allowOp = preset.allow_operator_use !== false; if (ctx?.requestingUserRole === 'operator' && !allowOp) { const err = new Error('This credential preset is restricted to administrators'); (err as Error & { statusCode?: number }).statusCode = 403; throw err; } return { api_username: preset.api_username, api_password: decrypt(preset.api_password_encrypted), api_port: preset.api_port, ssh_username: preset.ssh_username, ssh_password: preset.ssh_password_encrypted ? decrypt(preset.ssh_password_encrypted) : null, ssh_port: preset.ssh_port, }; } const router = Router(); router.use(requireAuth); let pollerService: PollerService | null = null; export function setPollerService(p: PollerService): void { pollerService = p; } // GET /api/devices router.get('/', async (_req: Request, res: Response) => { const devices = await query( `SELECT id, name, ip_address, api_port, api_username, model, serial_number, firmware_version, ros_version, latest_ros_version, firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version, device_type, status, last_seen, notes, location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, rack_name, rack_slot, created_at FROM devices ORDER BY name ASC` ); // Attach tags to each device const tagRows = await query<{ device_id: number; id: number; name: string; color: string }>( `SELECT dt.device_id, t.id, t.name, t.color FROM device_tags dt JOIN tags t ON t.id = dt.tag_id` ); const tagsByDevice: Record = {}; for (const t of tagRows) { if (!tagsByDevice[t.device_id]) tagsByDevice[t.device_id] = []; tagsByDevice[t.device_id].push({ id: t.id, name: t.name, color: t.color }); } const result = (devices as { id: number }[]).map((d) => ({ ...d, tags: tagsByDevice[d.id] ?? [], })); res.json(result); }); // ─── Routers overview (router-type devices with route counts) ───────────────── router.get('/routers/overview', async (_req: Request, res: Response) => { const routers = 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 ifaces_up, COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ifaces_down, COUNT(i.id) FILTER (WHERE i.disabled = true) AS ifaces_disabled, COUNT(i.id) AS ifaces_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 = 'router' GROUP BY d.id ORDER BY d.name ASC `); res.json(routers); }); // GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links router.get('/discovered', async (_req: Request, res: Response) => { const rows = await query<{ neighbor_identity: string | null; neighbor_address: string | null; neighbor_mac: string | null; neighbor_platform: string | null; discovered_at: string; seen_by: string; }>(` SELECT tl.neighbor_identity, COALESCE(NULLIF(tl.neighbor_address, ''), c.ip_address) AS neighbor_address, tl.neighbor_mac, tl.neighbor_platform, tl.discovered_at, d.name AS seen_by FROM topology_links tl JOIN devices d ON d.id = tl.from_device_id LEFT JOIN clients c ON LOWER(c.mac_address) = LOWER(tl.neighbor_mac) AND c.ip_address IS NOT NULL AND c.ip_address != '' WHERE tl.to_device_id IS NULL AND tl.neighbor_platform ILIKE '%mikrotik%' AND (tl.neighbor_address IS NULL OR tl.neighbor_address = '' OR tl.neighbor_address NOT LIKE '%:%') ORDER BY tl.discovered_at DESC `); // Build lookup tables for "is this actually a managed device we already know?" // We match a discovered neighbor to a managed device by any of: // - its MAC matching any interface MAC of a managed device, // - its IP matching the managed device's primary ip_address, // - its identity matching the managed device's name. const [managedByMac, managedByIp, managedByName] = await Promise.all([ query<{ device_id: number; device_name: string; mac: string }>( `SELECT i.device_id AS device_id, d.name AS device_name, LOWER(i.mac_address) AS mac FROM interfaces i JOIN devices d ON d.id = i.device_id WHERE i.mac_address IS NOT NULL AND i.mac_address <> ''` ), query<{ id: number; name: string; ip_address: string }>( `SELECT id, name, ip_address FROM devices WHERE ip_address IS NOT NULL AND ip_address <> ''` ), query<{ id: number; name: string }>( `SELECT id, name FROM devices WHERE name IS NOT NULL AND name <> ''` ), ]); const macToDevice = new Map(); for (const r of managedByMac) macToDevice.set(r.mac, { id: r.device_id, name: r.device_name }); const ipToDevice = new Map(); for (const r of managedByIp) ipToDevice.set(r.ip_address, { id: r.id, name: r.name }); const nameToDevice = new Map(); for (const r of managedByName) nameToDevice.set(r.name.toLowerCase(), { id: r.id, name: r.name }); // Deduplicate across all three identifiers so that a neighbor seen by multiple managed // devices — some via LLDP (has MAC), others via MNDP (no MAC) — merges into one entry. // Three indexes cross-reference mac/ip/identity to the canonical dedup key. const seen = new Map(); const macIdx = new Map(); // normalised-mac → canonical key const ipIdx = new Map(); // ip → canonical key const nameIdx = new Map(); // lower-identity → canonical key for (const row of rows) { const mac = (row.neighbor_mac || '').toLowerCase(); const ip = row.neighbor_address || ''; const identity = (row.neighbor_identity || '').toLowerCase(); // Find whether any identifier matches something already in the map const existingKey = (mac && macIdx.get(mac)) || (ip && ipIdx.get(ip)) || (identity && nameIdx.get(identity)); if (existingKey) { const existing = seen.get(existingKey)!; if (row.seen_by && !existing.seen_by_list.includes(row.seen_by)) { existing.seen_by_list.push(row.seen_by); } // Register any newly-learned identifiers against the same key if (mac && !macIdx.has(mac)) macIdx.set(mac, existingKey); if (ip && !ipIdx.has(ip)) ipIdx.set(ip, existingKey); if (identity && !nameIdx.has(identity)) nameIdx.set(identity, existingKey); } else { const key = mac || ip || identity; if (!key) continue; seen.set(key, { ...row, seen_by_list: row.seen_by ? [row.seen_by] : [] }); if (mac) macIdx.set(mac, key); if (ip) ipIdx.set(ip, key); if (identity) nameIdx.set(identity, key); } } const results = Array.from(seen.values()).map((r) => { const mac = (r.neighbor_mac || '').toLowerCase(); const match = (mac && macToDevice.get(mac)) || (r.neighbor_address && ipToDevice.get(r.neighbor_address)) || (r.neighbor_identity && nameToDevice.get(r.neighbor_identity.toLowerCase())) || null; return { identity: r.neighbor_identity || '', address: r.neighbor_address || '', mac_address: r.neighbor_mac || '', platform: r.neighbor_platform || '', discovered_at: r.discovered_at, seen_by: r.seen_by_list.join(', '), duplicate_of_device_id: match ? match.id : null, duplicate_of_device_name: match ? match.name : null, }; }); res.json(results); }); // POST /api/devices router.post('/', requireWrite, async (req: Request, res: Response) => { const result = await createDeviceFromBody(req.body, pollerService, { requestingUserRole: req.user?.role, }); return res.status(result.status).json(result.body); }); const BULK_ADD_META_TTL_SEC = 86400; // POST /api/devices/bulk-add/jobs — enqueue Try-All style adds (survives tab close) router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response) => { const { items } = req.body as { items?: unknown }; if (!Array.isArray(items) || items.length === 0) { return res.status(400).json({ error: 'items must be a non-empty array' }); } if (items.length > 500) { return res.status(400).json({ error: 'items array exceeds maximum of 500' }); } const jobId = randomUUID(); const ownerUserId = req.user!.userId; await redis.set( `device-bulk-add:${jobId}:meta`, JSON.stringify({ status: 'queued', total: items.length, processed: 0, owner_user_id: ownerUserId, created_at: new Date().toISOString(), }), 'EX', BULK_ADD_META_TTL_SEC ); await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC); await enqueueBulkAddJob(jobId, items as CreateDeviceInput[]); return res.status(202).json({ job_id: jobId, total: items.length }); }); // POST /api/devices/bulk-add/jobs/:jobId/cancel — request cooperative cancel router.post('/bulk-add/jobs/:jobId/cancel', requireWrite, async (req: Request, res: Response) => { const state = await getBulkAddJobState(req.params.jobId); if (!state.found) { return res.status(404).json({ error: 'Job not found or expired' }); } const ownerId = state.meta.owner_user_id; if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) { return res.status(403).json({ error: 'Forbidden' }); } await redis.set(`device-bulk-add:${req.params.jobId}:cancel`, '1', 'EX', BULK_ADD_META_TTL_SEC); return res.json({ message: 'Cancel requested' }); }); // GET /api/devices/bulk-add/jobs/:jobId — poll job status and incremental results router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Response) => { const state = await getBulkAddJobState(req.params.jobId); if (!state.found) { return res.status(404).json({ error: 'Job not found or expired' }); } const ownerId = state.meta.owner_user_id; if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) { return res.status(403).json({ error: 'Forbidden' }); } return res.json({ job_id: req.params.jobId, ...state.meta, results: state.results, }); }); // GET /api/devices/:id router.get('/:id', async (req: Request, res: Response) => { const device = await queryOne( `SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model, serial_number, firmware_version, ros_version, latest_ros_version, firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version, device_type, status, last_seen, notes, location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, rack_name, rack_slot, created_at, updated_at FROM devices WHERE id = $1`, [req.params.id] ); if (!device) return res.status(404).json({ error: 'Device not found' }); return res.json(device); }); // PATCH /api/devices/:id/location — save physical location & rack info router.patch('/:id/location', requireWrite, async (req: Request, res: Response) => { const { location_address, location_lat, location_lng, rack_name, rack_slot, notes } = req.body; const existing = await queryOne(`SELECT id FROM devices WHERE id = $1`, [req.params.id]); if (!existing) return res.status(404).json({ error: 'Device not found' }); await query( `UPDATE devices SET location_address = $1, location_lat = $2, location_lng = $3, rack_name = $4, rack_slot = $5, notes = $6, updated_at = NOW() WHERE id = $7`, [ location_address ?? null, location_lat ?? null, location_lng ?? null, rack_name ?? null, rack_slot ?? null, notes ?? null, req.params.id, ] ); const updated = await queryOne( `SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model, serial_number, firmware_version, ros_version, device_type, status, last_seen, notes, location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, rack_name, rack_slot, created_at, updated_at FROM devices WHERE id = $1`, [req.params.id] ); return res.json(updated); }); // PUT /api/devices/:id router.put('/:id', requireWrite, async (req: Request, res: Response) => { const { name, ip_address, device_type, notes, credential_preset_id } = req.body as { name?: string; ip_address?: string; device_type?: string; notes?: string; credential_preset_id?: number | null; }; const existing = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string; ssh_port: number | null; }>( `SELECT id, ip_address, api_port, api_username, api_password_encrypted, ssh_port FROM devices WHERE id = $1`, [req.params.id] ); if (!existing) return res.status(404).json({ error: 'Device not found' }); let preset: Awaited> = null; try { preset = await loadCredentialPreset(credential_preset_id ?? null, { requestingUserRole: req.user?.role, }); } catch (err) { const status = (err as Error & { statusCode?: number }).statusCode ?? 400; return res.status(status).json({ error: (err as Error).message }); } // When a preset is applied, its values take precedence over anything else // in the body so "apply preset" has a single unambiguous meaning. const api_port = preset?.api_port ?? parsePort(req.body.api_port, existing.api_port); const api_username = preset?.api_username ?? req.body.api_username; const api_password = preset?.api_password ?? req.body.api_password; const ssh_port = preset?.ssh_port ?? parsePort(req.body.ssh_port, existing.ssh_port ?? 22); const ssh_username = preset ? preset.ssh_username : req.body.ssh_username; const ssh_password = preset ? preset.ssh_password : req.body.ssh_password; // If the user is changing the IP (or port / username / password), verify the // RouterOS API is still reachable with the new values before persisting — // otherwise we could silently brick the device record. const ipChanged = typeof ip_address === 'string' && ip_address && ip_address !== existing.ip_address; const portChanged = typeof api_port === 'number' && api_port !== existing.api_port; const userChanged = typeof api_username === 'string' && api_username && api_username !== existing.api_username; const presetReplacesApiCreds = !!preset; if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) { const testIp = ip_address ?? existing.ip_address; const testPort = api_port; const testUser = api_username ?? existing.api_username; const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted); const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000); try { await testClient.connect(); testClient.disconnect(); } catch (err) { return res.status(422).json({ error: safeConnectionError('PUT /devices/:id', err), }); } } const encPass = api_password ? encrypt(api_password) : existing.api_password_encrypted; const encSshPass = ssh_password ? encrypt(ssh_password) : null; await query( `UPDATE devices SET name=COALESCE($1,name), ip_address=COALESCE($2,ip_address), api_port=COALESCE($3,api_port), api_username=COALESCE($4,api_username), api_password_encrypted=$5, ssh_port=COALESCE($6,ssh_port), ssh_username=COALESCE($7,ssh_username), ssh_password_encrypted=COALESCE($8,ssh_password_encrypted), device_type=COALESCE($9,device_type), notes=COALESCE($10,notes), updated_at=NOW() WHERE id = $11`, [name, ip_address, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id] ); const updated = await queryOne( `SELECT id, name, ip_address, api_port, api_username, model, serial_number, firmware_version, ros_version, device_type, status, last_seen, notes FROM devices WHERE id = $1`, [req.params.id] ); return res.json(updated); }); // DELETE /api/devices/:id router.delete('/:id', requireWrite, async (req: Request, res: Response) => { const result = await query(`DELETE FROM devices WHERE id = $1 RETURNING id`, [req.params.id]); if (!result.length) return res.status(404).json({ error: 'Device not found' }); return res.json({ message: 'Device deleted' }); }); // POST /api/devices/:id/sync - run a full resync and wait for it to complete router.post('/:id/sync', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT * FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.collectAll(); return res.json({ message: 'Sync completed' }); } catch (err) { return res.status(500).json({ error: `Sync failed: ${(err as Error).message}` }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/interfaces router.get('/:id/interfaces', async (req: Request, res: Response) => { const ifaces = await query( `SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name ASC`, [req.params.id] ); return res.json(ifaces); }); // PUT /api/devices/:id/interfaces/:name router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow as unknown as DeviceRow); try { await collector.connect(); const { disabled, comment, mtu, poe_out, fec_mode, tx_flow_control, rx_flow_control, auto_negotiation, speed } = req.body; if (typeof disabled === 'boolean') { await collector.setInterfaceEnabled(req.params.name, !disabled); } if (comment !== undefined) { await collector.setInterfaceComment(req.params.name, comment); } if (typeof mtu === 'number' && mtu >= 576 && mtu <= 9216) { await collector.setInterfaceMtu(req.params.name, mtu); } if (poe_out && ['auto-on', 'forced-on', 'off'].includes(poe_out)) { await collector.setPoeOut(req.params.name, poe_out as 'auto-on' | 'forced-on' | 'off'); } if (fec_mode && ['clause-74', 'clause-91', 'off'].includes(fec_mode)) { await collector.setFecMode(req.params.name, fec_mode); } if (tx_flow_control !== undefined || rx_flow_control !== undefined) { const txFc = tx_flow_control ?? 'off'; const rxFc = rx_flow_control ?? 'off'; if (['on', 'off', 'auto'].includes(txFc) && ['on', 'off', 'auto'].includes(rxFc)) { await collector.setFlowControl(req.params.name, txFc, rxFc); } } if (typeof auto_negotiation === 'boolean') { await collector.setAutoNegotiation(req.params.name, auto_negotiation, speed); } await collector.collectInterfaces(); return res.json({ message: 'Interface updated' }); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/ports/:name/vlan - configure VLAN for a switch port router.put('/:id/ports/:name/vlan', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { pvid, tagged_vlans = [], untagged_vlans = [] } = req.body; if (pvid !== undefined && (typeof pvid !== 'number' || pvid < 1 || pvid > 4094)) { return res.status(400).json({ error: 'pvid must be 1-4094' }); } const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.setPortVlanConfig( req.params.name, pvid ?? 1, tagged_vlans as number[], untagged_vlans as number[] ); await collector.collectVlans(); return res.json({ message: 'VLAN configuration applied' }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/vlans router.get('/:id/vlans', async (req: Request, res: Response) => { const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); return res.json(vlans); }); // GET /api/devices/:id/ports/:name/monitor — live ethernet monitor + SFP DDM router.get('/:id/ports/:name/monitor', async (req: Request, res: Response) => { const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow as unknown as DeviceRow); try { await collector.connect(); const monitor = await collector.getPortMonitor(req.params.name); return res.json(monitor); } finally { collector.disconnect(); } }); // GET /api/devices/:id/ports (switch port layout with VLAN info) router.get('/:id/ports', async (req: Request, res: Response) => { const [ifaces, bridgePorts, vlans] = await Promise.all([ query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]), query(`SELECT * FROM bridge_vlan_entries WHERE device_id = $1`, [req.params.id]), query(`SELECT * FROM vlans WHERE device_id = $1`, [req.params.id]), ]); // Enrich interface data with VLAN info const bridgePortMap = new Map(bridgePorts.map((bp: Record) => [bp['port'], bp])); const ports = ifaces .filter((i: Record) => String(i['type'] || '').match(/^(ether|sfp|combo|bridge|bond)/i) || String(i['name'] || '').match(/^(ether|sfp|combo|bridge|bond|lag)/i) ) .map((i: Record) => ({ ...i, bridgeInfo: bridgePortMap.get(i['name']) || null, })); return res.json({ ports, vlans }); }); // GET /api/devices/:id/routing router.get('/:id/routing', async (req: Request, res: Response) => { const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow as any); try { await collector.connect(); const routes = await collector.getRoutingTable(); return res.json(routes); } finally { collector.disconnect(); } }); // GET /api/devices/:id/firewall router.get('/:id/firewall', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getFirewallRules()); } finally { collector.disconnect(); } }); const FW_FIELD_MAP: Record = { chain: 'chain', action: 'action', protocol: 'protocol', comment: 'comment', disabled: 'disabled', src_address: 'src-address', dst_address: 'dst-address', src_port: 'src-port', dst_port: 'dst-port', in_interface: 'in-interface', out_interface: 'out-interface', connection_state: 'connection-state', jump_target: 'jump-target', log: 'log', log_prefix: 'log-prefix', src_address_list: 'src-address-list', dst_address_list: 'dst-address-list', }; function bodyToRosParams(body: Record): Record { const params: Record = {}; for (const [jsKey, rosKey] of Object.entries(FW_FIELD_MAP)) { const val = body[jsKey]; if (val !== undefined && val !== null && val !== '') { params[rosKey] = String(val); } } return params; } // POST /api/devices/:id/firewall router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) => { const { chain, action } = req.body; if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' }); const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addFirewallRule(bodyToRosParams(req.body)); return res.status(201).json(await collector.getFirewallRules()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/firewall/:ruleId router.put('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.updateFirewallRule(req.params.ruleId, bodyToRosParams(req.body)); return res.json(await collector.getFirewallRules()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/firewall/:ruleId router.delete('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.deleteFirewallRule(req.params.ruleId); return res.json({ message: 'Rule deleted' }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // ─── NAT Rules ──────────────────────────────────────────────────────────────── const NAT_FIELD_MAP: Record = { chain: 'chain', action: 'action', protocol: 'protocol', comment: 'comment', disabled: 'disabled', src_address: 'src-address', dst_address: 'dst-address', src_port: 'src-port', dst_port: 'dst-port', in_interface: 'in-interface', out_interface: 'out-interface', to_addresses: 'to-addresses', to_ports: 'to-ports', log: 'log', log_prefix: 'log-prefix', }; function natBodyToRosParams(body: Record): Record { const params: Record = {}; for (const [jsKey, rosKey] of Object.entries(NAT_FIELD_MAP)) { const val = body[jsKey]; if (val !== undefined && val !== null && val !== '') { params[rosKey] = String(val); } } return params; } // GET /api/devices/:id/nat router.get('/:id/nat', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getNatRules()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/nat router.post('/:id/nat', requireWrite, async (req: Request, res: Response) => { const { chain, action } = req.body; if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' }); const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addNatRule(natBodyToRosParams(req.body)); return res.status(201).json(await collector.getNatRules()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/nat/:ruleId router.put('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.updateNatRule(req.params.ruleId, natBodyToRosParams(req.body)); return res.json(await collector.getNatRules()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/nat/:ruleId router.delete('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.deleteNatRule(req.params.ruleId); return res.json({ message: 'NAT rule deleted' }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/resources (live resource usage) router.get('/:id/resources', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const resource = await collector.getSystemResource(); return res.json(resource); } finally { collector.disconnect(); } }); // GET /api/devices/:id/system-config router.get('/:id/system-config', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const config = await collector.getSystemConfig(); return res.json(config); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/system-config router.put('/:id/system-config', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { identity, ntp_enabled, ntp_primary, ntp_secondary, dns_servers, dns_allow_remote } = req.body; const collector = new DeviceCollector(deviceRow); try { await collector.connect(); if (identity !== undefined) { await collector.setSystemIdentity(identity); } if (ntp_primary !== undefined) { await collector.setNtpConfig( ntp_enabled !== false, ntp_primary || '', ntp_secondary || '' ); } if (dns_servers !== undefined) { await collector.setDnsConfig(dns_servers, dns_allow_remote === true); } return res.json({ message: 'System configuration updated' }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/ip-addresses router.get('/:id/ip-addresses', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const addresses = await collector.getIpAddresses(); return res.json(addresses); } finally { collector.disconnect(); } }); // POST /api/devices/:id/ip-addresses router.post('/:id/ip-addresses', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { address, interface: iface } = req.body; if (!address || !iface) { return res.status(400).json({ error: 'address and interface are required' }); } const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addIpAddress(address, iface); const addresses = await collector.getIpAddresses(); return res.status(201).json(addresses); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/ip-addresses/:addrId router.delete('/:id/ip-addresses/:addrId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeIpAddress(req.params.addrId); return res.json({ message: 'IP address removed' }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/check-update router.post('/:id/check-update', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const updateInfo = await collector.checkForUpdates(); // Persist results so the UI reflects the latest check immediately const latestVersion = (updateInfo['latest-version'] ?? '').trim(); const installedVersion = (updateInfo['installed-version'] ?? '').trim(); const statusText = (updateInfo['status'] ?? '').toLowerCase(); const hasUpdate = statusText.includes('available') || Boolean(latestVersion && installedVersion && latestVersion !== installedVersion); await query( `UPDATE devices SET firmware_update_available = $1, latest_ros_version = $2, updated_at = NOW() WHERE id = $3`, [hasUpdate, latestVersion || null, deviceRow.id] ); return res.json(updateInfo); } finally { collector.disconnect(); } }); // POST /api/devices/:id/install-update router.post('/:id/install-update', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.installUpdate(); // Clear the update flag — device is rebooting with new firmware await query( `UPDATE devices SET firmware_update_available = FALSE, updated_at = NOW() WHERE id = $1`, [deviceRow.id] ); return res.json({ message: 'Update installation initiated. Device will reboot.' }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/check-routerboard router.post('/:id/check-routerboard', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const info = await collector.checkRouterboardUpgrade(); await query( `UPDATE devices SET routerboard_upgrade_available = $1, upgrade_firmware_version = $2, updated_at = NOW() WHERE id = $3`, [info.upgradeAvailable, info.upgradeFirmware || null, deviceRow.id] ); return res.json(info); } finally { collector.disconnect(); } }); // POST /api/devices/:id/install-routerboard router.post('/:id/install-routerboard', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.installRouterboardUpgrade(); await query( `UPDATE devices SET routerboard_upgrade_available = FALSE, updated_at = NOW() WHERE id = $1`, [deviceRow.id] ); return res.json({ message: 'RouterBOOT upgrade initiated. Device will reboot.' }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/reboot router.post('/:id/reboot', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.reboot(); return res.json({ message: 'Reboot command sent. Device will restart shortly.' }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/clock router.get('/:id/clock', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const clock = await collector.getClockConfig(); return res.json(clock); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/clock router.put('/:id/clock', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.setClockConfig(req.body); return res.json({ message: 'Clock updated' }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/routing (add static route) router.post('/:id/routing', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { dst_address, gateway, distance, comment } = req.body; if (!dst_address || !gateway) { return res.status(400).json({ error: 'dst_address and gateway are required' }); } const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addRoute(dst_address, gateway, distance, comment); return res.status(201).json({ message: 'Route added' }); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/routing/:routeId router.delete('/:id/routing/:routeId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeRoute(req.params.routeId); return res.json({ message: 'Route removed' }); } finally { collector.disconnect(); } }); // ─── OSPF ───────────────────────────────────────────────────────────────────── router.get('/:id/routing/ospf', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getOspfData()); } finally { collector.disconnect(); } }); router.post('/:id/routing/ospf/instance', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addOspfInstance(req.body); return res.status(201).json(await collector.getOspfData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.delete('/:id/routing/ospf/instance/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeOspfInstance(decodeURIComponent(req.params.itemId)); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.post('/:id/routing/ospf/area', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addOspfArea(req.body); return res.status(201).json(await collector.getOspfData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.delete('/:id/routing/ospf/area/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeOspfArea(decodeURIComponent(req.params.itemId)); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // ─── BGP ────────────────────────────────────────────────────────────────────── router.get('/:id/routing/bgp', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getBgpData()); } finally { collector.disconnect(); } }); router.post('/:id/routing/bgp/connection', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addBgpConnection(req.body); return res.status(201).json(await collector.getBgpData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.delete('/:id/routing/bgp/connection/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeBgpConnection(decodeURIComponent(req.params.itemId)); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // ─── Routing Tables ─────────────────────────────────────────────────────────── router.get('/:id/routing/tables', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getRoutingTablesData()); } finally { collector.disconnect(); } }); router.post('/:id/routing/tables', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addRoutingTable(req.body); return res.status(201).json(await collector.getRoutingTablesData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.delete('/:id/routing/tables/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeRoutingTable(decodeURIComponent(req.params.itemId)); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // ─── Route Filters ──────────────────────────────────────────────────────────── router.get('/:id/routing/filters', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getRouteFiltersData()); } finally { collector.disconnect(); } }); router.post('/:id/routing/filters/rule', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addFilterRule(req.body); return res.status(201).json(await collector.getRouteFiltersData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.put('/:id/routing/filters/rule/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.updateFilterRule(decodeURIComponent(req.params.itemId), req.body); return res.json(await collector.getRouteFiltersData()); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); router.delete('/:id/routing/filters/rule/:itemId', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeFilterRule(decodeURIComponent(req.params.itemId)); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // ─── Router IDs ─────────────────────────────────────────────────────────────── router.get('/:id/routing/router-id', async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); return res.json(await collector.getRouterIds()); } finally { collector.disconnect(); } }); // POST /api/devices/:id/vlans (add bridge VLAN) router.post('/:id/vlans', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { bridge, vlan_id, tagged_ports = [], untagged_ports = [] } = req.body; if (!bridge || !vlan_id || vlan_id < 1 || vlan_id > 4094) { return res.status(400).json({ error: 'bridge and vlan_id (1-4094) are required' }); } const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.addBridgeVlan(bridge, vlan_id, tagged_ports, untagged_ports); await collector.collectVlans(); const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); return res.status(201).json(vlans); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/vlans/:vlanDbId (update tagged/untagged ports) router.put('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => { const vlan = await queryOne<{ id: number; vlan_id: number; bridge: string }>( `SELECT id, vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`, [req.params.vlanDbId, req.params.id] ); if (!vlan) return res.status(404).json({ error: 'VLAN not found' }); const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { tagged_ports = [], untagged_ports = [] } = req.body; const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.updateBridgeVlan(vlan.bridge, vlan.vlan_id, tagged_ports, untagged_ports); await collector.collectVlans(); const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); return res.json(vlans); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/vlans/:vlanDbId router.delete('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => { const vlan = await queryOne<{ vlan_id: number; bridge: string }>( `SELECT vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`, [req.params.vlanDbId, req.params.id] ); if (!vlan) return res.status(404).json({ error: 'VLAN not found' }); const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.removeBridgeVlan(vlan.bridge, vlan.vlan_id); await query(`DELETE FROM vlans WHERE id = $1`, [req.params.vlanDbId]); return res.json({ message: 'VLAN removed' }); } finally { collector.disconnect(); } }); // POST /api/devices/:id/vlans/copy (bulk copy VLANs from another switch) router.post('/:id/vlans/copy', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const { operations } = req.body as { operations: Array<{ action: 'add' | 'update'; vlan_id: number; bridge: string; tagged_ports: string[]; untagged_ports: string[]; }>; }; if (!Array.isArray(operations) || operations.length === 0) { return res.status(400).json({ error: 'operations array is required and must not be empty' }); } const collector = new DeviceCollector(deviceRow); const results: Array<{ vlan_id: number; action: string; success: boolean; error?: string }> = []; try { await collector.connect(); for (const op of operations) { try { if (op.action === 'add') { await collector.addBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports); } else { await collector.updateBridgeVlan(op.bridge, op.vlan_id, op.tagged_ports, op.untagged_ports); } results.push({ vlan_id: op.vlan_id, action: op.action, success: true }); } catch (err) { results.push({ vlan_id: op.vlan_id, action: op.action, success: false, error: err instanceof Error ? err.message : String(err) }); } } await collector.collectVlans(); const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); return res.json({ results, vlans }); } finally { collector.disconnect(); } }); // ─── Bond (LAG / LACP) routes ──────────────────────────────────────────────── // POST /api/devices/:id/bonds router.post('/:id/bonds', requireWrite, async (req: Request, res: Response) => { const { name, mode, slaves, lacp_rate, transmit_hash_policy, mtu, min_links } = req.body; if (!name || !mode || !Array.isArray(slaves) || slaves.length < 2) { return res.status(400).json({ error: 'name, mode, and at least 2 slaves are required' }); } const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.createBond(name, slaves, mode, { lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links, }); await collector.collectInterfaces(); const ports = await query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]); return res.status(201).json(ports.find((p: Record) => p['name'] === name) ?? { message: 'Bond created' }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/bonds/:bondName router.put('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => { const { mode, slaves, lacp_rate, transmit_hash_policy, mtu, min_links } = req.body; if (!mode || !Array.isArray(slaves) || slaves.length < 1) { return res.status(400).json({ error: 'mode and slaves are required' }); } const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.updateBond(req.params.bondName, slaves, mode, { lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links, }); await collector.collectInterfaces(); return res.json({ message: 'Bond updated' }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // DELETE /api/devices/:id/bonds/:bondName router.delete('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => { const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.deleteBond(req.params.bondName); await query(`DELETE FROM interfaces WHERE device_id = $1 AND name = $2`, [req.params.id, req.params.bondName]); await collector.collectInterfaces(); return res.json({ message: 'Bond deleted' }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // PUT /api/devices/:id/bridge/:bridgeName/vlan-filtering router.put('/:id/bridge/:bridgeName/vlan-filtering', requireWrite, async (req: Request, res: Response) => { const { enabled } = req.body as { enabled: boolean }; const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); await collector.setBridgeVlanFiltering(req.params.bridgeName, Boolean(enabled)); await collector.collectInterfaces(); return res.json({ success: true, vlan_filtering: enabled }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { collector.disconnect(); } }); // GET /api/devices/:id/hardware (live health: temps, voltages, fans, PSU) router.get('/:id/hardware', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const collector = new DeviceCollector(deviceRow); try { await collector.connect(); const health = await collector.getHardware(); return res.json(health); } finally { collector.disconnect(); } }); // GET /api/devices/:id/wireless — cached wireless interfaces from postgres (for Radios tab) router.get('/:id/wireless', async (req: Request, res: Response) => { const rows = await query( `SELECT * FROM wireless_interfaces WHERE device_id = $1 ORDER BY name ASC`, [req.params.id] ); return res.json(rows); }); // GET /api/devices/:id/wireless/metrics — InfluxDB wireless_stats time series router.get('/:id/wireless/metrics', async (req: Request, res: Response) => { const { iface, range = '6h' } = req.query as { iface?: string; range?: string }; const ranges: Record = { '1h': '1h', '3h': '3h', '6h': '6h', '12h': '12h', '24h': '24h', '7d': '7d', }; const fluxRange = ranges[range] || '6h'; const { getQueryApi } = await import('../config/influxdb'); const queryApi = getQueryApi(); const org = process.env.INFLUXDB_ORG || 'mikrotik'; const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik'; const ifaceFilter = iface ? `|> filter(fn: (r) => r["interface"] == "${iface}")` : ''; const flux = ` from(bucket: "${bucket}") |> range(start: -${fluxRange}) |> filter(fn: (r) => r["_measurement"] == "wireless_stats") |> filter(fn: (r) => r["device_id"] == "${req.params.id}") ${ifaceFilter} |> filter(fn: (r) => r["_field"] == "registered_clients" or r["_field"] == "noise_floor") |> aggregateWindow(every: 5m, fn: mean, createEmpty: false) |> pivot(rowKey:["_time","interface","ssid"], columnKey: ["_field"], valueColumn: "_value") |> sort(columns: ["_time"]) `; try { const rows: { time: string; interface: string; ssid?: string; registered_clients?: number; noise_floor?: number }[] = []; await new Promise((resolve, reject) => { queryApi.queryRows(flux, { next(row, tableMeta) { const obj = tableMeta.toObject(row) as Record; rows.push({ time: String(obj['_time'] || obj['time'] || ''), interface: String(obj['interface'] || ''), ssid: obj['ssid'] ? String(obj['ssid']) : undefined, registered_clients: obj['registered_clients'] != null ? Number(obj['registered_clients']) : undefined, noise_floor: obj['noise_floor'] != null ? Number(obj['noise_floor']) : undefined, }); }, error: reject, complete: resolve, }); }); return res.json(rows); } catch (err) { console.error('Wireless metrics InfluxDB error:', err); return res.json([]); } }); // POST /api/devices/:id/test router.post('/:id/test', async (req: Request, res: Response) => { const deviceRow = await queryOne( `SELECT ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, [req.params.id] ); if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); const client = new RouterOSClient( deviceRow.ip_address, deviceRow.api_port, deviceRow.api_username, decrypt(deviceRow.api_password_encrypted), 8000 ); try { await client.connect(); const identity = await client.execute('/system/identity/print'); client.disconnect(); return res.json({ success: true, identity: identity[0]?.['name'] }); } catch (err) { client.disconnect(); return res.status(422).json({ success: false, error: (err as Error).message }); } }); // ─── Network Tools ───────────────────────────────────────────────────────── // Tools use a long read-timeout (120s) since ping/traceroute/ip-scan can take time. async function getToolDevice(id: string) { return queryOne(`SELECT * FROM devices WHERE id = $1`, [id]); } function makeToolClient(device: DeviceRow): RouterOSClient { return new RouterOSClient( device.ip_address, device.api_port, device.api_username, decrypt(device.api_password_encrypted), 15_000, // connect timeout 120_000 // read timeout — traceroute/ip-scan can take a while ); } // POST /api/devices/:id/tools/ping router.post('/:id/tools/ping', requireWrite, async (req: Request, res: Response) => { const { address, count, interface: iface } = req.body as { address?: string; count?: number; interface?: string }; if (!address) return res.status(400).json({ error: 'address is required' }); const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); const client = makeToolClient(device); try { await client.connect(); const params: Record = { address, count: String(Math.min(Math.max(1, Number(count) || 4), 20)), }; if (iface) params['interface'] = iface; const results = await client.execute('/tool/ping', params); return res.json(results); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { client.disconnect(); } }); // POST /api/devices/:id/tools/traceroute router.post('/:id/tools/traceroute', requireWrite, async (req: Request, res: Response) => { const { address } = req.body as { address?: string }; if (!address) return res.status(400).json({ error: 'address is required' }); const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); const client = makeToolClient(device); try { await client.connect(); const raw = await client.execute('/tool/traceroute', { address, count: '3', timeout: '1' }); // RouterOS sends multiple !re updates per hop as probes return. // Deduplicate by .id, keeping the last (most complete) row per hop. const byId = new Map>(); for (const row of raw) { if (row['id']) byId.set(row['id'], row); } const results = Array.from(byId.values()).sort( (a, b) => Number(a['id'] || 0) - Number(b['id'] || 0) ); return res.json(results); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { client.disconnect(); } }); // POST /api/devices/:id/tools/ip-scan router.post('/:id/tools/ip-scan', requireWrite, async (req: Request, res: Response) => { const { addressRange, interface: iface, rdns } = req.body as { addressRange?: string; interface?: string; rdns?: boolean; }; if (!addressRange) return res.status(400).json({ error: 'addressRange is required' }); if (!iface) return res.status(400).json({ error: 'interface is required' }); const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); const client = makeToolClient(device); try { await client.connect(); // /tool/ip-scan is a generator — it never sends !done on its own. // Use executeStreaming to collect results for up to 30s then cancel. const raw = await client.executeStreaming('/tool/ip-scan', { 'address-range': addressRange, interface: iface, }, 30_000); // ip-scan re-emits the same host on every rescan cycle — deduplicate by address, // keeping the last-seen entry (RouterOS updates fields like status over time). const seen = new Map>(); for (const entry of raw) { const key = entry['address'] ?? JSON.stringify(entry); seen.set(key, entry); } const results = Array.from(seen.values()); if (!rdns) return res.json(results); // Perform reverse DNS lookups concurrently on all discovered IPs. const { reverse } = await import('dns/promises'); const enriched = await Promise.all( results.map(async (r) => { const ip = r['address']; if (!ip) return { ...r, hostname: '' }; try { const names = await reverse(ip); return { ...r, hostname: names[0] ?? '' }; } catch { return { ...r, hostname: '' }; } }) ); return res.json(enriched); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { client.disconnect(); } }); // POST /api/devices/:id/tools/wol router.post('/:id/tools/wol', requireWrite, async (req: Request, res: Response) => { const { mac, interface: iface } = req.body as { mac?: string; interface?: string }; if (!mac) return res.status(400).json({ error: 'mac is required' }); if (!iface) return res.status(400).json({ error: 'interface is required' }); // Basic MAC validation if (!/^([0-9A-Fa-f]{2}[-:]){5}[0-9A-Fa-f]{2}$/.test(mac)) { return res.status(400).json({ error: 'Invalid MAC address format' }); } const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); const client = makeToolClient(device); try { await client.connect(); await client.execute('/tool/wol', { mac, interface: iface }); return res.json({ success: true, message: `WoL magic packet sent to ${mac} on ${iface}` }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { client.disconnect(); } }); // POST /api/devices/:id/tools/capture — packet capture via RouterOS sniffer + SFTP download router.post('/:id/tools/capture', requireWrite, async (req: Request, res: Response) => { const { interface: iface, filter_ip, duration } = req.body as { interface?: string; filter_ip?: string; duration?: number; }; const captureSec = Math.min(Math.max(5, Number(duration) || 10), 60); const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device.ssh_username || !device.ssh_password_encrypted) { return res.status(400).json({ error: 'SSH credentials required for packet capture. Add SSH username/password in device settings.' }); } const fileName = `cap-${Date.now()}`; // RouterOS SFTP root = device file system root; sniffer writes .pcap there const remotePath = `/${fileName}.pcap`; const client = makeToolClient(device); try { console.log(`[capture] connecting to device ${device.id} (${device.ip_address})`); await client.connect(); console.log(`[capture] connected; configuring sniffer`); // Stop any previous capture before reconfiguring await client.execute('/tool/sniffer/stop').catch(() => {}); const setParams: Record = { 'file-name': fileName, 'file-limit': '10240' }; if (iface) setParams['filter-interface'] = iface; if (filter_ip) setParams['filter-ip-address'] = filter_ip; await client.execute('/tool/sniffer/set', setParams); console.log(`[capture] sniffer configured; starting ${captureSec}s capture`); await client.execute('/tool/sniffer/start'); await new Promise((r) => setTimeout(r, captureSec * 1000)); await client.execute('/tool/sniffer/stop').catch(() => {}); client.disconnect(); console.log(`[capture] sniffer stopped; waiting for file flush`); // Allow RouterOS a moment to flush the PCAP file before SFTP download await new Promise((r) => setTimeout(r, 2000)); console.log(`[capture] opening SFTP to ${device.ip_address}:${device.ssh_port ?? 22} path=${remotePath}`); // Download PCAP via SFTP then delete remote file const pcapBuffer = await new Promise((resolve, reject) => { const ssh = new SshClient(); ssh.on('ready', () => { ssh.sftp((err, sftp) => { if (err) { ssh.end(); return reject(new Error(`SFTP init failed: ${err.message}`)); } const chunks: Buffer[] = []; const stream = sftp.createReadStream(remotePath); stream.on('data', (chunk: Buffer) => chunks.push(chunk)); stream.on('end', () => { // Best-effort delete; don't block the response on it sftp.unlink(remotePath, () => { ssh.end(); }); resolve(Buffer.concat(chunks)); }); stream.on('error', (e: Error) => { ssh.end(); reject(new Error(`SFTP read failed (path=${remotePath}): ${e.message}`)); }); }); }); ssh.on('error', (e) => reject(new Error(`SSH connect failed: ${e.message}`))); ssh.connect({ host: device.ip_address, port: device.ssh_port ?? 22, username: device.ssh_username!, password: decrypt(device.ssh_password_encrypted!), readyTimeout: 10_000, }); }); res.setHeader('Content-Type', 'application/vnd.tcpdump.pcap'); res.setHeader('Content-Disposition', `attachment; filename="${fileName}.pcap"`); return res.send(pcapBuffer); } catch (err) { client.disconnect(); console.error('[capture]', (err as Error).message); return res.status(500).json({ error: (err as Error).message }); } }); // POST /api/devices/:id/tools/btest — bandwidth test via RouterOS router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response) => { const { address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as { address?: string; direction?: string; duration?: number; protocol?: string; }; if (!address) return res.status(400).json({ error: 'address is required' }); const testSec = Math.min(Math.max(1, Number(duration) || 5), 30); const device = await getToolDevice(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); const client = new RouterOSClient( device.ip_address, device.api_port, device.api_username, decrypt(device.api_password_encrypted), 15_000, (testSec + 10) * 1000 ); try { await client.connect(); const rows = await client.executeStreaming('/tool/bandwidth-test', { address, direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both', duration: String(testSec), protocol: ['tcp', 'udp'].includes(protocol) ? protocol : 'tcp', }, (testSec + 10) * 1000); // Return the final summary row (last row that has tx/rx total averages) const lastRow = rows.filter((r) => r['tx-total-average'] !== undefined || r['rx-total-average'] !== undefined).at(-1); const toMbps = (v: string | undefined) => v ? Math.round(parseInt(v) / 1_000_000) : 0; return res.json({ tx_mbps: toMbps(lastRow?.['tx-total-average']), rx_mbps: toMbps(lastRow?.['rx-total-average']), direction, protocol, duration: testSec, raw: lastRow ?? rows.at(-1) ?? null, }); } catch (err) { return res.status(500).json({ error: (err as Error).message }); } finally { client.disconnect(); } }); export default router;