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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user