Applies the open dependabot PRs in one validated pass: - react-router-dom 6.30 -> 7.18 (drop-in for our API surface; verified in-browser: login, sidebar routing, ?type= deep-links, legacy redirects) - react + react-dom -> 19.2.7, axios -> 1.18.0, lucide-react -> 1.21.0 - actions/checkout v6 -> v7 across all workflows - backend: eslint 10 + @eslint/js 10 (fix 5 new no-useless-assignment findings), typescript 6.0.3 (explicit types: ["node","jest"] in tsconfig — TS 6 no longer auto-includes @types), remove zod (unused) - frontend: @vitejs/plugin-react 6 Not taken: ioredis 5.11.1 (#55) — bullmq pins ioredis to exactly 5.10.1 (including latest bullmq 5.79), so the bump nests a second ioredis and breaks BullMQ connection types. Validation: backend tsc/lint/build + 92 tests green; frontend tsc/lint/ build + 11 tests green; docker stack rebuilt and browser-verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
8.0 KiB
TypeScript
243 lines
8.0 KiB
TypeScript
import { query, queryOne } from '../config/database';
|
|
import { encrypt, decrypt } from '../utils/crypto';
|
|
import { parsePort } from '../utils/parsePort';
|
|
import { safeConnectionError } from '../utils/safeClientError';
|
|
import { RouterOSClient } from './mikrotik/RouterOSClient';
|
|
import type { PollerService } from './PollerService';
|
|
import type { CredentialPresetRow } from '../routes/credentialPresets';
|
|
|
|
export type CreateDeviceContext = {
|
|
/** JWT role of the caller ('admin' | 'operator' | 'viewer'). Preset use may be restricted for operators. */
|
|
requestingUserRole?: string;
|
|
};
|
|
|
|
async function loadCredentialPreset(
|
|
id: number | null | undefined,
|
|
ctx?: CreateDeviceContext
|
|
): Promise<{
|
|
api_username: string;
|
|
api_password: string;
|
|
api_port: number | null;
|
|
ssh_username: string | null;
|
|
ssh_password: string | null;
|
|
ssh_port: number | null;
|
|
} | null> {
|
|
if (id === null || id === undefined) return null;
|
|
const preset = await queryOne<CredentialPresetRow>(
|
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (!preset) throw new Error(`Credential preset ${id} not found`);
|
|
const allowOp = (preset as CredentialPresetRow & { allow_operator_use?: boolean }).allow_operator_use !== false;
|
|
if (ctx?.requestingUserRole === 'operator' && !allowOp) {
|
|
const err = new Error('This credential preset is restricted to administrators');
|
|
(err as Error & { statusCode?: number }).statusCode = 403;
|
|
throw err;
|
|
}
|
|
return {
|
|
api_username: preset.api_username,
|
|
api_password: decrypt(preset.api_password_encrypted),
|
|
api_port: preset.api_port,
|
|
ssh_username: preset.ssh_username,
|
|
ssh_password: preset.ssh_password_encrypted ? decrypt(preset.ssh_password_encrypted) : null,
|
|
ssh_port: preset.ssh_port,
|
|
};
|
|
}
|
|
|
|
export interface CreateDeviceInput {
|
|
name?: string;
|
|
ip_address?: string;
|
|
device_type?: string;
|
|
notes?: string;
|
|
credential_preset_id?: number | null;
|
|
api_username?: string;
|
|
api_password?: string;
|
|
api_port?: unknown;
|
|
ssh_username?: string | null;
|
|
ssh_password?: string | null;
|
|
ssh_port?: unknown;
|
|
combine_with_device_id?: number;
|
|
force_replace_existing_by_serial?: boolean;
|
|
}
|
|
|
|
export type CreateDeviceResult =
|
|
| { ok: true; status: 200 | 201; body: Record<string, unknown> }
|
|
| { ok: false; status: number; body: Record<string, unknown> };
|
|
|
|
/**
|
|
* Shared device creation logic used by POST /api/devices and bulk-add worker.
|
|
*/
|
|
export async function createDeviceFromBody(
|
|
input: CreateDeviceInput,
|
|
pollerService: PollerService | null,
|
|
ctx?: CreateDeviceContext
|
|
): Promise<CreateDeviceResult> {
|
|
const {
|
|
name,
|
|
ip_address,
|
|
device_type = 'router',
|
|
notes,
|
|
credential_preset_id,
|
|
} = input;
|
|
|
|
let preset: Awaited<ReturnType<typeof loadCredentialPreset>>;
|
|
try {
|
|
preset = await loadCredentialPreset(credential_preset_id ?? null, ctx);
|
|
} catch (err) {
|
|
const status = (err as Error & { statusCode?: number }).statusCode ?? 400;
|
|
return { ok: false, status, body: { error: (err as Error).message } };
|
|
}
|
|
|
|
const api_username: string | undefined = preset?.api_username ?? input.api_username;
|
|
const api_password: string | undefined = preset?.api_password ?? input.api_password;
|
|
const api_port: number = preset?.api_port ?? parsePort(input.api_port, 8728);
|
|
const ssh_username: string | null = preset ? preset.ssh_username : (input.ssh_username ?? null);
|
|
const ssh_password: string | null = preset ? preset.ssh_password : (input.ssh_password ?? null);
|
|
const ssh_port: number = preset?.ssh_port ?? parsePort(input.ssh_port, 22);
|
|
const combineWithDeviceId =
|
|
typeof input.combine_with_device_id === 'number' ? input.combine_with_device_id : null;
|
|
const forceReplaceBySerial = input.force_replace_existing_by_serial === true;
|
|
|
|
if (!name || !ip_address || !api_username || !api_password) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
body: { error: 'name, ip_address, api_username, api_password are required' },
|
|
};
|
|
}
|
|
|
|
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
|
|
let detectedSerial: string | null;
|
|
try {
|
|
await testClient.connect();
|
|
const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
|
|
detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null;
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
status: 422,
|
|
body: { error: safeConnectionError('createDeviceFromBody', err) },
|
|
};
|
|
} 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 {
|
|
ok: false,
|
|
status: 409,
|
|
body: {
|
|
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 {
|
|
ok: false,
|
|
status: 500,
|
|
body: {
|
|
error: 'Duplicate merge succeeded but device row could not be reloaded',
|
|
device_id: existingBySerial.id,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: { ...updatedExisting, merged_from_duplicate: true },
|
|
};
|
|
}
|
|
}
|
|
|
|
const encryptedPass = encrypt(api_password);
|
|
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
|
|
|
|
const rows = await query<{ id: number }>(
|
|
`INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted,
|
|
ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`,
|
|
[name, ip_address, api_port, api_username, encryptedPass,
|
|
ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null]
|
|
);
|
|
|
|
const newId = rows[0].id;
|
|
|
|
if (pollerService) {
|
|
await pollerService.scheduleDeviceSync(newId, 'full');
|
|
}
|
|
|
|
const device = 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`,
|
|
[newId]
|
|
);
|
|
|
|
return { ok: true, status: 201, body: device as Record<string, unknown> };
|
|
}
|