2 * MeshCentral Device and Group Auto-Sync Worker
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
10 * This makes our PostgreSQL database the source of truth for both
11 * agents and mesh groups, preventing duplicates and ensuring persistence.
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.
19const MeshCentralAPI = require('../lib/meshcentral-api');
20const { getDevices } = require('../services/meshcentral-db');
21const db = require('../db');
22const crypto = require('crypto');
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]';
29 * MeshCentral device synchronization worker.
30 * Syncs devices from MeshCentral to local agents database.
32class MeshCentralDeviceSync {
34 * Initialize MeshCentralDeviceSync worker.
35 * Sets up sync intervals, API instance, and statistics tracking.
39 this.intervalId = null;
40 this.groupIntervalId = null;
41 this.lastSyncTime = null;
42 this.lastGroupSyncTime = null;
57 * Get or create MeshCentral API instance.
58 * @returns {Promise<MeshCentralAPI>} Authenticated API client
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
67 await this.meshApi.login();
73 * Start the sync worker
77 console.log(`${LOG_PREFIX} Already running`);
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`);
86 // Run device sync immediately
87 this.sync().catch(err => {
88 console.error(`${LOG_PREFIX} Initial device sync failed:`, err.message);
91 // Run group sync after 2 seconds
93 this.syncMeshGroups().catch(err => {
94 console.error(`${LOG_PREFIX} Initial group sync failed:`, err.message);
98 // Then run periodically
99 this.intervalId = setInterval(() => {
100 this.sync().catch(err => {
101 console.error(`${LOG_PREFIX} Device sync failed:`, err.message);
106 this.groupIntervalId = setInterval(() => {
107 this.syncMeshGroups().catch(err => {
108 console.error(`${LOG_PREFIX} Group sync failed:`, err.message);
111 }, GROUP_SYNC_INTERVAL);
115 * Stop the sync worker
118 if (!this.running) return;
120 console.log(`${LOG_PREFIX} Stopping device & group sync worker`);
121 this.running = false;
123 if (this.intervalId) {
124 clearInterval(this.intervalId);
125 this.intervalId = null;
128 if (this.groupIntervalId) {
129 clearInterval(this.groupIntervalId);
130 this.groupIntervalId = null;
135 * Sync mesh groups from our database to MeshCentral
136 * Our database is the source of truth for tenants and mesh groups
138 async syncMeshGroups() {
140 console.log(`\n${LOG_PREFIX} 🔄 Starting mesh group sync...`);
142 const api = await this.getMeshAPI();
144 // Get all tenants from our database
145 const tenantsResult = await db.query(`
146 SELECT tenant_id, name, meshcentral_group_id
148 WHERE tenant_id != '00000000-0000-0000-0000-000000000001'
152 const tenants = tenantsResult.rows;
153 console.log(`${LOG_PREFIX} Found ${tenants.length} tenants to sync`);
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);
162 console.log(`${LOG_PREFIX} Found ${existingMeshes.length} existing mesh groups in MeshCentral`);
164 // Track which meshes we want to keep
165 const desiredMeshIds = new Set();
167 // Process each tenant
168 for (const tenant of tenants) {
170 const desiredMeshName = `${tenant.name} Devices`;
171 let meshId = tenant.meshcentral_group_id;
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)
181 desiredMeshIds.add(meshId);
182 this.stats.groupsSynced++;
184 // Mesh doesn't exist or tenant not linked - create it
185 console.log(`${LOG_PREFIX} 🆕 Creating mesh group for tenant: ${tenant.name}`);
188 const newMesh = await api.createMesh(desiredMeshName);
189 meshId = newMesh.meshid;
191 // Update tenant with new mesh ID
193 'UPDATE tenants SET meshcentral_group_id = $1 WHERE tenant_id = $2',
194 [meshId, tenant.tenant_id]
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);
204 console.log(`${LOG_PREFIX} Found existing mesh "${desiredMeshName}", linking to tenant ${tenant.name}`);
206 'UPDATE tenants SET meshcentral_group_id = $1 WHERE tenant_id = $2',
207 [matchingMesh.meshid, tenant.tenant_id]
209 desiredMeshIds.add(matchingMesh.meshid);
210 this.stats.groupsSynced++;
217 console.error(`${LOG_PREFIX} Error syncing mesh group for tenant ${tenant.name}:`, err.message);
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}"`);
234 this.lastGroupSyncTime = new Date();
235 console.log(`${LOG_PREFIX} ✅ Mesh group sync complete`);
238 console.error(`${LOG_PREFIX} Mesh group sync error:`, error);
249 const startTime = Date.now();
250 console.log(`\n${LOG_PREFIX} Starting sync...`);
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++;
257 if (devices.length === 0) {
258 console.log(`${LOG_PREFIX} No devices found in MeshCentral`);
259 this.lastSyncTime = new Date();
263 console.log(`${LOG_PREFIX} Found ${devices.length} devices in MeshCentral`);
265 // Process each device
266 for (const device of devices) {
268 await this.processDevice(device);
270 console.error(`${LOG_PREFIX} Error processing device ${device.nodeId}:`, err.message);
275 // Update disconnected devices
276 await this.updateDisconnectedDevices(devices);
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
286 this.lastSyncTime = new Date();
289 console.error(`${LOG_PREFIX} Sync error:`, error);
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>}
301 async processDevice(device) {
302 const nodeId = device.nodeId;
303 const hostname = device.hostname || 'Unknown';
304 const isConnected = device.connected || false;
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',
312 if (existingAgent.rows.length > 0) {
313 // Agent exists - update connection status
314 const agent = existingAgent.rows[0];
316 if (agent.meshcentral_connected !== isConnected) {
319 SET meshcentral_connected = $1,
320 meshcentral_last_seen = NOW(),
323 WHERE agent_id = $3`,
324 [isConnected, isConnected ? 'online' : 'offline', agent.agent_id]
327 console.log(`${LOG_PREFIX} Updated agent ${agent.agent_id} (${agent.hostname}) - ${isConnected ? 'online' : 'offline'}`);
328 this.stats.agentsUpdated++;
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',
340 if (hostnameMatch.rows.length > 0) {
341 // Found matching agent - link it
342 const agent = hostnameMatch.rows[0];
346 SET meshcentral_nodeid = $1,
347 meshcentral_connected = $2,
348 meshcentral_last_seen = NOW(),
351 WHERE agent_id = $4`,
352 [nodeId, isConnected, isConnected ? 'online' : 'offline', agent.agent_id]
355 console.log(`${LOG_PREFIX} ✅ Linked existing agent ${agent.agent_id} (${agent.hostname}) to device ${nodeId}`);
356 this.stats.agentsLinked++;
360 // No matching agent found - create a new one
361 console.log(`${LOG_PREFIX} 🆕 Creating new agent for device: ${hostname} (${nodeId})`);
363 const newAgentUuid = crypto.randomUUID();
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';
374 // Get mesh group ID to determine tenant
377 const tenantMatch = await db.query(
378 'SELECT tenant_id FROM tenants WHERE meshcentral_group_id = $1',
381 if (tenantMatch.rows.length > 0) {
382 tenantId = tenantMatch.rows[0].tenant_id;
386 // If no tenant found, use default tenant (root)
388 const defaultTenant = await db.query(
389 "SELECT tenant_id FROM tenants WHERE tenant_id = '00000000-0000-0000-0000-000000000001' LIMIT 1"
391 if (defaultTenant.rows.length > 0) {
392 tenantId = defaultTenant.rows[0].tenant_id;
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`,
411 isConnected ? 'online' : 'offline'
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++;
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>}
425 async updateDisconnectedDevices(currentDevices) {
426 const connectedNodeIds = currentDevices.map(d => d.nodeId);
428 if (connectedNodeIds.length === 0) {
429 // If no devices, mark all as disconnected
432 SET meshcentral_connected = false,
434 WHERE meshcentral_connected = true`
439 // Mark agents as disconnected if their node ID is not in the current list
440 const result = await db.query(
442 SET meshcentral_connected = false,
444 WHERE meshcentral_connected = true
445 AND meshcentral_nodeid IS NOT NULL
446 AND NOT (meshcentral_nodeid = ANY($1))
447 RETURNING agent_id, hostname`,
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`);
460 * Get worker status and statistics.
461 * @returns {object} Status object with running state, intervals, sync times, and stats
465 running: this.running,
466 deviceSyncInterval: SYNC_INTERVAL,
467 groupSyncInterval: GROUP_SYNC_INTERVAL,
468 lastDeviceSync: this.lastSyncTime,
469 lastGroupSync: this.lastGroupSyncTime,
475// Create singleton instance
476const worker = new MeshCentralDeviceSync();
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
486 console.log(`${LOG_PREFIX} Auto-sync disabled (set MESHCENTRAL_AUTO_SYNC=true to enable)`);
489// Export worker instance
490module.exports = worker;