Add NetBox device inventory integration framework
Adds API helper and route module for NetBox DCIM integration as a future replacement for Granite inventory management. Helper (helpers/netboxApi.js): - Token-based auth (Authorization: Token <token>) - Device CRUD: list, get, create, update, delete - IP cross-reference: find device by IP via IPAM - Reference data: sites, device types, device roles - Connection test, TLS skip support Routes (routes/netbox.js) mounted at /api/netbox: - GET /status — config and connectivity check - GET /devices — list with filters and pagination - GET /devices/search — general search - GET /devices/by-ip/:ip — cross-reference IP to device - GET /devices/:id — single device detail - POST /devices — create (Admin, Standard_User) - PATCH /devices/:id — partial update - DELETE /devices/:id — delete (Admin only) - GET /sites, /device-types, /device-roles — reference data Integration is optional — gracefully returns 503 if env vars are unset. New env vars: NETBOX_API_URL, NETBOX_API_TOKEN, NETBOX_SKIP_TLS
This commit is contained in:
@@ -64,6 +64,14 @@ CARD_API_PASS=
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
CARD_SKIP_TLS=false
|
||||
|
||||
# NetBox Device Inventory API (replacing Granite for inventory management)
|
||||
# Token-based auth — create a token in NetBox under Admin > API Tokens.
|
||||
# The token must have read/write permissions on DCIM and IPAM models.
|
||||
NETBOX_API_URL=
|
||||
NETBOX_API_TOKEN=
|
||||
# Set to true if behind Charter's SSL inspection proxy
|
||||
NETBOX_SKIP_TLS=false
|
||||
|
||||
# PostgreSQL Database (Docker container steam-postgres)
|
||||
# If set, the backend uses Postgres instead of SQLite.
|
||||
# Format: postgresql://user:password@host:port/database
|
||||
|
||||
374
backend/helpers/netboxApi.js
Normal file
374
backend/helpers/netboxApi.js
Normal file
@@ -0,0 +1,374 @@
|
||||
// NetBox API helpers
|
||||
// Centralizes HTTP calls for the NetBox DCIM (device inventory) API.
|
||||
// Follows the same pattern as cardApi.js — token-based auth, generic request
|
||||
// wrapper, convenience methods for device CRUD.
|
||||
//
|
||||
// NetBox REST API conventions:
|
||||
// - Base URL: NETBOX_API_URL (e.g., https://netbox.example.com)
|
||||
// - Auth: Token-based — `Authorization: Token <NETBOX_API_TOKEN>`
|
||||
// - Content-Type: application/json
|
||||
// - Pagination: `?limit=N&offset=N`, response: { count, next, previous, results }
|
||||
// - CRUD: GET (list/detail), POST (create), PATCH (partial update), PUT (full update), DELETE
|
||||
// - Device endpoint: /api/dcim/devices/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const NETBOX_API_URL = process.env.NETBOX_API_URL || '';
|
||||
const NETBOX_API_TOKEN = process.env.NETBOX_API_TOKEN || '';
|
||||
const NETBOX_SKIP_TLS = process.env.NETBOX_SKIP_TLS === 'true';
|
||||
|
||||
const requiredVars = ['NETBOX_API_URL', 'NETBOX_API_TOKEN'];
|
||||
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`[netbox-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. NetBox API calls will fail.`);
|
||||
}
|
||||
|
||||
const isConfigured = missingVars.length === 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic request — supports GET, POST, PATCH, PUT, DELETE with Token auth
|
||||
// ---------------------------------------------------------------------------
|
||||
function netboxRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 30000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const fullUrl = new URL(NETBOX_API_URL + urlPath);
|
||||
const isHttps = fullUrl.protocol === 'https:';
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const headers = {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Token ' + NETBOX_API_TOKEN,
|
||||
};
|
||||
|
||||
let bodyStr = null;
|
||||
if (body !== null && body !== undefined) {
|
||||
bodyStr = JSON.stringify(body);
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||
}
|
||||
|
||||
const reqOptions = {
|
||||
hostname: fullUrl.hostname,
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
headers,
|
||||
timeout,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
reqOptions.rejectUnauthorized = !NETBOX_SKIP_TLS;
|
||||
}
|
||||
|
||||
const req = transport.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`[netbox-api] ${method} ${urlPath} failed: ${err.message}`));
|
||||
});
|
||||
|
||||
if (bodyStr) req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
function netboxGet(urlPath, options) {
|
||||
return netboxRequest('GET', urlPath, null, options);
|
||||
}
|
||||
|
||||
function netboxPost(urlPath, body, options) {
|
||||
return netboxRequest('POST', urlPath, body, options);
|
||||
}
|
||||
|
||||
function netboxPatch(urlPath, body, options) {
|
||||
return netboxRequest('PATCH', urlPath, body, options);
|
||||
}
|
||||
|
||||
function netboxPut(urlPath, body, options) {
|
||||
return netboxRequest('PUT', urlPath, body, options);
|
||||
}
|
||||
|
||||
function netboxDelete(urlPath, options) {
|
||||
return netboxRequest('DELETE', urlPath, null, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level helpers — Device CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Test connection by fetching /api/status/. Returns { ok, version } or { ok, error }.
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const res = await netboxGet('/api/status/');
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(res.body); } catch (_) { parsed = {}; }
|
||||
return { ok: true, version: parsed['netbox-version'] || 'unknown' };
|
||||
}
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List devices with optional filters and pagination.
|
||||
*
|
||||
* @param {object} [filters] - Query parameters (name, site, role, status, tag, etc.)
|
||||
* @param {object} [options] - { limit, offset, timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function listDevices(filters, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
const limit = (options && options.limit) || 50;
|
||||
const offset = (options && options.offset) || 0;
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await netboxGet(`/api/dcim/devices/?${qs}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single device by ID.
|
||||
*
|
||||
* @param {number|string} deviceId - NetBox device ID
|
||||
* @param {object} [options] - { timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function getDevice(deviceId, options) {
|
||||
const res = await netboxGet(`/api/dcim/devices/${deviceId}/`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new device.
|
||||
*
|
||||
* Required fields (minimum): name, role (slug or id), device_type (id), site (id)
|
||||
* Optional: serial, asset_tag, status, platform, tenant, primary_ip4, custom_fields, tags, etc.
|
||||
*
|
||||
* @param {object} deviceData - Device object matching NetBox device serializer
|
||||
* @param {object} [options] - { timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function createDevice(deviceData, options) {
|
||||
const res = await netboxPost('/api/dcim/devices/', deviceData, options);
|
||||
return { status: res.status, body: res.body, ok: res.status === 201 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a device (partial update via PATCH).
|
||||
*
|
||||
* @param {number|string} deviceId - NetBox device ID
|
||||
* @param {object} updates - Fields to update
|
||||
* @param {object} [options] - { timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function updateDevice(deviceId, updates, options) {
|
||||
const res = await netboxPatch(`/api/dcim/devices/${deviceId}/`, updates, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device.
|
||||
*
|
||||
* @param {number|string} deviceId - NetBox device ID
|
||||
* @param {object} [options] - { timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function deleteDevice(deviceId, options) {
|
||||
const res = await netboxDelete(`/api/dcim/devices/${deviceId}/`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status === 204 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search devices by name or IP address.
|
||||
* NetBox supports ?q= for general search across name/serial/asset_tag.
|
||||
*
|
||||
* @param {string} query - Search string
|
||||
* @param {object} [options] - { limit, offset, timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function searchDevices(query, options) {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
const limit = (options && options.limit) || 50;
|
||||
const offset = (options && options.offset) || 0;
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
|
||||
const res = await netboxGet(`/api/dcim/devices/?${params.toString()}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IP Address helpers — for cross-referencing with Ivanti findings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List IP addresses with optional filters.
|
||||
* Useful for finding devices by IP (cross-reference with Ivanti host findings).
|
||||
*
|
||||
* @param {object} [filters] - Query parameters (address, device, interface, etc.)
|
||||
* @param {object} [options] - { limit, offset, timeout }
|
||||
* @returns {{ status, body, ok }}
|
||||
*/
|
||||
async function listIpAddresses(filters, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
const limit = (options && options.limit) || 50;
|
||||
const offset = (options && options.offset) || 0;
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await netboxGet(`/api/ipam/ip-addresses/?${qs}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a device by its IP address via IPAM.
|
||||
* Returns the device record if the IP is assigned to an interface on a device.
|
||||
*
|
||||
* @param {string} ipAddress - IP address to look up (e.g., "10.244.11.55")
|
||||
* @param {object} [options] - { timeout }
|
||||
* @returns {{ status, device, ip, ok }} - device is null if IP not found or not assigned
|
||||
*/
|
||||
async function findDeviceByIp(ipAddress, options) {
|
||||
// NetBox IPAM accepts CIDR or bare IP (auto-matches /32 or /128)
|
||||
const searchIp = ipAddress.includes('/') ? ipAddress : ipAddress;
|
||||
const params = new URLSearchParams({ address: searchIp, limit: '1' });
|
||||
const res = await netboxGet(`/api/ipam/ip-addresses/?${params.toString()}`, options);
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
return { status: res.status, device: null, ip: null, ok: false };
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(res.body); } catch (_) { parsed = { results: [] }; }
|
||||
|
||||
const ipRecord = parsed.results && parsed.results[0];
|
||||
if (!ipRecord) {
|
||||
return { status: 404, device: null, ip: null, ok: false };
|
||||
}
|
||||
|
||||
// IP may be assigned to an interface → interface belongs to a device
|
||||
const assignedDevice = ipRecord.assigned_object && ipRecord.assigned_object.device;
|
||||
return {
|
||||
status: res.status,
|
||||
device: assignedDevice || null,
|
||||
ip: ipRecord,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Site & Device Type helpers — needed when creating devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List sites (paginated).
|
||||
*/
|
||||
async function listSites(filters, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
params.set('limit', String((options && options.limit) || 100));
|
||||
params.set('offset', String((options && options.offset) || 0));
|
||||
|
||||
const res = await netboxGet(`/api/dcim/sites/?${params.toString()}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* List device types (paginated).
|
||||
*/
|
||||
async function listDeviceTypes(filters, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
params.set('limit', String((options && options.limit) || 100));
|
||||
params.set('offset', String((options && options.offset) || 0));
|
||||
|
||||
const res = await netboxGet(`/api/dcim/device-types/?${params.toString()}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* List device roles (paginated).
|
||||
*/
|
||||
async function listDeviceRoles(filters, options) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
params.set('limit', String((options && options.limit) || 100));
|
||||
params.set('offset', String((options && options.offset) || 0));
|
||||
|
||||
const res = await netboxGet(`/api/dcim/device-roles/?${params.toString()}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
netboxRequest,
|
||||
netboxGet,
|
||||
netboxPost,
|
||||
netboxPatch,
|
||||
netboxPut,
|
||||
netboxDelete,
|
||||
testConnection,
|
||||
listDevices,
|
||||
getDevice,
|
||||
createDevice,
|
||||
updateDevice,
|
||||
deleteDevice,
|
||||
searchDevices,
|
||||
listIpAddresses,
|
||||
findDeviceByIp,
|
||||
listSites,
|
||||
listDeviceTypes,
|
||||
listDeviceRoles,
|
||||
};
|
||||
445
backend/routes/netbox.js
Normal file
445
backend/routes/netbox.js
Normal file
@@ -0,0 +1,445 @@
|
||||
// NetBox Device Inventory API Routes
|
||||
// Proxies NetBox DCIM operations (list, get, create, update, delete devices)
|
||||
// and provides cross-reference lookups between Ivanti findings and NetBox devices.
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
const {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
testConnection,
|
||||
listDevices,
|
||||
getDevice,
|
||||
createDevice,
|
||||
updateDevice,
|
||||
deleteDevice,
|
||||
searchDevices,
|
||||
findDeviceByIp,
|
||||
listSites,
|
||||
listDeviceTypes,
|
||||
listDeviceRoles,
|
||||
} = require('../helpers/netboxApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — maps NetBox API errors to client responses
|
||||
// ---------------------------------------------------------------------------
|
||||
function handleNetboxError(err, res) {
|
||||
const msg = err.message || String(err);
|
||||
console.error('[netbox-api]', msg);
|
||||
|
||||
if (msg.includes('401') || msg.includes('Unauthorized')) {
|
||||
return res.status(401).json({ error: 'NetBox authorization failed. Check API token.' });
|
||||
}
|
||||
if (msg.includes('403') || msg.includes('Forbidden')) {
|
||||
return res.status(403).json({ error: 'Insufficient NetBox permissions for this operation.' });
|
||||
}
|
||||
if (msg.includes('timed out')) {
|
||||
return res.status(504).json({ error: 'NetBox API request timed out.', timeout: true });
|
||||
}
|
||||
|
||||
return res.status(502).json({ error: 'NetBox API request failed.', details: msg });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response parser helper
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseBody(raw) {
|
||||
try { return JSON.parse(raw); } catch (_) { return raw; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
function createNetboxRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// All NetBox routes require authentication and Admin or Standard_User group
|
||||
router.use(requireAuth(), requireGroup('Admin', 'Standard_User'));
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
*
|
||||
* Returns whether the NetBox integration is configured and reachable.
|
||||
*
|
||||
* @response 200 - { configured: true, connected: true, version: string }
|
||||
* @response 503 - { configured: false, error: string, missingVars: string[] }
|
||||
*/
|
||||
router.get('/status', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ configured: false, error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await testConnection();
|
||||
if (result.ok) {
|
||||
return res.json({ configured: true, connected: true, version: result.version });
|
||||
}
|
||||
return res.json({ configured: true, connected: false, error: result.error });
|
||||
} catch (err) {
|
||||
return res.json({ configured: true, connected: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /devices
|
||||
*
|
||||
* List devices with optional filters and pagination.
|
||||
*
|
||||
* @query {string} [name] - Filter by device name (exact or contains)
|
||||
* @query {string} [site] - Filter by site slug
|
||||
* @query {string} [role] - Filter by device role slug
|
||||
* @query {string} [status] - Filter by status (active, planned, staged, decommissioning, offline)
|
||||
* @query {string} [tag] - Filter by tag
|
||||
* @query {string} [q] - General search (name, serial, asset_tag)
|
||||
* @query {number} [limit=50] - Results per page
|
||||
* @query {number} [offset=0] - Pagination offset
|
||||
* @response 200 - { count, next, previous, results: device[] }
|
||||
* @response 503 - NetBox not configured
|
||||
*/
|
||||
router.get('/devices', requireTeam(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { limit, offset, ...filters } = req.query;
|
||||
|
||||
try {
|
||||
const result = await listDevices(filters, {
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
return res.json(parseBody(result.body));
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /devices/search
|
||||
*
|
||||
* Search devices by name, serial, or asset tag.
|
||||
*
|
||||
* @query {string} q - Search query (required)
|
||||
* @query {number} [limit=50] - Results per page
|
||||
* @query {number} [offset=0] - Pagination offset
|
||||
* @response 200 - { count, next, previous, results: device[] }
|
||||
* @response 400 - { error: string } — missing query
|
||||
*/
|
||||
router.get('/devices/search', requireTeam(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { q, limit, offset } = req.query;
|
||||
if (!q || !q.trim()) {
|
||||
return res.status(400).json({ error: 'Search query (q) is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchDevices(q.trim(), {
|
||||
limit: limit ? parseInt(limit, 10) : 50,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
return res.json(parseBody(result.body));
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /devices/by-ip/:ip
|
||||
*
|
||||
* Look up a NetBox device by IP address (cross-reference with Ivanti findings).
|
||||
*
|
||||
* @param {string} ip - IP address to look up
|
||||
* @response 200 - { device, ip } — device info and IP record
|
||||
* @response 404 - { error: string } — IP not found or not assigned to a device
|
||||
*/
|
||||
router.get('/devices/by-ip/:ip', requireTeam(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { ip } = req.params;
|
||||
if (!ip || !ip.trim()) {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await findDeviceByIp(ip.trim());
|
||||
if (result.ok && result.device) {
|
||||
return res.json({ device: result.device, ip: result.ip });
|
||||
}
|
||||
if (result.ok && !result.device) {
|
||||
return res.status(404).json({ error: 'IP found in NetBox but not assigned to a device.', ip: result.ip });
|
||||
}
|
||||
return res.status(404).json({ error: `IP ${ip} not found in NetBox.` });
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /devices/:id
|
||||
*
|
||||
* Get a single device by NetBox ID.
|
||||
*
|
||||
* @param {string} id - NetBox device ID
|
||||
* @response 200 - Device object
|
||||
* @response 404 - { error: string }
|
||||
*/
|
||||
router.get('/devices/:id', requireTeam(), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await getDevice(id);
|
||||
if (result.ok) {
|
||||
return res.json(parseBody(result.body));
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /devices
|
||||
*
|
||||
* Create a new device in NetBox.
|
||||
*
|
||||
* @body {string} name - Device name (required)
|
||||
* @body {number|object} device_type - Device type ID or nested object (required)
|
||||
* @body {number|object} role - Device role ID or slug (required)
|
||||
* @body {number|object} site - Site ID or slug (required)
|
||||
* @body {string} [serial] - Serial number
|
||||
* @body {string} [asset_tag] - Asset tag
|
||||
* @body {string} [status] - Status (active, planned, staged, decommissioning, offline)
|
||||
* @body {object} [custom_fields] - Custom field values
|
||||
* @body {string[]} [tags] - Tag slugs
|
||||
* @response 201 - Created device object
|
||||
* @response 400 - { error: string } — validation errors from NetBox
|
||||
*/
|
||||
router.post('/devices', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { name, device_type, role, site } = req.body;
|
||||
|
||||
// Basic validation — NetBox will do full validation, but catch obvious omissions
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Device name is required.' });
|
||||
}
|
||||
if (!device_type) {
|
||||
return res.status(400).json({ error: 'device_type is required.' });
|
||||
}
|
||||
if (!role) {
|
||||
return res.status(400).json({ error: 'role is required.' });
|
||||
}
|
||||
if (!site) {
|
||||
return res.status(400).json({ error: 'site is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createDevice(req.body);
|
||||
if (result.ok) {
|
||||
const created = parseBody(result.body);
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'netbox_device_create',
|
||||
entityType: 'netbox_device',
|
||||
entityId: String(created.id || name),
|
||||
details: { name, site, role, device_type },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(201).json(created);
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /devices/:id
|
||||
*
|
||||
* Partially update a device in NetBox.
|
||||
*
|
||||
* @param {string} id - NetBox device ID
|
||||
* @body {object} - Fields to update (any writable device field)
|
||||
* @response 200 - Updated device object
|
||||
* @response 400 - { error: string } — validation errors from NetBox
|
||||
* @response 404 - { error: string } — device not found
|
||||
*/
|
||||
router.patch('/devices/:id', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: 'No update fields provided.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateDevice(id, updates);
|
||||
if (result.ok) {
|
||||
const updated = parseBody(result.body);
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'netbox_device_update',
|
||||
entityType: 'netbox_device',
|
||||
entityId: String(id),
|
||||
details: { updatedFields: Object.keys(updates) },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.json(updated);
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /devices/:id
|
||||
*
|
||||
* Delete a device from NetBox. Admin only.
|
||||
*
|
||||
* @param {string} id - NetBox device ID
|
||||
* @response 204 - No content (success)
|
||||
* @response 404 - { error: string } — device not found
|
||||
*/
|
||||
router.delete('/devices/:id', requireGroup('Admin'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await deleteDevice(id);
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'netbox_device_delete',
|
||||
entityType: 'netbox_device',
|
||||
entityId: String(id),
|
||||
details: {},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return res.status(204).send();
|
||||
}
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reference data endpoints — needed for device creation forms
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /sites
|
||||
*
|
||||
* List available NetBox sites for device creation.
|
||||
*
|
||||
* @query {number} [limit=100] - Results per page
|
||||
* @query {number} [offset=0] - Pagination offset
|
||||
* @response 200 - { count, results: site[] }
|
||||
*/
|
||||
router.get('/sites', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { limit, offset, ...filters } = req.query;
|
||||
try {
|
||||
const result = await listSites(filters, {
|
||||
limit: limit ? parseInt(limit, 10) : 100,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
if (result.ok) return res.json(parseBody(result.body));
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /device-types
|
||||
*
|
||||
* List available device types for device creation.
|
||||
*
|
||||
* @query {number} [limit=100] - Results per page
|
||||
* @query {number} [offset=0] - Pagination offset
|
||||
* @response 200 - { count, results: deviceType[] }
|
||||
*/
|
||||
router.get('/device-types', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { limit, offset, ...filters } = req.query;
|
||||
try {
|
||||
const result = await listDeviceTypes(filters, {
|
||||
limit: limit ? parseInt(limit, 10) : 100,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
if (result.ok) return res.json(parseBody(result.body));
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /device-roles
|
||||
*
|
||||
* List available device roles for device creation.
|
||||
*
|
||||
* @query {number} [limit=100] - Results per page
|
||||
* @query {number} [offset=0] - Pagination offset
|
||||
* @response 200 - { count, results: role[] }
|
||||
*/
|
||||
router.get('/device-roles', async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'NetBox API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { limit, offset, ...filters } = req.query;
|
||||
try {
|
||||
const result = await listDeviceRoles(filters, {
|
||||
limit: limit ? parseInt(limit, 10) : 100,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
if (result.ok) return res.json(parseBody(result.body));
|
||||
return res.status(result.status).json(parseBody(result.body));
|
||||
} catch (err) {
|
||||
return handleNetboxError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createNetboxRouter;
|
||||
@@ -40,6 +40,7 @@ const createCardApiRouter = require('./routes/cardApi');
|
||||
const createFeedbackRouter = require('./routes/feedback');
|
||||
const createWebhooksRouter = require('./routes/webhooks');
|
||||
const createNotificationsRouter = require('./routes/notifications');
|
||||
const createNetboxRouter = require('./routes/netbox');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -281,6 +282,9 @@ app.use('/api/feedback', createFeedbackRouter());
|
||||
// In-app notifications routes (authenticated users)
|
||||
app.use('/api/notifications', createNotificationsRouter());
|
||||
|
||||
// NetBox device inventory routes — DCIM device CRUD, IP cross-reference
|
||||
app.use('/api/netbox', createNetboxRouter());
|
||||
|
||||
// GitLab webhook routes — receives issue lifecycle events (no auth required)
|
||||
app.use('/api/webhooks', createWebhooksRouter());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user