- Audit log: middleware captures all write operations, paginated admin UI in Settings - Device tags: full CRUD, device assignment, filter bar and chips on Devices page - Maintenance windows: alert suppression during planned downtime, Settings management UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1777 lines
72 KiB
TypeScript
1777 lines
72 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { randomUUID } from 'crypto';
|
|
import { query, queryOne } from '../config/database';
|
|
import { requireAuth, requireWrite } from '../middleware/auth';
|
|
import { encrypt, decrypt } from '../utils/crypto';
|
|
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
|
|
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
|
import { PollerService } from '../services/PollerService';
|
|
import type { CredentialPresetRow } from './credentialPresets';
|
|
import { createDeviceFromBody, type CreateDeviceInput, type CreateDeviceContext } from '../services/deviceCreation';
|
|
import { parsePort } from '../utils/parsePort';
|
|
import { safeConnectionError } from '../utils/safeClientError';
|
|
import { redis } from '../config/redis';
|
|
import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker';
|
|
|
|
// Resolve a credential preset id into decrypted credentials. Returns null if
|
|
// no id was provided; throws a readable error if the id is invalid.
|
|
async function loadCredentialPreset(
|
|
id: number | null | undefined,
|
|
ctx?: CreateDeviceContext
|
|
): Promise<{
|
|
api_username: string;
|
|
api_password: string;
|
|
api_port: number | null;
|
|
ssh_username: string | null;
|
|
ssh_password: string | null;
|
|
ssh_port: number | null;
|
|
} | null> {
|
|
if (id === null || id === undefined) return null;
|
|
const preset = await queryOne<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);
|
|
|
|
let pollerService: PollerService | null = null;
|
|
export function setPollerService(p: PollerService): void {
|
|
pollerService = p;
|
|
}
|
|
|
|
// GET /api/devices
|
|
router.get('/', async (_req: Request, res: Response) => {
|
|
const devices = await query(
|
|
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
|
|
firmware_version, ros_version, latest_ros_version, firmware_update_available,
|
|
routerboard_upgrade_available, upgrade_firmware_version,
|
|
device_type, status, last_seen, notes,
|
|
location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng,
|
|
rack_name, rack_slot, created_at
|
|
FROM devices ORDER BY name ASC`
|
|
);
|
|
|
|
// Attach tags to each device
|
|
const tagRows = await query<{ device_id: number; id: number; name: string; color: string }>(
|
|
`SELECT dt.device_id, t.id, t.name, t.color
|
|
FROM device_tags dt JOIN tags t ON t.id = dt.tag_id`
|
|
);
|
|
const tagsByDevice: Record<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 routers = await query(`
|
|
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
|
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
|
COUNT(i.id) FILTER (WHERE i.running = true AND i.disabled = false) AS ifaces_up,
|
|
COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ifaces_down,
|
|
COUNT(i.id) FILTER (WHERE i.disabled = true) AS ifaces_disabled,
|
|
COUNT(i.id) AS ifaces_total
|
|
FROM devices d
|
|
LEFT JOIN interfaces i ON i.device_id = d.id
|
|
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
|
|
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
|
|
WHERE d.device_type = 'router'
|
|
GROUP BY d.id
|
|
ORDER BY d.name ASC
|
|
`);
|
|
res.json(routers);
|
|
});
|
|
|
|
// GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links
|
|
router.get('/discovered', async (_req: Request, res: Response) => {
|
|
const rows = await query<{
|
|
neighbor_identity: string | null;
|
|
neighbor_address: string | null;
|
|
neighbor_mac: string | null;
|
|
neighbor_platform: string | null;
|
|
discovered_at: string;
|
|
seen_by: string;
|
|
}>(`
|
|
SELECT tl.neighbor_identity,
|
|
COALESCE(NULLIF(tl.neighbor_address, ''), c.ip_address) AS neighbor_address,
|
|
tl.neighbor_mac,
|
|
tl.neighbor_platform, tl.discovered_at, d.name AS seen_by
|
|
FROM topology_links tl
|
|
JOIN devices d ON d.id = tl.from_device_id
|
|
LEFT JOIN clients c ON LOWER(c.mac_address) = LOWER(tl.neighbor_mac)
|
|
AND c.ip_address IS NOT NULL AND c.ip_address != ''
|
|
WHERE tl.to_device_id IS NULL
|
|
AND tl.neighbor_platform ILIKE '%mikrotik%'
|
|
AND (tl.neighbor_address IS NULL OR tl.neighbor_address = '' OR tl.neighbor_address NOT LIKE '%:%')
|
|
ORDER BY tl.discovered_at DESC
|
|
`);
|
|
|
|
// Build lookup tables for "is this actually a managed device we already know?"
|
|
// We match a discovered neighbor to a managed device by any of:
|
|
// - its MAC matching any interface MAC of a managed device,
|
|
// - its IP matching the managed device's primary ip_address,
|
|
// - its identity matching the managed device's name.
|
|
const [managedByMac, managedByIp, managedByName] = await Promise.all([
|
|
query<{ device_id: number; device_name: string; mac: string }>(
|
|
`SELECT i.device_id AS device_id, d.name AS device_name, LOWER(i.mac_address) AS mac
|
|
FROM interfaces i
|
|
JOIN devices d ON d.id = i.device_id
|
|
WHERE i.mac_address IS NOT NULL AND i.mac_address <> ''`
|
|
),
|
|
query<{ id: number; name: string; ip_address: string }>(
|
|
`SELECT id, name, ip_address FROM devices WHERE ip_address IS NOT NULL AND ip_address <> ''`
|
|
),
|
|
query<{ id: number; name: string }>(
|
|
`SELECT id, name FROM devices WHERE name IS NOT NULL AND name <> ''`
|
|
),
|
|
]);
|
|
const macToDevice = new Map<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,
|
|
});
|
|
return res.status(result.status).json(result.body);
|
|
});
|
|
|
|
const BULK_ADD_META_TTL_SEC = 86400;
|
|
|
|
// POST /api/devices/bulk-add/jobs — enqueue Try-All style adds (survives tab close)
|
|
router.post('/bulk-add/jobs', requireWrite, async (req: Request, res: Response) => {
|
|
const { items } = req.body as { items?: unknown };
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
return res.status(400).json({ error: 'items must be a non-empty array' });
|
|
}
|
|
if (items.length > 500) {
|
|
return res.status(400).json({ error: 'items array exceeds maximum of 500' });
|
|
}
|
|
const jobId = randomUUID();
|
|
const ownerUserId = req.user!.userId;
|
|
await redis.set(
|
|
`device-bulk-add:${jobId}:meta`,
|
|
JSON.stringify({
|
|
status: 'queued',
|
|
total: items.length,
|
|
processed: 0,
|
|
owner_user_id: ownerUserId,
|
|
created_at: new Date().toISOString(),
|
|
}),
|
|
'EX',
|
|
BULK_ADD_META_TTL_SEC
|
|
);
|
|
await redis.set(`device-bulk-add:${jobId}:results`, '[]', 'EX', BULK_ADD_META_TTL_SEC);
|
|
await enqueueBulkAddJob(jobId, items as CreateDeviceInput[]);
|
|
return res.status(202).json({ job_id: jobId, total: items.length });
|
|
});
|
|
|
|
// POST /api/devices/bulk-add/jobs/:jobId/cancel — request cooperative cancel
|
|
router.post('/bulk-add/jobs/:jobId/cancel', requireWrite, async (req: Request, res: Response) => {
|
|
const state = await getBulkAddJobState(req.params.jobId);
|
|
if (!state.found) {
|
|
return res.status(404).json({ error: 'Job not found or expired' });
|
|
}
|
|
const ownerId = state.meta.owner_user_id;
|
|
if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
await redis.set(`device-bulk-add:${req.params.jobId}:cancel`, '1', 'EX', BULK_ADD_META_TTL_SEC);
|
|
return res.json({ message: 'Cancel requested' });
|
|
});
|
|
|
|
// GET /api/devices/bulk-add/jobs/:jobId — poll job status and incremental results
|
|
router.get('/bulk-add/jobs/:jobId', requireWrite, async (req: Request, res: Response) => {
|
|
const state = await getBulkAddJobState(req.params.jobId);
|
|
if (!state.found) {
|
|
return res.status(404).json({ error: 'Job not found or expired' });
|
|
}
|
|
const ownerId = state.meta.owner_user_id;
|
|
if (typeof ownerId !== 'number' || ownerId !== req.user!.userId) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
return res.json({
|
|
job_id: req.params.jobId,
|
|
...state.meta,
|
|
results: state.results,
|
|
});
|
|
});
|
|
|
|
// GET /api/devices/:id
|
|
router.get('/:id', async (req: Request, res: Response) => {
|
|
const device = await queryOne(
|
|
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
|
|
serial_number, firmware_version, ros_version, latest_ros_version,
|
|
firmware_update_available, routerboard_upgrade_available, upgrade_firmware_version,
|
|
device_type, status, last_seen,
|
|
notes, location_address,
|
|
location_lat::float8 AS location_lat,
|
|
location_lng::float8 AS location_lng,
|
|
rack_name, rack_slot, created_at, updated_at
|
|
FROM devices WHERE id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
|
return res.json(device);
|
|
});
|
|
|
|
// PATCH /api/devices/:id/location — save physical location & rack info
|
|
router.patch('/:id/location', requireWrite, async (req: Request, res: Response) => {
|
|
const { location_address, location_lat, location_lng, rack_name, rack_slot, notes } = req.body;
|
|
const existing = await queryOne(`SELECT id FROM devices WHERE id = $1`, [req.params.id]);
|
|
if (!existing) return res.status(404).json({ error: 'Device not found' });
|
|
|
|
await query(
|
|
`UPDATE devices SET
|
|
location_address = $1,
|
|
location_lat = $2,
|
|
location_lng = $3,
|
|
rack_name = $4,
|
|
rack_slot = $5,
|
|
notes = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $7`,
|
|
[
|
|
location_address ?? null,
|
|
location_lat ?? null,
|
|
location_lng ?? null,
|
|
rack_name ?? null,
|
|
rack_slot ?? null,
|
|
notes ?? null,
|
|
req.params.id,
|
|
]
|
|
);
|
|
|
|
const updated = await queryOne(
|
|
`SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model,
|
|
serial_number, firmware_version, ros_version, device_type, status, last_seen,
|
|
notes, location_address,
|
|
location_lat::float8 AS location_lat,
|
|
location_lng::float8 AS location_lng,
|
|
rack_name, rack_slot, created_at, updated_at
|
|
FROM devices WHERE id = $1`,
|
|
[req.params.id]
|
|
);
|
|
return res.json(updated);
|
|
});
|
|
|
|
// PUT /api/devices/:id
|
|
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
|
|
const { name, ip_address, device_type, notes, credential_preset_id } = req.body as {
|
|
name?: string;
|
|
ip_address?: string;
|
|
device_type?: string;
|
|
notes?: string;
|
|
credential_preset_id?: number | null;
|
|
};
|
|
|
|
const existing = await queryOne<{
|
|
id: number;
|
|
ip_address: string;
|
|
api_port: number;
|
|
api_username: string;
|
|
api_password_encrypted: string;
|
|
ssh_port: number | null;
|
|
}>(
|
|
`SELECT id, ip_address, api_port, api_username, api_password_encrypted, ssh_port
|
|
FROM devices WHERE id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!existing) return res.status(404).json({ error: 'Device not found' });
|
|
|
|
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
|
|
try {
|
|
preset = await loadCredentialPreset(credential_preset_id ?? null, {
|
|
requestingUserRole: req.user?.role,
|
|
});
|
|
} catch (err) {
|
|
const status = (err as Error & { statusCode?: number }).statusCode ?? 400;
|
|
return res.status(status).json({ error: (err as Error).message });
|
|
}
|
|
|
|
// When a preset is applied, its values take precedence over anything else
|
|
// in the body so "apply preset" has a single unambiguous meaning.
|
|
const api_port = preset?.api_port ?? parsePort(req.body.api_port, existing.api_port);
|
|
const api_username = preset?.api_username ?? req.body.api_username;
|
|
const api_password = preset?.api_password ?? req.body.api_password;
|
|
const ssh_port = preset?.ssh_port ?? parsePort(req.body.ssh_port, existing.ssh_port ?? 22);
|
|
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
|
|
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
|
|
|
|
// If the user is changing the IP (or port / username / password), verify the
|
|
// RouterOS API is still reachable with the new values before persisting —
|
|
// otherwise we could silently brick the device record.
|
|
const ipChanged = typeof ip_address === 'string' && ip_address && ip_address !== existing.ip_address;
|
|
const portChanged = typeof api_port === 'number' && api_port !== existing.api_port;
|
|
const userChanged = typeof api_username === 'string' && api_username && api_username !== existing.api_username;
|
|
const presetReplacesApiCreds = !!preset;
|
|
if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) {
|
|
const testIp = ip_address ?? existing.ip_address;
|
|
const testPort = api_port;
|
|
const testUser = api_username ?? existing.api_username;
|
|
const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted);
|
|
const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000);
|
|
try {
|
|
await testClient.connect();
|
|
testClient.disconnect();
|
|
} catch (err) {
|
|
return res.status(422).json({
|
|
error: safeConnectionError('PUT /devices/:id', err),
|
|
});
|
|
}
|
|
}
|
|
|
|
const encPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
|
|
const encSshPass = ssh_password ? encrypt(ssh_password) : null;
|
|
|
|
await query(
|
|
`UPDATE devices SET
|
|
name=COALESCE($1,name), ip_address=COALESCE($2,ip_address),
|
|
api_port=COALESCE($3,api_port),
|
|
api_username=COALESCE($4,api_username), api_password_encrypted=$5,
|
|
ssh_port=COALESCE($6,ssh_port), ssh_username=COALESCE($7,ssh_username),
|
|
ssh_password_encrypted=COALESCE($8,ssh_password_encrypted),
|
|
device_type=COALESCE($9,device_type), notes=COALESCE($10,notes),
|
|
updated_at=NOW()
|
|
WHERE id = $11`,
|
|
[name, ip_address, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
|
|
);
|
|
|
|
const updated = await queryOne(
|
|
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
|
|
firmware_version, ros_version, device_type, status, last_seen, notes
|
|
FROM devices WHERE id = $1`,
|
|
[req.params.id]
|
|
);
|
|
return res.json(updated);
|
|
});
|
|
|
|
// DELETE /api/devices/:id
|
|
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
|
|
const result = await query(`DELETE FROM devices WHERE id = $1 RETURNING id`, [req.params.id]);
|
|
if (!result.length) return res.status(404).json({ error: 'Device not found' });
|
|
return res.json({ message: 'Device deleted' });
|
|
});
|
|
|
|
// POST /api/devices/:id/sync - run a full resync and wait for it to complete
|
|
router.post('/:id/sync', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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
|
|
router.put('/:id/ports/:name/vlan', 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 { pvid, tagged_vlans = [], untagged_vlans = [] } = req.body;
|
|
if (pvid !== undefined && (typeof pvid !== 'number' || pvid < 1 || pvid > 4094)) {
|
|
return res.status(400).json({ error: 'pvid must be 1-4094' });
|
|
}
|
|
|
|
const collector = new DeviceCollector(deviceRow);
|
|
try {
|
|
await collector.connect();
|
|
await collector.setPortVlanConfig(
|
|
req.params.name,
|
|
pvid ?? 1,
|
|
tagged_vlans as number[],
|
|
untagged_vlans as number[]
|
|
);
|
|
await collector.collectVlans();
|
|
return res.json({ message: 'VLAN configuration applied' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// GET /api/devices/:id/vlans
|
|
router.get('/:id/vlans', async (req: Request, res: Response) => {
|
|
const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]);
|
|
return res.json(vlans);
|
|
});
|
|
|
|
// GET /api/devices/:id/ports/:name/monitor — live ethernet monitor + SFP DDM
|
|
router.get('/:id/ports/:name/monitor', async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>(
|
|
`SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`,
|
|
[req.params.id]
|
|
);
|
|
if (!deviceRow) return res.status(404).json({ error: 'Device not found' });
|
|
|
|
const collector = new DeviceCollector(deviceRow as unknown as DeviceRow);
|
|
try {
|
|
await collector.connect();
|
|
const monitor = await collector.getPortMonitor(req.params.name);
|
|
return res.json(monitor);
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// GET /api/devices/:id/ports (switch port layout with VLAN info)
|
|
router.get('/:id/ports', async (req: Request, res: Response) => {
|
|
const [ifaces, bridgePorts, vlans] = await Promise.all([
|
|
query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]),
|
|
query(`SELECT * FROM bridge_vlan_entries WHERE device_id = $1`, [req.params.id]),
|
|
query(`SELECT * FROM vlans WHERE device_id = $1`, [req.params.id]),
|
|
]);
|
|
|
|
// Enrich interface data with VLAN info
|
|
const bridgePortMap = new Map(bridgePorts.map((bp: Record<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 } = 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.addFirewallRule(bodyToRosParams(req.body));
|
|
return res.status(201).json(await collector.getFirewallRules());
|
|
} catch (err) {
|
|
return res.status(500).json({ error: (err as Error).message });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// PUT /api/devices/:id/firewall/:ruleId
|
|
router.put('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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, bodyToRosParams(req.body));
|
|
return res.json(await collector.getFirewallRules());
|
|
} catch (err) {
|
|
return res.status(500).json({ error: (err as Error).message });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// DELETE /api/devices/:id/firewall/:ruleId
|
|
router.delete('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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();
|
|
}
|
|
});
|
|
|
|
// 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 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 { address, interface: iface } = req.body;
|
|
if (!address || !iface) {
|
|
return res.status(400).json({ error: 'address and interface are required' });
|
|
}
|
|
|
|
const collector = new DeviceCollector(deviceRow);
|
|
try {
|
|
await collector.connect();
|
|
await collector.addIpAddress(address, iface);
|
|
const addresses = await collector.getIpAddresses();
|
|
return res.status(201).json(addresses);
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// DELETE /api/devices/:id/ip-addresses/:addrId
|
|
router.delete('/:id/ip-addresses/:addrId', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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.removeIpAddress(req.params.addrId);
|
|
return res.json({ message: 'IP address removed' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// POST /api/devices/:id/check-update
|
|
router.post('/:id/check-update', async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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();
|
|
}
|
|
});
|
|
|
|
// POST /api/devices/:id/install-update
|
|
router.post('/:id/install-update', 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.installUpdate();
|
|
// Clear the update flag — device is rebooting with new firmware
|
|
await query(
|
|
`UPDATE devices SET firmware_update_available = FALSE, updated_at = NOW() WHERE id = $1`,
|
|
[deviceRow.id]
|
|
);
|
|
return res.json({ message: 'Update installation initiated. Device will reboot.' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// POST /api/devices/:id/check-routerboard
|
|
router.post('/:id/check-routerboard', async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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
|
|
router.post('/:id/install-routerboard', 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.installRouterboardUpgrade();
|
|
await query(
|
|
`UPDATE devices SET routerboard_upgrade_available = FALSE, updated_at = NOW() WHERE id = $1`,
|
|
[deviceRow.id]
|
|
);
|
|
return res.json({ message: 'RouterBOOT upgrade initiated. Device will reboot.' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// POST /api/devices/:id/reboot
|
|
router.post('/:id/reboot', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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)
|
|
router.post('/:id/routing', 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 { dst_address, gateway, distance, comment } = req.body;
|
|
if (!dst_address || !gateway) {
|
|
return res.status(400).json({ error: 'dst_address and gateway are required' });
|
|
}
|
|
|
|
const collector = new DeviceCollector(deviceRow);
|
|
try {
|
|
await collector.connect();
|
|
await collector.addRoute(dst_address, gateway, distance, comment);
|
|
return res.status(201).json({ message: 'Route added' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// DELETE /api/devices/:id/routing/:routeId
|
|
router.delete('/:id/routing/:routeId', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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.removeRoute(req.params.routeId);
|
|
return res.json({ message: 'Route removed' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// ─── OSPF ─────────────────────────────────────────────────────────────────────
|
|
router.get('/:id/routing/ospf', async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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)
|
|
router.post('/:id/vlans', 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 { bridge, vlan_id, tagged_ports = [], untagged_ports = [] } = req.body;
|
|
if (!bridge || !vlan_id || vlan_id < 1 || vlan_id > 4094) {
|
|
return res.status(400).json({ error: 'bridge and vlan_id (1-4094) are required' });
|
|
}
|
|
|
|
const collector = new DeviceCollector(deviceRow);
|
|
try {
|
|
await collector.connect();
|
|
await collector.addBridgeVlan(bridge, vlan_id, tagged_ports, untagged_ports);
|
|
await collector.collectVlans();
|
|
const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]);
|
|
return res.status(201).json(vlans);
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// PUT /api/devices/:id/vlans/:vlanDbId (update tagged/untagged ports)
|
|
router.put('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => {
|
|
const vlan = await queryOne<{ id: number; vlan_id: number; bridge: string }>(
|
|
`SELECT id, vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`,
|
|
[req.params.vlanDbId, req.params.id]
|
|
);
|
|
if (!vlan) return res.status(404).json({ error: 'VLAN not found' });
|
|
|
|
const deviceRow = await queryOne<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 { tagged_ports = [], untagged_ports = [] } = req.body;
|
|
|
|
const collector = new DeviceCollector(deviceRow);
|
|
try {
|
|
await collector.connect();
|
|
await collector.updateBridgeVlan(vlan.bridge, vlan.vlan_id, tagged_ports, untagged_ports);
|
|
await collector.collectVlans();
|
|
const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]);
|
|
return res.json(vlans);
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// DELETE /api/devices/:id/vlans/:vlanDbId
|
|
router.delete('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => {
|
|
const vlan = await queryOne<{ vlan_id: number; bridge: string }>(
|
|
`SELECT vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`,
|
|
[req.params.vlanDbId, req.params.id]
|
|
);
|
|
if (!vlan) return res.status(404).json({ error: 'VLAN not found' });
|
|
|
|
const deviceRow = await queryOne<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.removeBridgeVlan(vlan.bridge, vlan.vlan_id);
|
|
await query(`DELETE FROM vlans WHERE id = $1`, [req.params.vlanDbId]);
|
|
return res.json({ message: 'VLAN removed' });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// POST /api/devices/:id/vlans/copy (bulk copy VLANs from another switch)
|
|
router.post('/:id/vlans/copy', requireWrite, async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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' });
|
|
}
|
|
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.createBond(name, slaves, mode, {
|
|
lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links,
|
|
});
|
|
await collector.collectInterfaces();
|
|
const ports = await query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]);
|
|
return res.status(201).json(ports.find((p: Record<string, unknown>) => p['name'] === name) ?? { message: 'Bond created' });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: (err as Error).message });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// PUT /api/devices/:id/bonds/:bondName
|
|
router.put('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => {
|
|
const { mode, slaves, lacp_rate, transmit_hash_policy, mtu, min_links } = req.body;
|
|
if (!mode || !Array.isArray(slaves) || slaves.length < 1) {
|
|
return res.status(400).json({ error: 'mode and slaves are required' });
|
|
}
|
|
const deviceRow = await queryOne<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
|
|
router.delete('/:id/bonds/:bondName', 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.deleteBond(req.params.bondName);
|
|
await query(`DELETE FROM interfaces WHERE device_id = $1 AND name = $2`, [req.params.id, req.params.bondName]);
|
|
await collector.collectInterfaces();
|
|
return res.json({ message: 'Bond deleted' });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: (err as Error).message });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// PUT /api/devices/:id/bridge/:bridgeName/vlan-filtering
|
|
router.put('/:id/bridge/:bridgeName/vlan-filtering', requireWrite, async (req: Request, res: Response) => {
|
|
const { enabled } = req.body as { enabled: boolean };
|
|
const deviceRow = await queryOne<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.setBridgeVlanFiltering(req.params.bridgeName, Boolean(enabled));
|
|
await collector.collectInterfaces();
|
|
return res.json({ success: true, vlan_filtering: enabled });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: (err as Error).message });
|
|
} finally {
|
|
collector.disconnect();
|
|
}
|
|
});
|
|
|
|
// GET /api/devices/:id/hardware (live health: temps, voltages, fans, PSU)
|
|
router.get('/:id/hardware', async (req: Request, res: Response) => {
|
|
const deviceRow = await queryOne<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);
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
export default router;
|