Files
MIKROTIK-RMM/backend/src/routes/devices.ts
T

3190 lines
136 KiB
TypeScript

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, requireAdmin } from '../middleware/auth';
import { deviceIdSiteGuard } from '../middleware/site';
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 { detectLockoutRisk } from '../utils/firewallSafety';
import { validateRouterOsUsername } from '../utils/routerOsUsername';
import { withSafeApply, probeCapability, probeUndoCapability, type GuardDevice } from '../services/changeGuard/ChangeGuard';
import { captureSnapshot, resolveManagementPath } from '../services/changeGuard/pathModel';
import { analyzeChange, type PlannedChange } from '../services/changeGuard/analyzeChange';
import { runConfigHealth } from '../services/changeGuard/configHealth';
import { isMultiVlanSpec } from '../utils/vlan';
import { redis } from '../config/redis';
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
import { logSafe } from '../utils/logSafe';
import { DEVICE_BASE_COLUMNS } from '../services/deviceColumns';
import { buildSegments, summarise, summariseBands } from '../utils/lteDwell';
import { siteScopeDevices, andSite, singleSiteOrNull } from '../utils/siteScope';
import { activeSite, enforceSiteAccess } from '../middleware/site';
/** Postgres interval text to milliseconds, for the few windows we offer. */
function intervalToMs(interval: string): number {
const [n, unit] = interval.split(' ');
const per: Record<string, number> = { hours: 3_600_000, days: 86_400_000 };
return (Number(n) || 0) * (per[unit] ?? 0);
}
import { parseBandList, uplinkAnchor, totalBandwidthMhz, type LteBandInfo } from '../utils/lte';
// 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<CredentialPresetRow>(
`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);
router.use(enforceSiteAccess);
// SEC-002: every :id in this router is a device id -- refuse it up front if
// the device belongs to a site the caller isn't authorized for, before any
// handler runs. Closes the IDOR of reaching another site's device by guessing
// or enumerating its numeric id.
router.param('id', deviceIdSiteGuard);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void {
pollerService = p;
}
// GET /api/devices
router.get('/', async (req: Request, res: Response) => {
const where = andSite('', siteScopeDevices(activeSite(req)));
const devices = await query(
`SELECT ${DEVICE_BASE_COLUMNS},
latest_ros_version, firmware_update_available,
routerboard_upgrade_available, upgrade_firmware_version,
location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng,
rack_name, rack_slot, created_at
FROM devices ${where} 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<number, { id: number; name: string; color: string }[]> = {};
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 siteFilter = siteScopeDevices(activeSite(req), 'd');
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' ${siteFilter ? `AND ${siteFilter}` : ''}
GROUP BY d.id
ORDER BY d.name ASC
`);
res.json(routers);
});
// ─── Security check suppressions (issue #101) ────────────────────────────────
/**
* A posture check the operator has judged inapplicable — cleartext API on a fleet
* reachable only over WireGuard, or a missing input firewall on a switch that sits
* behind one. Suppressed checks stay visible and marked; they simply stop counting
* against the score, so the posture remains auditable.
*
* `device_id` omitted suppresses fleet-wide; supplying one silences that device only.
*/
router.get('/security/suppressions', async (_req: Request, res: Response) => {
const rows = await query(`
SELECT s.id, s.check_id, s.device_id, s.reason, s.created_by, s.created_at,
d.name AS device_name
FROM security_check_suppressions s
LEFT JOIN devices d ON d.id = s.device_id
ORDER BY s.check_id, d.name NULLS FIRST`);
res.json(rows);
});
router.post('/security/suppressions', requireWrite, async (req: Request, res: Response) => {
const { check_id, device_id, reason } = req.body as
{ check_id?: string; device_id?: number | null; reason?: string };
if (!check_id) return res.status(400).json({ error: 'check_id is required' });
const rows = await query(
`INSERT INTO security_check_suppressions (check_id, device_id, reason, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING *`,
[check_id, device_id ?? null, reason ?? null,
(req as unknown as { user?: { username?: string } }).user?.username ?? null]
);
// ON CONFLICT DO NOTHING returns no row when it already existed, which is not an
// error — the caller asked for a state that now holds either way.
res.status(201).json(rows[0] ?? { check_id, device_id: device_id ?? null, already: true });
});
router.delete('/security/suppressions/:id', requireWrite, async (req: Request, res: Response) => {
await query(`DELETE FROM security_check_suppressions WHERE id = $1`, [req.params.id]);
res.status(204).end();
});
// 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<string, { id: number; name: string }>();
for (const r of managedByMac) macToDevice.set(r.mac, { id: r.device_id, name: r.device_name });
const ipToDevice = new Map<string, { id: number; name: string }>();
for (const r of managedByIp) ipToDevice.set(r.ip_address, { id: r.id, name: r.name });
const nameToDevice = new Map<string, { id: number; name: string }>();
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<string, typeof rows[0] & { seen_by_list: string[] }>();
const macIdx = new Map<string, string>(); // normalised-mac → canonical key
const ipIdx = new Map<string, string>(); // ip → canonical key
const nameIdx = new Map<string, string>(); // 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,
// A device added while viewing a site joins that site (issue #130).
siteId: singleSiteOrNull(activeSite(req)),
});
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[], singleSiteOrNull(activeSite(req)));
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 ${DEVICE_BASE_COLUMNS},
ssh_port, ssh_username, latest_ros_version,
firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version,
location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, wifi_role, has_lte, 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 ${DEVICE_BASE_COLUMNS},
ssh_port, ssh_username, location_address,
location_lat::float8 AS location_lat,
location_lng::float8 AS location_lng,
rack_name, rack_slot, wifi_role, has_lte, 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<ReturnType<typeof loadCredentialPreset>>;
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;
// SEC-006: reject an unsafe ssh_username before it can ever reach the
// RouterOS CLI command SshKeyService builds from it.
const sshUsernameError = validateRouterOsUsername(ssh_username);
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
// 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 ${DEVICE_BASE_COLUMNS} 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<DeviceRow>(
`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
// Guarded: changing a port's PVID or its tagged/untagged membership is one of the
// most common ways to strand the management VLAN on a switch.
router.put('/:id/ports/:name/vlan', requireWrite, async (req: Request, res: Response) => {
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' });
}
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'port.vlan',
summary: `VLAN config on port ${req.params.name} (pvid ${pvid ?? 1})`,
change: {
kind: 'port.vlan',
port: req.params.name,
pvid: pvid ?? 1,
tagged: tagged_vlans as number[],
untagged: untagged_vlans as number[],
},
},
async (c) => {
await c.setPortVlanConfig(
req.params.name,
pvid ?? 1,
tagged_vlans as number[],
untagged_vlans as number[]
);
return { message: 'VLAN configuration applied' };
},
async (c) => { await c.collectVlans(); }
);
});
// 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/:name/clients — clients on this port.
//
// The bridge FDB lists every MAC *reachable through* a port, so on an
// uplink/trunk it shows the whole network. We classify the port and, by
// default, only return clients that are genuinely *physically* connected
// (a single access VLAN, no infra neighbour, not an aggregation point).
// Pass ?all=true to get the raw FDB list regardless (the "view MAC table"
// disclosure in the UI).
const UPLINK_MAC_THRESHOLD = 8;
router.get('/:id/ports/:name/clients', async (req: Request, res: Response) => {
const all = req.query.all === 'true';
const rows = await query<{
mac_address: string; hostname: string | null; custom_name: string | null;
vendor: string | null; ip_address: string | null; client_type: string;
vlan_id: number | null; signal_strength: number | null;
first_seen: string | null; last_seen: string | null;
}>(`
SELECT mac_address, hostname, custom_name, vendor, ip_address, client_type,
vlan_id, signal_strength, first_seen, last_seen
FROM clients
WHERE device_id = $1 AND interface_name = $2 AND active = TRUE
ORDER BY COALESCE(NULLIF(custom_name, ''), hostname, mac_address) ASC
`, [req.params.id, req.params.name]);
// A discovered LLDP/MNDP neighbour on this interface means it links to
// another managed device → definitively an uplink.
const neighbor = await queryOne<{ neighbor_identity: string | null; neighbor_platform: string | null }>(
`SELECT neighbor_identity, neighbor_platform FROM topology_links
WHERE from_device_id = $1 AND from_interface = $2
ORDER BY (neighbor_identity IS NOT NULL) DESC LIMIT 1`,
[req.params.id, req.params.name]
);
const macCount = rows.length;
const vlanCount = new Set(rows.filter(r => r.vlan_id != null).map(r => r.vlan_id)).size;
let classification: 'access' | 'uplink' = 'access';
let reason = '';
if (neighbor) {
classification = 'uplink';
reason = `Links to ${neighbor.neighbor_identity || 'another device'}`;
} else if (vlanCount > 1) {
classification = 'uplink';
reason = `MACs seen across ${vlanCount} VLANs — carrying tagged/trunk traffic`;
} else if (macCount > UPLINK_MAC_THRESHOLD) {
classification = 'uplink';
reason = `${macCount} MAC addresses reachable through this port`;
}
res.json({
classification,
reason,
mac_count: macCount,
vlan_count: vlanCount,
neighbor: neighbor || null,
// Physically-connected view hides everything on an uplink; ?all=true returns the raw FDB.
clients: all || classification === 'access' ? rows : [],
});
});
// 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<string, unknown>) => [bp['port'], bp]));
const ports = ifaces
.filter((i: Record<string, unknown>) =>
String(i['type'] || '').match(/^(ether|sfp|combo|bridge|bond)/i) ||
String(i['name'] || '').match(/^(ether|sfp|combo|bridge|bond|lag)/i)
)
.map((i: Record<string, unknown>) => ({
...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<any>(
`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<string, string> = {
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<string, unknown>): Record<string, string> {
const params: Record<string, string> = {};
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, force } = req.body;
if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' });
const params = bodyToRosParams(req.body);
// Safe-apply: refuse a self-lockout rule unless the operator confirms (force).
if (!force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`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(params);
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 params = bodyToRosParams(req.body);
if (!req.body.force) {
const lock = detectLockoutRisk(params);
if (lock.risky) return res.status(409).json({ lockout: true, reason: lock.reason });
}
const deviceRow = await queryOne<DeviceRow>(`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, params);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/move { id, destination? }
router.post('/:id/firewall/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`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.moveFirewallRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getFirewallRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/firewall/reset-counters
router.post('/:id/firewall/reset-counters', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`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.resetFirewallCounters();
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<DeviceRow>(`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<string, string> = {
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<string, unknown>): Record<string, string> {
const params: Record<string, string> = {};
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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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();
}
});
// POST /api/devices/:id/nat/move { id, destination? }
router.post('/:id/nat/move', requireWrite, async (req: Request, res: Response) => {
const { id, destination } = req.body;
if (!id) return res.status(400).json({ error: 'id is required' });
const deviceRow = await queryOne<DeviceRow>(`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.moveNatRule(String(id), destination ? String(destination) : undefined);
return res.json(await collector.getNatRules());
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
});
// ─── Firewall Address Lists ─────────────────────────────────────────────────────
// Shared helper for the security-feature routes below (DRYs the device fetch +
// connect/disconnect lifecycle the rest of this file uses inline).
/**
* Run a device mutation under the Change Guard safety net (see
* services/changeGuard/ChangeGuard.ts): the device saves a restore point and arms
* a self-restore before the change, and disarms it only once we prove the device
* is still reachable. Used for changes that can sever the manager's own path.
*
* The response always carries a `guard` block so the UI can say whether the change
* was confirmed, is being auto-reverted, or ran unprotected.
*/
async function withGuardedChange<T>(
id: string,
req: Request,
res: Response,
meta: { kind: string; summary: string; change?: PlannedChange },
fn: (c: DeviceCollector) => Promise<T>,
afterConfirmed?: (c: DeviceCollector) => Promise<void>
): Promise<void> {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
if (!deviceRow) { res.status(404).json({ error: 'Device not found' }); return; }
// Pre-flight: simulate the change against live state and refuse a predicted
// lockout unless the user has explicitly accepted it. Analysis failures must
// never block a legitimate change — the auto-revert net still applies.
if (meta.change && req.body?.confirm_lockout !== true) {
try {
const snap = await captureSnapshot(deviceRow as unknown as GuardDevice);
const verdict = analyzeChange(snap, deviceRow as unknown as GuardDevice, meta.change);
if (verdict.severity === 'critical') {
res.status(409).json({
lockout: true,
reason: verdict.headline,
verdict: {
severity: verdict.severity,
headline: verdict.headline,
violations: verdict.violations,
warnings: verdict.warnings,
path: {
mgmt_interface: verdict.path.mgmtInterface,
bridge: verdict.path.bridge,
mgmt_vlan_id: verdict.path.mgmtVlanId,
tagged_management: verdict.path.taggedManagement,
ingress_port: verdict.path.ingressPort,
ingress_port_source: verdict.path.ingressPortSource,
hops: verdict.path.hops,
},
},
});
return;
}
} catch (err) {
console.warn(`[preflight] analysis skipped for device ${logSafe(id)}: ${logSafe((err as Error).message)}`);
}
}
const collector = new DeviceCollector(deviceRow);
try {
const outcome = await withSafeApply(
deviceRow as unknown as GuardDevice,
{ ...meta, userId: req.user?.userId ?? null },
async () => {
await collector.connect();
return fn(collector);
}
);
// Only re-read the device once we know it's still reachable.
if (outcome.confirmed && afterConfirmed) {
await afterConfirmed(collector).catch(() => { /* best effort */ });
}
res.json({
...(typeof outcome.result === 'object' && outcome.result !== null
? outcome.result as Record<string, unknown>
: { result: outcome.result }),
// A change that severed the connection produces no result — the request never
// got a reply. Say what is happening rather than returning a bare guard block.
...(outcome.result === undefined && outcome.autoReverting
? { message: 'Contact with the device was lost while applying this change. It is restoring itself and should come back shortly.' }
: {}),
guard: {
protected: !outcome.unprotectedReason,
confirmed: outcome.confirmed,
auto_reverting: outcome.autoReverting,
unprotected_reason: outcome.unprotectedReason ?? null,
},
});
} catch (err) {
res.status(500).json({ error: (err as Error).message });
} finally {
try { collector.disconnect(); } catch { /* device may be gone */ }
}
}
async function withCollector<T>(
id: string,
res: Response,
fn: (c: DeviceCollector) => Promise<T>
): Promise<void> {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
if (!deviceRow) { res.status(404).json({ error: 'Device not found' }); return; }
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
const result = await fn(collector);
res.json(result);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
} finally {
collector.disconnect();
}
}
// GET /api/devices/:id/management-path — how the manager reaches this device, and
// which objects are therefore load-bearing. Read-only; useful on its own and as the
// explanation behind a lockout warning.
router.get('/:id/management-path', async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
try {
const snap = await captureSnapshot(deviceRow as unknown as GuardDevice);
const path = resolveManagementPath(snap, deviceRow as unknown as GuardDevice);
return res.json(path);
} catch (err) {
return res.status(502).json({ error: `Could not read device state: ${(err as Error).message}` });
}
});
// POST /api/devices/:id/preflight — analyse a change WITHOUT applying it, so the UI
// can warn while the user is still editing. Body: { change: PlannedChange }.
router.post('/:id/preflight', requireWrite, async (req: Request, res: Response) => {
const change = req.body?.change as PlannedChange | undefined;
if (!change || typeof change !== 'object' || !('kind' in change)) {
return res.status(400).json({ error: 'change (with a kind) is required' });
}
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
try {
const snap = await captureSnapshot(deviceRow as unknown as GuardDevice);
return res.json(analyzeChange(snap, deviceRow as unknown as GuardDevice, change));
} catch (err) {
return res.status(502).json({ error: `Could not read device state: ${(err as Error).message}` });
}
});
// POST /api/devices/:id/change-guard/probe — what safety mechanisms this device
// actually supports. Non-destructive: both probes create and remove a harmless
// scheduler, and neither leaves anything behind.
router.post('/:id/change-guard/probe', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const device = deviceRow as unknown as GuardDevice;
try {
// Sequential, not parallel: both add a scheduler, and overlapping them would
// make each one's history diff ambiguous.
const scheduler = await probeCapability(device);
const undo = await probeUndoCapability(device);
return res.json({ scheduler, undo });
} catch (err) {
return res.status(502).json({ error: `Could not probe device: ${(err as Error).message}` });
}
});
// GET /api/devices/:id/config-health — the standing audit's latest findings, read
// from cache so the page loads without touching the device.
router.get('/:id/config-health', async (req: Request, res: Response) => {
const device = await queryOne<{ config_health_checked_at: string | null }>(
`SELECT config_health_checked_at FROM devices WHERE id = $1`,
[req.params.id]
);
if (!device) return res.status(404).json({ error: 'Device not found' });
const findings = await query(
`SELECT rule, severity, title, detail, remediation, doc_url, objects, first_seen, last_seen
FROM device_config_findings
WHERE device_id = $1
ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, rule`,
[req.params.id]
);
return res.json({ findings, checked_at: device.config_health_checked_at });
});
// POST /api/devices/:id/config-health/scan — audit now instead of waiting for the
// scheduled pass. Read-only on the device, but it opens a connection.
router.post('/:id/config-health/scan', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
try {
const { findings, checkedAt } = await runConfigHealth(deviceRow as unknown as GuardDevice);
// Same shape as the cached GET, so the UI has one contract for both.
return res.json({
findings: findings.map((f) => ({
rule: f.rule, severity: f.severity, title: f.title, detail: f.detail,
remediation: f.remediation, doc_url: f.docUrl, objects: f.objects,
first_seen: null, last_seen: checkedAt.toISOString(),
})),
checked_at: checkedAt.toISOString(),
});
} catch (err) {
return res.status(502).json({ error: `Could not read device state: ${(err as Error).message}` });
}
});
router.get('/:id/address-lists', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getAddressLists());
});
router.post('/:id/address-lists', requireWrite, async (req, res) => {
const { list, address } = req.body;
if (!list || !address) return res.status(400).json({ error: 'list and address are required' });
const params: Record<string, string> = { list: String(list), address: String(address) };
if (req.body.comment) params.comment = String(req.body.comment);
if (req.body.timeout) params.timeout = String(req.body.timeout);
await withCollector(req.params.id, res, async (c) => { await c.addAddressListEntry(params); return c.getAddressLists(); });
});
router.put('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
const params: Record<string, string> = {};
for (const k of ['list', 'address', 'comment'] as const) if (req.body[k] !== undefined) params[k] = String(req.body[k]);
if (req.body.disabled !== undefined) params.disabled = req.body.disabled ? 'yes' : 'no';
await withCollector(req.params.id, res, async (c) => { await c.updateAddressListEntry(req.params.entryId, params); return c.getAddressLists(); });
});
router.delete('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeAddressListEntry(req.params.entryId); return { message: 'Entry removed' }; });
});
// ─── Active Connections (read-only) ─────────────────────────────────────────────
router.get('/:id/connections', async (req, res) => {
const limit = Math.min(parseInt(String(req.query.limit || '500'), 10) || 500, 2000);
await withCollector(req.params.id, res, async (c) => {
const [rows, tracking] = await Promise.all([c.getConnections(), c.getConnectionTracking()]);
// Connection tables can be huge; cap and surface the total so the UI can note truncation.
return { total: rows.length, connections: rows.slice(0, limit), tracking };
});
});
// ─── Simple Queues (bandwidth control) ──────────────────────────────────────────
const QUEUE_FIELD_MAP: Record<string, string> = {
name: 'name', target: 'target', max_limit: 'max-limit', burst_limit: 'burst-limit',
burst_threshold: 'burst-threshold', burst_time: 'burst-time', priority: 'priority',
comment: 'comment', parent: 'parent',
};
function queueBodyToRos(body: Record<string, unknown>): Record<string, string> {
const p: Record<string, string> = {};
for (const [js, ros] of Object.entries(QUEUE_FIELD_MAP)) {
const v = body[js];
if (v !== undefined && v !== null && v !== '') p[ros] = String(v);
}
if (body.disabled !== undefined) p.disabled = body.disabled ? 'yes' : 'no';
return p;
}
router.get('/:id/queues', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getSimpleQueues());
});
router.post('/:id/queues', requireWrite, async (req, res) => {
const { name, target, max_limit } = req.body;
if (!name || !target || !max_limit) return res.status(400).json({ error: 'name, target and max_limit are required' });
await withCollector(req.params.id, res, async (c) => { await c.addSimpleQueue(queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.put('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.updateSimpleQueue(req.params.queueId, queueBodyToRos(req.body)); return c.getSimpleQueues(); });
});
router.delete('/:id/queues/:queueId', requireWrite, async (req, res) => {
await withCollector(req.params.id, res, async (c) => { await c.removeSimpleQueue(req.params.queueId); return { message: 'Queue removed' }; });
});
// ─── IP Services + Security Posture audit ────────────────────────────────────────
router.get('/:id/services', async (req, res) => {
await withCollector(req.params.id, res, (c) => c.getServices());
});
router.put('/:id/services/:serviceId', requireWrite, async (req, res) => {
if (typeof req.body.disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
const deviceRow = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
// Disabling the very service MikroTik Manager talks to is an immediate, certain
// self-lockout — and unlike other changes there is no legitimate way to do it
// from here, so it is refused outright rather than merely guarded.
if (req.body.disabled === true) {
const mgmtService = deviceRow.api_port === 8729 ? 'api-ssl' : 'api';
const collector = new DeviceCollector(deviceRow);
try {
await collector.connect();
const services = await collector.getServices();
const target = (services as Record<string, string>[]).find((s) => s['.id'] === req.params.serviceId);
if (target && target['name'] === mgmtService) {
return res.status(409).json({
lockout: true,
reason: `'${mgmtService}' is the service MikroTik Manager uses to reach this device. `
+ `Disabling it would make the device unmanageable from here.`,
});
}
} catch { /* fall through to the guarded path */ } finally {
collector.disconnect();
}
}
await withGuardedChange(
req.params.id,
req,
res,
{ kind: 'service.toggle', summary: `${req.body.disabled ? 'Disable' : 'Enable'} IP service` },
async (c) => {
await c.setServiceDisabled(req.params.serviceId, req.body.disabled);
return { services: await c.getServices() };
}
);
});
// GET /api/devices/:id/security-posture — computes a hardening checklist.
// Heuristic, not standards-based: graduated weighting that won't saturate to 0,
// de-duplicated services, and it never proposes disabling the management
// service MikroTik Manager itself connects through (that would self-lockout).
router.get('/:id/security-posture', async (req, res) => {
const device = await queryOne<DeviceRow & {
firmware_update_available?: boolean;
latest_ros_version?: string | null;
routerboard_upgrade_available?: boolean;
firmware_version?: string | null;
upgrade_firmware_version?: string | null;
}>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!device) return res.status(404).json({ error: 'Device not found' });
// Which RouterOS service the platform uses to reach this device. Default api
// (8728); api-ssl is 8729. We must never offer to disable this one.
const mgmtService = device.api_port === 8729 ? 'api-ssl' : 'api';
const isRouter = device.device_type === 'router';
await withCollector(req.params.id, res, async (c) => {
const [servicesRaw, fwRules, snmp] = await Promise.all([
c.getServices().catch(() => [] as Record<string, string>[]),
c.getFirewallRules().catch(() => [] as Record<string, string>[]),
c.getSnmpConfig().catch(() => ({} as Record<string, string>)),
]);
// De-duplicate /ip/service rows by name (some devices report duplicates,
// e.g. api/route_BFD twice) so a service is only ever counted once.
const services = Array.from(new Map(servicesRaw.map(s => [s['name'] ?? s['.id'], s])).values());
type Sev = 'high' | 'medium' | 'low';
type Check = { id: string; severity: Sev; title: string; detail: string; serviceId?: string };
const checks: Check[] = [];
for (const s of services) {
const name = s['name'] ?? '';
if (s['disabled'] === 'true') continue;
if (name === 'telnet' || name === 'ftp') {
checks.push({ id: `service-${name}`, severity: 'high', serviceId: s['.id'],
title: `Cleartext service "${name}" is enabled`,
detail: `${name} sends credentials and data unencrypted. Disable it; use SSH/SFTP instead.` });
} else if (name === 'www') {
checks.push({ id: 'service-www', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted WebFig (www) is enabled',
detail: 'The HTTP WebFig interface is unencrypted. Disable "www" and use "www-ssl" (HTTPS) instead.' });
} else if (name === 'api' && mgmtService !== 'api') {
// Plaintext API is on but the platform connects via api-ssl — safe to flag/disable.
checks.push({ id: 'service-api', severity: 'medium', serviceId: s['.id'],
title: 'Unencrypted API (api) is enabled',
detail: 'The plaintext RouterOS API is exposed. Disable "api" and use "api-ssl" (8729).' });
} else if (name === mgmtService && name === 'api') {
// The platform is connected through cleartext API — advise, but do NOT
// offer to disable it (that would cut MikroTik Manager off the device).
checks.push({ id: 'mgmt-api-cleartext', severity: 'low',
title: 'MikroTik Manager connects over cleartext API',
detail: 'This device is managed via the unencrypted API (8728). Consider migrating the device and its credentials to API-SSL (8729) for encrypted management.' });
}
}
// Input-chain firewall: a real concern on routers; switches/APs commonly
// (and legitimately) rely on an upstream router's firewall, so it's only
// informational there rather than a high-severity hit.
const hasInputDrop = fwRules.some(r => (r['chain'] === 'input') && ['drop', 'reject'].includes(r['action'] ?? '') && r['disabled'] !== 'true');
if (!hasInputDrop) {
checks.push({
id: 'no-input-firewall', severity: isRouter ? 'high' : 'low',
title: 'No input-chain firewall rule',
detail: isRouter
? 'This router has no enabled drop/reject rule on the input chain, leaving its own services exposed. Add a baseline input firewall.'
: 'No input-chain firewall on this device. Switches/APs often rely on an upstream router firewall — confirm that is the case.',
});
}
if (snmp && snmp['enabled'] === 'true') {
checks.push({ id: 'snmp-enabled', severity: 'low', title: 'SNMP is enabled',
detail: 'Ensure SNMP uses a non-default community string and is restricted to trusted addresses.' });
}
// Outdated firmware is a security exposure: RouterOS releases routinely
// ship security fixes, so a pending update counts against the posture.
// Titles stay version-free so the Security Center's "Common Findings"
// rollup aggregates devices on different versions into one row.
if (device.firmware_update_available && device.latest_ros_version) {
checks.push({
id: 'firmware-outdated', severity: 'medium',
title: 'RouterOS update available',
detail: `This device runs RouterOS ${device.ros_version || '(unknown)'} but ${device.latest_ros_version} is available. RouterOS releases regularly include security fixes — review the changelog and schedule the upgrade from the Firmware section.`,
});
}
if (device.routerboard_upgrade_available) {
checks.push({
id: 'routerboot-outdated', severity: 'low',
title: 'RouterBOOT upgrade pending',
detail: `The bootloader (${device.firmware_version || 'current'}) has an available upgrade${device.upgrade_firmware_version ? ` to ${device.upgrade_firmware_version}` : ''}. Apply it after the next RouterOS upgrade from the device's Config tab.`,
});
}
// Suppressions (issue #101). A finding the operator has judged inapplicable is
// marked rather than hidden: it stays visible, greyed, and stops counting
// against the score. Hiding it outright would make the posture unauditable.
const supp = await query<{ check_id: string; device_id: number | null; reason: string | null }>(
`SELECT check_id, device_id, reason FROM security_check_suppressions
WHERE device_id IS NULL OR device_id = $1`,
[device.id]
).catch(() => []);
const suppressedBy = new Map<string, { scope: 'device' | 'fleet'; reason: string | null }>();
for (const row of supp) {
const scope = row.device_id == null ? 'fleet' : 'device';
// A device-specific row wins over a fleet-wide one, so the UI can offer the
// narrower undo.
if (scope === 'device' || !suppressedBy.has(row.check_id)) {
suppressedBy.set(row.check_id, { scope, reason: row.reason });
}
}
const marked = checks.map((c2) => {
const hit = suppressedBy.get(c2.id);
return hit ? { ...c2, suppressed: true, suppressed_scope: hit.scope, suppressed_reason: hit.reason } : c2;
});
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
const WEIGHT: Record<Sev, number> = { high: 15, medium: 7, low: 3 };
const counted = marked.filter((c2) => !('suppressed' in c2));
const penalty = counted.reduce((n, c2) => n + WEIGHT[c2.severity], 0);
const score = counted.length === 0 ? 100 : Math.max(5, 100 - penalty);
return { score, checks: marked };
});
});
// GET /api/devices/:id/resources (live resource usage)
router.get('/:id/resources', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
`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<any>(
`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<any>(
`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<any>(
`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 { address, interface: iface } = req.body;
if (!address || !iface) {
return res.status(400).json({ error: 'address and interface are required' });
}
await withGuardedChange(
req.params.id,
req,
res,
{ kind: 'ip.add', summary: `Add ${address} on ${iface}` },
async (c) => {
await c.addIpAddress(address, iface);
return { message: `Address ${address} added` };
},
async (c) => { await c.collectIpAddressesCache(); }
);
});
// DELETE /api/devices/:id/ip-addresses/:addrId
// Guarded: removing the address the manager connects to is an immediate, total cut.
router.delete('/:id/ip-addresses/:addrId', requireWrite, async (req: Request, res: Response) => {
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'ip.remove',
summary: `Remove IP address ${req.params.addrId}`,
change: { kind: 'ip.remove', addressId: req.params.addrId },
},
async (c) => {
await c.removeIpAddress(req.params.addrId);
return { message: 'IP address removed' };
},
async (c) => { await c.collectIpAddressesCache(); }
);
});
// POST /api/devices/:id/check-update
router.post('/:id/check-update', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
`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();
}
});
// ─── Per-device SSH keys (#110) ──────────────────────────────────────────────
const SSH_TARGET_COLS =
`id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
api_port, api_username, api_password_encrypted`;
/**
* GET /api/devices/:id/ssh-key — key status.
*
* The private half is never returned. Not here, not anywhere: it exists so that
* nobody has to hold a credential, and handing it back through the API would
* defeat the point while widening the surface at the same time.
*/
router.get('/:id/ssh-key', async (req: Request, res: Response) => {
try {
const row = await queryOne<Record<string, unknown>>(
`SELECT device_id, key_type, public_key, fingerprint, ssh_username, status,
last_error, last_verified_at, deployed_at, created_at
FROM device_ssh_keys WHERE device_id = $1`,
[req.params.id]
);
res.json({ key: row ?? null });
} catch (error) {
console.error('Error fetching SSH key:', error);
res.status(500).json({ error: 'Failed to fetch SSH key' });
}
});
// POST /api/devices/:id/ssh-key — generate, deploy and verify (rotates if one exists)
router.post('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => {
const target = await queryOne<any>(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
if (!target) return res.status(404).json({ error: 'Device not found' });
try {
const { sshKeyService } = await import('../services/SshKeyService');
const rotate = (req.body as { rotate?: boolean })?.rotate === true;
const key = await sshKeyService.deploy(target, { rotate });
res.json({
// Not "password access is unchanged" — that was wrong, and testing on
// hardware proved it. RouterOS refuses password SSH for a user once a key
// is bound to them, so this is a real trade the operator must know about.
message: rotate
? 'New key deployed and verified; the previous one has been removed.'
: 'Key deployed and verified. Note that RouterOS now requires this key for SSH — '
+ 'password SSH is disabled for this user until the key is revoked. '
+ 'The API and WinBox are unaffected.',
key: { fingerprint: key.fingerprint, status: key.status, public_key: key.public_key },
});
} catch (error) {
// Deployment failures leave password auth untouched by design, and the
// message says so — otherwise the natural fear is that access was lost.
console.error('SSH key deployment failed:', (error as Error).message);
res.status(502).json({ error: (error as Error).message });
}
});
// POST /api/devices/:id/ssh-key/verify — re-prove an existing key
router.post('/:id/ssh-key/verify', requireWrite, async (req: Request, res: Response) => {
const target = await queryOne<any>(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
if (!target) return res.status(404).json({ error: 'Device not found' });
const { sshKeyService } = await import('../services/SshKeyService');
const result = await sshKeyService.verify(target);
res.status(result.ok ? 200 : 502).json(
result.ok
? { message: 'Key authenticated successfully.' }
: { error: `Key did not authenticate: ${result.error}` }
);
});
// DELETE /api/devices/:id/ssh-key — stop using the key, and remove it if reachable
router.delete('/:id/ssh-key', requireWrite, async (req: Request, res: Response) => {
const target = await queryOne<any>(`SELECT ${SSH_TARGET_COLS} FROM devices WHERE id = $1`, [req.params.id]);
if (!target) return res.status(404).json({ error: 'Device not found' });
const { sshKeyService } = await import('../services/SshKeyService');
const result = await sshKeyService.revoke(target);
res.json({
message: result.removedFromDevice
? 'Key revoked and removed from the device.'
: `Key forgotten here, but it could not be removed from the device (${result.error}). Remove it manually via /user/ssh-keys.`,
removed_from_device: result.removedFromDevice,
});
});
/**
* POST /api/devices/ssh-keys/deploy-all — retroactively key every device missing one.
*
* Sequential rather than parallel: each deployment opens two SSH sessions and
* ends by proving the key, and running sixty of those at once would be a
* thundering herd against the fleet for no wall-clock gain worth having.
*/
router.post('/ssh-keys/deploy-all', requireWrite, async (req: Request, res: Response) => {
// This is the most consequential call in the feature: it disables password SSH
// across an entire fleet in one request. The per-device path asks for an
// acknowledgement in the UI, and an endpoint that can do it sixty times over
// should not be easier to trigger than the one that does it once.
if ((req.body as { confirm?: boolean })?.confirm !== true) {
return res.status(400).json({
error: 'Refusing to deploy fleet-wide without confirmation. This disables password ' +
'SSH for the configured user on every device it touches; the API and WinBox ' +
'are unaffected. Repeat with {"confirm": true} once that is understood.',
});
}
const targets = await query<any>(
`SELECT ${SSH_TARGET_COLS} FROM devices d
WHERE d.status != 'disabled' AND d.ssh_username IS NOT NULL
AND d.ssh_password_encrypted IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM device_ssh_keys k WHERE k.device_id = d.id AND k.status = 'verified')
ORDER BY d.name`
);
const { sshKeyService } = await import('../services/SshKeyService');
const results: { device_id: number; name: string; ok: boolean; error?: string }[] = [];
for (const t of targets) {
try {
await sshKeyService.deploy(t);
results.push({ device_id: t.id, name: t.name, ok: true });
} catch (e) {
results.push({ device_id: t.id, name: t.name, ok: false, error: (e as Error).message });
}
}
res.json({ considered: targets.length, succeeded: results.filter((r) => r.ok).length, results });
});
/**
* POST /api/devices/:id/install-update — install a pending RouterOS update.
*
* The check on the line below is load-bearing, not a courtesy. RouterOS installs
* the version discovered by `check-for-updates` **in the same API session**;
* issued on its own, `/system/package/update/install` replies `!done` and does
* nothing at all. This route used to do exactly that, so the button reported
* "Update installation initiated. Device will reboot." while the device sat
* untouched — verified against a switch that ignored this endpoint and then
* upgraded two minutes later through the rollout path, which does check first.
*
* The flag is likewise not cleared here. An upgrade takes minutes and a reboot,
* neither of which an HTTP request can wait for, so claiming the device is
* current the moment the command is sent puts a false "up to date" on every
* screen until the poller contradicts it. The poller owns that transition; this
* route reports only what it actually knows.
*/
router.post('/:id/install-update', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(`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();
const status = await collector.checkForUpdates();
const installed = (status['installed-version'] || '').trim();
const latest = (status['latest-version'] || '').trim();
if (!latest || latest === installed) {
// Nothing to do is a real answer, not a failure — and it is the one state
// in which the device genuinely is current, so the flag can be trusted.
await query(
`UPDATE devices SET firmware_update_available = FALSE,
latest_ros_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`,
[deviceRow.id, latest]
);
return res.json({
started: false,
installed_version: installed || null,
message: `Already running ${installed || 'the latest version'} — nothing to install.`,
});
}
await collector.installUpdate();
await query(
`UPDATE devices SET latest_ros_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`,
[deviceRow.id, latest]
);
return res.json({
started: true,
installed_version: installed || null,
target_version: latest,
message: `Installing ${latest}. The device will reboot; the new version appears once it is back and polled.`,
});
} finally {
collector.disconnect();
}
});
// POST /api/devices/:id/check-routerboard
router.post('/:id/check-routerboard', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
`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 — apply a pending RouterBOOT upgrade.
*
* Same shape as install-update above, and it had the same two faults: no check
* that anything was pending, and the flag cleared before the reboot it depends
* on had happened. `/system/routerboard/upgrade` on a current bootloader is a
* no-op, so the reboot that followed accomplished nothing while the UI reported
* an upgrade — and rebooting a device to achieve nothing is worse here than in
* the RouterOS case, because it costs an outage.
*/
router.post('/:id/install-routerboard', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(`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();
const info = await collector.checkRouterboardUpgrade();
if (!info.upgradeAvailable) {
await query(
`UPDATE devices SET routerboard_upgrade_available = FALSE,
upgrade_firmware_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`,
[deviceRow.id, info.upgradeFirmware]
);
return res.json({
started: false,
current_firmware: info.currentFirmware || null,
message: `RouterBOOT is already at ${info.currentFirmware || 'the current version'} — nothing to upgrade.`,
});
}
await collector.installRouterboardUpgrade();
await query(
`UPDATE devices SET upgrade_firmware_version = NULLIF($2,''), updated_at = NOW() WHERE id = $1`,
[deviceRow.id, info.upgradeFirmware]
);
return res.json({
started: true,
current_firmware: info.currentFirmware || null,
target_firmware: info.upgradeFirmware,
message: `Upgrading RouterBOOT to ${info.upgradeFirmware}. The 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<any>(
`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<any>(
`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<any>(
`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)
// Guarded: a new low-distance default route can re-point the path back to the manager.
router.post('/:id/routing', requireWrite, async (req: Request, res: Response) => {
const { dst_address, gateway, distance, comment } = req.body;
if (!dst_address || !gateway) {
return res.status(400).json({ error: 'dst_address and gateway are required' });
}
await withGuardedChange(
req.params.id,
req,
res,
{ kind: 'route.add', summary: `Add route ${dst_address} via ${gateway}` },
async (c) => {
await c.addRoute(dst_address, gateway, distance, comment);
return { message: 'Route added' };
}
);
});
// DELETE /api/devices/:id/routing/:routeId
// Guarded: removing the default route cuts off any manager that isn't on-subnet.
router.delete('/:id/routing/:routeId', requireWrite, async (req: Request, res: Response) => {
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'route.remove',
summary: `Remove route ${req.params.routeId}`,
change: { kind: 'route.remove', routeId: req.params.routeId },
},
async (c) => {
await c.removeRoute(req.params.routeId);
return { message: 'Route removed' };
}
);
});
// ─── OSPF ─────────────────────────────────────────────────────────────────────
router.get('/:id/routing/ospf', async (req: Request, res: Response) => {
const deviceRow = await queryOne<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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<DeviceRow>(`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)
/**
* A single RouterOS bridge VLAN row can cover a range or list ("10-20"), which the
* cache expands into one row per VLAN. Editing or deleting one of those rows would
* rewrite the whole entry, silently taking the other VLANs with it — so refuse, and
* refuse *before* arming the guard, since a mid-apply failure would cost a reboot.
*/
function multiVlanRefusal(vlan: { vlan_id: number; bridge: string; vlan_ids: string | null }): string | null {
if (!isMultiVlanSpec(vlan.vlan_ids ?? undefined)) return null;
return `VLAN ${vlan.vlan_id} on ${vlan.bridge} is part of the multi-VLAN entry "${vlan.vlan_ids}". `
+ `Changing it here would affect every VLAN in that entry, so split the entry on the device first.`;
}
// Guarded: bridge VLAN table edits decide which ports (and the bridge/CPU port
// itself) may carry the management VLAN.
router.post('/:id/vlans', requireWrite, async (req: Request, res: Response) => {
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' });
}
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'vlan.add',
summary: `Add VLAN ${vlan_id} on ${bridge}`,
change: { kind: 'vlan.add', bridge, vlanId: vlan_id, tagged: tagged_ports, untagged: untagged_ports },
},
async (c) => {
await c.addBridgeVlan(bridge, vlan_id, tagged_ports, untagged_ports);
return { message: `VLAN ${vlan_id} added` };
},
async (c) => { await c.collectVlans(); }
);
});
// 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; vlan_ids: string | null }>(
`SELECT id, vlan_id, bridge, vlan_ids 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 multi = multiVlanRefusal(vlan);
if (multi) return res.status(409).json({ error: multi, reason: multi });
const { tagged_ports = [], untagged_ports = [] } = req.body;
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'vlan.update',
summary: `Update VLAN ${vlan.vlan_id} membership on ${vlan.bridge}`,
change: {
kind: 'vlan.update', bridge: vlan.bridge, vlanId: vlan.vlan_id,
tagged: tagged_ports, untagged: untagged_ports,
},
},
async (c) => {
await c.updateBridgeVlan(vlan.bridge, vlan.vlan_id, tagged_ports, untagged_ports);
return { message: `VLAN ${vlan.vlan_id} updated` };
},
async (c) => { await c.collectVlans(); }
);
});
// 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; vlan_ids: string | null }>(
`SELECT vlan_id, bridge, vlan_ids 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 multi = multiVlanRefusal(vlan);
if (multi) return res.status(409).json({ error: multi, reason: multi });
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'vlan.delete',
summary: `Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}`,
change: { kind: 'vlan.delete', bridge: vlan.bridge, vlanId: vlan.vlan_id },
},
async (c) => {
await c.removeBridgeVlan(vlan.bridge, vlan.vlan_id);
return { message: 'VLAN removed' };
},
// Only drop the local row once the device is confirmed reachable — if the
// change auto-reverts, the VLAN still exists on the device.
async () => { await query(`DELETE FROM vlans WHERE id = $1`, [req.params.vlanDbId]); }
);
});
// 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<any>(
`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' });
}
// Guarded: creating a bond pulls its member ports out of their bridge first, so a
// failure part-way through can strand the uplink.
await withGuardedChange(
req.params.id,
req,
res,
{ kind: 'bond.create', summary: `Create bond '${name}' over ${slaves.join(', ')}` },
async (c) => {
await c.createBond(name, slaves, mode, {
lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links,
});
return { message: 'Bond created' };
},
async (c) => { await c.collectInterfaces(); }
);
});
// 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<DeviceRow>(`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
// Guarded: removing a bond releases its member ports, which can take the device
// off the network even when the bond looks unused.
router.delete('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => {
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'bond.delete',
summary: `Delete bond '${req.params.bondName}'`,
change: { kind: 'bond.delete', name: req.params.bondName },
},
async (c) => {
await c.deleteBond(req.params.bondName);
return { message: 'Bond deleted' };
},
async (c) => {
await query(`DELETE FROM interfaces WHERE device_id = $1 AND name = $2`, [req.params.id, req.params.bondName]);
await c.collectInterfaces();
}
);
});
// 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 };
// Guarded: turning on vlan-filtering with untagged management on the bridge is
// the single most common way to lock yourself out of a MikroTik.
await withGuardedChange(
req.params.id,
req,
res,
{
kind: 'bridge.vlan-filtering',
summary: `${enabled ? 'Enable' : 'Disable'} VLAN filtering on bridge '${req.params.bridgeName}'`,
change: {
kind: 'bridge.vlan-filtering',
bridge: req.params.bridgeName,
enabled: Boolean(enabled),
},
},
async (c) => {
await c.setBridgeVlanFiltering(req.params.bridgeName, Boolean(enabled));
return { success: true, vlan_filtering: enabled };
},
async (c) => { await c.collectInterfaces(); }
);
});
// GET /api/devices/:id/hardware (live health: temps, voltages, fans, PSU)
router.get('/:id/hardware', async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(
`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);
});
// ─── LTE / cellular (discussion #85) ─────────────────────────────────────────
// GET /api/devices/:id/lte — current state of every LTE interface
router.get('/:id/lte', async (req: Request, res: Response) => {
try {
const interfaces = await query<Record<string, unknown>>(
`SELECT interface_name, status, data_class, modem_model, modem_revision, operator,
cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s,
primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs,
dl_modulation, quality, allowed_bands, network_mode, apn_profiles,
allow_roaming, updated_at
FROM lte_interfaces WHERE device_id = $1 ORDER BY interface_name`,
[req.params.id],
);
// Bands seen serving this device. This is what a band lock gets checked
// against, since a scan interrupts service and finds nothing at a site with
// a single base station.
const observed = await query<{ interface_name: string; band: number; observations: number; last_seen: string }>(
`SELECT interface_name, band, observations, last_seen
FROM lte_observed_bands WHERE device_id = $1 ORDER BY band`,
[req.params.id],
);
res.json({
interfaces: interfaces.map(i => {
const carriers = {
primaryBand: (i.primary_band as LteBandInfo | null) ?? null,
caBands: (i.ca_bands as LteBandInfo[] | null) ?? [],
};
return {
...i,
// Reconstruct the interpreted view so the client never re-derives it.
allowed_bands: parseBandList(i.allowed_bands as string | null),
total_bandwidth_mhz: totalBandwidthMhz(carriers),
// Uplink rides the primary carrier alone, so an anchor narrower than
// what is being aggregated caps upload however wide the downlink is.
uplink_anchor: uplinkAnchor(carriers),
observed_bands: observed
.filter(o => o.interface_name === i.interface_name)
.map(o => ({ band: o.band, observations: o.observations, last_seen: o.last_seen })),
};
}),
});
} catch (error) {
console.error('Error fetching LTE state:', error);
res.status(500).json({ error: 'Failed to fetch LTE state' });
}
});
// ─── Cellular data-cap SMS (discussion #85) ──────────────────────────────────
// GET /api/devices/:id/lte/data-cap — rules and recent sends
router.get('/:id/lte/data-cap', async (req: Request, res: Response) => {
try {
const rules = await query(
`SELECT id, interface_name, enabled, phone_number, message, threshold_bytes,
margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes,
period_key, period_bytes, last_sent_at, updated_at
FROM lte_data_cap_rules WHERE device_id = $1 ORDER BY interface_name`,
[req.params.id]
);
const sends = await query(
`SELECT interface_name, at, trigger, phone_number, period_bytes, ok, error
FROM lte_data_cap_sends WHERE device_id = $1 ORDER BY at DESC LIMIT 20`,
[req.params.id]
);
res.json({ rules, sends });
} catch (error) {
console.error('Error fetching data-cap rules:', error);
res.status(500).json({ error: 'Failed to fetch data-cap rules' });
}
});
// PUT /api/devices/:id/lte/data-cap/:iface — create or update a rule
router.put('/:id/lte/data-cap/:iface', requireWrite, async (req: Request, res: Response) => {
const b = req.body as Record<string, unknown>;
const phone = String(b.phone_number ?? '').trim();
if (!phone) return res.status(400).json({ error: 'phone_number is required' });
const threshold = Number(b.threshold_bytes);
if (!Number.isFinite(threshold) || threshold <= 0) {
return res.status(400).json({ error: 'threshold_bytes must be a positive number' });
}
// An unknown zone would silently park the counter in the wrong day, so reject
// it here rather than discovering it at midnight.
const tz = String(b.timezone ?? 'UTC');
try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); }
catch { return res.status(400).json({ error: `Unknown timezone "${tz}"` }); }
const clamp = (v: unknown, lo: number, hi: number, dflt: number) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.floor(n))) : dflt;
};
try {
await query(
`INSERT INTO lte_data_cap_rules
(device_id, interface_name, enabled, phone_number, message, threshold_bytes,
margin_pct, reset_hour, reset_minute, timezone, cooldown_minutes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (device_id, interface_name) DO UPDATE SET
enabled = EXCLUDED.enabled, phone_number = EXCLUDED.phone_number,
message = EXCLUDED.message, threshold_bytes = EXCLUDED.threshold_bytes,
margin_pct = EXCLUDED.margin_pct, reset_hour = EXCLUDED.reset_hour,
reset_minute = EXCLUDED.reset_minute, timezone = EXCLUDED.timezone,
cooldown_minutes = EXCLUDED.cooldown_minutes, updated_at = NOW()`,
[req.params.id, req.params.iface, b.enabled === true, phone,
String(b.message ?? ''), Math.floor(threshold),
clamp(b.margin_pct, 0, 50, 5), clamp(b.reset_hour, 0, 23, 0),
clamp(b.reset_minute, 0, 59, 0), tz, clamp(b.cooldown_minutes, 1, 1440, 60)]
);
res.json({ message: 'Data-cap rule saved' });
} catch (error) {
console.error('Error saving data-cap rule:', error);
res.status(500).json({ error: 'Failed to save rule' });
}
});
/**
* POST /api/devices/:id/lte/data-cap/:iface/send — send the SMS now.
*
* Deliberately independent of the rule being enabled, or of any threshold. This
* is the half of the feature that is trustworthy: the automatic trigger depends
* on a usage figure we reconstruct and therefore undercount, whereas pressing
* the button does exactly one thing for a reason the operator already knows.
*/
router.post('/:id/lte/data-cap/:iface/send', requireWrite, async (req: Request, res: Response) => {
const deviceRow = await queryOne<any>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
const rule = await queryOne<{ phone_number: string; message: string; period_bytes: string }>(
`SELECT phone_number, message, period_bytes FROM lte_data_cap_rules
WHERE device_id = $1 AND interface_name = $2`,
[req.params.id, req.params.iface]
);
const phone = String((req.body as { phone_number?: string })?.phone_number || rule?.phone_number || '').trim();
if (!phone) return res.status(400).json({ error: 'No phone number configured for this interface' });
// Construction is inside the try on purpose: DeviceCollector decrypts the
// stored credential in its constructor, so a corrupted or missing one throws
// before a single line of the body runs. Left outside, that surfaced as a raw
// 500 with an internal message and no record of the attempt.
let collector: DeviceCollector | null = null;
try {
collector = new DeviceCollector(deviceRow);
await collector.connect();
await collector.sendSms(req.params.iface, phone, rule?.message ?? '');
await query(
`INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, period_bytes, ok)
VALUES ($1,$2,'manual',$3,$4,TRUE)`,
[deviceRow.id, req.params.iface, phone, rule ? Number(rule.period_bytes) : null]
).catch(() => {});
// The counter restarts with the carrier's, for the same reason the automatic
// path resets it: the message is what zeroes their allowance.
await query(
`UPDATE lte_data_cap_rules SET last_sent_at = NOW(), period_bytes = 0
WHERE device_id = $1 AND interface_name = $2`,
[deviceRow.id, req.params.iface]
).catch(() => {});
res.json({ message: `SMS sent to ${phone}.` });
} catch (error) {
const raw = (error as Error).message;
// Credential problems are ours, not the modem's, and saying "could not send
// SMS: invalid encrypted text format" would send someone hunting the wrong
// fault entirely.
const msg = /encrypt|decrypt/i.test(raw)
? 'stored credentials for this device could not be read'
: raw;
await query(
`INSERT INTO lte_data_cap_sends (device_id, interface_name, trigger, phone_number, ok, error)
VALUES ($1,$2,'manual',$3,FALSE,$4)`,
[deviceRow.id, req.params.iface, phone, msg]
).catch(() => {});
console.error('Error sending data-cap SMS:', raw);
res.status(502).json({ error: `Could not send SMS: ${msg}` });
} finally {
collector?.disconnect();
}
});
// GET /api/devices/:id/lte/history — handovers, re-registrations and band changes
router.get('/:id/lte/history', async (req: Request, res: Response) => {
const { range = '24h', limit = '200' } = req.query as { range?: string; limit?: string };
const windows: Record<string, string> = {
'1h': '1 hour', '24h': '24 hours', '7d': '7 days', '30d': '30 days',
};
const window = windows[range] || '24 hours';
const cap = Math.min(Math.max(parseInt(limit, 10) || 200, 1), 1000);
try {
const events = await query(
`SELECT interface_name, at, kind, detail, cell_id, enb_id, bands, rsrp, sinr
FROM lte_cell_history
WHERE device_id = $1 AND at > NOW() - $2::interval
ORDER BY at DESC LIMIT $3`,
[req.params.id, window, cap],
);
res.json({ events });
} catch (error) {
console.error('Error fetching LTE history:', error);
res.status(500).json({ error: 'Failed to fetch LTE history' });
}
});
/**
* GET /api/devices/:id/lte/dwell — time spent on each band and each cell.
*
* The history panel lists every handover and grows without bound; this answers
* the question that list cannot, which is what the pattern actually is (#120).
*/
router.get('/:id/lte/dwell', async (req: Request, res: Response) => {
const { range = '7d', iface } = req.query as { range?: string; iface?: string };
const windows: Record<string, string> = {
'24h': '24 hours', '7d': '7 days', '30d': '30 days',
};
const window = windows[range] || '7 days';
try {
const rows = await query<{
interface_name: string; at: string; cell_id: string | null;
enb_id: string | null; bands: string | null;
}>(
`SELECT interface_name, at, cell_id, enb_id, bands
FROM lte_cell_history
WHERE device_id = $1 AND at > NOW() - $2::interval
AND ($3::text IS NULL OR interface_name = $3)
ORDER BY at ASC`,
[req.params.id, window, iface ?? null]
);
// The state in force when the window opened. Without it, a link that changed
// once yesterday would report a few minutes of dwell across seven days,
// having silently dropped everything before the first change in range.
const prior = await queryOne<{
interface_name: string; at: string; cell_id: string | null;
enb_id: string | null; bands: string | null;
}>(
`SELECT interface_name, at, cell_id, enb_id, bands
FROM lte_cell_history
WHERE device_id = $1 AND at <= NOW() - $2::interval
AND ($3::text IS NULL OR interface_name = $3)
ORDER BY at DESC LIMIT 1`,
[req.params.id, window, iface ?? null]
);
const [bounds] = await query<{ earliest: string | null }>(
`SELECT MIN(at) AS earliest FROM lte_cell_history WHERE device_id = $1`,
[req.params.id]
);
const now = new Date();
const windowStart = new Date(now.getTime() - intervalToMs(window));
const toEvent = (r: { at: string; cell_id: string | null; enb_id: string | null; bands: string | null }) =>
({ at: new Date(r.at), cellId: r.cell_id, enbId: r.enb_id, bands: r.bands });
const segments = buildSegments(
rows.map(toEvent), now, windowStart, prior ? toEvent(prior) : null
);
const measuredSeconds = segments.reduce((n, s) => n + s.seconds, 0);
res.json({
range,
// Stated so the UI can be honest about a window longer than our records.
measured_seconds: measuredSeconds,
earliest_record: bounds?.earliest ?? null,
truncated: Boolean(bounds?.earliest && new Date(bounds.earliest) > windowStart),
changes: rows.length,
bands: summariseBands(segments, measuredSeconds),
cells: summarise(
segments,
(s) => s.cellId,
(key, s) => (s.enbId ? `Cell ${key} · eNB ${s.enbId}` : `Cell ${key}`)
),
});
} catch (error) {
console.error('Error building LTE dwell stats:', error);
res.status(500).json({ error: 'Failed to build dwell statistics' });
}
});
// GET /api/devices/:id/lte/metrics — InfluxDB lte_signal time series
router.get('/:id/lte/metrics', async (req: Request, res: Response) => {
const { iface, range = '6h' } = req.query as { iface?: string; range?: string };
const ranges: Record<string, string> = {
'1h': '1h', '6h': '6h', '24h': '24h', '7d': '7d', '30d': '30d',
};
const fluxRange = ranges[range] || '6h';
// Keep roughly 200 points regardless of window, so a month is as readable as an hour.
const windows: Record<string, string> = {
'1h': '30s', '6h': '2m', '24h': '10m', '7d': '1h', '30d': '4h',
};
const { getQueryApi } = await import('../config/influxdb');
const queryApi = getQueryApi();
const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik';
const ifaceFilter = iface
? `|> filter(fn: (r) => r["interface"] == "${iface.replace(/["\\]/g, '')}")`
: '';
const flux = `
from(bucket: "${bucket}")
|> range(start: -${fluxRange})
|> filter(fn: (r) => r["_measurement"] == "lte_signal")
|> filter(fn: (r) => r["device_id"] == "${String(req.params.id).replace(/[^0-9]/g, '')}")
${ifaceFilter}
|> filter(fn: (r) => r["_field"] == "rsrp" or r["_field"] == "rsrq"
or r["_field"] == "sinr" or r["_field"] == "rssi"
or r["_field"] == "cqi" or r["_field"] == "carriers")
|> aggregateWindow(every: ${windows[fluxRange] || '2m'}, fn: mean, createEmpty: false)
// Drop the tag columns before pivoting. Serving band is a tag, so leaving
// it in splits the result into one table per band — which streams back as
// a run of B1 rows followed by a run of B3 rows, and draws a chart that
// jumps backwards in time at the seam.
|> keep(columns: ["_time", "_field", "_value", "interface"])
|> group(columns: ["interface"])
|> pivot(rowKey:["_time","interface"], columnKey: ["_field"], valueColumn: "_value")
|> sort(columns: ["_time"])
`;
try {
const num = (v: unknown) => (v == null ? undefined : Math.round(Number(v) * 10) / 10);
const rows: Record<string, unknown>[] = [];
await new Promise<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row) as Record<string, unknown>;
rows.push({
time: String(obj['_time'] || ''),
interface: String(obj['interface'] || ''),
rsrp: num(obj['rsrp']), rsrq: num(obj['rsrq']),
sinr: num(obj['sinr']), rssi: num(obj['rssi']),
cqi: num(obj['cqi']), carriers: num(obj['carriers']),
});
},
error: reject,
complete: resolve,
});
});
// One table per interface still streams sequentially, so order globally.
rows.sort((a, b) => String(a.time).localeCompare(String(b.time)));
res.json({ metrics: rows });
} catch (error) {
console.error('Error fetching LTE metrics:', error);
res.status(500).json({ error: 'Failed to fetch LTE metrics' });
}
});
// 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<string, string> = {
'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<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const obj = tableMeta.toObject(row) as Record<string, unknown>;
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<any>(
`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<DeviceRow>(`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<string, string> = {
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<string, Record<string, string>>();
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<string, Record<string, string>>();
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()}`;
const client = makeToolClient(device);
try {
console.log(`[capture] connecting to device ${device.id} (${device.ip_address})`);
await client.connect();
// Stop any in-progress capture before reconfiguring
await client.execute('/tool/sniffer/stop').catch(() => {});
const setParams: Record<string, string> = { '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] 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(() => {});
// Poll /file/print until the capture file appears (up to 10s)
// RouterOS reports the exact SFTP-accessible path in the 'name' field
let remotePath: string | null = null;
for (let i = 0; i < 20 && !remotePath; i++) {
await new Promise((r) => setTimeout(r, 500));
const files = await client.execute('/file/print');
const hit = files.find((f) => (f['name'] ?? '').includes(fileName));
if (hit) remotePath = hit['name'];
}
client.disconnect();
if (!remotePath) {
throw new Error(`Sniffer did not create file "${fileName}.pcap" — check device storage`);
}
// RouterOS SFTP root is the device filesystem root; 'name' from /file/print is the path
const sftpPath = remotePath.startsWith('/') ? remotePath : `/${remotePath}`;
console.log(`[capture] file found: ${remotePath}; downloading via SFTP`);
// Download PCAP via SFTP then delete remote file
const pcapBuffer = await new Promise<Buffer>((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(sftpPath);
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => {
sftp.unlink(sftpPath, () => { ssh.end(); });
resolve(Buffer.concat(chunks));
});
stream.on('error', (e: Error) => {
// Try without leading slash in case RouterOS SFTP is rooted differently
if (sftpPath.startsWith('/') && e.message.includes('No such file')) {
const altPath = sftpPath.slice(1);
console.log(`[capture] retrying SFTP with path: ${altPath}`);
const chunks2: Buffer[] = [];
const stream2 = sftp.createReadStream(altPath);
stream2.on('data', (c: Buffer) => chunks2.push(c));
stream2.on('end', () => { sftp.unlink(altPath, () => { ssh.end(); }); resolve(Buffer.concat(chunks2)); });
stream2.on('error', (e2: Error) => { ssh.end(); reject(new Error(`SFTP read failed (tried ${sftpPath} and ${altPath}): ${e2.message}`)); });
} else {
ssh.end();
reject(new Error(`SFTP read failed (path=${sftpPath}): ${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
// If target_device_id is provided: automatically enable/disable btest server on the target device.
// If only address is provided: manual mode — user must ensure the btest server is already running.
router.post('/:id/tools/btest', requireWrite, async (req: Request, res: Response) => {
const { target_device_id, address, direction = 'both', duration = 5, protocol = 'tcp' } = req.body as {
target_device_id?: number; address?: string; direction?: string; duration?: number; protocol?: string;
};
if (!target_device_id && !address) {
return res.status(400).json({ error: 'target_device_id or 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' });
// Resolve target: either a managed device (auto server management) or a manual IP
let targetAddress = address ?? '';
let targetUser: string | undefined;
let targetPassword: string | undefined;
let serverClient: RouterOSClient | null = null;
if (target_device_id) {
const target = await getToolDevice(String(target_device_id));
if (!target) return res.status(404).json({ error: 'Target device not found' });
targetAddress = target.ip_address;
targetUser = target.api_username;
targetPassword = decrypt(target.api_password_encrypted);
serverClient = new RouterOSClient(
target.ip_address, target.api_port, target.api_username, targetPassword, 15_000, 20_000
);
}
const client = new RouterOSClient(
device.ip_address, device.api_port, device.api_username,
decrypt(device.api_password_encrypted),
15_000,
(testSec + 15) * 1000
);
try {
// Enable bandwidth-test server on target device before running test
if (serverClient) {
await serverClient.connect();
await serverClient.execute('/tool/bandwidth-server/set', { enabled: 'yes', authenticate: 'yes' });
serverClient.disconnect();
}
await client.connect();
const btestParams: Record<string, string> = {
address: targetAddress,
direction: ['receive', 'transmit', 'both'].includes(direction) ? direction : 'both',
duration: String(testSec),
protocol: ['tcp', 'udp'].includes(protocol) ? protocol : 'tcp',
};
// When testing against a managed device, authenticate with its RouterOS credentials
if (targetUser) btestParams['user'] = targetUser;
if (targetPassword) btestParams['password'] = targetPassword;
const rows = await client.executeStreaming('/tool/bandwidth-test', btestParams, (testSec + 15) * 1000);
client.disconnect();
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,
target_ip: targetAddress,
raw: lastRow ?? rows.at(-1) ?? null,
});
} catch (err) {
return res.status(500).json({ error: (err as Error).message });
} finally {
client.disconnect();
// Always disable btest server on target when done (success or failure)
if (serverClient) {
try {
await serverClient.connect();
await serverClient.execute('/tool/bandwidth-server/set', { enabled: 'no' });
serverClient.disconnect();
} catch {
// Best-effort — don't mask the original error
}
}
}
});
export default router;