3190 lines
136 KiB
TypeScript
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;
|