feature(devices,topology,tz): devices bulk-add, duplicate safety, and firmware/timezone fixes

Squash-merge of PR #28 from @denis-ev. All three review blockers addressed: encryption key management documented, string truncation replaced with skip+warn validation, and undefined return on duplicate merge fixed with proper 500. Local test stack verified against cloned prod DB — migrations, new features, and backend all clean.
This commit is contained in:
Denis
2026-04-21 16:52:04 -07:00
committed by GitHub
parent 5562ac31dd
commit 30bf691596
23 changed files with 2770 additions and 213 deletions
+28
View File
@@ -173,9 +173,19 @@ ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_name VARCHAR(100);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_slot VARCHAR(20);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_caps VARCHAR(255);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
-- RouterOS can return a long comma-separated discovered-by list; 50 chars was too small.
ALTER TABLE topology_links ALTER COLUMN discovered_by TYPE VARCHAR(512);
-- Interface names from neighbor discovery can exceed 50 chars (long bridge/bond names).
ALTER TABLE topology_links ALTER COLUMN from_interface TYPE VARCHAR(512);
ALTER TABLE topology_links ALTER COLUMN to_interface TYPE VARCHAR(512);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
-- Cached IPv4/IPv6 addresses from /ip/address (per device) for topology resolution:
-- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even
-- when the address is not the device's management IP.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS ip_addresses_jsonb JSONB;
-- Allow multiple neighbors per interface (one row per neighbor, not per port)
ALTER TABLE topology_links DROP CONSTRAINT IF EXISTS topology_links_from_device_id_from_interface_key;
@@ -260,6 +270,24 @@ CREATE TABLE IF NOT EXISTS ap_scan_data (
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
ON ap_scan_data(device_id, scanned_at DESC);
-- Device credential presets — reusable API/SSH credential sets, referenced
-- by name when adding or editing a managed device. Passwords are stored
-- encrypted at rest (same scheme as devices.api_password_encrypted) so the
-- plaintext never leaves the backend.
CREATE TABLE IF NOT EXISTS credential_presets (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
api_username VARCHAR(50) NOT NULL,
api_password_encrypted TEXT NOT NULL,
api_port INTEGER,
ssh_username VARCHAR(50),
ssh_password_encrypted TEXT,
ssh_port INTEGER,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Wireless security profiles (WPA/WPA2/WPA3 config)
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
id SERIAL PRIMARY KEY,
+2
View File
@@ -36,6 +36,7 @@ import routersRoutes from './routes/routers';
import alertsRoutes from './routes/alerts';
import wirelessRoutes from './routes/wireless';
import networkServicesRoutes from './routes/networkServices';
import credentialPresetsRoutes from './routes/credentialPresets';
const app = express();
const httpServer = createServer(app);
@@ -197,6 +198,7 @@ app.use('/api/routers', routersRoutes);
app.use('/api/alerts', alertsRoutes);
app.use('/api/wireless', wirelessRoutes);
app.use('/api/network-services', networkServicesRoutes);
app.use('/api/credential-presets', credentialPresetsRoutes);
// ─── Error Handler ────────────────────────────────────────────────────────────
app.use(errorHandler);
+214
View File
@@ -0,0 +1,214 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
import { encrypt } from '../utils/crypto';
const router = Router();
router.use(requireAuth);
export interface CredentialPresetRow {
id: number;
name: string;
api_username: string;
api_password_encrypted: string;
api_port: number | null;
ssh_username: string | null;
ssh_password_encrypted: string | null;
ssh_port: number | null;
notes: string | null;
created_at: string;
updated_at: string;
}
// Public (listable) shape — never returns the encrypted secrets themselves,
// just booleans telling the UI which slots are populated.
interface CredentialPresetPublic {
id: number;
name: string;
api_username: string;
api_port: number | null;
ssh_username: string | null;
ssh_port: number | null;
notes: string | null;
has_api_password: boolean;
has_ssh_password: boolean;
created_at: string;
updated_at: string;
}
function toPublic(row: CredentialPresetRow): CredentialPresetPublic {
return {
id: row.id,
name: row.name,
api_username: row.api_username,
api_port: row.api_port,
ssh_username: row.ssh_username,
ssh_port: row.ssh_port,
notes: row.notes,
has_api_password: !!row.api_password_encrypted,
has_ssh_password: !!row.ssh_password_encrypted,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
// GET /api/credential-presets — any authenticated user can list (the Add
// Device modal needs this for its picker), but secrets are never exposed.
router.get('/', async (_req: Request, res: Response) => {
const rows = await query<CredentialPresetRow>(
`SELECT * FROM credential_presets ORDER BY name ASC`
);
res.json(rows.map(toPublic));
});
// POST /api/credential-presets — admin only
router.post('/', requireAdmin, async (req: Request, res: Response) => {
const {
name,
api_username,
api_password,
api_port,
ssh_username,
ssh_password,
ssh_port,
notes,
} = req.body as {
name?: string;
api_username?: string;
api_password?: string;
api_port?: number | null;
ssh_username?: string | null;
ssh_password?: string | null;
ssh_port?: number | null;
notes?: string | null;
};
if (!name || !api_username || !api_password) {
return res
.status(400)
.json({ error: 'name, api_username, and api_password are required' });
}
const existing = await queryOne<{ id: number }>(
`SELECT id FROM credential_presets WHERE name = $1`,
[name]
);
if (existing) {
return res.status(409).json({ error: 'A preset with this name already exists' });
}
const encApi = encrypt(api_password);
const encSsh = ssh_password ? encrypt(ssh_password) : null;
const rows = await query<CredentialPresetRow>(
`INSERT INTO credential_presets
(name, api_username, api_password_encrypted, api_port,
ssh_username, ssh_password_encrypted, ssh_port, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[
name,
api_username,
encApi,
api_port ?? null,
ssh_username || null,
encSsh,
ssh_port ?? null,
notes || null,
]
);
return res.status(201).json(toPublic(rows[0]));
});
// PUT /api/credential-presets/:id — admin only
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const existing = await queryOne<CredentialPresetRow>(
`SELECT * FROM credential_presets WHERE id = $1`,
[id]
);
if (!existing) return res.status(404).json({ error: 'Preset not found' });
const {
name,
api_username,
api_password,
api_port,
ssh_username,
ssh_password,
ssh_port,
notes,
clear_ssh_password,
} = req.body as {
name?: string;
api_username?: string;
api_password?: string;
api_port?: number | null;
ssh_username?: string | null;
ssh_password?: string | null;
ssh_port?: number | null;
notes?: string | null;
// Explicit flag to remove a previously-saved SSH password
clear_ssh_password?: boolean;
};
if (typeof name === 'string' && name && name !== existing.name) {
const clash = await queryOne<{ id: number }>(
`SELECT id FROM credential_presets WHERE name = $1 AND id <> $2`,
[name, id]
);
if (clash) return res.status(409).json({ error: 'A preset with this name already exists' });
}
const newApiPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
let newSshPass: string | null = existing.ssh_password_encrypted;
if (clear_ssh_password) newSshPass = null;
if (ssh_password) newSshPass = encrypt(ssh_password);
await query(
`UPDATE credential_presets SET
name = COALESCE($1, name),
api_username = COALESCE($2, api_username),
api_password_encrypted = $3,
api_port = $4,
ssh_username = $5,
ssh_password_encrypted = $6,
ssh_port = $7,
notes = $8,
updated_at = NOW()
WHERE id = $9`,
[
name ?? null,
api_username ?? null,
newApiPass,
api_port === undefined ? existing.api_port : api_port,
ssh_username === undefined ? existing.ssh_username : (ssh_username || null),
newSshPass,
ssh_port === undefined ? existing.ssh_port : ssh_port,
notes === undefined ? existing.notes : (notes || null),
id,
]
);
const updated = await queryOne<CredentialPresetRow>(
`SELECT * FROM credential_presets WHERE id = $1`,
[id]
);
return res.json(toPublic(updated!));
});
// DELETE /api/credential-presets/:id — admin only
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
const result = await query(
`DELETE FROM credential_presets WHERE id = $1 RETURNING id`,
[id]
);
if (!result.length) return res.status(404).json({ error: 'Preset not found' });
return res.json({ message: 'Preset deleted' });
});
export default router;
+256 -24
View File
@@ -5,6 +5,35 @@ 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';
// 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
): 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`);
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);
@@ -71,6 +100,32 @@ router.get('/discovered', async (_req: Request, res: Response) => {
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 by MAC (most reliable), fallback to IP, then identity
const seen = new Map<string, typeof rows[0] & { seen_by_list: string[] }>();
for (const row of rows) {
@@ -86,36 +141,161 @@ router.get('/discovered', async (_req: Request, res: Response) => {
}
}
const results = Array.from(seen.values()).map((r) => ({
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(', '),
}));
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 { name, ip_address, api_port = 8728, api_username, api_password,
ssh_port = 22, ssh_username, ssh_password, device_type = 'router', notes } = req.body;
const {
name, ip_address, device_type = 'router', notes,
credential_preset_id,
} = req.body as { name?: string; ip_address?: string; device_type?: string; notes?: string; credential_preset_id?: number | null };
// If a preset is supplied, its credentials win over anything else in the
// body — the client just needs to say "use preset 3". Anything the body
// also sends (like ssh_username) is ignored in favor of preset values, so
// "deploying" a preset always produces a consistent device record.
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
try {
preset = await loadCredentialPreset(credential_preset_id ?? null);
} catch (err) {
return res.status(400).json({ error: (err as Error).message });
}
const api_username: string | undefined = preset?.api_username ?? req.body.api_username;
const api_password: string | undefined = preset?.api_password ?? req.body.api_password;
const parsePort = (v: unknown, fallback: number): number => {
if (v == null || v === '') return fallback;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) ? n : fallback;
};
const api_port: number = preset?.api_port ?? parsePort(req.body.api_port, 8728);
const ssh_username: string | null = preset ? preset.ssh_username : (req.body.ssh_username ?? null);
const ssh_password: string | null = preset ? preset.ssh_password : (req.body.ssh_password ?? null);
const ssh_port: number = preset?.ssh_port ?? parsePort(req.body.ssh_port, 22);
const combineWithDeviceId =
typeof req.body.combine_with_device_id === 'number' ? req.body.combine_with_device_id : null;
const forceReplaceBySerial = req.body.force_replace_existing_by_serial === true;
if (!name || !ip_address || !api_username || !api_password) {
return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' });
}
// Test connection before saving
// Test connection and detect hardware serial before saving
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
let detectedSerial: string | null = null;
try {
await testClient.connect();
testClient.disconnect();
const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null;
} catch (err) {
return res.status(422).json({
error: `Cannot connect to device: ${(err as Error).message}`,
});
} finally {
testClient.disconnect();
}
if (detectedSerial) {
const existingBySerial = await queryOne<{
id: number;
name: string;
ip_address: string;
serial_number: string;
}>(
`SELECT id, name, ip_address, serial_number
FROM devices
WHERE serial_number = $1`,
[detectedSerial]
);
if (existingBySerial) {
const shouldCombine =
combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id;
if (!shouldCombine && !forceReplaceBySerial) {
return res.status(409).json({
error: 'duplicate_serial',
code: 'duplicate_serial',
existing_device: existingBySerial,
candidate: {
serial_number: detectedSerial,
identity: name,
ip_address,
},
});
}
const encryptedPass = encrypt(api_password);
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
await query(
`UPDATE devices SET
name=COALESCE($1,name),
ip_address=$2,
api_port=$3,
api_username=$4,
api_password_encrypted=$5,
ssh_port=$6,
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,
encryptedPass,
ssh_port,
ssh_username,
encryptedSshPass,
device_type,
notes || null,
existingBySerial.id,
]
);
if (pollerService) {
await pollerService.scheduleDeviceSync(existingBySerial.id, 'full');
}
const updatedExisting = await queryOne(
`SELECT id, name, ip_address, api_port, api_username, model, serial_number,
firmware_version, ros_version, device_type, status, last_seen, notes, created_at
FROM devices WHERE id = $1`,
[existingBySerial.id]
);
if (!updatedExisting) {
return res.status(500).json({
error: 'Duplicate merge succeeded but device row could not be reloaded',
device_id: existingBySerial.id,
});
}
return res.status(200).json({
...updatedExisting,
merged_from_duplicate: true,
});
}
}
const encryptedPass = encrypt(api_password);
@@ -204,28 +384,80 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
// PUT /api/devices/:id
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
const { name, api_port, api_username, api_password, ssh_port, ssh_username,
ssh_password, device_type, notes } = req.body;
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; api_password_encrypted: string }>(
`SELECT id, api_password_encrypted FROM devices WHERE id = $1`,
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
try {
preset = await loadCredentialPreset(credential_preset_id ?? null);
} catch (err) {
return res.status(400).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 ?? req.body.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 ?? req.body.ssh_port;
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
const existing = 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 (!existing) return res.status(404).json({ error: 'Device not found' });
// 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 = (typeof api_port === 'number' ? api_port : undefined) ?? existing.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: `Cannot connect to device with new settings: ${(err as Error).message}`,
});
}
}
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), api_port=COALESCE($2,api_port),
api_username=COALESCE($3,api_username), api_password_encrypted=$4,
ssh_port=COALESCE($5,ssh_port), ssh_username=COALESCE($6,ssh_username),
ssh_password_encrypted=COALESCE($7,ssh_password_encrypted),
device_type=COALESCE($8,device_type), notes=COALESCE($9,notes),
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 = $10`,
[name, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
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(
@@ -732,7 +964,7 @@ router.post('/:id/check-update', async (req: Request, res: Response) => {
const statusText = (updateInfo['status'] ?? '').toLowerCase();
const hasUpdate =
statusText.includes('available') ||
(latestVersion && installedVersion && latestVersion !== installedVersion);
Boolean(latestVersion && installedVersion && latestVersion !== installedVersion);
await query(
`UPDATE devices SET firmware_update_available = $1, latest_ros_version = $2, updated_at = NOW() WHERE id = $3`,
+47 -1
View File
@@ -39,7 +39,8 @@ router.get('/', async (_req: Request, res: Response) => {
const [devices, allLinks] = await Promise.all([
query(
`SELECT id, name, ip_address, model, device_type, status, ros_version
`SELECT id, name, ip_address, model, device_type, status, ros_version,
ip_addresses_jsonb
FROM devices ORDER BY name ASC`
),
query<LinkRow>(
@@ -70,6 +71,51 @@ router.get('/', async (_req: Request, res: Response) => {
}
const links = Array.from(bestLinks.values());
// ── Resolve neighbor IP → managed device (any IP on that device, not just mgmt) ──
// CDP/MNDP often report only an IP. We cache /ip/address per device during sync;
// if a neighbor address matches any managed device's known address, treat the
// link as device-to-device instead of creating an external node.
const stripCidr = (addr: string): string => {
const a = addr.trim();
const slash = a.indexOf('/');
return slash === -1 ? a : a.slice(0, slash);
};
const normIp = (addr: string): string => stripCidr(addr).toLowerCase();
const ipToDevice = new Map<string, { id: number; name: string }>();
for (const d of devices as {
id: number; name: string; ip_address: string;
ip_addresses_jsonb?: unknown;
}[]) {
if (d.ip_address) {
const k = normIp(d.ip_address);
if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name });
}
const list = d.ip_addresses_jsonb;
if (Array.isArray(list)) {
for (const entry of list) {
const raw = typeof entry === 'object' && entry && 'address' in entry
? String((entry as { address?: string }).address || '')
: '';
const k = normIp(raw);
if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name });
}
}
}
for (const link of links) {
if (link.to_device_id) continue;
const na = link.neighbor_address?.trim();
if (!na || na.includes('%')) continue; // skip zone-id style
const key = normIp(na);
if (!key) continue;
const hit = ipToDevice.get(key);
if (hit && hit.id !== link.from_device_id) {
link.to_device_id = hit.id;
link.to_device_name = hit.name;
}
}
// Build synthetic external nodes from unresolved neighbor entries
const externalMap = new Map<string, {
id: string;
+3 -2
View File
@@ -656,9 +656,10 @@ export class PollerService {
const installedVersion = (updateInfo['installed-version'] ?? '').trim();
const statusText = (updateInfo['status'] ?? '').toLowerCase();
const hasUpdate =
const hasUpdate = Boolean(
statusText.includes('available') ||
(latestVersion && installedVersion && latestVersion !== installedVersion);
(latestVersion && installedVersion && latestVersion !== installedVersion)
);
// Read current flag before updating so we can detect first-discovery
const current = await query<{ firmware_update_available: boolean }>(
@@ -6,6 +6,27 @@ import { decrypt } from '../../utils/crypto';
import { lookupVendor } from '../../utils/oui';
import { buildServerArpMap } from '../../utils/serverArp';
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
const TOPOLOGY_LINK_LIMITS = {
from_interface: 512,
to_interface: 512,
neighbor_address: 45,
neighbor_identity: 255,
neighbor_platform: 255,
neighbor_mac: 17,
neighbor_caps: 255,
discovered_by: 512,
} as const;
function topologyNeighborOversized(
field: keyof typeof TOPOLOGY_LINK_LIMITS,
value: string | null | undefined
): { limit: number; len: number } | null {
if (value == null || value === '') return null;
const limit = TOPOLOGY_LINK_LIMITS[field];
return value.length > limit ? { limit, len: value.length } : null;
}
export interface DeviceRow {
id: number;
name: string;
@@ -57,6 +78,7 @@ export class DeviceCollector {
async collectSlow(): Promise<void> {
await this.collectInterfaces();
await this.collectIpAddressesCache();
await this.collectVlans();
await this.collectSystemInfo();
await this.collectStp();
@@ -77,6 +99,7 @@ export class DeviceCollector {
async collectAll(): Promise<void> {
await this.collectSystemInfo();
await this.collectInterfaces();
await this.collectIpAddressesCache();
await this.collectVlans();
await this.collectInterfaceTraffic();
await this.collectResourceUsage();
@@ -269,6 +292,29 @@ export class DeviceCollector {
}
}
/** Store /ip/address rows (sanitized) for topology matching of neighbor IPs. */
async collectIpAddressesCache(): Promise<void> {
try {
const rows = await this.client
.execute('/ip/address/print', { detail: '' })
.catch(() => [] as Record<string, string>[]);
const minimalist = rows
.filter((r) => r['disabled'] !== 'true' && r['invalid'] !== 'true')
.map((r) => ({
address: (r['address'] || '').trim(),
interface: (r['interface'] || '').trim(),
dynamic: r['dynamic'] === 'true',
}))
.filter((x) => x.address.length > 0);
await query(
`UPDATE devices SET ip_addresses_jsonb = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify(minimalist), this.device.id]
);
} catch (err) {
console.error(`[${this.device.name}] Failed to cache IP addresses:`, err);
}
}
// ─── VLANs → Postgres ────────────────────────────────────────────────────
async collectVlans(): Promise<void> {
@@ -769,10 +815,11 @@ export class DeviceCollector {
// first entry is always the physical port; the rest are the bridge/bond parents.
// Store only the physical port so topology edges show the actual cable endpoint.
const rawInterface = nb['interface'] || '';
const fromInterface = rawInterface.split(',')[0].trim() || rawInterface;
const fromInterface = (rawInterface.split(',')[0].trim() || rawInterface);
// interface-name is the neighbor's own outgoing interface (format: bridge/port or just port)
const toInterface = (nb['interface-name'] || '').trim() || null;
const toInterfaceRaw = (nb['interface-name'] || '').trim() || null;
const toInterface = toInterfaceRaw || null;
// discovered-by lists protocols that found this neighbor: lldp, cdp, mndp, etc.
// Rank them by reliability: lldp (point-to-point) > cdp (also flooded in ROS but
@@ -783,6 +830,9 @@ export class DeviceCollector {
else if (discoveredByRaw.includes('cdp')) discoveredBy = 'cdp';
else if (discoveredByRaw.includes('mndp')) discoveredBy = 'mndp';
else discoveredBy = discoveredByRaw || 'mndp';
// Store the full RouterOS list in discovered_by (column is VARCHAR(512)); the raw
// string can be a long comma-separated list and used to exceed VARCHAR(50).
const discoveredByStored = discoveredByRaw || null;
// RouterOS v7+ may put an IPv6 link-local in 'address'. Try all known
// IPv4 field names, split on whitespace/commas in case multiple are packed
@@ -800,20 +850,52 @@ export class DeviceCollector {
const neighborAddress = ipv4FromNeighbor
|| (mac ? macToIpv4[mac] ?? null : null)
|| (mac ? serverArpMap[mac] ?? null : null);
const neighborIdentity = nb['identity'] || '';
const neighborPlatform = nb['platform'] || '';
const neighborMac = nb['mac-address'] || null;
const neighborCaps = nb['system-caps-enabled'] || nb['system-caps'] || '';
const neighborIdentity = (nb['identity'] || '');
const neighborPlatform = (nb['platform'] || '');
const neighborMac = nb['mac-address'] ? String(nb['mac-address']) : null;
const neighborCaps = (nb['system-caps-enabled'] || nb['system-caps'] || '');
if (!fromInterface) continue;
const oversize: { field: keyof typeof TOPOLOGY_LINK_LIMITS; limit: number; len: number }[] = [];
const check = (field: keyof typeof TOPOLOGY_LINK_LIMITS, val: string | null | undefined) => {
const o = topologyNeighborOversized(field, val);
if (o) oversize.push({ field, ...o });
};
check('from_interface', fromInterface);
check('to_interface', toInterface);
check('neighbor_address', neighborAddress ? String(neighborAddress) : null);
check('neighbor_identity', neighborIdentity || null);
check('neighbor_platform', neighborPlatform || null);
check('neighbor_mac', neighborMac);
check('neighbor_caps', neighborCaps || null);
check('discovered_by', discoveredByStored);
if (oversize.length > 0) {
console.warn(
`[${this.device.name}] Skipping topology neighbor row: value(s) exceed DB column limits ` +
`(no silent truncation). Details: ${JSON.stringify(oversize)}`
);
continue;
}
await query(
`INSERT INTO topology_links
(from_device_id, from_interface, to_interface, neighbor_address, neighbor_identity,
neighbor_platform, neighbor_mac, neighbor_caps, link_type, discovered_by, discovered_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())`,
[this.device.id, fromInterface, toInterface, neighborAddress, neighborIdentity,
neighborPlatform, neighborMac, neighborCaps || null, discoveredBy, discoveredByRaw || null]
[
this.device.id,
fromInterface,
toInterface,
neighborAddress ? String(neighborAddress) : null,
neighborIdentity,
neighborPlatform,
neighborMac,
neighborCaps || null,
discoveredBy,
discoveredByStored,
]
);
}
+8
View File
@@ -1,3 +1,11 @@
/**
* AES-256-GCM helpers for encrypting device credentials at rest in PostgreSQL.
*
* Key material: set `ENCRYPTION_KEY` in the environment (see README). If unset,
* a development-only default is used — production deployments must set a
* strong secret. Key rotation / recovery is documented under README
* "Credential encryption (ENCRYPTION_KEY)".
*/
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';