EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
meshcentral-device-sync.js
Go to the documentation of this file.
1/**
2 * MeshCentral Device and Group Auto-Sync Worker
3 *
4 * Polls MeshCentral and our database to ensure synchronization:
5 * - Syncs mesh groups from our DB tenants to MeshCentral
6 * - Creates new agents in our database for untracked devices
7 * - Updates connection status for existing agents
8 * - Links MeshCentral node IDs to agents
9 *
10 * This makes our PostgreSQL database the source of truth for both
11 * agents and mesh groups, preventing duplicates and ensuring persistence.
12 *
13 * NOTE: Polling is used as a fallback. Once MeshCentral webhooks are configured
14 * (see /api/webhooks/meshcentral and MESHCENTRAL_WEBHOOK_SETUP.md), webhooks
15 * will provide real-time updates (~2s) and this polling can be reduced to a
16 * 10-minute fallback interval for missed events.
17 */
18
19const MeshCentralAPI = require('../lib/meshcentral-api');
20const { getDevices } = require('../services/meshcentral-db');
21const db = require('../db');
22const crypto = require('crypto');
23
24const SYNC_INTERVAL = 300000; // 5 minutes (will reduce to 10min once webhooks are active)
25const GROUP_SYNC_INTERVAL = 300000; // 5 minutes for mesh groups
26const LOG_PREFIX = '[MeshSync]';
27
28/**
29 * MeshCentral device synchronization worker.
30 * Syncs devices from MeshCentral to local agents database.
31 */
32class MeshCentralDeviceSync {
33 /**
34 * Initialize MeshCentralDeviceSync worker.
35 * Sets up sync intervals, API instance, and statistics tracking.
36 */
37 constructor() {
38 this.running = false;
39 this.intervalId = null;
40 this.groupIntervalId = null;
41 this.lastSyncTime = null;
42 this.lastGroupSyncTime = null;
43 this.meshApi = null;
44 this.stats = {
45 totalSyncs: 0,
46 devicesFound: 0,
47 agentsCreated: 0,
48 agentsLinked: 0,
49 agentsUpdated: 0,
50 groupsCreated: 0,
51 groupsSynced: 0,
52 errors: 0
53 };
54 }
55
56 /**
57 * Get or create MeshCentral API instance.
58 * @returns {Promise<MeshCentralAPI>} Authenticated API client
59 */
60 async getMeshAPI() {
61 if (!this.meshApi) {
62 this.meshApi = new MeshCentralAPI({
63 url: process.env.MESHCENTRAL_URL || 'https://rmm-psa-meshcentral-aq48h.ondigitalocean.app',
64 username: process.env.MESHCENTRAL_ADMIN_USER || process.env.MESHCENTRAL_USERNAME,
65 password: process.env.MESHCENTRAL_ADMIN_PASS || process.env.MESHCENTRAL_PASSWORD
66 });
67 await this.meshApi.login();
68 }
69 return this.meshApi;
70 }
71
72 /**
73 * Start the sync worker
74 */
75 start() {
76 if (this.running) {
77 console.log(`${LOG_PREFIX} Already running`);
78 return;
79 }
80
81 console.log(`${LOG_PREFIX} Starting device & group sync worker`);
82 console.log(`${LOG_PREFIX} Device sync interval: ${SYNC_INTERVAL}ms`);
83 console.log(`${LOG_PREFIX} Group sync interval: ${GROUP_SYNC_INTERVAL}ms`);
84 this.running = true;
85
86 // Run device sync immediately
87 this.sync().catch(err => {
88 console.error(`${LOG_PREFIX} Initial device sync failed:`, err.message);
89 });
90
91 // Run group sync after 2 seconds
92 setTimeout(() => {
93 this.syncMeshGroups().catch(err => {
94 console.error(`${LOG_PREFIX} Initial group sync failed:`, err.message);
95 });
96 }, 2000);
97
98 // Then run periodically
99 this.intervalId = setInterval(() => {
100 this.sync().catch(err => {
101 console.error(`${LOG_PREFIX} Device sync failed:`, err.message);
102 this.stats.errors++;
103 });
104 }, SYNC_INTERVAL);
105
106 this.groupIntervalId = setInterval(() => {
107 this.syncMeshGroups().catch(err => {
108 console.error(`${LOG_PREFIX} Group sync failed:`, err.message);
109 this.stats.errors++;
110 });
111 }, GROUP_SYNC_INTERVAL);
112 }
113
114 /**
115 * Stop the sync worker
116 */
117 stop() {
118 if (!this.running) return;
119
120 console.log(`${LOG_PREFIX} Stopping device & group sync worker`);
121 this.running = false;
122
123 if (this.intervalId) {
124 clearInterval(this.intervalId);
125 this.intervalId = null;
126 }
127
128 if (this.groupIntervalId) {
129 clearInterval(this.groupIntervalId);
130 this.groupIntervalId = null;
131 }
132 }
133
134 /**
135 * Sync mesh groups from our database to MeshCentral
136 * Our database is the source of truth for tenants and mesh groups
137 */
138 async syncMeshGroups() {
139 try {
140 console.log(`\n${LOG_PREFIX} 🔄 Starting mesh group sync...`);
141
142 const api = await this.getMeshAPI();
143
144 // Get all tenants from our database
145 const tenantsResult = await db.query(`
146 SELECT tenant_id, name, meshcentral_group_id
147 FROM tenants
148 WHERE tenant_id != '00000000-0000-0000-0000-000000000001'
149 ORDER BY name
150 `);
151
152 const tenants = tenantsResult.rows;
153 console.log(`${LOG_PREFIX} Found ${tenants.length} tenants to sync`);
154
155 // Get all existing mesh groups from MeshCentral
156 const existingMeshes = await api.getMeshes();
157 const existingMeshMap = new Map();
158 for (const mesh of existingMeshes) {
159 existingMeshMap.set(mesh.meshid, mesh);
160 }
161
162 console.log(`${LOG_PREFIX} Found ${existingMeshes.length} existing mesh groups in MeshCentral`);
163
164 // Track which meshes we want to keep
165 const desiredMeshIds = new Set();
166
167 // Process each tenant
168 for (const tenant of tenants) {
169 try {
170 const desiredMeshName = `${tenant.name} Devices`;
171 let meshId = tenant.meshcentral_group_id;
172
173 // Check if tenant's mesh group exists in MeshCentral
174 if (meshId && existingMeshMap.has(meshId)) {
175 // Mesh exists - verify name matches
176 const existingMesh = existingMeshMap.get(meshId);
177 if (existingMesh.name !== desiredMeshName) {
178 console.log(`${LOG_PREFIX} Mesh name mismatch for ${tenant.name}: "${existingMesh.name}" -> "${desiredMeshName}"`);
179 // Update mesh name (MeshCentral API might not support this, so we'll skip for now)
180 }
181 desiredMeshIds.add(meshId);
182 this.stats.groupsSynced++;
183 } else {
184 // Mesh doesn't exist or tenant not linked - create it
185 console.log(`${LOG_PREFIX} 🆕 Creating mesh group for tenant: ${tenant.name}`);
186
187 try {
188 const newMesh = await api.createMesh(desiredMeshName);
189 meshId = newMesh.meshid;
190
191 // Update tenant with new mesh ID
192 await db.query(
193 'UPDATE tenants SET meshcentral_group_id = $1 WHERE tenant_id = $2',
194 [meshId, tenant.tenant_id]
195 );
196
197 console.log(`${LOG_PREFIX} ✅ Created mesh group "${desiredMeshName}" (${meshId}) for tenant ${tenant.name}`);
198 desiredMeshIds.add(meshId);
199 this.stats.groupsCreated++;
200 } catch (createErr) {
201 // Mesh might already exist with this name - try to find it
202 const matchingMesh = existingMeshes.find(m => m.name === desiredMeshName);
203 if (matchingMesh) {
204 console.log(`${LOG_PREFIX} Found existing mesh "${desiredMeshName}", linking to tenant ${tenant.name}`);
205 await db.query(
206 'UPDATE tenants SET meshcentral_group_id = $1 WHERE tenant_id = $2',
207 [matchingMesh.meshid, tenant.tenant_id]
208 );
209 desiredMeshIds.add(matchingMesh.meshid);
210 this.stats.groupsSynced++;
211 } else {
212 throw createErr;
213 }
214 }
215 }
216 } catch (err) {
217 console.error(`${LOG_PREFIX} Error syncing mesh group for tenant ${tenant.name}:`, err.message);
218 this.stats.errors++;
219 }
220 }
221
222 // Clean up orphaned mesh groups (ones not in our DB)
223 const orphanedMeshes = existingMeshes.filter(mesh => !desiredMeshIds.has(mesh.meshid));
224 if (orphanedMeshes.length > 0) {
225 console.log(`${LOG_PREFIX} Found ${orphanedMeshes.length} orphaned mesh groups:`);
226 for (const mesh of orphanedMeshes) {
227 console.log(`${LOG_PREFIX} - ${mesh.name} (${mesh.meshid})`);
228 // Optionally delete orphaned meshes (commented out for safety)
229 // await api.deleteMesh(mesh.meshid);
230 // console.log(`${LOG_PREFIX} ✅ Deleted orphaned mesh group "${mesh.name}"`);
231 }
232 }
233
234 this.lastGroupSyncTime = new Date();
235 console.log(`${LOG_PREFIX} ✅ Mesh group sync complete`);
236
237 } catch (error) {
238 console.error(`${LOG_PREFIX} Mesh group sync error:`, error);
239 this.stats.errors++;
240 throw error;
241 }
242 }
243
244 /**
245 * Main sync function
246 */
247 async sync() {
248 try {
249 const startTime = Date.now();
250 console.log(`\n${LOG_PREFIX} Starting sync...`);
251
252 // Get all devices from MeshCentral
253 const devices = await getDevices(null); // null = get ALL devices
254 this.stats.devicesFound = devices.length;
255 this.stats.totalSyncs++;
256
257 if (devices.length === 0) {
258 console.log(`${LOG_PREFIX} No devices found in MeshCentral`);
259 this.lastSyncTime = new Date();
260 return;
261 }
262
263 console.log(`${LOG_PREFIX} Found ${devices.length} devices in MeshCentral`);
264
265 // Process each device
266 for (const device of devices) {
267 try {
268 await this.processDevice(device);
269 } catch (err) {
270 console.error(`${LOG_PREFIX} Error processing device ${device.nodeId}:`, err.message);
271 this.stats.errors++;
272 }
273 }
274
275 // Update disconnected devices
276 await this.updateDisconnectedDevices(devices);
277
278 const elapsed = Date.now() - startTime;
279 console.log(`${LOG_PREFIX} Sync complete in ${elapsed}ms`);
280 console.log(`${LOG_PREFIX} Stats:`, {
281 created: this.stats.agentsCreated,
282 linked: this.stats.agentsLinked,
283 updated: this.stats.agentsUpdated
284 });
285
286 this.lastSyncTime = new Date();
287
288 } catch (error) {
289 console.error(`${LOG_PREFIX} Sync error:`, error);
290 this.stats.errors++;
291 throw error;
292 }
293 }
294
295 /**
296 * Process a single device from MeshCentral.
297 * Creates or updates agent record in database.
298 * @param {object} device - MeshCentral device object with nodeId, hostname, connected
299 * @returns {Promise<void>}
300 */
301 async processDevice(device) {
302 const nodeId = device.nodeId;
303 const hostname = device.hostname || 'Unknown';
304 const isConnected = device.connected || false;
305
306 // Check if device is already linked to an agent
307 const existingAgent = await db.query(
308 'SELECT agent_id, hostname, meshcentral_connected FROM agents WHERE meshcentral_nodeid = $1',
309 [nodeId]
310 );
311
312 if (existingAgent.rows.length > 0) {
313 // Agent exists - update connection status
314 const agent = existingAgent.rows[0];
315
316 if (agent.meshcentral_connected !== isConnected) {
317 await db.query(
318 `UPDATE agents
319 SET meshcentral_connected = $1,
320 meshcentral_last_seen = NOW(),
321 status = $2,
322 last_seen = NOW()
323 WHERE agent_id = $3`,
324 [isConnected, isConnected ? 'online' : 'offline', agent.agent_id]
325 );
326
327 console.log(`${LOG_PREFIX} Updated agent ${agent.agent_id} (${agent.hostname}) - ${isConnected ? 'online' : 'offline'}`);
328 this.stats.agentsUpdated++;
329 }
330
331 return;
332 }
333
334 // Device not linked - try to find matching agent by hostname
335 const hostnameMatch = await db.query(
336 'SELECT agent_id, agent_uuid, hostname, tenant_id FROM agents WHERE LOWER(hostname) = LOWER($1) AND meshcentral_nodeid IS NULL LIMIT 1',
337 [hostname]
338 );
339
340 if (hostnameMatch.rows.length > 0) {
341 // Found matching agent - link it
342 const agent = hostnameMatch.rows[0];
343
344 await db.query(
345 `UPDATE agents
346 SET meshcentral_nodeid = $1,
347 meshcentral_connected = $2,
348 meshcentral_last_seen = NOW(),
349 status = $3,
350 last_seen = NOW()
351 WHERE agent_id = $4`,
352 [nodeId, isConnected, isConnected ? 'online' : 'offline', agent.agent_id]
353 );
354
355 console.log(`${LOG_PREFIX} ✅ Linked existing agent ${agent.agent_id} (${agent.hostname}) to device ${nodeId}`);
356 this.stats.agentsLinked++;
357 return;
358 }
359
360 // No matching agent found - create a new one
361 console.log(`${LOG_PREFIX} 🆕 Creating new agent for device: ${hostname} (${nodeId})`);
362
363 const newAgentUuid = crypto.randomUUID();
364
365 // Determine platform
366 let platform = 'unknown';
367 if (device.platform) {
368 const p = device.platform.toLowerCase();
369 if (p.includes('windows')) platform = 'windows';
370 else if (p.includes('linux')) platform = 'linux';
371 else if (p.includes('darwin') || p.includes('mac')) platform = 'darwin';
372 }
373
374 // Get mesh group ID to determine tenant
375 let tenantId = null;
376 if (device.meshId) {
377 const tenantMatch = await db.query(
378 'SELECT tenant_id FROM tenants WHERE meshcentral_group_id = $1',
379 [device.meshId]
380 );
381 if (tenantMatch.rows.length > 0) {
382 tenantId = tenantMatch.rows[0].tenant_id;
383 }
384 }
385
386 // If no tenant found, use default tenant (root)
387 if (!tenantId) {
388 const defaultTenant = await db.query(
389 "SELECT tenant_id FROM tenants WHERE tenant_id = '00000000-0000-0000-0000-000000000001' LIMIT 1"
390 );
391 if (defaultTenant.rows.length > 0) {
392 tenantId = defaultTenant.rows[0].tenant_id;
393 }
394 }
395
396 // Create new agent
397 const insertResult = await db.query(
398 `INSERT INTO agents (
399 agent_uuid, tenant_id, hostname, platform,
400 meshcentral_nodeid, meshcentral_connected, meshcentral_last_seen,
401 status, last_seen, created_at
402 ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, NOW(), NOW())
403 RETURNING agent_id, agent_uuid, hostname`,
404 [
405 newAgentUuid,
406 tenantId,
407 hostname,
408 platform,
409 nodeId,
410 isConnected,
411 isConnected ? 'online' : 'offline'
412 ]
413 );
414
415 const newAgent = insertResult.rows[0];
416 console.log(`${LOG_PREFIX} ✅ Created agent ${newAgent.agent_id} (${newAgent.hostname}) for device ${nodeId}`);
417 this.stats.agentsCreated++;
418 }
419
420 /**
421 * Mark agents as disconnected if they're not in the current device list.
422 * @param {Array<object>} currentDevices - Array of current MeshCentral device objects
423 * @returns {Promise<void>}
424 */
425 async updateDisconnectedDevices(currentDevices) {
426 const connectedNodeIds = currentDevices.map(d => d.nodeId);
427
428 if (connectedNodeIds.length === 0) {
429 // If no devices, mark all as disconnected
430 await db.query(
431 `UPDATE agents
432 SET meshcentral_connected = false,
433 status = 'offline'
434 WHERE meshcentral_connected = true`
435 );
436 return;
437 }
438
439 // Mark agents as disconnected if their node ID is not in the current list
440 const result = await db.query(
441 `UPDATE agents
442 SET meshcentral_connected = false,
443 status = 'offline'
444 WHERE meshcentral_connected = true
445 AND meshcentral_nodeid IS NOT NULL
446 AND NOT (meshcentral_nodeid = ANY($1))
447 RETURNING agent_id, hostname`,
448 [connectedNodeIds]
449 );
450
451 if (result.rowCount > 0) {
452 console.log(`${LOG_PREFIX} Marked ${result.rowCount} agents as disconnected`);
453 for (const agent of result.rows) {
454 console.log(`${LOG_PREFIX} - Agent ${agent.agent_id} (${agent.hostname}) went offline`);
455 }
456 }
457 }
458
459 /**
460 * Get worker status and statistics.
461 * @returns {object} Status object with running state, intervals, sync times, and stats
462 */
463 getStatus() {
464 return {
465 running: this.running,
466 deviceSyncInterval: SYNC_INTERVAL,
467 groupSyncInterval: GROUP_SYNC_INTERVAL,
468 lastDeviceSync: this.lastSyncTime,
469 lastGroupSync: this.lastGroupSyncTime,
470 stats: this.stats
471 };
472 }
473}
474
475// Create singleton instance
476const worker = new MeshCentralDeviceSync();
477
478// Auto-start if enabled
479if (process.env.MESHCENTRAL_AUTO_SYNC !== 'false') {
480 console.log(`${LOG_PREFIX} Auto-sync enabled`);
481 // Start after 5 seconds to allow server to fully initialize
482 setTimeout(() => {
483 worker.start();
484 }, 5000);
485} else {
486 console.log(`${LOG_PREFIX} Auto-sync disabled (set MESHCENTRAL_AUTO_SYNC=true to enable)`);
487}
488
489// Export worker instance
490module.exports = worker;