2 * Direct PostgreSQL connection to MeshCentral database
4 * Use this for READING data (fast):
6 * - Online/offline status
10 * Use MeshCentralAPI for WRITING (safe):
12 * - Updating device tags
16const { Pool } = require('pg');
19 * Sanitize JSON data by removing null bytes (\u0000)
20 * @description PostgreSQL cannot store null bytes in text/varchar/json fields.
21 * MeshCentral device data sometimes contains null bytes in hardware info fields
22 * (e.g., ManufacturerId from SMBIOS), causing "unsupported Unicode escape sequence" errors.
23 * This function recursively removes null bytes from all string values in JSON.
24 * @param {any} data - Data to sanitize (object, array, string, etc.)
25 * @returns {any} Sanitized data with null bytes removed
27function sanitizeNullBytes(data) {
28 if (data === null || data === undefined) {
32 // If it's a string, remove null bytes
33 if (typeof data === 'string') {
34 return data.replace(/\u0000/g, '');
37 // If it's an array, sanitize each element
38 if (Array.isArray(data)) {
39 return data.map(item => sanitizeNullBytes(item));
42 // If it's an object, sanitize each property
43 if (typeof data === 'object') {
45 for (const key in data) {
46 if (data.hasOwnProperty(key)) {
47 sanitized[key] = sanitizeNullBytes(data[key]);
53 // For primitives (number, boolean), return as-is
57// Connection state tracking
58let isConnected = false;
59let connectionPromise = null;
61// Create separate connection pool for MeshCentral database
62const meshPool = new Pool({
63 host: process.env.POSTGRES_HOST,
64 port: parseInt(process.env.POSTGRES_PORT) || 5432,
65 user: process.env.POSTGRES_USER,
66 password: process.env.POSTGRES_PASSWORD,
67 database: 'meshcentral', // MeshCentral database name
68 max: 20, // Maximum 20 connections
69 idleTimeoutMillis: 30000,
70 connectionTimeoutMillis: 5000,
71 ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
74// Test connection on startup and track state
75connectionPromise = meshPool.query('SELECT 1')
77 console.log('✅ Connected to MeshCentral PostgreSQL');
82 console.error('❌ MeshCentral PostgreSQL connection failed:', err.message);
88 * Wait for database connection to be ready
89 * @param {number} timeoutMs - Timeout in milliseconds
90 * @returns {Promise<boolean>} True if connected
92async function waitForConnection(timeoutMs = 3000) {
97 // Wait for initial connection or timeout
98 const timeout = new Promise(resolve => setTimeout(() => resolve(false), timeoutMs));
99 const result = await Promise.race([connectionPromise, timeout]);
104 * Get all devices from MeshCentral database
105 * @param {string} meshGroupId - Optional: filter by mesh group ID
106 * @returns {Promise<Array>} Array of device objects
108async function getDevices(meshGroupId = null) {
109 // Wait for connection with timeout
110 const connected = await waitForConnection(3000);
112 throw new Error('MeshCentral database not ready');
115 // Query with null byte sanitization at PostgreSQL level
116 // MeshCentral SMBIOS data sometimes contains \u0000 bytes which PostgreSQL text fields cannot handle
117 // We cast to text, remove null bytes, then cast back to jsonb for safe JSON operations
120 (sanitized_doc->>'_id') as nodeid,
121 (sanitized_doc->>'name') as hostname,
122 (sanitized_doc->>'meshid') as meshid,
123 (sanitized_doc->'agent'->>'osdesc') as os,
124 (sanitized_doc->>'host') as ip_address,
125 (sanitized_doc->>'conn')::int as connection_status,
126 (sanitized_doc->>'lastseen') as last_seen,
127 (sanitized_doc->>'lastconnect') as last_connect,
128 (sanitized_doc->>'tags')::jsonb as tags,
129 (sanitized_doc->>'desc') as description,
130 (sanitized_doc->'agent'->>'ver') as agent_version,
131 (sanitized_doc->>'lastaddr') as last_address,
132 (sanitized_doc->>'rname') as real_name,
133 sanitized_doc as full_doc
135 SELECT replace(doc::text, CHR(0), '')::jsonb as sanitized_doc,
138 WHERE (doc->>'type') = 'node'
139 ${meshGroupId ? "AND (doc->>'meshid') = $1" : ""}
141 ORDER BY (sanitized_doc->>'name')
144 const params = meshGroupId ? [meshGroupId] : [];
146 console.log('[MeshCentral DB] Querying devices (with null byte sanitization):', {
147 filterByMesh: meshGroupId ? 'yes' : 'no',
148 meshGroupId: meshGroupId || 'all'
151 const result = await meshPool.query(query, params);
153 console.log('[MeshCentral DB] Query result:', {
154 rowCount: result.rows.length,
155 sampleNode: result.rows[0] && {
156 nodeid: result.rows[0].nodeid,
157 hostname: result.rows[0].hostname,
158 meshid: result.rows[0].meshid
162 // If filtering by mesh but got 0 results, check if there are ANY nodes
163 if (meshGroupId && result.rows.length === 0) {
164 const totalNodesResult = await meshPool.query(`
165 SELECT COUNT(*) as total,
166 json_agg(DISTINCT (doc->>'meshid')) as mesh_ids
168 WHERE (doc->>'type') = 'node'
171 console.log('[MeshCentral DB] Total nodes in database:', {
172 total: totalNodesResult.rows[0].total,
173 availableMeshIds: totalNodesResult.rows[0].mesh_ids,
174 requestedMeshId: meshGroupId
178 // Parse each device - sanitization already done at DB level
179 return result.rows.map(row => {
180 // Additional Node.js level sanitization for extra safety
181 const sanitizedRow = sanitizeNullBytes(row);
184 nodeId: sanitizedRow.nodeid,
185 hostname: sanitizedRow.hostname || 'Unknown',
186 meshId: sanitizedRow.meshid,
187 os: sanitizedRow.os || 'Unknown',
188 ipAddress: sanitizedRow.ip_address,
189 connected: isDeviceOnline(sanitizedRow.connection_status),
190 connectionStatus: sanitizedRow.connection_status,
191 lastSeen: sanitizedRow.last_seen,
192 lastConnect: sanitizedRow.last_connect,
193 tags: sanitizedRow.tags || [],
194 description: sanitizedRow.description,
195 agentVersion: sanitizedRow.agent_version,
196 lastAddress: sanitizedRow.last_address,
197 realName: sanitizedRow.real_name,
198 rawDoc: sanitizedRow.full_doc
204 * Get single device by node ID
205 * @param {string} nodeId - MeshCentral node ID
206 * @returns {Promise<object>} Device object
208async function getDevice(nodeId) {
209 const devices = await getDevices();
210 return devices.find(d => d.nodeId === nodeId);
214 * Get all mesh groups
215 * @returns {Promise<Array>} Array of mesh group objects
217async function getMeshGroups() {
218 // Wait for connection with timeout
219 const connected = await waitForConnection(3000);
221 throw new Error('MeshCentral database not ready');
224 // Sanitize null bytes at PostgreSQL level
225 const result = await meshPool.query(`
227 (sanitized_doc->>'_id') as meshid,
228 (sanitized_doc->>'name') as name,
229 (sanitized_doc->>'desc') as description,
230 (sanitized_doc->>'mtype')::int as mesh_type,
231 sanitized_doc as full_doc
233 SELECT replace(doc::text, CHR(0), '')::jsonb as sanitized_doc
235 WHERE (doc->>'type') = 'mesh'
237 ORDER BY (sanitized_doc->>'name')
240 return result.rows.map(row => {
241 const sanitizedRow = sanitizeNullBytes(row);
243 _id: sanitizedRow.meshid,
244 meshId: sanitizedRow.meshid,
245 name: sanitizedRow.name,
246 description: sanitizedRow.description,
247 meshType: sanitizedRow.mesh_type,
248 rawDoc: sanitizedRow.full_doc
254 * Get device hardware info from SMBIOS table
255 * @param {string} nodeId - MeshCentral node ID
256 * @returns {Promise<object>} Hardware info object
258async function getDeviceHardware(nodeId) {
259 // Wait for connection with timeout
260 const connected = await waitForConnection(3000);
262 throw new Error('MeshCentral database not ready');
265 const result = await meshPool.query(`
266 SELECT doc->'hardware' as hardware_info
267 FROM meshcentral_smbios
271 if (result.rows.length === 0) {
275 return result.rows[0].hardware_info;
279 * Check if device is online based on connection status bitmask
280 * @param {number} conn - Connection status bitmask from MeshCentral
281 * @returns {boolean} True if device is online
283function isDeviceOnline(conn) {
284 if (!conn) return false;
287 // 1 = Agent connected
288 // 2 = CIRA connected
290 // 8 = AMT relay connected
291 // 16 = MQTT connected
293 // Device is online if agent (1) or MQTT (16) is connected
294 return (conn & 1) > 0 || (conn & 16) > 0;
298 * Parse device tags to extract tenant_id and customer_id
299 * @param {Array} tags - Array of tag strings
300 * @returns {object} {tenantId, customerId, customTags}
302function parseDeviceTags(tags) {
303 if (!Array.isArray(tags)) {
304 return { tenantId: null, customerId: null, customTags: [] };
308 let customerId = null;
309 const customTags = [];
311 for (const tag of tags) {
312 if (typeof tag !== 'string') continue;
314 if (tag.startsWith('tenant:')) {
315 tenantId = tag.substring(7);
316 } else if (tag.startsWith('customer:')) {
317 customerId = tag.substring(9);
319 customTags.push(tag);
323 return { tenantId, customerId, customTags };
327 * Get device count by mesh group
328 * @param {string} meshGroupId - Mesh group ID
329 * @returns {Promise<number>} Number of devices in mesh
331async function getDeviceCount(meshGroupId) {
332 // Wait for connection with timeout
333 const connected = await waitForConnection(3000);
335 throw new Error('MeshCentral database not ready');
338 const result = await meshPool.query(`
339 SELECT COUNT(*) as count
341 WHERE (doc->>'type') = 'node'
342 AND (doc->>'meshid') = $1
345 return parseInt(result.rows[0].count);
349 * Get online device count
350 * @param {string} meshGroupId - Optional: filter by mesh group
351 * @returns {Promise<number>} Number of online devices
353async function getOnlineDeviceCount(meshGroupId = null) {
354 // Wait for connection with timeout
355 const connected = await waitForConnection(3000);
357 throw new Error('MeshCentral database not ready');
361 SELECT COUNT(*) as count
363 WHERE (doc->>'type') = 'node'
364 ${meshGroupId ? "AND (doc->>'meshid') = $1" : ""}
366 ((doc->>'conn')::int & 1) > 0
367 OR ((doc->>'conn')::int & 16) > 0
371 const params = meshGroupId ? [meshGroupId] : [];
372 const result = await meshPool.query(query, params);
374 return parseInt(result.rows[0].count);