diff --git a/.env.example b/.env.example index 9edba41..6072087 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Host ports (maps to the nginx container) HTTP_PORT=80 HTTPS_PORT=443 +TZ=UTC # Database DB_PASSWORD=mikrotik_secure_pw diff --git a/README.md b/README.md index d58c7a8..13b7976 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,19 @@ All configuration is done via environment variables in `.env`: | `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) | | `HTTPS_PORT` | `443` | Host port for HTTPS | +### Credential encryption (`ENCRYPTION_KEY`) + +Device API and SSH passwords stored in PostgreSQL are encrypted at rest using **AES-256-GCM** (`backend/src/utils/crypto.ts`). The key material comes from the **`ENCRYPTION_KEY` environment variable** (see table above). If the value is exactly 32 bytes long it is used as-is; otherwise the implementation derives a 32-byte key with SHA-256 (still driven by that env var). + +**If the key is lost:** ciphertext cannot be decrypted; you must re-enter credentials on each device (or restore a database backup that was encrypted with the old key). + +**If the key is rotated (compromise or policy):** there is no automatic bulk re-encryption in the app today. Operational recovery is: + +1. Set a new `ENCRYPTION_KEY` and restart the backend. +2. For each managed device, update API/SSH credentials via the UI (or API) so the server stores new ciphertext under the new key. Old rows still hold ciphertext from the previous key until updated. + +For a planned rotation with many devices, restore from backup or script updates against the API using plaintext passwords from your vault. + --- ## Updating diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 75c7611..7ec11a1 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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, diff --git a/backend/src/index.ts b/backend/src/index.ts index 8f0446c..f61a04a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/credentialPresets.ts b/backend/src/routes/credentialPresets.ts new file mode 100644 index 0000000..7aebe0e --- /dev/null +++ b/backend/src/routes/credentialPresets.ts @@ -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( + `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( + `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( + `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( + `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; diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 8f3d143..87ebae8 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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( + `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(); + for (const r of managedByMac) macToDevice.set(r.mac, { id: r.device_id, name: r.device_name }); + const ipToDevice = new Map(); + for (const r of managedByIp) ipToDevice.set(r.ip_address, { id: r.id, name: r.name }); + const nameToDevice = new Map(); + 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(); 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> = 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[]); + 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> = 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`, diff --git a/backend/src/routes/topology.ts b/backend/src/routes/topology.ts index b759f7f..acf2247 100644 --- a/backend/src/routes/topology.ts +++ b/backend/src/routes/topology.ts @@ -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( @@ -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(); + 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( diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 474950f..9518254 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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 { 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 { 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 { + try { + const rows = await this.client + .execute('/ip/address/print', { detail: '' }) + .catch(() => [] as Record[]); + 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 { @@ -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, + ] ); } diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index ba72855..58fdfef 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -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'; diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index 5342660..6b14a31 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -21,6 +21,7 @@ services: backend: image: ghcr.io/2gt-media-group-llc/mikrotik-manager-backend:latest environment: + TZ: ${TZ:-UTC} NODE_ENV: production DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager REDIS_URL: redis://redis:6379 diff --git a/docker-compose.yml b/docker-compose.yml index 4ff60fb..712f7fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: dockerfile: Dockerfile environment: NODE_ENV: production + TZ: ${TZ:-UTC} DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager REDIS_URL: redis://redis:6379 INFLUXDB_URL: http://influxdb:8086 @@ -57,6 +58,7 @@ services: postgres: image: postgres:15-alpine environment: + TZ: ${TZ:-UTC} POSTGRES_DB: mikrotik_manager POSTGRES_USER: mikrotik POSTGRES_PASSWORD: ${DB_PASSWORD:-mikrotik_secure_pw} diff --git a/frontend/src/components/devices/AddDeviceModal.tsx b/frontend/src/components/devices/AddDeviceModal.tsx index be56fe1..b5f9c8f 100644 --- a/frontend/src/components/devices/AddDeviceModal.tsx +++ b/frontend/src/components/devices/AddDeviceModal.tsx @@ -1,6 +1,12 @@ import { useState, FormEvent } from 'react'; -import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; -import { devicesApi } from '../../services/api'; +import { useQuery } from '@tanstack/react-query'; +import { X, CheckCircle, AlertCircle, Loader2, KeyRound } from 'lucide-react'; +import { + devicesApi, + credentialPresetsApi, + type DuplicateSerialError, +} from '../../services/api'; +import ConfirmDuplicateModal from './ConfirmDuplicateModal'; interface Props { onClose: () => void; @@ -21,36 +27,120 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) { device_type: 'router', notes: '', }); + const [presetId, setPresetId] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [duplicateSerial, setDuplicateSerial] = useState(null); + + const { data: presets = [] } = useQuery({ + queryKey: ['credential-presets'], + queryFn: () => credentialPresetsApi.list().then((r) => r.data), + staleTime: 30_000, + }); + + const selectedPreset = presetId != null ? presets.find((p) => p.id === presetId) ?? null : null; const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v })); + const createPayload = ( + opts: { combineWithDeviceId?: number; forceReplace?: boolean } = {} + ) => { + const extra = { + combine_with_device_id: opts.combineWithDeviceId, + force_replace_existing_by_serial: opts.forceReplace, + }; + if (selectedPreset) { + return { + name: form.name, + ip_address: form.ip_address, + device_type: form.device_type as import('../../types').DeviceType, + notes: form.notes, + credential_preset_id: selectedPreset.id, + ...extra, + }; + } + return { + name: form.name, + ip_address: form.ip_address, + api_port: parseInt(form.api_port, 10), + api_username: form.api_username, + api_password: form.api_password, + ssh_port: parseInt(form.ssh_port, 10), + ssh_username: form.ssh_username || undefined, + ssh_password: form.ssh_password || undefined, + device_type: form.device_type as import('../../types').DeviceType, + notes: form.notes, + ...extra, + }; + }; + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!form.name || !form.ip_address || !form.api_username || !form.api_password) { - setError('Name, IP address, username, and password are required'); + if (!form.name || !form.ip_address) { + setError('Name and IP address are required'); + return; + } + if (!selectedPreset && (!form.api_username || !form.api_password)) { + setError('Either pick a credential preset or enter username and password'); return; } setLoading(true); setError(''); + setDuplicateSerial(null); try { - await devicesApi.create({ - ...form, - api_port: parseInt(form.api_port), - ssh_port: parseInt(form.ssh_port), - device_type: form.device_type as import('../../types').DeviceType, - }); + await devicesApi.create(createPayload()); onSuccess(); } catch (err: unknown) { - const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + const status = (err as { response?: { status?: number } })?.response?.status; + const data = (err as { response?: { data?: unknown } })?.response?.data; + if ( + status === 409 && + data && + typeof data === 'object' && + (data as { code?: string }).code === 'duplicate_serial' + ) { + setDuplicateSerial(data as DuplicateSerialError); + return; + } + const msg = (data as { error?: string } | undefined)?.error; setError(msg || 'Failed to add device'); } finally { setLoading(false); } }; + const handleCombineDuplicate = async () => { + if (!duplicateSerial?.existing_device?.id) return; + setLoading(true); + setError(''); + try { + await devicesApi.create(createPayload({ combineWithDeviceId: duplicateSerial.existing_device.id })); + onSuccess(); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || 'Failed to combine duplicate by serial'); + } finally { + setLoading(false); + setDuplicateSerial(null); + } + }; + + const handleReplaceDuplicate = async () => { + setLoading(true); + setError(''); + try { + await devicesApi.create(createPayload({ forceReplace: true })); + onSuccess(); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || 'Failed to replace duplicate serial details'); + } finally { + setLoading(false); + setDuplicateSerial(null); + } + }; + return (
@@ -100,82 +190,118 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
- {/* API credentials */} + {/* Credentials: preset or manual */}
-

- RouterOS API Credentials +

+ + Credentials

-
-
- - set('api_username', e.target.value)} - placeholder="admin" - /> -
-
- - set('api_port', e.target.value)} - placeholder="8728" - /> -
-
- - set('api_password', e.target.value)} - placeholder="RouterOS API password" - autoComplete="new-password" - /> -
-
-
- {/* SSH credentials (optional) */} -
- - SSH Credentials (optional, for backup/restore) - -
-
- - 0 && ( +
+ + + {selectedPreset && ( +

+ Using preset {selectedPreset.name}. The server will pull the + API username/password + {selectedPreset.has_ssh_password && selectedPreset.ssh_username + ? ' and SSH credentials' + : ''} + {' '}from this preset. +

+ )}
-
- - set('ssh_port', e.target.value)} - placeholder="22" - /> -
-
- - set('ssh_password', e.target.value)} - autoComplete="new-password" - /> -
-
-
+ )} + + {!selectedPreset && ( + <> +
+
+ + set('api_username', e.target.value)} + placeholder="admin" + /> +
+
+ + set('api_port', e.target.value)} + placeholder="8728" + /> +
+
+ + set('api_password', e.target.value)} + placeholder="RouterOS API password" + autoComplete="new-password" + /> +
+
+ + {/* SSH credentials (optional) */} +
+ + SSH Credentials (optional, for backup/restore) + +
+
+ + set('ssh_username', e.target.value)} + placeholder="admin" + /> +
+
+ + set('ssh_port', e.target.value)} + placeholder="22" + /> +
+
+ + set('ssh_password', e.target.value)} + autoComplete="new-password" + /> +
+
+
+ + )} +
@@ -219,6 +345,19 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
+ {duplicateSerial && ( + { + setDuplicateSerial(null); + }} + onCombine={handleCombineDuplicate} + onReplace={handleReplaceDuplicate} + loading={loading} + /> + )} ); } diff --git a/frontend/src/components/devices/ConfirmDuplicateModal.tsx b/frontend/src/components/devices/ConfirmDuplicateModal.tsx new file mode 100644 index 0000000..0b2d59a --- /dev/null +++ b/frontend/src/components/devices/ConfirmDuplicateModal.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { AlertTriangle, GitMerge, RefreshCcw, X } from 'lucide-react'; +import type { DuplicateSerialError } from '../../services/api'; + +interface Props { + duplicate: DuplicateSerialError; + pendingName: string; + pendingIp: string; + onCancel: () => void; + onCombine: () => void; + onReplace: () => void; + loading: boolean; +} + +export default function ConfirmDuplicateModal({ + duplicate, + pendingName, + pendingIp, + onCancel, + onCombine, + onReplace, + loading, +}: Props) { + const [confirmCombineStep, setConfirmCombineStep] = useState(false); + const existingSerial = (duplicate.existing_device.serial_number || '').trim().toUpperCase(); + const candidateSerial = (duplicate.candidate.serial_number || '').trim().toUpperCase(); + const serialsMatch = Boolean(existingSerial && candidateSerial && existingSerial === candidateSerial); + return ( +
+
+
+

+ + Duplicate serial detected +

+ +
+
+

+ Serial {duplicate.candidate.serial_number} already exists on a managed device. +

+ {confirmCombineStep && ( +
+

+ Confirm combine +

+

+ Existing serial: {duplicate.existing_device.serial_number} +

+

+ New device serial: {duplicate.candidate.serial_number} +

+

+ Only continue if you are certain these represent the same physical device. +

+ {!serialsMatch && ( +

+ Combine is disabled because the serial numbers do not match. +

+ )} +
+ )} +
+
+

Existing managed device

+

{duplicate.existing_device.name}

+

{duplicate.existing_device.ip_address}

+

+ Serial: {duplicate.existing_device.serial_number} +

+
+
+

New device request

+

{pendingName}

+

{pendingIp}

+

+ Serial: {duplicate.candidate.serial_number} +

+
+
+
+ + {confirmCombineStep ? ( + + ) : ( + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/devices/EditDeviceModal.tsx b/frontend/src/components/devices/EditDeviceModal.tsx new file mode 100644 index 0000000..5052e83 --- /dev/null +++ b/frontend/src/components/devices/EditDeviceModal.tsx @@ -0,0 +1,337 @@ +import { useEffect, useMemo, useState, FormEvent } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react'; +import { devicesApi } from '../../services/api'; +import type { Device, DeviceType, IpAddress } from '../../types'; + +interface Props { + device: Device; + onClose: () => void; + onSuccess: () => void; +} + +// Strip the CIDR mask from "192.168.88.1/24" → "192.168.88.1" so the value +// matches the format stored in `devices.ip_address`. +function addrWithoutMask(addr: string): string { + if (!addr) return ''; + const slash = addr.indexOf('/'); + return slash === -1 ? addr : addr.slice(0, slash); +} + +// Hide link-local / localhost / invalid rows from the IP picker. +function isSelectableIp(row: IpAddress): boolean { + const raw = addrWithoutMask(row.address || ''); + if (!raw) return false; + if (row.disabled === 'true') return false; + if (row.invalid === 'true') return false; + if (raw === '127.0.0.1' || raw === '::1') return false; + if (raw.toLowerCase().startsWith('fe80:')) return false; + if (raw.startsWith('169.254.')) return false; + return true; +} + +export default function EditDeviceModal({ device, onClose, onSuccess }: Props) { + const [form, setForm] = useState({ + name: device.name, + ip_address: device.ip_address, + api_port: String(device.api_port ?? 8728), + api_username: device.api_username ?? 'admin', + api_password: '', + ssh_port: String(device.ssh_port ?? 22), + ssh_username: device.ssh_username ?? '', + ssh_password: '', + device_type: device.device_type, + notes: device.notes ?? '', + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v })); + + // Live IPs from the router itself — useful so you can change the + // management IP to something the device actually owns instead of guessing. + const { data: ipRows, isLoading: ipsLoading, error: ipsError } = useQuery({ + queryKey: ['device-ips', device.id], + queryFn: () => devicesApi.getIpAddresses(device.id).then((r) => r.data), + enabled: device.status === 'online', + staleTime: 30_000, + }); + + const candidateIps = useMemo(() => { + const seen = new Set(); + const out: { address: string; cidr: string; iface: string }[] = []; + for (const row of (ipRows ?? []) as IpAddress[]) { + if (!isSelectableIp(row)) continue; + const bare = addrWithoutMask(row.address); + if (seen.has(bare)) continue; + seen.add(bare); + out.push({ address: bare, cidr: row.address, iface: row.interface }); + } + // Always include the current IP so it can be reselected + if (device.ip_address && !seen.has(device.ip_address)) { + out.unshift({ address: device.ip_address, cidr: device.ip_address, iface: '(current)' }); + } + return out; + }, [ipRows, device.ip_address]); + + const ipWasDetected = candidateIps.some((c) => c.address === form.ip_address); + + useEffect(() => { setError(''); }, [form.ip_address, form.api_port, form.api_username, form.api_password]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!form.name || !form.ip_address || !form.api_username) { + setError('Name, IP address, and username are required'); + return; + } + setLoading(true); + setError(''); + try { + const payload: Record = { + name: form.name, + ip_address: form.ip_address, + api_port: parseInt(form.api_port, 10) || 8728, + api_username: form.api_username, + ssh_port: parseInt(form.ssh_port, 10) || 22, + ssh_username: form.ssh_username || null, + device_type: form.device_type as DeviceType, + notes: form.notes, + }; + if (form.api_password) payload.api_password = form.api_password; + if (form.ssh_password) payload.ssh_password = form.ssh_password; + await devicesApi.update(device.id, payload); + onSuccess(); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || 'Failed to update device'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ Edit Device +

+ +
+ +
+ {/* Basic info */} +
+
+ + set('name', e.target.value)} + /> +
+ +
+ + set('ip_address', e.target.value)} + placeholder="192.168.1.1" + /> + {device.status === 'online' && ( +
+
+ + IPs configured on this device +
+ {ipsLoading ? ( +
+ Fetching from router… +
+ ) : ipsError ? ( +
+ Could not fetch IP list. You can still enter one manually. +
+ ) : candidateIps.length === 0 ? ( +
+ No usable IP addresses returned by the router. +
+ ) : ( +
    + {candidateIps.map((c) => { + const selected = form.ip_address === c.address; + return ( +
  • + +
  • + ); + })} +
+ )} + {!ipsLoading && !ipsError && !ipWasDetected && form.ip_address && ( +
+ Entered IP isn't in the router's address list — double-check before saving. +
+ )} +
+ )} +
+ +
+ + +
+
+ + {/* API credentials */} +
+

+ RouterOS API Credentials +

+
+
+ + set('api_username', e.target.value)} + /> +
+
+ + set('api_port', e.target.value)} + /> +
+
+ + set('api_password', e.target.value)} + autoComplete="new-password" + placeholder="••••••••" + /> +
+
+
+ + {/* SSH credentials (optional) */} +
+ + SSH Credentials (optional, for backup/restore) + +
+
+ + set('ssh_username', e.target.value)} + /> +
+
+ + set('ssh_port', e.target.value)} + /> +
+
+ + set('ssh_password', e.target.value)} + autoComplete="new-password" + /> +
+
+
+ +
+ +