EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
tenant-meshcentral-sync.js
Go to the documentation of this file.
1/**
2 * @file Tenant MeshCentral Device Group Auto-Sync Service
3 * @description
4 * Provides automated synchronization of tenant data with MeshCentral device groups (meshes).
5 * Ensures every tenant has a corresponding device group in MeshCentral for agent organization,
6 * handles group creation/linking, and maintains persistent associations across MeshCentral
7 * server restarts or database migrations.
8 *
9 * Key features:
10 * - **Auto-group creation**: Creates "{Tenant Name} Devices" groups in MeshCentral
11 * - **Intelligent matching**: Links existing groups by ID or name to prevent duplicates
12 * - **Bulk synchronization**: Sync all tenants at once (/sync-all endpoint)
13 * - **Individual tenant sync**: Ensure specific tenant has group (/ensure/:tenantId)
14 * - **Status reporting**: Check sync status and identify missing groups (/status)
15 * - **Startup integration**: Can run automatically on server startup
16 *
17 * Synchronization logic:
18 * 1. Query all tenants from database
19 * 2. Fetch all existing device groups (meshes) from MeshCentral
20 * 3. For each tenant:
21 * a. If tenant has `meshcentral_group_id`, verify group still exists
22 * b. If group missing, search for match by name (exact then partial)
23 * c. If match found, link to tenant (update database)
24 * d. If no match, create new group in MeshCentral
25 * e. Update tenant record with group ID and `meshcentral_setup_complete=true`
26 *
27 * Name matching strategy (strict to prevent duplicates):
28 * - **Exact match**: "{Tenant Name} Devices"
29 * - **Fallback exact**: "{Tenant Name}"
30 * - **Partial match**: Contains tenant name AND "Devices"
31 *
32 * Use cases:
33 * - Initial platform setup (create groups for all tenants)
34 * - MeshCentral server migration (re-link to new server)
35 * - Manual group cleanup recovery
36 * - Automated startup sync on server restart
37 * - New tenant onboarding
38 *
39 * Security:
40 * - All endpoints require authentication (`authenticateToken` middleware)
41 * - Root tenant credentials required for MeshCentral API access
42 * - Environment variables store admin credentials
43 *
44 * Environment variables:
45 * - `MESHCENTRAL_URL`: MeshCentral server URL (default: Dev App Platform URL)
46 * - `MESHCENTRAL_ADMIN_USER`: Admin username (default: 'admin')
47 * - `MESHCENTRAL_ADMIN_PASS`: Admin password (default: 'admin')
48 * @module routes/tenant-meshcentral-sync
49 * @requires express
50 * @requires ../db - PostgreSQL database connection
51 * @requires ../lib/meshcentral-api - MeshCentral API client library
52 * @requires ../middleware/auth - JWT authentication middleware
53 * @see {@link module:lib/meshcentral-api}
54 */
55
56/**
57 * Tenant MeshCentral Auto-Sync
58 * Ensures all tenants have device groups in MeshCentral
59 *
60 * This route handles:
61 * - Auto-creation of device groups for tenants without them
62 * - Syncing tenant names with MeshCentral group names
63 * - Ensuring persistent device groups across MeshCentral restarts
64 */
65
66const express = require('express');
67const router = express.Router();
68const db = require('../db');
69const MeshCentralAPI = require('../lib/meshcentral-api');
70const authenticateToken = require('../middleware/auth');
71
72const MESHCENTRAL_URL = process.env.MESHCENTRAL_URL || 'https://rmm-psa-meshcentral-aq48h.ondigitalocean.app';
73const MESHCENTRAL_USER = process.env.MESHCENTRAL_ADMIN_USER || 'admin';
74const MESHCENTRAL_PASSWORD = process.env.MESHCENTRAL_ADMIN_PASS || 'admin';
75
76/**
77 * Creates and authenticates MeshCentral API client instance.
78 *
79 * Initializes MeshCentralAPI with root admin credentials from environment variables.
80 * Performs login to obtain authentication cookies/tokens. Returns authenticated API
81 * instance ready for device group operations (create, list, delete, etc.).
82 * @async
83 * @private
84 * @function getMeshAPI
85 * @returns {Promise<MeshCentralAPI>} Authenticated MeshCentral API client instance
86 * @throws {Error} If MeshCentral server unreachable or authentication fails
87 * @example
88 * const api = await getMeshAPI();
89 * const meshes = await api.getMeshes();
90 */
91
92/**
93 * Get or create MeshCentral API instance
94 */
95async function getMeshAPI() {
96 const api = new MeshCentralAPI({
97 url: MESHCENTRAL_URL,
98 username: MESHCENTRAL_USER,
99 password: MESHCENTRAL_PASSWORD
100 });
101
102 await api.login();
103 return api;
104}
105
106/**
107 * Ensures tenant has associated device group (mesh) in MeshCentral.
108 *
109 * Implements intelligent group matching and creation logic to prevent duplicates:
110 * 1. If tenant has `meshcentral_group_id`, verifies group still exists in MeshCentral
111 * 2. If group missing, searches existing groups by name (exact then partial match)
112 * 3. If match found, links group to tenant (updates database)
113 * 4. If no match, creates new group "{Tenant Name} Devices" in MeshCentral
114 * 5. Updates tenant record with group ID and sets `meshcentral_setup_complete=true`
115 *
116 * Name matching precedence:
117 * - **Exact match**: "{tenant.name} Devices"
118 * - **Exact fallback**: "{tenant.name}"
119 * - **Partial match**: Contains tenant.name AND "Devices"
120 * @async
121 * @private
122 * @function ensureTenantMesh
123 * @param {MeshCentralAPI} api - Authenticated MeshCentral API client
124 * @param {object} tenant - Tenant database record
125 * @param {number} tenant.tenant_id - Unique tenant identifier
126 * @param {string} tenant.name - Tenant name (used for group naming)
127 * @param {string} [tenant.meshcentral_group_id] - Existing MeshCentral group ID (if previously synced)
128 * @param {object[]} [existingMeshes=null] - Cached array of existing MeshCentral groups (performance optimization)
129 * @returns {Promise<object>} Sync result
130 * @returns {string} return.meshId - MeshCentral group ID (existing or newly created)
131 * @returns {boolean} return.created - True if new group was created in this call
132 * @returns {boolean} return.linked - True if existing group was linked to tenant in this call
133 * @throws {Error} If MeshCentral API calls fail or database updates fail
134 * @example
135 * const api = await getMeshAPI();
136 * const meshes = await api.getMeshes();
137 * const result = await ensureTenantMesh(api, { tenant_id: 5, name: 'ACME Corp' }, meshes);
138 * // result: { meshId: 'mesh/domain/abc123', created: true, linked: false }
139 */
140
141/**
142 * Ensure tenant has a device group in MeshCentral
143 * Matches existing groups by ID or name, creates new ones if needed
144 * @param api
145 * @param tenant
146 * @param existingMeshes
147 */
148async function ensureTenantMesh(api, tenant, existingMeshes = null) {
149 try {
150 const expectedMeshName = `${tenant.name} Devices`;
151
152 // Fetch meshes if not provided
153 if (!existingMeshes) {
154 existingMeshes = await api.getMeshes();
155 }
156
157 // Check if tenant already has a mesh ID
158 if (tenant.meshcentral_group_id) {
159 // Verify it still exists in MeshCentral
160 const meshExists = existingMeshes.some(m => m._id === tenant.meshcentral_group_id);
161
162 if (meshExists) {
163 console.log(`āœ… [TenantSync] Tenant ${tenant.tenant_id} has mesh: ${tenant.meshcentral_group_id}`);
164 return { meshId: tenant.meshcentral_group_id, created: false, linked: false };
165 } else {
166 console.log(`āš ļø [TenantSync] Tenant ${tenant.tenant_id} mesh not found, searching for match...`);
167 }
168 }
169
170 // Try to find existing mesh by name (strict matching to prevent duplicates)
171 // First try exact match with expected name, then exact match with tenant name
172 let matchingMesh = existingMeshes.find(m => (m.name || '') === expectedMeshName);
173 if (!matchingMesh) {
174 matchingMesh = existingMeshes.find(m => (m.name || '') === tenant.name);
175 }
176 // Only fall back to partial match if no exact matches found
177 if (!matchingMesh) {
178 matchingMesh = existingMeshes.find(m => (m.name || '').includes(tenant.name) && (m.name || '').includes('Devices'));
179 }
180
181 if (matchingMesh) {
182 console.log(`šŸ”— [TenantSync] Found existing mesh "${matchingMesh.name}" - linking to tenant ${tenant.tenant_id}`);
183
184 // Link this mesh to the tenant
185 await db.query(
186 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
187 [matchingMesh._id, tenant.tenant_id]
188 );
189
190 console.log(`āœ… [TenantSync] Linked mesh ${matchingMesh._id} to tenant ${tenant.tenant_id}`);
191 return { meshId: matchingMesh._id, created: false, linked: true };
192 }
193
194 // Create new mesh
195 console.log(`šŸ“¦ [TenantSync] Creating mesh for tenant ${tenant.tenant_id}: ${expectedMeshName}`);
196
197 const result = await api.createMesh(expectedMeshName, `Device group for ${tenant.name} (tenant:${tenant.tenant_id})`);
198
199 if (!result || !result.meshId) {
200 throw new Error('Failed to create mesh in MeshCentral');
201 }
202
203 // Store mesh ID in database
204 await db.query(
205 'UPDATE tenants SET meshcentral_group_id = $1, meshcentral_setup_complete = true WHERE tenant_id = $2',
206 [result.meshId, tenant.tenant_id]
207 );
208
209 console.log(`āœ… [TenantSync] Created and linked mesh ${result.meshId} for tenant ${tenant.tenant_id}`);
210
211 return { meshId: result.meshId, created: true, linked: false };
212
213 } catch (error) {
214 console.error(`āŒ [TenantSync] Error ensuring mesh for tenant ${tenant.tenant_id}:`, error.message);
215 throw error;
216 }
217}
218
219/**
220 * @api {post} /tenant-meshcentral-sync/sync-all Sync All Tenants with MeshCentral
221 * @apiName SyncAllTenantsMeshCentral
222 * @apiGroup TenantSync
223 * @apiDescription
224 * Performs bulk synchronization of all tenants with MeshCentral device groups. Creates
225 * missing groups, links existing groups by name matching, and updates database records.
226 * Useful for initial setup, MeshCentral server migrations, or manual cleanup after data loss.
227 *
228 * Operation workflow:
229 * 1. Authenticate with MeshCentral as root admin
230 * 2. Fetch all tenants from database
231 * 3. Fetch all existing device groups from MeshCentral
232 * 4. For each tenant: call ensureTenantMesh() to create/link group
233 * 5. Return detailed results for each tenant
234 * @apiPermission authenticated
235 * @apiUse AuthHeader
236 * @apiSuccess {number} totalTenants Total number of tenants processed
237 * @apiSuccess {number} synced Number of tenants successfully synced
238 * @apiSuccess {number} created Number of new device groups created
239 * @apiSuccess {number} linked Number of existing groups linked
240 * @apiSuccess {object[]} results Detailed results for each tenant
241 * @apiSuccess {number} results.tenantId Tenant ID
242 * @apiSuccess {string} results.name Tenant name
243 * @apiSuccess {string} results.meshId MeshCentral group ID
244 * @apiSuccess {boolean} results.created True if group was newly created
245 * @apiSuccess {boolean} results.linked True if existing group was linked
246 * @apiSuccess {string} [results.error] Error message if sync failed for this tenant
247 * @apiError (500) {String} error "Sync failed"
248 * @apiError (500) {String} details Detailed error message
249 * @apiExample {curl} Example Request:
250 * curl -X POST https://api.ibghub.com/api/tenant-meshcentral-sync/sync-all \\
251 * -H "Authorization: Bearer YOUR_JWT_TOKEN"
252 * @apiExample {json} Example Response:
253 * {
254 * "totalTenants": 10,
255 * "synced": 10,
256 * "created": 3,
257 * "linked": 2,
258 * "results": [
259 * {"tenantId": 1, "name": "ACME Corp", "meshId": "mesh/domain/abc", "created": true, "linked": false},
260 * {"tenantId": 2, "name": "Tech Inc", "meshId": "mesh/domain/def", "created": false, "linked": true}
261 * ]
262 * }
263 */
264
265/**
266 * Sync all tenants with MeshCentral
267 * POST /api/tenant-meshcentral-sync/sync-all
268 */
269router.post('/sync-all', authenticateToken, async (req, res) => {
270 try {
271 console.log('\nšŸ”„ [TenantSync] Starting full tenant-mesh sync...');
272
273 // Get all tenants
274 const tenantsResult = await db.query(
275 'SELECT tenant_id, name, meshcentral_group_id, meshcentral_setup_complete FROM tenants ORDER BY created_at'
276 );
277
278 if (tenantsResult.rows.length === 0) {
279 return res.json({
280 success: true,
281 message: 'No tenants to sync',
282 processed: 0
283 });
284 }
285
286 console.log(`šŸ“Š [TenantSync] Found ${tenantsResult.rows.length} tenants`);
287
288 // Connect to MeshCentral
289 const api = await getMeshAPI();
290
291 // Fetch all existing meshes once
292 console.log('šŸ“¦ [TenantSync] Fetching existing mesh groups...');
293 const existingMeshes = await api.getMeshes();
294 console.log(`āœ… [TenantSync] Found ${existingMeshes.length} existing mesh group(s)`);
295
296 // Process each tenant
297 const results = [];
298 for (const tenant of tenantsResult.rows) {
299 try {
300 const result = await ensureTenantMesh(api, tenant, existingMeshes);
301 results.push({
302 tenantId: tenant.tenant_id,
303 tenantName: tenant.name,
304 meshId: result.meshId,
305 created: result.created,
306 linked: result.linked,
307 success: true
308 });
309 } catch (error) {
310 results.push({
311 tenantId: tenant.tenant_id,
312 tenantName: tenant.name,
313 success: false,
314 error: error.message
315 });
316 }
317 }
318
319 const created = results.filter(r => r.created).length;
320 const linked = results.filter(r => r.linked).length;
321 const existing = results.filter(r => r.success && !r.created && !r.linked).length;
322 const failed = results.filter(r => !r.success).length;
323
324 console.log(`\nāœ… [TenantSync] Sync complete: ${created} created, ${linked} linked, ${existing} existing, ${failed} failed`);
325
326 res.json({
327 success: true,
328 message: `Synced ${tenantsResult.rows.length} tenants`,
329 created,
330 linked,
331 existing,
332 failed,
333 results
334 });
335
336 } catch (error) {
337 console.error('āŒ [TenantSync] Error during sync:', error);
338 res.status(500).json({
339 success: false,
340 error: 'Failed to sync tenants with MeshCentral',
341 details: error.message
342 });
343 }
344});
345
346/**
347 * @api {post} /tenant-meshcentral-sync/ensure/:tenantId Ensure Tenant Device Group Exists
348 * @apiName EnsureTenantMeshCentral
349 * @apiGroup TenantSync
350 * @apiDescription
351 * Ensures a specific tenant has a device group in MeshCentral. Creates group if missing,
352 * links if existing group found by name. Used during tenant onboarding or to manually
353 * fix sync issues for individual tenants.
354 * @apiPermission authenticated
355 * @apiUse AuthHeader
356 * @apiParam {number} tenantId Tenant ID to sync
357 * @apiSuccess {boolean} success Operation success status (true)
358 * @apiSuccess {string} meshId MeshCentral device group ID
359 * @apiSuccess {boolean} created True if new group was created
360 * @apiSuccess {boolean} linked True if existing group was linked
361 * @apiSuccess {string} message Success message with operation details
362 * @apiError (404) {String} error "Tenant not found"
363 * @apiError (500) {String} error "Sync failed"
364 * @apiError (500) {String} details Detailed error message
365 * @apiExample {curl} Example Request:
366 * curl -X POST https://api.ibghub.com/api/tenant-meshcentral-sync/ensure/5 \
367 * -H "Authorization: Bearer YOUR_JWT_TOKEN"
368 * @apiExample {json} Example Response:
369 * {
370 * "success": true,
371 * "meshId": "mesh/domain/abc123",
372 * "created": true,
373 * "linked": false,
374 * "message": "Created new mesh for tenant 5"
375 * }
376 */
377
378/**
379 * Ensure single tenant has a mesh
380 * POST /api/tenant-meshcentral-sync/ensure/:tenantId
381 */
382router.post('/ensure/:tenantId', authenticateToken, async (req, res) => {
383 try {
384 const { tenantId } = req.params;
385
386 console.log(`\nšŸ”„ [TenantSync] Ensuring mesh for tenant ${tenantId}...`);
387
388 // Get tenant
389 const tenantResult = await db.query(
390 'SELECT tenant_id, name, meshcentral_group_id, meshcentral_setup_complete FROM tenants WHERE tenant_id = $1',
391 [tenantId]
392 );
393
394 if (tenantResult.rows.length === 0) {
395 return res.status(404).json({
396 success: false,
397 error: 'Tenant not found'
398 });
399 }
400
401 const tenant = tenantResult.rows[0];
402
403 // Connect to MeshCentral and ensure mesh exists
404 const api = await getMeshAPI();
405 const result = await ensureTenantMesh(api, tenant);
406
407 res.json({
408 success: true,
409 tenantId: tenant.tenant_id,
410 tenantName: tenant.name,
411 meshId: result.meshId,
412 created: result.created
413 });
414
415 } catch (error) {
416 console.error('āŒ [TenantSync] Error ensuring mesh:', error);
417 res.status(500).json({
418 success: false,
419 error: 'Failed to ensure mesh for tenant',
420 details: error.message
421 });
422 }
423});
424
425/**
426 * @api {get} /tenant-meshcentral-sync/status Get Tenant Sync Status
427 * @apiName GetTenantSyncStatus
428 * @apiGroup TenantSync
429 * @apiDescription
430 * Returns synchronization status for all tenants, identifying which tenants have device
431 * groups configured and which need sync. Useful for monitoring dashboards and diagnosing
432 * sync issues before running bulk operations.
433 * @apiPermission authenticated
434 * @apiUse AuthHeader
435 * @apiSuccess {number} totalTenants Total number of tenants in database
436 * @apiSuccess {number} synced Number of tenants with `meshcentral_setup_complete=true`
437 * @apiSuccess {number} unsynced Number of tenants without device groups
438 * @apiSuccess {object[]} unsyncedTenants List of tenants needing sync
439 * @apiSuccess {number} unsyncedTenants.tenant_id Tenant ID
440 * @apiSuccess {string} unsyncedTenants.name Tenant name
441 * @apiSuccess {string} unsyncedTenants.subdomain Tenant subdomain
442 * @apiSuccess {boolean} unsyncedTenants.meshcentral_setup_complete Always false for this array
443 * @apiError (500) {String} error "Failed to get sync status"
444 * @apiError (500) {String} details Detailed error message
445 * @apiExample {curl} Example Request:
446 * curl -X GET https://api.ibghub.com/api/tenant-meshcentral-sync/status \
447 * -H "Authorization: Bearer YOUR_JWT_TOKEN"
448 * @apiExample {json} Example Response:
449 * {
450 * "totalTenants": 15,
451 * "synced": 12,
452 * "unsynced": 3,
453 * "unsyncedTenants": [
454 * {"tenant_id": 8, "name": "New Customer LLC", "subdomain": "newcustomer", "meshcentral_setup_complete": false},
455 * {"tenant_id": 9, "name": "Beta Testing Co", "subdomain": "betatest", "meshcentral_setup_complete": false}
456 * ]
457 * }
458 */
459
460/**
461 * Get sync status for all tenants
462 * GET /api/tenant-meshcentral-sync/status
463 */
464router.get('/status', authenticateToken, async (req, res) => {
465 try {
466 const tenantsResult = await db.query(`
467 SELECT
468 tenant_id,
469 name,
470 meshcentral_group_id,
471 meshcentral_setup_complete,
472 (SELECT COUNT(*) FROM agents WHERE tenant_id = tenants.tenant_id AND meshcentral_nodeid IS NOT NULL) as linked_agents
473 FROM tenants
474 ORDER BY created_at
475 `);
476
477 // Get all meshes from MeshCentral
478 const api = await getMeshAPI();
479 const meshes = await api.getMeshes();
480 const meshIds = new Set(meshes.map(m => m._id));
481
482 // Check each tenant's status
483 const status = tenantsResult.rows.map(tenant => ({
484 tenantId: tenant.tenant_id,
485 tenantName: tenant.name,
486 hasMeshId: !!tenant.meshcentral_group_id,
487 meshId: tenant.meshcentral_group_id,
488 meshExists: tenant.meshcentral_group_id ? meshIds.has(tenant.meshcentral_group_id) : false,
489 setupComplete: tenant.meshcentral_setup_complete,
490 linkedAgents: parseInt(tenant.linked_agents) || 0,
491 needsSync: !tenant.meshcentral_group_id || !meshIds.has(tenant.meshcentral_group_id)
492 }));
493
494 const needsSync = status.filter(s => s.needsSync).length;
495
496 res.json({
497 success: true,
498 totalTenants: status.length,
499 needsSync,
500 tenants: status
501 });
502
503 } catch (error) {
504 console.error('āŒ [TenantSync] Error getting status:', error);
505 res.status(500).json({
506 success: false,
507 error: 'Failed to get sync status',
508 details: error.message
509 });
510 }
511});
512
513module.exports = router;